sprints enhancements

This commit is contained in:
StellaOps Bot
2025-12-25 19:52:30 +02:00
parent ef6ac36323
commit b8b2d83f4a
138 changed files with 25133 additions and 594 deletions

View File

@@ -0,0 +1,435 @@
// -----------------------------------------------------------------------------
// CachingCanonicalAdvisoryServiceTests.cs
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
// Task: CANSVC-8200-015
// Description: Unit tests for caching canonical advisory service decorator
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Concelier.Core.Canonical;
namespace StellaOps.Concelier.Core.Tests.Canonical;
public sealed class CachingCanonicalAdvisoryServiceTests : IDisposable
{
private readonly Mock<ICanonicalAdvisoryService> _innerMock;
private readonly IMemoryCache _cache;
private readonly ILogger<CachingCanonicalAdvisoryService> _logger;
private readonly CanonicalCacheOptions _options;
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private const string TestMergeHash = "sha256:abc123def456";
private const string TestCve = "CVE-2025-0001";
public CachingCanonicalAdvisoryServiceTests()
{
_innerMock = new Mock<ICanonicalAdvisoryService>();
_cache = new MemoryCache(new MemoryCacheOptions());
_logger = NullLogger<CachingCanonicalAdvisoryService>.Instance;
_options = new CanonicalCacheOptions
{
Enabled = true,
DefaultTtl = TimeSpan.FromMinutes(5),
CveTtl = TimeSpan.FromMinutes(2),
ArtifactTtl = TimeSpan.FromMinutes(2)
};
}
public void Dispose()
{
_cache.Dispose();
}
#region GetByIdAsync - Caching
[Fact]
public async Task GetByIdAsync_ReturnsCachedResult_OnSecondCall()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService();
// Act - first call hits inner service
var result1 = await service.GetByIdAsync(TestCanonicalId);
// Second call should hit cache
var result2 = await service.GetByIdAsync(TestCanonicalId);
// Assert
result1.Should().Be(canonical);
result2.Should().Be(canonical);
// Inner service called only once
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
{
// Arrange
_innerMock
.Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
var service = CreateService();
// Act
var result = await service.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetByIdAsync_CachesNullResult_DoesNotCallInnerTwice()
{
// Arrange
var id = Guid.NewGuid();
_innerMock
.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
var service = CreateService();
// Act
await service.GetByIdAsync(id);
var result = await service.GetByIdAsync(id);
// Assert - null is not cached, so inner is called twice
result.Should().BeNull();
_innerMock.Verify(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
#endregion
#region GetByMergeHashAsync - Caching
[Fact]
public async Task GetByMergeHashAsync_ReturnsCachedResult_OnSecondCall()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService();
// Act
var result1 = await service.GetByMergeHashAsync(TestMergeHash);
var result2 = await service.GetByMergeHashAsync(TestMergeHash);
// Assert
result1.Should().Be(canonical);
result2.Should().Be(canonical);
_innerMock.Verify(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByMergeHashAsync_CachesByIdToo_AllowsCrossLookup()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService();
// Act - fetch by hash first
await service.GetByMergeHashAsync(TestMergeHash);
// Then fetch by ID - should hit cache
var result = await service.GetByIdAsync(TestCanonicalId);
// Assert
result.Should().Be(canonical);
_innerMock.Verify(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never);
}
#endregion
#region GetByCveAsync - Caching
[Fact]
public async Task GetByCveAsync_ReturnsCachedResult_OnSecondCall()
{
// Arrange
var canonicals = new List<CanonicalAdvisory>
{
CreateCanonicalAdvisory(TestCanonicalId),
CreateCanonicalAdvisory(Guid.NewGuid())
};
_innerMock
.Setup(x => x.GetByCveAsync(TestCve, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
var service = CreateService();
// Act
var result1 = await service.GetByCveAsync(TestCve);
var result2 = await service.GetByCveAsync(TestCve);
// Assert
result1.Should().HaveCount(2);
result2.Should().HaveCount(2);
_innerMock.Verify(x => x.GetByCveAsync(TestCve, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByCveAsync_NormalizesToUpperCase()
{
// Arrange
var canonicals = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId) };
_innerMock
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
var service = CreateService();
// Act - lowercase
await service.GetByCveAsync("cve-2025-0001");
// uppercase should hit cache
await service.GetByCveAsync("CVE-2025-0001");
// Assert
_innerMock.Verify(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByCveAsync_ReturnsEmptyList_WhenNoResults()
{
// Arrange
_innerMock
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CanonicalAdvisory>());
var service = CreateService();
// Act
var result = await service.GetByCveAsync("CVE-2025-9999");
// Assert
result.Should().BeEmpty();
}
#endregion
#region GetByArtifactAsync - Caching
[Fact]
public async Task GetByArtifactAsync_ReturnsCachedResult_OnSecondCall()
{
// Arrange
const string artifactKey = "pkg:npm/lodash@1";
var canonicals = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId) };
_innerMock
.Setup(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
var service = CreateService();
// Act
var result1 = await service.GetByArtifactAsync(artifactKey);
var result2 = await service.GetByArtifactAsync(artifactKey);
// Assert
result1.Should().HaveCount(1);
result2.Should().HaveCount(1);
_innerMock.Verify(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByArtifactAsync_NormalizesToLowerCase()
{
// Arrange
var canonicals = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId) };
_innerMock
.Setup(x => x.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
var service = CreateService();
// Act
await service.GetByArtifactAsync("PKG:NPM/LODASH@1");
await service.GetByArtifactAsync("pkg:npm/lodash@1");
// Assert - both should hit cache
_innerMock.Verify(x => x.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
#region QueryAsync - Pass-through
[Fact]
public async Task QueryAsync_DoesNotCache_PassesThroughToInner()
{
// Arrange
var options = new CanonicalQueryOptions();
var result = new PagedResult<CanonicalAdvisory> { Items = [], TotalCount = 0, Offset = 0, Limit = 10 };
_innerMock
.Setup(x => x.QueryAsync(options, It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
var service = CreateService();
// Act
await service.QueryAsync(options);
await service.QueryAsync(options);
// Assert - called twice (no caching)
_innerMock.Verify(x => x.QueryAsync(options, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
#endregion
#region IngestAsync - Cache Invalidation
[Fact]
public async Task IngestAsync_InvalidatesCache_WhenNotDuplicate()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
_innerMock
.Setup(x => x.IngestAsync(It.IsAny<string>(), It.IsAny<RawAdvisory>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IngestResult.Created(TestCanonicalId, TestMergeHash, Guid.NewGuid(), "nvd", "NVD-001"));
var service = CreateService();
// Prime the cache
await service.GetByIdAsync(TestCanonicalId);
// Act - ingest that modifies the canonical
await service.IngestAsync("nvd", CreateRawAdvisory(TestCve));
// Now fetch again - should call inner again
await service.GetByIdAsync(TestCanonicalId);
// Assert - inner called twice (before and after ingest)
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task IngestAsync_DoesNotInvalidateCache_WhenDuplicate()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
_innerMock
.Setup(x => x.IngestAsync(It.IsAny<string>(), It.IsAny<RawAdvisory>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IngestResult.Duplicate(TestCanonicalId, TestMergeHash, "nvd", "NVD-001"));
var service = CreateService();
// Prime the cache
await service.GetByIdAsync(TestCanonicalId);
// Act - duplicate ingest (no changes)
await service.IngestAsync("nvd", CreateRawAdvisory(TestCve));
// Now fetch again - should hit cache
await service.GetByIdAsync(TestCanonicalId);
// Assert - inner called only once
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Once);
}
#endregion
#region UpdateStatusAsync - Cache Invalidation
[Fact]
public async Task UpdateStatusAsync_InvalidatesCache()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService();
// Prime the cache
await service.GetByIdAsync(TestCanonicalId);
// Act - update status
await service.UpdateStatusAsync(TestCanonicalId, CanonicalStatus.Withdrawn);
// Now fetch again - should call inner again
await service.GetByIdAsync(TestCanonicalId);
// Assert
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
#endregion
#region Disabled Caching
[Fact]
public async Task GetByIdAsync_DoesNotCache_WhenCachingDisabled()
{
// Arrange
var disabledOptions = new CanonicalCacheOptions { Enabled = false };
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
_innerMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService(disabledOptions);
// Act
await service.GetByIdAsync(TestCanonicalId);
await service.GetByIdAsync(TestCanonicalId);
// Assert - called twice when caching disabled
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Exactly(2));
}
#endregion
#region Helpers
private CachingCanonicalAdvisoryService CreateService() =>
CreateService(_options);
private CachingCanonicalAdvisoryService CreateService(CanonicalCacheOptions options) =>
new(_innerMock.Object, _cache, Options.Create(options), _logger);
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id) => new()
{
Id = id,
Cve = TestCve,
AffectsKey = "pkg:npm/example@1",
MergeHash = TestMergeHash,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
private static RawAdvisory CreateRawAdvisory(string cve) => new()
{
SourceAdvisoryId = $"ADV-{cve}",
Cve = cve,
AffectsKey = "pkg:npm/example@1",
VersionRangeJson = "{}",
Weaknesses = [],
FetchedAt = DateTimeOffset.UtcNow
};
#endregion
}

View File

@@ -0,0 +1,801 @@
// -----------------------------------------------------------------------------
// CanonicalAdvisoryServiceTests.cs
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
// Task: CANSVC-8200-009
// Description: Unit tests for canonical advisory service ingest pipeline
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.Core.Canonical;
namespace StellaOps.Concelier.Core.Tests.Canonical;
public sealed class CanonicalAdvisoryServiceTests
{
private readonly Mock<ICanonicalAdvisoryStore> _storeMock;
private readonly Mock<IMergeHashCalculator> _hashCalculatorMock;
private readonly Mock<ISourceEdgeSigner> _signerMock;
private readonly ILogger<CanonicalAdvisoryService> _logger;
private const string TestSource = "nvd";
private const string TestMergeHash = "sha256:abc123def456";
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid TestSourceId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid TestEdgeId = Guid.Parse("33333333-3333-3333-3333-333333333333");
public CanonicalAdvisoryServiceTests()
{
_storeMock = new Mock<ICanonicalAdvisoryStore>();
_hashCalculatorMock = new Mock<IMergeHashCalculator>();
_signerMock = new Mock<ISourceEdgeSigner>();
_logger = NullLogger<CanonicalAdvisoryService>.Instance;
// Default merge hash calculation
_hashCalculatorMock
.Setup(x => x.ComputeMergeHash(It.IsAny<MergeHashInput>()))
.Returns(TestMergeHash);
// Default source resolution
_storeMock
.Setup(x => x.ResolveSourceIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestSourceId);
// Default source edge creation
_storeMock
.Setup(x => x.AddSourceEdgeAsync(It.IsAny<AddSourceEdgeRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SourceEdgeResult.Created(TestEdgeId));
}
#region IngestAsync - New Canonical
[Fact]
public async Task IngestAsync_CreatesNewCanonical_WhenNoExistingMergeHash()
{
// Arrange
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateService();
var advisory = CreateRawAdvisory("CVE-2025-0001");
// Act
var result = await service.IngestAsync(TestSource, advisory);
// Assert
result.Decision.Should().Be(MergeDecision.Created);
result.CanonicalId.Should().Be(TestCanonicalId);
result.MergeHash.Should().Be(TestMergeHash);
result.SourceEdgeId.Should().Be(TestEdgeId);
_storeMock.Verify(x => x.UpsertCanonicalAsync(
It.Is<UpsertCanonicalRequest>(r =>
r.Cve == "CVE-2025-0001" &&
r.MergeHash == TestMergeHash),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task IngestAsync_ComputesMergeHash_FromAdvisoryFields()
{
// Arrange
var advisory = CreateRawAdvisory(
cve: "CVE-2025-0002",
affectsKey: "pkg:npm/lodash@1",
weaknesses: ["CWE-79", "CWE-89"]);
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateService();
// Act
await service.IngestAsync(TestSource, advisory);
// Assert
_hashCalculatorMock.Verify(x => x.ComputeMergeHash(
It.Is<MergeHashInput>(input =>
input.Cve == "CVE-2025-0002" &&
input.AffectsKey == "pkg:npm/lodash@1" &&
input.Weaknesses != null &&
input.Weaknesses.Contains("CWE-79") &&
input.Weaknesses.Contains("CWE-89"))),
Times.Once);
}
#endregion
#region IngestAsync - Merge Existing
[Fact]
public async Task IngestAsync_MergesIntoExisting_WhenMergeHashExists()
{
// Arrange - include source edge with high precedence so metadata update is skipped
var existingCanonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0003", withSourceEdge: true);
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingCanonical);
_storeMock
.Setup(x => x.SourceEdgeExistsAsync(TestCanonicalId, TestSourceId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var service = CreateService();
var advisory = CreateRawAdvisory("CVE-2025-0003");
// Act
var result = await service.IngestAsync(TestSource, advisory);
// Assert
result.Decision.Should().Be(MergeDecision.Merged);
result.CanonicalId.Should().Be(TestCanonicalId);
result.SourceEdgeId.Should().Be(TestEdgeId);
}
[Fact]
public async Task IngestAsync_AddsSourceEdge_ForMergedAdvisory()
{
// Arrange
var existingCanonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0004");
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingCanonical);
_storeMock
.Setup(x => x.SourceEdgeExistsAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var service = CreateService();
var advisory = CreateRawAdvisory("CVE-2025-0004", sourceAdvisoryId: "NVD-2025-0004");
// Act
await service.IngestAsync(TestSource, advisory);
// Assert
_storeMock.Verify(x => x.AddSourceEdgeAsync(
It.Is<AddSourceEdgeRequest>(r =>
r.CanonicalId == TestCanonicalId &&
r.SourceId == TestSourceId &&
r.SourceAdvisoryId == "NVD-2025-0004"),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region IngestAsync - Duplicate Detection
[Fact]
public async Task IngestAsync_ReturnsDuplicate_WhenSourceEdgeExists()
{
// Arrange
var existingCanonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0005");
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingCanonical);
_storeMock
.Setup(x => x.SourceEdgeExistsAsync(TestCanonicalId, TestSourceId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = CreateService();
var advisory = CreateRawAdvisory("CVE-2025-0005");
// Act
var result = await service.IngestAsync(TestSource, advisory);
// Assert
result.Decision.Should().Be(MergeDecision.Duplicate);
result.CanonicalId.Should().Be(TestCanonicalId);
result.SourceEdgeId.Should().BeNull();
// Should not add source edge
_storeMock.Verify(x => x.AddSourceEdgeAsync(
It.IsAny<AddSourceEdgeRequest>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
#region IngestAsync - DSSE Signing
[Fact]
public async Task IngestAsync_SignsSourceEdge_WhenSignerAvailable()
{
// Arrange
var signatureRef = Guid.NewGuid();
var envelope = new DsseEnvelope
{
PayloadType = "application/vnd.stellaops.advisory.v1+json",
Payload = "eyJhZHZpc29yeSI6InRlc3QifQ==",
Signatures = [new DsseSignature { KeyId = "test-key", Sig = "abc123" }]
};
_signerMock
.Setup(x => x.SignAsync(It.IsAny<SourceEdgeSigningRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SourceEdgeSigningResult.Signed(envelope, signatureRef));
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateServiceWithSigner();
var advisory = CreateRawAdvisory("CVE-2025-0006", rawPayloadJson: "{\"cve\":\"CVE-2025-0006\"}");
// Act
var result = await service.IngestAsync(TestSource, advisory);
// Assert
result.SignatureRef.Should().Be(signatureRef);
_storeMock.Verify(x => x.AddSourceEdgeAsync(
It.Is<AddSourceEdgeRequest>(r =>
r.DsseEnvelopeJson != null &&
r.DsseEnvelopeJson.Contains("PayloadType")),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task IngestAsync_ContinuesWithoutSignature_WhenSignerFails()
{
// Arrange
_signerMock
.Setup(x => x.SignAsync(It.IsAny<SourceEdgeSigningRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SourceEdgeSigningResult.Failed("Signing service unavailable"));
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateServiceWithSigner();
var advisory = CreateRawAdvisory("CVE-2025-0007", rawPayloadJson: "{\"cve\":\"CVE-2025-0007\"}");
// Act
var result = await service.IngestAsync(TestSource, advisory);
// Assert
result.Decision.Should().Be(MergeDecision.Created);
result.SignatureRef.Should().BeNull();
// Should still add source edge without DSSE
_storeMock.Verify(x => x.AddSourceEdgeAsync(
It.Is<AddSourceEdgeRequest>(r => r.DsseEnvelopeJson == null),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task IngestAsync_SkipsSigning_WhenNoRawPayload()
{
// Arrange
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateServiceWithSigner();
var advisory = CreateRawAdvisory("CVE-2025-0008", rawPayloadJson: null);
// Act
await service.IngestAsync(TestSource, advisory);
// Assert - signer should not be called
_signerMock.Verify(x => x.SignAsync(
It.IsAny<SourceEdgeSigningRequest>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task IngestAsync_WorksWithoutSigner()
{
// Arrange - service without signer
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateService(); // No signer
var advisory = CreateRawAdvisory("CVE-2025-0009", rawPayloadJson: "{\"cve\":\"CVE-2025-0009\"}");
// Act
var result = await service.IngestAsync(TestSource, advisory);
// Assert
result.Decision.Should().Be(MergeDecision.Created);
result.SignatureRef.Should().BeNull();
}
#endregion
#region IngestAsync - Source Precedence
[Theory]
[InlineData("vendor", 10)]
[InlineData("redhat", 20)]
[InlineData("debian", 20)]
[InlineData("osv", 30)]
[InlineData("ghsa", 35)]
[InlineData("nvd", 40)]
[InlineData("unknown", 100)]
public async Task IngestAsync_AssignsCorrectPrecedence_BySource(string source, int expectedRank)
{
// Arrange
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateService();
var advisory = CreateRawAdvisory("CVE-2025-0010");
// Act
await service.IngestAsync(source, advisory);
// Assert
_storeMock.Verify(x => x.AddSourceEdgeAsync(
It.Is<AddSourceEdgeRequest>(r => r.PrecedenceRank == expectedRank),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region IngestBatchAsync
[Fact]
public async Task IngestBatchAsync_ProcessesAllAdvisories()
{
// Arrange
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
var service = CreateService();
var advisories = new[]
{
CreateRawAdvisory("CVE-2025-0011"),
CreateRawAdvisory("CVE-2025-0012"),
CreateRawAdvisory("CVE-2025-0013")
};
// Act
var results = await service.IngestBatchAsync(TestSource, advisories);
// Assert
results.Should().HaveCount(3);
results.Should().OnlyContain(r => r.Decision == MergeDecision.Created);
}
[Fact]
public async Task IngestBatchAsync_ContinuesOnError_ReturnsConflictForFailed()
{
// Arrange
var callCount = 0;
_storeMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.Returns(() =>
{
callCount++;
if (callCount == 2)
throw new InvalidOperationException("Simulated failure");
return Task.FromResult(TestCanonicalId);
});
var service = CreateService();
var advisories = new[]
{
CreateRawAdvisory("CVE-2025-0014"),
CreateRawAdvisory("CVE-2025-0015"),
CreateRawAdvisory("CVE-2025-0016")
};
// Act
var results = await service.IngestBatchAsync(TestSource, advisories);
// Assert
results.Should().HaveCount(3);
results[0].Decision.Should().Be(MergeDecision.Created);
results[1].Decision.Should().Be(MergeDecision.Conflict);
results[1].ConflictReason.Should().Contain("Simulated failure");
results[2].Decision.Should().Be(MergeDecision.Created);
}
#endregion
#region Query Operations - GetByIdAsync
[Fact]
public async Task GetByIdAsync_DelegatesToStore()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0018");
_storeMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService();
// Act
var result = await service.GetByIdAsync(TestCanonicalId);
// Assert
result.Should().Be(canonical);
_storeMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
{
// Arrange
_storeMock
.Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
var service = CreateService();
// Act
var result = await service.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
#endregion
#region Query Operations - GetByMergeHashAsync
[Fact]
public async Task GetByMergeHashAsync_DelegatesToStore()
{
// Arrange
var canonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0019");
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
var service = CreateService();
// Act
var result = await service.GetByMergeHashAsync(TestMergeHash);
// Assert
result.Should().Be(canonical);
_storeMock.Verify(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByMergeHashAsync_ThrowsArgumentException_WhenHashIsNullOrEmpty()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetByMergeHashAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByMergeHashAsync(""));
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByMergeHashAsync(" "));
}
#endregion
#region Query Operations - GetByCveAsync
[Fact]
public async Task GetByCveAsync_DelegatesToStore()
{
// Arrange
var canonicals = new List<CanonicalAdvisory>
{
CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0020"),
CreateCanonicalAdvisory(Guid.NewGuid(), "CVE-2025-0020")
};
_storeMock
.Setup(x => x.GetByCveAsync("CVE-2025-0020", It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
var service = CreateService();
// Act
var result = await service.GetByCveAsync("CVE-2025-0020");
// Assert
result.Should().HaveCount(2);
_storeMock.Verify(x => x.GetByCveAsync("CVE-2025-0020", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByCveAsync_ReturnsEmptyList_WhenNoResults()
{
// Arrange
_storeMock
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CanonicalAdvisory>());
var service = CreateService();
// Act
var result = await service.GetByCveAsync("CVE-2025-9999");
// Assert
result.Should().BeEmpty();
}
[Fact]
public async Task GetByCveAsync_ThrowsArgumentException_WhenCveIsNullOrEmpty()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetByCveAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByCveAsync(""));
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByCveAsync(" "));
}
#endregion
#region Query Operations - GetByArtifactAsync
[Fact]
public async Task GetByArtifactAsync_DelegatesToStore()
{
// Arrange
const string artifactKey = "pkg:npm/lodash@4";
var canonicals = new List<CanonicalAdvisory>
{
CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0021")
};
_storeMock
.Setup(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
var service = CreateService();
// Act
var result = await service.GetByArtifactAsync(artifactKey);
// Assert
result.Should().HaveCount(1);
_storeMock.Verify(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetByArtifactAsync_ThrowsArgumentException_WhenArtifactKeyIsNullOrEmpty()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetByArtifactAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByArtifactAsync(""));
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByArtifactAsync(" "));
}
#endregion
#region Query Operations - QueryAsync
[Fact]
public async Task QueryAsync_DelegatesToStore()
{
// Arrange
var options = new CanonicalQueryOptions { Severity = "critical", Limit = 10 };
var pagedResult = new PagedResult<CanonicalAdvisory>
{
Items = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0022") },
TotalCount = 1,
Offset = 0,
Limit = 10
};
_storeMock
.Setup(x => x.QueryAsync(options, It.IsAny<CancellationToken>()))
.ReturnsAsync(pagedResult);
var service = CreateService();
// Act
var result = await service.QueryAsync(options);
// Assert
result.Items.Should().HaveCount(1);
result.TotalCount.Should().Be(1);
_storeMock.Verify(x => x.QueryAsync(options, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task QueryAsync_ThrowsArgumentNullException_WhenOptionsIsNull()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentNullException>(() => service.QueryAsync(null!));
}
#endregion
#region Status Operations - UpdateStatusAsync
[Fact]
public async Task UpdateStatusAsync_DelegatesToStore()
{
// Arrange
var service = CreateService();
// Act
await service.UpdateStatusAsync(TestCanonicalId, CanonicalStatus.Withdrawn);
// Assert
_storeMock.Verify(x => x.UpdateStatusAsync(
TestCanonicalId,
CanonicalStatus.Withdrawn,
It.IsAny<CancellationToken>()),
Times.Once);
}
[Theory]
[InlineData(CanonicalStatus.Active)]
[InlineData(CanonicalStatus.Stub)]
[InlineData(CanonicalStatus.Withdrawn)]
public async Task UpdateStatusAsync_AcceptsAllStatusValues(CanonicalStatus status)
{
// Arrange
var service = CreateService();
// Act
await service.UpdateStatusAsync(TestCanonicalId, status);
// Assert
_storeMock.Verify(x => x.UpdateStatusAsync(
TestCanonicalId,
status,
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Status Operations - DegradeToStubsAsync
[Fact]
public async Task DegradeToStubsAsync_ReturnsZero_NotYetImplemented()
{
// Arrange
var service = CreateService();
// Act
var result = await service.DegradeToStubsAsync(0.001);
// Assert - currently returns 0 as not implemented
result.Should().Be(0);
}
#endregion
#region Validation
[Fact]
public async Task IngestAsync_ThrowsArgumentException_WhenSourceIsNullOrEmpty()
{
var service = CreateService();
var advisory = CreateRawAdvisory("CVE-2025-0017");
// ArgumentNullException is thrown for null
await Assert.ThrowsAsync<ArgumentNullException>(() =>
service.IngestAsync(null!, advisory));
// ArgumentException is thrown for empty/whitespace
await Assert.ThrowsAsync<ArgumentException>(() =>
service.IngestAsync("", advisory));
await Assert.ThrowsAsync<ArgumentException>(() =>
service.IngestAsync(" ", advisory));
}
[Fact]
public async Task IngestAsync_ThrowsArgumentNullException_WhenAdvisoryIsNull()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
service.IngestAsync(TestSource, null!));
}
#endregion
#region Helpers
private CanonicalAdvisoryService CreateService() =>
new(_storeMock.Object, _hashCalculatorMock.Object, _logger);
private CanonicalAdvisoryService CreateServiceWithSigner() =>
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, _signerMock.Object);
private static RawAdvisory CreateRawAdvisory(
string cve,
string? sourceAdvisoryId = null,
string? affectsKey = null,
IReadOnlyList<string>? weaknesses = null,
string? rawPayloadJson = null)
{
return new RawAdvisory
{
SourceAdvisoryId = sourceAdvisoryId ?? $"ADV-{cve}",
Cve = cve,
AffectsKey = affectsKey ?? "pkg:npm/example@1",
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.3\"}",
Weaknesses = weaknesses ?? [],
Severity = "high",
Title = $"Test Advisory for {cve}",
Summary = "Test summary",
RawPayloadJson = rawPayloadJson,
FetchedAt = DateTimeOffset.UtcNow
};
}
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id, string cve, bool withSourceEdge = false)
{
var sourceEdges = withSourceEdge
? new List<SourceEdge>
{
new SourceEdge
{
Id = Guid.NewGuid(),
SourceName = "vendor",
SourceAdvisoryId = $"VENDOR-{cve}",
SourceDocHash = "sha256:existing",
PrecedenceRank = 10, // High precedence
FetchedAt = DateTimeOffset.UtcNow
}
}
: new List<SourceEdge>();
return new CanonicalAdvisory
{
Id = id,
Cve = cve,
AffectsKey = "pkg:npm/example@1",
MergeHash = TestMergeHash,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
SourceEdges = sourceEdges
};
}
#endregion
}

View File

@@ -16,5 +16,6 @@
<!-- Test packages inherited from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,267 @@
{
"corpus": "dedup-alias-collision",
"version": "1.0.0",
"description": "Test corpus for GHSA to CVE alias mapping edge cases",
"items": [
{
"id": "GHSA-CVE-same-package",
"description": "GHSA and CVE for same package should have same hash",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-abc1-def2-ghi3",
"cve": "CVE-2024-1001",
"affects_key": "pkg:npm/express@4.18.0",
"version_range": "<4.18.2",
"weaknesses": ["CWE-400"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1001",
"cve": "cve-2024-1001",
"affects_key": "pkg:NPM/express@4.18.0",
"version_range": "<4.18.2",
"weaknesses": ["cwe-400"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization produces identical identity"
}
},
{
"id": "GHSA-CVE-different-package",
"description": "GHSA and CVE for different packages should differ",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-xyz1-uvw2-rst3",
"cve": "CVE-2024-1002",
"affects_key": "pkg:npm/lodash@4.17.0",
"version_range": "<4.17.21",
"weaknesses": ["CWE-1321"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1002",
"cve": "CVE-2024-1002",
"affects_key": "pkg:npm/underscore@1.13.0",
"version_range": "<1.13.6",
"weaknesses": ["CWE-1321"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different packages produce different hashes"
}
},
{
"id": "PYSEC-CVE-mapping",
"description": "PyPI security advisory with CVE mapping",
"sources": [
{
"source": "osv",
"advisory_id": "PYSEC-2024-001",
"cve": "CVE-2024-1003",
"affects_key": "pkg:pypi/django@4.2.0",
"version_range": "<4.2.7",
"weaknesses": ["CWE-79"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1003",
"cve": "CVE-2024-1003",
"affects_key": "pkg:PYPI/Django@4.2.0",
"version_range": "<4.2.7",
"weaknesses": ["CWE-79"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization for PyPI package names"
}
},
{
"id": "RUSTSEC-CVE-mapping",
"description": "Rust security advisory with CVE mapping",
"sources": [
{
"source": "osv",
"advisory_id": "RUSTSEC-2024-0001",
"cve": "CVE-2024-1004",
"affects_key": "pkg:cargo/tokio@1.28.0",
"version_range": "<1.28.2",
"weaknesses": ["CWE-416"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1004",
"cve": "cve-2024-1004",
"affects_key": "pkg:CARGO/Tokio@1.28.0",
"version_range": "< 1.28.2",
"weaknesses": ["cwe-416"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization for CVE, PURL, and CWE produces same identity"
}
},
{
"id": "GO-CVE-scoped-package",
"description": "Go advisory with module path normalization",
"sources": [
{
"source": "osv",
"advisory_id": "GO-2024-0001",
"cve": "CVE-2024-1005",
"affects_key": "pkg:golang/github.com/example/module@v1.0.0",
"version_range": "<v1.2.0",
"weaknesses": ["CWE-94"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1005",
"cve": "CVE-2024-1005",
"affects_key": "pkg:golang/github.com/Example/Module@v1.0.0",
"version_range": "<v1.2.0",
"weaknesses": ["CWE-94"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Go module paths are normalized to lowercase"
}
},
{
"id": "CVE-reserved-no-data",
"description": "CVE reserved but no vulnerability data yet",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-1006",
"cve": "CVE-2024-1006",
"affects_key": "pkg:npm/test@1.0.0",
"version_range": "*",
"weaknesses": []
},
{
"source": "github",
"advisory_id": "GHSA-test-test-test",
"cve": "CVE-2024-1006",
"affects_key": "pkg:npm/test@1.0.0",
"version_range": "all",
"weaknesses": []
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Wildcard version ranges normalize to same value"
}
},
{
"id": "OSV-multi-ecosystem",
"description": "OSV advisory affecting multiple ecosystems",
"sources": [
{
"source": "osv",
"advisory_id": "OSV-2024-001-npm",
"cve": "CVE-2024-1007",
"affects_key": "pkg:npm/shared-lib@1.0.0",
"version_range": "<1.5.0",
"weaknesses": ["CWE-20"]
},
{
"source": "osv",
"advisory_id": "OSV-2024-001-pypi",
"cve": "CVE-2024-1007",
"affects_key": "pkg:pypi/shared-lib@1.0.0",
"version_range": "<1.5.0",
"weaknesses": ["CWE-20"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different ecosystems (npm vs pypi) produce different hashes"
}
},
{
"id": "GHSA-CVE-partial-cwe",
"description": "GHSA has more CWEs than CVE",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-full-cwe-list",
"cve": "CVE-2024-1008",
"affects_key": "pkg:npm/vuln-pkg@1.0.0",
"version_range": "<1.1.0",
"weaknesses": ["CWE-79", "CWE-89", "CWE-94"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1008",
"cve": "CVE-2024-1008",
"affects_key": "pkg:npm/vuln-pkg@1.0.0",
"version_range": "<1.1.0",
"weaknesses": ["CWE-79"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different CWE sets produce different hashes"
}
},
{
"id": "GHSA-no-CVE-yet",
"description": "GHSA published before CVE assignment",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-pend-cve-asn",
"cve": "CVE-2024-1009",
"affects_key": "pkg:npm/new-vuln@2.0.0",
"version_range": "<2.0.5",
"weaknesses": ["CWE-352"]
},
{
"source": "github",
"advisory_id": "GHSA-pend-cve-asn",
"cve": "cve-2024-1009",
"affects_key": "pkg:NPM/new-vuln@2.0.0",
"version_range": "<2.0.5",
"weaknesses": ["cwe-352"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same GHSA with case variations produces same hash"
}
},
{
"id": "NuGet-GHSA-CVE",
"description": "NuGet package with GHSA and CVE",
"sources": [
{
"source": "github",
"advisory_id": "GHSA-nuget-test-001",
"cve": "CVE-2024-1010",
"affects_key": "pkg:nuget/Newtonsoft.Json@13.0.0",
"version_range": "<13.0.3",
"weaknesses": ["CWE-502"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-1010",
"cve": "CVE-2024-1010",
"affects_key": "pkg:NUGET/newtonsoft.json@13.0.0",
"version_range": "<13.0.3",
"weaknesses": ["CWE-502"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "NuGet package names are case-insensitive"
}
}
]
}

View File

@@ -0,0 +1,281 @@
{
"corpus": "dedup-backport-variants",
"version": "1.0.0",
"description": "Test corpus for merge hash deduplication with Alpine/SUSE backport variants",
"items": [
{
"id": "CVE-2024-0001-openssl-alpine-backport",
"description": "Alpine backport with upstream commit reference",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-001",
"cve": "CVE-2024-0001",
"affects_key": "pkg:apk/alpine/openssl@1.1.1w",
"version_range": "<1.1.1w-r1",
"weaknesses": ["CWE-476"],
"patch_lineage": "https://github.com/openssl/openssl/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-001",
"cve": "CVE-2024-0001",
"affects_key": "pkg:apk/alpine/openssl@1.1.1w",
"version_range": "<1.1.1w-r1",
"weaknesses": ["CWE-476"],
"patch_lineage": "backport of a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same SHA extracted from both URL and backport reference"
}
},
{
"id": "CVE-2024-0002-curl-suse-backport",
"description": "SUSE backport with PATCH-ID format",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0001-1",
"cve": "CVE-2024-0002",
"affects_key": "pkg:rpm/suse/curl@7.79.1",
"version_range": "<7.79.1-150400.5.36.1",
"weaknesses": ["CWE-120"],
"patch_lineage": "PATCH-12345"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0001-1",
"cve": "CVE-2024-0002",
"affects_key": "pkg:rpm/suse/curl@7.79.1",
"version_range": "<7.79.1-150400.5.36.1",
"weaknesses": ["CWE-120"],
"patch_lineage": "patch-12345"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "PATCH-ID is case-normalized to uppercase"
}
},
{
"id": "CVE-2024-0003-nginx-different-backports",
"description": "Same CVE with different backport lineages should differ",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-002",
"cve": "CVE-2024-0003",
"affects_key": "pkg:apk/alpine/nginx@1.24.0",
"version_range": "<1.24.0-r7",
"weaknesses": ["CWE-400"],
"patch_lineage": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0002-1",
"cve": "CVE-2024-0003",
"affects_key": "pkg:rpm/suse/nginx@1.24.0",
"version_range": "<1.24.0-150400.3.7.1",
"weaknesses": ["CWE-400"],
"patch_lineage": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different package ecosystems and different patch lineages"
}
},
{
"id": "CVE-2024-0004-busybox-no-lineage",
"description": "Backport without lineage info should still match on case normalization",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-003",
"cve": "CVE-2024-0004",
"affects_key": "pkg:apk/alpine/busybox@1.36.1",
"version_range": "<1.36.1-r6",
"weaknesses": ["CWE-78"]
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-003",
"cve": "cve-2024-0004",
"affects_key": "pkg:APK/alpine/busybox@1.36.1",
"version_range": "<1.36.1-r6",
"weaknesses": ["cwe-78"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization produces identical identity when no patch lineage"
}
},
{
"id": "CVE-2024-0005-musl-abbreviated-sha",
"description": "Abbreviated vs full SHA should normalize differently",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-004",
"cve": "CVE-2024-0005",
"affects_key": "pkg:apk/alpine/musl@1.2.4",
"version_range": "<1.2.4-r2",
"weaknesses": ["CWE-119"],
"patch_lineage": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-004",
"cve": "CVE-2024-0005",
"affects_key": "pkg:apk/alpine/musl@1.2.4",
"version_range": "<1.2.4-r2",
"weaknesses": ["CWE-119"],
"patch_lineage": "commit a1b2c3d"
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Full SHA vs abbreviated SHA produce different normalized lineages"
}
},
{
"id": "CVE-2024-0006-zlib-multiple-shas",
"description": "Multiple SHAs in lineage - should extract first full SHA",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0003-1",
"cve": "CVE-2024-0006",
"affects_key": "pkg:rpm/suse/zlib@1.2.13",
"version_range": "<1.2.13-150500.4.3.1",
"weaknesses": ["CWE-787"],
"patch_lineage": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0003-1",
"cve": "CVE-2024-0006",
"affects_key": "pkg:rpm/suse/zlib@1.2.13",
"version_range": "<1.2.13-150500.4.3.1",
"weaknesses": ["CWE-787"],
"patch_lineage": "fixes include f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2 and abc1234"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Full SHA is extracted and normalized from both lineage descriptions"
}
},
{
"id": "CVE-2024-0007-libpng-distro-versions",
"description": "Same upstream fix with different notation but same semantic meaning",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-005",
"cve": "CVE-2024-0007",
"affects_key": "pkg:apk/alpine/libpng@1.6.40",
"version_range": "<1.6.40-r0",
"weaknesses": ["CWE-125"]
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-005",
"cve": "cve-2024-0007",
"affects_key": "pkg:APK/alpine/libpng@1.6.40",
"version_range": "< 1.6.40-r0",
"weaknesses": ["cwe-125"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Case normalization and whitespace trimming produce identical identity"
}
},
{
"id": "CVE-2024-0008-git-github-url",
"description": "GitHub vs GitLab commit URL extraction",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0004-1",
"cve": "CVE-2024-0008",
"affects_key": "pkg:rpm/suse/git@2.42.0",
"version_range": "<2.42.0-150500.3.6.1",
"weaknesses": ["CWE-78"],
"patch_lineage": "https://github.com/git/git/commit/abc123def456abc123def456abc123def456abc1"
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0004-1",
"cve": "CVE-2024-0008",
"affects_key": "pkg:rpm/suse/git@2.42.0",
"version_range": "<2.42.0-150500.3.6.1",
"weaknesses": ["CWE-78"],
"patch_lineage": "https://gitlab.com/git/git/commit/abc123def456abc123def456abc123def456abc1"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Both GitHub and GitLab URL patterns extract same commit SHA"
}
},
{
"id": "CVE-2024-0009-expat-unrecognized-lineage",
"description": "Unrecognized patch lineage format returns null",
"sources": [
{
"source": "alpine",
"advisory_id": "ALPINE-2024-006",
"cve": "CVE-2024-0009",
"affects_key": "pkg:apk/alpine/expat@2.5.0",
"version_range": "<2.5.0-r1",
"weaknesses": ["CWE-611"],
"patch_lineage": "some random text without sha"
},
{
"source": "alpine",
"advisory_id": "ALPINE-2024-006",
"cve": "CVE-2024-0009",
"affects_key": "pkg:apk/alpine/expat@2.5.0",
"version_range": "<2.5.0-r1",
"weaknesses": ["CWE-611"],
"patch_lineage": "another unrecognized format"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Both unrecognized lineages normalize to null, producing same hash"
}
},
{
"id": "CVE-2024-0010-sqlite-fixed-notation",
"description": "Fixed version notation normalization",
"sources": [
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0005-1",
"cve": "CVE-2024-0010",
"affects_key": "pkg:rpm/suse/sqlite3@3.43.0",
"version_range": "fixed: 3.43.2",
"weaknesses": ["CWE-476"]
},
{
"source": "suse",
"advisory_id": "SUSE-SU-2024:0005-1",
"cve": "CVE-2024-0010",
"affects_key": "pkg:rpm/suse/sqlite3@3.43.0",
"version_range": ">=3.43.2",
"weaknesses": ["CWE-476"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "fixed: notation normalizes to >= comparison"
}
}
]
}

View File

@@ -0,0 +1,269 @@
{
"corpus": "dedup-debian-rhel-cve-2024",
"version": "1.0.0",
"description": "Test corpus for merge hash deduplication across Debian and RHEL sources",
"items": [
{
"id": "CVE-2024-1234-curl",
"description": "Same curl CVE from Debian and RHEL - should produce same identity hash for same package",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5678-1",
"cve": "CVE-2024-1234",
"affects_key": "pkg:deb/debian/curl@7.68.0",
"version_range": "<7.68.0-1+deb10u2",
"weaknesses": ["CWE-120"]
},
{
"source": "redhat",
"advisory_id": "RHSA-2024:1234",
"cve": "CVE-2024-1234",
"affects_key": "pkg:deb/debian/curl@7.68.0",
"version_range": "<7.68.0-1+deb10u2",
"weaknesses": ["cwe-120"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same CVE, same package identity, same version range, same CWE (case-insensitive)"
}
},
{
"id": "CVE-2024-2345-openssl",
"description": "Same OpenSSL CVE from Debian and RHEL with different package identifiers",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5680-1",
"cve": "CVE-2024-2345",
"affects_key": "pkg:deb/debian/openssl@1.1.1n",
"version_range": "<1.1.1n-0+deb11u5",
"weaknesses": ["CWE-200", "CWE-326"]
},
{
"source": "redhat",
"advisory_id": "RHSA-2024:2345",
"cve": "cve-2024-2345",
"affects_key": "pkg:rpm/redhat/openssl@1.1.1k",
"version_range": "<1.1.1k-12.el8_9",
"weaknesses": ["CWE-326", "CWE-200"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different package identifiers (deb vs rpm), so different merge hash despite same CVE"
}
},
{
"id": "CVE-2024-3456-nginx",
"description": "Same nginx CVE with normalized version ranges",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5681-1",
"cve": "CVE-2024-3456",
"affects_key": "pkg:deb/debian/nginx@1.22.0",
"version_range": "[1.0.0, 1.22.1)",
"weaknesses": ["CWE-79"]
},
{
"source": "debian_tracker",
"advisory_id": "CVE-2024-3456",
"cve": "CVE-2024-3456",
"affects_key": "pkg:deb/debian/nginx@1.22.0",
"version_range": ">=1.0.0,<1.22.1",
"weaknesses": ["CWE-79"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Same CVE, same package, version ranges normalize to same format"
}
},
{
"id": "CVE-2024-4567-log4j",
"description": "Different CVEs for same package should have different hash",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-4567",
"cve": "CVE-2024-4567",
"affects_key": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0",
"version_range": "<2.17.1",
"weaknesses": ["CWE-502"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-4568",
"cve": "CVE-2024-4568",
"affects_key": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0",
"version_range": "<2.17.1",
"weaknesses": ["CWE-502"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different CVEs, even with same package and version range"
}
},
{
"id": "CVE-2024-5678-postgres",
"description": "Same CVE with different CWEs should have different hash",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-5678",
"cve": "CVE-2024-5678",
"affects_key": "pkg:generic/postgresql@15.0",
"version_range": "<15.4",
"weaknesses": ["CWE-89"]
},
{
"source": "vendor",
"advisory_id": "CVE-2024-5678",
"cve": "CVE-2024-5678",
"affects_key": "pkg:generic/postgresql@15.0",
"version_range": "<15.4",
"weaknesses": ["CWE-89", "CWE-94"]
}
],
"expected": {
"same_merge_hash": false,
"rationale": "Different CWE sets change the identity"
}
},
{
"id": "CVE-2024-6789-python",
"description": "Same CVE with PURL qualifier stripping",
"sources": [
{
"source": "pypi",
"advisory_id": "PYSEC-2024-001",
"cve": "CVE-2024-6789",
"affects_key": "pkg:pypi/requests@2.28.0?arch=x86_64",
"version_range": "<2.28.2",
"weaknesses": ["CWE-400"]
},
{
"source": "osv",
"advisory_id": "CVE-2024-6789",
"cve": "CVE-2024-6789",
"affects_key": "pkg:pypi/requests@2.28.0",
"version_range": "<2.28.2",
"weaknesses": ["CWE-400"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "arch qualifier is stripped during normalization, so packages are identical"
}
},
{
"id": "CVE-2024-7890-npm",
"description": "Same CVE with scoped npm package - case normalization",
"sources": [
{
"source": "npm",
"advisory_id": "GHSA-abc1-def2-ghi3",
"cve": "CVE-2024-7890",
"affects_key": "pkg:npm/@angular/core@14.0.0",
"version_range": "<14.2.0",
"weaknesses": ["CWE-79"]
},
{
"source": "nvd",
"advisory_id": "CVE-2024-7890",
"cve": "cve-2024-7890",
"affects_key": "pkg:NPM/@Angular/CORE@14.0.0",
"version_range": "<14.2.0",
"weaknesses": ["cwe-79"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "PURL type/namespace/name case normalization produces same identity"
}
},
{
"id": "CVE-2024-8901-redis",
"description": "Same CVE with CPE identifier",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-8901",
"cve": "CVE-2024-8901",
"affects_key": "cpe:2.3:a:redis:redis:7.0.0:*:*:*:*:*:*:*",
"version_range": "<7.0.12",
"weaknesses": ["CWE-416"]
},
{
"source": "vendor",
"advisory_id": "CVE-2024-8901",
"cve": "CVE-2024-8901",
"affects_key": "CPE:2.3:A:Redis:REDIS:7.0.0:*:*:*:*:*:*:*",
"version_range": "<7.0.12",
"weaknesses": ["CWE-416"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "CPE normalization lowercases all components"
}
},
{
"id": "CVE-2024-9012-kernel",
"description": "Same CVE with CPE 2.2 vs 2.3 format",
"sources": [
{
"source": "nvd",
"advisory_id": "CVE-2024-9012",
"cve": "CVE-2024-9012",
"affects_key": "cpe:/o:linux:linux_kernel:5.15",
"version_range": "<5.15.120",
"weaknesses": ["CWE-416"]
},
{
"source": "vendor",
"advisory_id": "CVE-2024-9012",
"cve": "CVE-2024-9012",
"affects_key": "cpe:2.3:o:linux:linux_kernel:5.15:*:*:*:*:*:*:*",
"version_range": "<5.15.120",
"weaknesses": ["CWE-416"]
}
],
"expected": {
"same_merge_hash": true,
"rationale": "CPE 2.2 is converted to CPE 2.3 format during normalization"
}
},
{
"id": "CVE-2024-1357-glibc",
"description": "Same CVE with patch lineage differentiation",
"sources": [
{
"source": "debian",
"advisory_id": "DSA-5690-1",
"cve": "CVE-2024-1357",
"affects_key": "pkg:deb/debian/glibc@2.31",
"version_range": "<2.31-13+deb11u7",
"weaknesses": ["CWE-787"],
"patch_lineage": "https://github.com/glibc/glibc/commit/abc123def456abc123def456abc123def456abc1"
},
{
"source": "debian",
"advisory_id": "DSA-5690-1",
"cve": "CVE-2024-1357",
"affects_key": "pkg:deb/debian/glibc@2.31",
"version_range": "<2.31-13+deb11u7",
"weaknesses": ["CWE-787"],
"patch_lineage": "commit abc123def456abc123def456abc123def456abc1"
}
],
"expected": {
"same_merge_hash": true,
"rationale": "Patch lineage normalization extracts SHA from both URL and plain commit reference"
}
}
]
}

View File

@@ -0,0 +1,244 @@
// -----------------------------------------------------------------------------
// CpeNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for CpeNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class CpeNormalizerTests
{
private readonly CpeNormalizer _normalizer = CpeNormalizer.Instance;
#region CPE 2.3 Normalization
[Fact]
public void Normalize_ValidCpe23_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23Uppercase_ReturnsLowercase()
{
var result = _normalizer.Normalize("CPE:2.3:A:VENDOR:PRODUCT:1.0:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23MixedCase_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:2.3:a:Apache:Log4j:2.14.0:*:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23WithAny_ReturnsWildcard()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:ANY:ANY:ANY:ANY:ANY:ANY:ANY:ANY");
Assert.Equal("cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe23WithNa_ReturnsDash()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:1.0:NA:*:*:*:*:*:*");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:-:*:*:*:*:*:*", result);
}
#endregion
#region CPE 2.2 to 2.3 Conversion
[Fact]
public void Normalize_Cpe22Simple_ConvertsToCpe23()
{
var result = _normalizer.Normalize("cpe:/a:vendor:product:1.0");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe22NoVersion_ConvertsToCpe23()
{
var result = _normalizer.Normalize("cpe:/a:vendor:product");
Assert.StartsWith("cpe:2.3:a:vendor:product:", result);
}
[Fact]
public void Normalize_Cpe22WithUpdate_ConvertsToCpe23()
{
var result = _normalizer.Normalize("cpe:/a:vendor:product:1.0:update1");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:update1:*:*:*:*:*:*", result);
}
[Fact]
public void Normalize_Cpe22Uppercase_ConvertsToCpe23Lowercase()
{
var result = _normalizer.Normalize("CPE:/A:VENDOR:PRODUCT:1.0");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
#endregion
#region Part Types
[Theory]
[InlineData("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", "a")] // Application
[InlineData("cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*", "o")] // Operating System
[InlineData("cpe:2.3:h:vendor:product:1.0:*:*:*:*:*:*:*", "h")] // Hardware
public void Normalize_DifferentPartTypes_PreservesPartType(string input, string expectedPart)
{
var result = _normalizer.Normalize(input);
Assert.StartsWith($"cpe:2.3:{expectedPart}:", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null!);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:* ");
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_InvalidCpeFormat_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:invalid:format");
Assert.Equal("cpe:invalid:format", result);
}
[Fact]
public void Normalize_NotCpe_ReturnsLowercase()
{
var result = _normalizer.Normalize("not-a-cpe");
Assert.Equal("not-a-cpe", result);
}
[Fact]
public void Normalize_TooFewComponents_ReturnsLowercase()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor");
Assert.Equal("cpe:2.3:a:vendor", result);
}
#endregion
#region Edge Cases - Empty Components
[Fact]
public void Normalize_EmptyVersion_ReturnsWildcard()
{
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product::*:*:*:*:*:*:*");
Assert.Contains(":*:", result);
}
[Fact]
public void Normalize_EmptyVendor_ReturnsWildcard()
{
var result = _normalizer.Normalize("cpe:2.3:a::product:1.0:*:*:*:*:*:*:*");
Assert.Contains(":*:", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*")]
[InlineData("CPE:2.3:A:VENDOR:PRODUCT:1.0:*:*:*:*:*:*:*")]
[InlineData("cpe:/a:vendor:product:1.0")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "CPE:2.3:A:Apache:LOG4J:2.14.0:*:*:*:*:*:*:*";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void Normalize_Cpe22And23_ProduceSameOutput()
{
var cpe22 = "cpe:/a:apache:log4j:2.14.0";
var cpe23 = "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*";
var result22 = _normalizer.Normalize(cpe22);
var result23 = _normalizer.Normalize(cpe23);
Assert.Equal(result22, result23);
}
#endregion
#region Real-World CPE Formats
[Theory]
[InlineData("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*")]
[InlineData("cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*", "cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*")]
[InlineData("cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*", "cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*")]
public void Normalize_RealWorldCpes_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = CpeNormalizer.Instance;
var instance2 = CpeNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,207 @@
// -----------------------------------------------------------------------------
// CveNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for CveNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class CveNormalizerTests
{
private readonly CveNormalizer _normalizer = CveNormalizer.Instance;
#region Basic Normalization
[Fact]
public void Normalize_ValidUppercase_ReturnsUnchanged()
{
var result = _normalizer.Normalize("CVE-2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_ValidLowercase_ReturnsUppercase()
{
var result = _normalizer.Normalize("cve-2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_MixedCase_ReturnsUppercase()
{
var result = _normalizer.Normalize("Cve-2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" CVE-2024-12345 ");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_JustNumberPart_AddsCvePrefix()
{
var result = _normalizer.Normalize("2024-12345");
Assert.Equal("CVE-2024-12345", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_ShortYear_ReturnsAsIs()
{
// Invalid year format (3 digits) - should return uppercase
var result = _normalizer.Normalize("CVE-202-12345");
Assert.Equal("CVE-202-12345", result);
}
[Fact]
public void Normalize_ShortSequence_ReturnsAsIs()
{
// Invalid sequence (3 digits, min is 4) - should return uppercase
var result = _normalizer.Normalize("CVE-2024-123");
Assert.Equal("CVE-2024-123", result);
}
[Fact]
public void Normalize_NonNumericYear_ReturnsUppercase()
{
var result = _normalizer.Normalize("CVE-XXXX-12345");
Assert.Equal("CVE-XXXX-12345", result);
}
[Fact]
public void Normalize_NonNumericSequence_ReturnsUppercase()
{
var result = _normalizer.Normalize("CVE-2024-ABCDE");
Assert.Equal("CVE-2024-ABCDE", result);
}
[Fact]
public void Normalize_ArbitraryText_ReturnsUppercase()
{
var result = _normalizer.Normalize("some-random-text");
Assert.Equal("SOME-RANDOM-TEXT", result);
}
#endregion
#region Edge Cases - Unicode and Special Characters
[Fact]
public void Normalize_UnicodeWhitespace_ReturnsTrimmed()
{
// Non-breaking space and other unicode whitespace
var result = _normalizer.Normalize("\u00A0CVE-2024-12345\u2003");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_WithNewlines_ReturnsTrimmed()
{
var result = _normalizer.Normalize("\nCVE-2024-12345\r\n");
Assert.Equal("CVE-2024-12345", result);
}
[Fact]
public void Normalize_WithTabs_ReturnsTrimmed()
{
var result = _normalizer.Normalize("\tCVE-2024-12345\t");
Assert.Equal("CVE-2024-12345", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("CVE-2024-12345")]
[InlineData("cve-2024-12345")]
[InlineData("2024-12345")]
[InlineData(" CVE-2024-12345 ")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "cve-2024-99999";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
#endregion
#region Real-World CVE Formats
[Theory]
[InlineData("CVE-2024-1234", "CVE-2024-1234")]
[InlineData("CVE-2024-12345", "CVE-2024-12345")]
[InlineData("CVE-2024-123456", "CVE-2024-123456")]
[InlineData("CVE-2021-44228", "CVE-2021-44228")] // Log4Shell
[InlineData("CVE-2017-5754", "CVE-2017-5754")] // Meltdown
[InlineData("CVE-2014-0160", "CVE-2014-0160")] // Heartbleed
public void Normalize_RealWorldCves_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = CveNormalizer.Instance;
var instance2 = CveNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// CweNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for CweNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class CweNormalizerTests
{
private readonly CweNormalizer _normalizer = CweNormalizer.Instance;
#region Basic Normalization
[Fact]
public void Normalize_SingleCwe_ReturnsUppercase()
{
var result = _normalizer.Normalize(["cwe-79"]);
Assert.Equal("CWE-79", result);
}
[Fact]
public void Normalize_MultipleCwes_ReturnsSortedCommaJoined()
{
var result = _normalizer.Normalize(["CWE-89", "CWE-79"]);
Assert.Equal("CWE-79,CWE-89", result);
}
[Fact]
public void Normalize_MixedCase_ReturnsUppercase()
{
var result = _normalizer.Normalize(["Cwe-79", "cwe-89", "CWE-120"]);
Assert.Equal("CWE-79,CWE-89,CWE-120", result);
}
[Fact]
public void Normalize_WithoutPrefix_AddsPrefix()
{
var result = _normalizer.Normalize(["79", "89"]);
Assert.Equal("CWE-79,CWE-89", result);
}
[Fact]
public void Normalize_MixedPrefixFormats_NormalizesAll()
{
var result = _normalizer.Normalize(["CWE-79", "89", "cwe-120"]);
Assert.Equal("CWE-79,CWE-89,CWE-120", result);
}
#endregion
#region Deduplication
[Fact]
public void Normalize_Duplicates_ReturnsUnique()
{
var result = _normalizer.Normalize(["CWE-79", "CWE-79", "cwe-79"]);
Assert.Equal("CWE-79", result);
}
[Fact]
public void Normalize_DuplicatesWithDifferentCase_ReturnsUnique()
{
var result = _normalizer.Normalize(["CWE-89", "cwe-89", "Cwe-89"]);
Assert.Equal("CWE-89", result);
}
[Fact]
public void Normalize_DuplicatesWithMixedFormats_ReturnsUnique()
{
var result = _normalizer.Normalize(["CWE-79", "79", "cwe-79"]);
Assert.Equal("CWE-79", result);
}
#endregion
#region Sorting
[Fact]
public void Normalize_UnsortedNumbers_ReturnsSortedNumerically()
{
var result = _normalizer.Normalize(["CWE-200", "CWE-79", "CWE-120", "CWE-1"]);
Assert.Equal("CWE-1,CWE-79,CWE-120,CWE-200", result);
}
[Fact]
public void Normalize_LargeNumbers_ReturnsSortedNumerically()
{
var result = _normalizer.Normalize(["CWE-1000", "CWE-100", "CWE-10"]);
Assert.Equal("CWE-10,CWE-100,CWE-1000", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyArray_ReturnsEmpty()
{
var result = _normalizer.Normalize([]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_ArrayWithNulls_ReturnsEmpty()
{
var result = _normalizer.Normalize([null!, null!]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_ArrayWithEmptyStrings_ReturnsEmpty()
{
var result = _normalizer.Normalize(["", " ", string.Empty]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_MixedValidAndEmpty_ReturnsValidOnly()
{
var result = _normalizer.Normalize(["CWE-79", "", null!, "CWE-89", " "]);
Assert.Equal("CWE-79,CWE-89", result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_InvalidFormat_FiltersOut()
{
var result = _normalizer.Normalize(["CWE-79", "not-a-cwe", "CWE-89"]);
Assert.Equal("CWE-79,CWE-89", result);
}
[Fact]
public void Normalize_AllInvalid_ReturnsEmpty()
{
var result = _normalizer.Normalize(["invalid", "not-cwe", "random"]);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_NonNumericSuffix_FiltersOut()
{
var result = _normalizer.Normalize(["CWE-ABC", "CWE-79"]);
Assert.Equal("CWE-79", result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize([" CWE-79 ", " CWE-89 "]);
Assert.Equal("CWE-79,CWE-89", result);
}
#endregion
#region Edge Cases - Unicode
[Fact]
public void Normalize_UnicodeWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(["\u00A0CWE-79\u00A0"]);
Assert.Equal("CWE-79", result);
}
#endregion
#region Determinism
[Fact]
public void Normalize_MultipleRuns_ReturnsSameResult()
{
var input = new[] { "cwe-89", "CWE-79", "120" };
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
var input = new[] { "CWE-200", "cwe-79", "120", "CWE-89" };
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void Normalize_DifferentOrdering_ReturnsSameResult()
{
var input1 = new[] { "CWE-79", "CWE-89", "CWE-120" };
var input2 = new[] { "CWE-120", "CWE-79", "CWE-89" };
var input3 = new[] { "CWE-89", "CWE-120", "CWE-79" };
var result1 = _normalizer.Normalize(input1);
var result2 = _normalizer.Normalize(input2);
var result3 = _normalizer.Normalize(input3);
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
}
#endregion
#region Real-World CWE Formats
[Theory]
[InlineData("CWE-79", "CWE-79")] // XSS
[InlineData("CWE-89", "CWE-89")] // SQL Injection
[InlineData("CWE-120", "CWE-120")] // Buffer Overflow
[InlineData("CWE-200", "CWE-200")] // Information Exposure
[InlineData("CWE-22", "CWE-22")] // Path Traversal
public void Normalize_RealWorldCwes_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize([input]);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = CweNormalizer.Instance;
var instance2 = CweNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,449 @@
// -----------------------------------------------------------------------------
// MergeHashCalculatorTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-012
// Description: Unit tests for MergeHashCalculator - determinism and correctness
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class MergeHashCalculatorTests
{
private readonly MergeHashCalculator _calculator = new();
#region Basic Hash Computation
[Fact]
public void ComputeMergeHash_ValidInput_ReturnsHashWithPrefix()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21"
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
Assert.Equal(71, result.Length); // "sha256:" (7) + 64 hex chars
}
[Fact]
public void ComputeMergeHash_WithAllFields_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21",
VersionRange = "[1.0.0, 2.0.0)",
Weaknesses = ["CWE-79", "CWE-89"],
PatchLineage = "https://github.com/lodash/lodash/commit/abc1234"
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_NullInput_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => _calculator.ComputeMergeHash((MergeHashInput)null!));
}
#endregion
#region Determinism - Same Input = Same Output
[Fact]
public void ComputeMergeHash_SameInput_ReturnsSameHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21",
Weaknesses = ["CWE-79"]
};
var first = _calculator.ComputeMergeHash(input);
var second = _calculator.ComputeMergeHash(input);
var third = _calculator.ComputeMergeHash(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void ComputeMergeHash_Determinism_100Runs()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-99999",
AffectsKey = "pkg:maven/org.apache/commons-lang3@3.12.0",
VersionRange = ">=1.0.0,<2.0.0",
Weaknesses = ["CWE-120", "CWE-200", "CWE-79"],
PatchLineage = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
};
var expected = _calculator.ComputeMergeHash(input);
for (var i = 0; i < 100; i++)
{
var result = _calculator.ComputeMergeHash(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void ComputeMergeHash_NewInstancesProduceSameHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/lodash@4.17.21"
};
var calc1 = new MergeHashCalculator();
var calc2 = new MergeHashCalculator();
var calc3 = new MergeHashCalculator();
var hash1 = calc1.ComputeMergeHash(input);
var hash2 = calc2.ComputeMergeHash(input);
var hash3 = calc3.ComputeMergeHash(input);
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
}
#endregion
#region Normalization Integration
[Fact]
public void ComputeMergeHash_CveNormalization_CaseInsensitive()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var input2 = new MergeHashInput { Cve = "cve-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var input3 = new MergeHashInput { Cve = "Cve-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
var hash3 = _calculator.ComputeMergeHash(input3);
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
}
[Fact]
public void ComputeMergeHash_PurlNormalization_TypeCaseInsensitive()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/lodash@1.0" };
var input2 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:NPM/lodash@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.Equal(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_CweNormalization_OrderIndependent()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-79", "CWE-89", "CWE-120"]
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-120", "CWE-79", "CWE-89"]
};
var input3 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["cwe-89", "CWE-120", "cwe-79"]
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
var hash3 = _calculator.ComputeMergeHash(input3);
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
}
[Fact]
public void ComputeMergeHash_VersionRangeNormalization_EquivalentFormats()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = "[1.0.0, 2.0.0)"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = ">=1.0.0,<2.0.0"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.Equal(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_PatchLineageNormalization_ShaExtraction()
{
// Both inputs contain the same SHA in different formats
// The normalizer extracts "abc1234567" from both
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "commit abc1234567"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "fix abc1234567 applied"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.Equal(hash1, hash2);
}
#endregion
#region Different Inputs = Different Hashes
[Fact]
public void ComputeMergeHash_DifferentCve_DifferentHash()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
var input2 = new MergeHashInput { Cve = "CVE-2024-5678", AffectsKey = "pkg:npm/test@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentPackage_DifferentHash()
{
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/lodash@1.0" };
var input2 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/underscore@1.0" };
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentVersion_DifferentHash()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = "<1.0.0"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = "<2.0.0"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentWeaknesses_DifferentHash()
{
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-79"]
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = ["CWE-89"]
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeMergeHash_DifferentPatchLineage_DifferentHash()
{
// Use full SHA hashes (40 chars) that will be recognized
var input1 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
};
var input2 = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
};
var hash1 = _calculator.ComputeMergeHash(input1);
var hash2 = _calculator.ComputeMergeHash(input2);
Assert.NotEqual(hash1, hash2);
}
#endregion
#region Edge Cases - Optional Fields
[Fact]
public void ComputeMergeHash_NoVersionRange_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
VersionRange = null
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_EmptyWeaknesses_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
Weaknesses = []
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_NoPatchLineage_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0",
PatchLineage = null
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
[Fact]
public void ComputeMergeHash_MinimalInput_ReturnsHash()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0"
};
var result = _calculator.ComputeMergeHash(input);
Assert.StartsWith("sha256:", result);
}
#endregion
#region Cross-Source Deduplication Scenarios
[Fact]
public void ComputeMergeHash_SameCveDifferentDistros_SameHash()
{
// Same CVE from Debian and RHEL should have same merge hash
// when identity components match
var debianInput = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:deb/debian/curl@7.68.0",
VersionRange = "<7.68.0-1",
Weaknesses = ["CWE-120"]
};
var rhelInput = new MergeHashInput
{
Cve = "cve-2024-1234", // Different case
AffectsKey = "pkg:deb/debian/curl@7.68.0", // Same package identity
VersionRange = "[,7.68.0-1)", // Equivalent interval
Weaknesses = ["cwe-120"] // Different case
};
var debianHash = _calculator.ComputeMergeHash(debianInput);
var rhelHash = _calculator.ComputeMergeHash(rhelInput);
// These should produce the same hash after normalization
Assert.Equal(debianHash, rhelHash);
}
#endregion
#region Hash Format Validation
[Fact]
public void ComputeMergeHash_ValidHashFormat()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0"
};
var result = _calculator.ComputeMergeHash(input);
// Should be "sha256:" followed by 64 lowercase hex chars
Assert.Matches(@"^sha256:[0-9a-f]{64}$", result);
}
[Fact]
public void ComputeMergeHash_HashIsLowercase()
{
var input = new MergeHashInput
{
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/test@1.0"
};
var result = _calculator.ComputeMergeHash(input);
var hashPart = result["sha256:".Length..];
Assert.Equal(hashPart.ToLowerInvariant(), hashPart);
}
#endregion
}

View File

@@ -0,0 +1,457 @@
// -----------------------------------------------------------------------------
// MergeHashDeduplicationIntegrationTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-021
// Description: Integration tests validating same CVE from different connectors
// produces identical merge hash when semantically equivalent
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests.Identity;
/// <summary>
/// Integration tests that verify merge hash deduplication behavior
/// when the same CVE is ingested from multiple source connectors.
/// </summary>
public sealed class MergeHashDeduplicationIntegrationTests
{
private readonly MergeHashCalculator _calculator = new();
[Fact]
public void SameCve_FromDebianAndRhel_WithSamePackage_ProducesSameMergeHash()
{
// Arrange - Debian advisory for curl vulnerability
var debianProvenance = new AdvisoryProvenance(
"debian", "dsa", "DSA-5678-1", DateTimeOffset.Parse("2024-02-15T00:00:00Z"));
var debianAdvisory = new Advisory(
"CVE-2024-1234",
"curl - security update",
"Buffer overflow in curl HTTP library",
"en",
DateTimeOffset.Parse("2024-02-10T00:00:00Z"),
DateTimeOffset.Parse("2024-02-15T12:00:00Z"),
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-1234", "DSA-5678-1" },
references: new[]
{
new AdvisoryReference("https://security-tracker.debian.org/tracker/CVE-2024-1234", "advisory", "debian", "Debian tracker", debianProvenance)
},
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Deb,
"pkg:deb/debian/curl@7.68.0",
"linux",
new[]
{
new AffectedVersionRange("semver", null, "7.68.0-1+deb10u2", null, "<7.68.0-1+deb10u2", debianProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { debianProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { debianProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-120", null, null, ImmutableArray.Create(debianProvenance))
});
// Arrange - RHEL advisory for the same curl vulnerability
var rhelProvenance = new AdvisoryProvenance(
"redhat", "rhsa", "RHSA-2024:1234", DateTimeOffset.Parse("2024-02-16T00:00:00Z"));
var rhelAdvisory = new Advisory(
"CVE-2024-1234",
"Moderate: curl security update",
"curl: buffer overflow vulnerability",
"en",
DateTimeOffset.Parse("2024-02-12T00:00:00Z"),
DateTimeOffset.Parse("2024-02-16T08:00:00Z"),
"moderate",
exploitKnown: false,
aliases: new[] { "CVE-2024-1234", "RHSA-2024:1234" },
references: new[]
{
new AdvisoryReference("https://access.redhat.com/errata/RHSA-2024:1234", "advisory", "redhat", "Red Hat errata", rhelProvenance)
},
affectedPackages: new[]
{
// Same logical package, just different distro versioning
new AffectedPackage(
AffectedPackageTypes.Deb,
"pkg:deb/debian/curl@7.68.0",
"linux",
new[]
{
new AffectedVersionRange("semver", null, "7.68.0-1+deb10u2", null, "<7.68.0-1+deb10u2", rhelProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { rhelProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { rhelProvenance },
cwes: new[]
{
// Same CWE but lowercase - should normalize
new AdvisoryWeakness("cwe", "cwe-120", null, null, ImmutableArray.Create(rhelProvenance))
});
// Act
var debianHash = _calculator.ComputeMergeHash(debianAdvisory);
var rhelHash = _calculator.ComputeMergeHash(rhelAdvisory);
// Assert - Same CVE, same package, same version range, same CWE => same hash
Assert.Equal(debianHash, rhelHash);
Assert.StartsWith("sha256:", debianHash);
}
[Fact]
public void SameCve_FromNvdAndGhsa_WithDifferentPackages_ProducesDifferentMergeHash()
{
// Arrange - NVD advisory affecting lodash
var nvdProvenance = new AdvisoryProvenance(
"nvd", "cve", "CVE-2024-5678", DateTimeOffset.Parse("2024-03-01T00:00:00Z"));
var nvdAdvisory = new Advisory(
"CVE-2024-5678",
"Prototype pollution in lodash",
"lodash before 4.17.21 is vulnerable to prototype pollution",
"en",
DateTimeOffset.Parse("2024-02-28T00:00:00Z"),
DateTimeOffset.Parse("2024-03-01T00:00:00Z"),
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-5678" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/lodash@4.17.0",
null,
new[]
{
new AffectedVersionRange("semver", "0", "4.17.21", null, "<4.17.21", nvdProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { nvdProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { nvdProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-1321", null, null, ImmutableArray.Create(nvdProvenance))
});
// Arrange - Same CVE but for underscore (related but different package)
var ghsaProvenance = new AdvisoryProvenance(
"ghsa", "advisory", "GHSA-xyz-abc-123", DateTimeOffset.Parse("2024-03-02T00:00:00Z"));
var ghsaAdvisory = new Advisory(
"CVE-2024-5678",
"Prototype pollution in underscore",
"underscore before 1.13.6 is vulnerable",
"en",
DateTimeOffset.Parse("2024-03-01T00:00:00Z"),
DateTimeOffset.Parse("2024-03-02T00:00:00Z"),
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-5678", "GHSA-xyz-abc-123" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/underscore@1.13.0",
null,
new[]
{
new AffectedVersionRange("semver", "0", "1.13.6", null, "<1.13.6", ghsaProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { ghsaProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-1321", null, null, ImmutableArray.Create(ghsaProvenance))
});
// Act
var nvdHash = _calculator.ComputeMergeHash(nvdAdvisory);
var ghsaHash = _calculator.ComputeMergeHash(ghsaAdvisory);
// Assert - Same CVE but different packages => different hash
Assert.NotEqual(nvdHash, ghsaHash);
}
[Fact]
public void SameCve_WithCaseVariations_ProducesSameMergeHash()
{
// Arrange - Advisory with uppercase identifiers
var upperProvenance = new AdvisoryProvenance(
"nvd", "cve", "CVE-2024-9999", DateTimeOffset.UtcNow);
var upperAdvisory = new Advisory(
"CVE-2024-9999",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-9999" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:NPM/@angular/CORE@14.0.0",
null,
new[]
{
new AffectedVersionRange("semver", null, "14.2.0", null, "<14.2.0", upperProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { upperProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { upperProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(upperProvenance))
});
// Arrange - Same advisory with lowercase identifiers
var lowerProvenance = new AdvisoryProvenance(
"osv", "advisory", "cve-2024-9999", DateTimeOffset.UtcNow);
var lowerAdvisory = new Advisory(
"cve-2024-9999",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "cve-2024-9999" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/@angular/core@14.0.0",
null,
new[]
{
new AffectedVersionRange("semver", null, "14.2.0", null, "<14.2.0", lowerProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { lowerProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { lowerProvenance },
cwes: new[]
{
new AdvisoryWeakness("cwe", "cwe-79", null, null, ImmutableArray.Create(lowerProvenance))
});
// Act
var upperHash = _calculator.ComputeMergeHash(upperAdvisory);
var lowerHash = _calculator.ComputeMergeHash(lowerAdvisory);
// Assert - Case normalization produces identical hash
Assert.Equal(upperHash, lowerHash);
}
[Fact]
public void SameCve_WithDifferentCweSet_ProducesDifferentMergeHash()
{
// Arrange - Advisory with one CWE
var prov1 = new AdvisoryProvenance("nvd", "cve", "CVE-2024-1111", DateTimeOffset.UtcNow);
var advisory1 = new Advisory(
"CVE-2024-1111",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-1111" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/test@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { prov1 })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { prov1 },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(prov1))
});
// Arrange - Same CVE but with additional CWEs
var prov2 = new AdvisoryProvenance("ghsa", "advisory", "CVE-2024-1111", DateTimeOffset.UtcNow);
var advisory2 = new Advisory(
"CVE-2024-1111",
"Test vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2024-1111" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/test@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { prov2 })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { prov2 },
cwes: new[]
{
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(prov2)),
new AdvisoryWeakness("cwe", "CWE-89", null, null, ImmutableArray.Create(prov2))
});
// Act
var hash1 = _calculator.ComputeMergeHash(advisory1);
var hash2 = _calculator.ComputeMergeHash(advisory2);
// Assert - Different CWE sets produce different hashes
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void MultiplePackageAdvisory_ComputesHashFromFirstPackage()
{
// Arrange - Advisory affecting multiple packages
var provenance = new AdvisoryProvenance(
"osv", "advisory", "CVE-2024-MULTI", DateTimeOffset.UtcNow);
var multiPackageAdvisory = new Advisory(
"CVE-2024-MULTI",
"Multi-package vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2024-MULTI" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/first-package@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance }),
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/second-package@2.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
// Arrange - Advisory with only the first package
var singlePackageAdvisory = new Advisory(
"CVE-2024-MULTI",
"Single package vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2024-MULTI" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/first-package@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
// Act
var multiHash = _calculator.ComputeMergeHash(multiPackageAdvisory);
var singleHash = _calculator.ComputeMergeHash(singlePackageAdvisory);
// Assert - Both use first package, so hashes should match
Assert.Equal(multiHash, singleHash);
}
[Fact]
public void MergeHash_SpecificPackage_ComputesDifferentHashPerPackage()
{
// Arrange
var provenance = new AdvisoryProvenance(
"osv", "advisory", "CVE-2024-PERPACK", DateTimeOffset.UtcNow);
var multiPackageAdvisory = new Advisory(
"CVE-2024-PERPACK",
"Multi-package vulnerability",
null,
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2024-PERPACK" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/package-a@1.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance }),
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/package-b@2.0.0",
null,
Array.Empty<AffectedVersionRange>(),
Array.Empty<AffectedPackageStatus>(),
new[] { provenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
// Act - Compute hash for each affected package
var hashA = _calculator.ComputeMergeHash(multiPackageAdvisory, multiPackageAdvisory.AffectedPackages[0]);
var hashB = _calculator.ComputeMergeHash(multiPackageAdvisory, multiPackageAdvisory.AffectedPackages[1]);
// Assert - Different packages produce different hashes
Assert.NotEqual(hashA, hashB);
Assert.StartsWith("sha256:", hashA);
Assert.StartsWith("sha256:", hashB);
}
}

View File

@@ -0,0 +1,429 @@
// -----------------------------------------------------------------------------
// MergeHashFuzzingTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-017
// Description: Fuzzing tests for malformed version ranges and unusual PURLs
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class MergeHashFuzzingTests
{
private readonly MergeHashCalculator _calculator = new();
private readonly Random _random = new(42); // Fixed seed for reproducibility
private const int FuzzIterations = 1000;
#region PURL Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void PurlNormalizer_RandomInputs_DoesNotThrow()
{
var normalizer = PurlNormalizer.Instance;
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomPurl();
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("pkg:")]
[InlineData("pkg:npm")]
[InlineData("pkg:npm/")]
[InlineData("pkg:npm//")]
[InlineData("pkg:npm/@/")]
[InlineData("pkg:npm/@scope/")]
[InlineData("pkg:npm/pkg@")]
[InlineData("pkg:npm/pkg@version?")]
[InlineData("pkg:npm/pkg@version?qualifier")]
[InlineData("pkg:npm/pkg@version?key=")]
[InlineData("pkg:npm/pkg@version?=value")]
[InlineData("pkg:npm/pkg#")]
[InlineData("pkg:npm/pkg#/")]
[InlineData("pkg:///")]
[InlineData("pkg:type/ns/name@v?q=v#sp")]
[InlineData("pkg:UNKNOWN/package@1.0.0")]
public void PurlNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = PurlNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("pkg:npm/\0package@1.0.0")]
[InlineData("pkg:npm/package\u0000@1.0.0")]
[InlineData("pkg:npm/package@1.0.0\t")]
[InlineData("pkg:npm/package@1.0.0\n")]
[InlineData("pkg:npm/package@1.0.0\r")]
[InlineData("pkg:npm/päckage@1.0.0")]
[InlineData("pkg:npm/包裹@1.0.0")]
[InlineData("pkg:npm/📦@1.0.0")]
public void PurlNormalizer_SpecialCharacters_DoesNotThrow(string input)
{
var normalizer = PurlNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region Version Range Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void VersionRangeNormalizer_RandomInputs_DoesNotThrow()
{
var normalizer = VersionRangeNormalizer.Instance;
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomVersionRange();
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("[")]
[InlineData("(")]
[InlineData("]")]
[InlineData(")")]
[InlineData("[,")]
[InlineData(",]")]
[InlineData("[,]")]
[InlineData("(,)")]
[InlineData("[1.0")]
[InlineData("1.0]")]
[InlineData("[1.0,")]
[InlineData(",1.0]")]
[InlineData(">=")]
[InlineData("<=")]
[InlineData(">")]
[InlineData("<")]
[InlineData("=")]
[InlineData("!=")]
[InlineData("~")]
[InlineData("^")]
[InlineData(">=<")]
[InlineData("<=>")]
[InlineData(">=1.0<2.0")]
[InlineData("1.0-2.0")]
[InlineData("1.0..2.0")]
[InlineData("v1.0.0")]
[InlineData("version1")]
public void VersionRangeNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = VersionRangeNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region CPE Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void CpeNormalizer_RandomInputs_DoesNotThrow()
{
var normalizer = CpeNormalizer.Instance;
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomCpe();
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
}
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("cpe:")]
[InlineData("cpe:/")]
[InlineData("cpe://")]
[InlineData("cpe:2.3")]
[InlineData("cpe:2.3:")]
[InlineData("cpe:2.3:a")]
[InlineData("cpe:2.3:a:")]
[InlineData("cpe:2.3:x:vendor:product:1.0:*:*:*:*:*:*:*")]
[InlineData("cpe:1.0:a:vendor:product:1.0")]
[InlineData("cpe:3.0:a:vendor:product:1.0")]
[InlineData("cpe:2.3:a:::::::::")]
[InlineData("cpe:2.3:a:vendor:product:::::::::")]
public void CpeNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = CpeNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region CVE Fuzzing
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("CVE")]
[InlineData("CVE-")]
[InlineData("CVE-2024")]
[InlineData("CVE-2024-")]
[InlineData("CVE-2024-1")]
[InlineData("CVE-2024-12")]
[InlineData("CVE-2024-123")]
[InlineData("CVE-24-1234")]
[InlineData("CVE-202-1234")]
[InlineData("CVE-20245-1234")]
[InlineData("CVE2024-1234")]
[InlineData("CVE_2024_1234")]
[InlineData("cve:2024:1234")]
public void CveNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = CveNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region CWE Fuzzing
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("CWE")]
[InlineData("CWE-")]
[InlineData("CWE-abc")]
[InlineData("CWE--79")]
[InlineData("CWE79")]
[InlineData("cwe79")]
[InlineData("79CWE")]
[InlineData("-79")]
public void CweNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = CweNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize([input]));
Assert.Null(exception);
}
[Fact]
[Trait("Category", "Fuzzing")]
public void CweNormalizer_LargeLists_DoesNotThrow()
{
var normalizer = CweNormalizer.Instance;
// Test with large list of CWEs
var largeCweList = Enumerable.Range(1, 1000)
.Select(i => $"CWE-{i}")
.ToList();
var exception = Record.Exception(() => normalizer.Normalize(largeCweList));
Assert.Null(exception);
}
#endregion
#region Patch Lineage Fuzzing
[Theory]
[Trait("Category", "Fuzzing")]
[InlineData("abc")]
[InlineData("abc123")]
[InlineData("abc12")]
[InlineData("12345")]
[InlineData("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG")]
[InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")]
[InlineData("https://")]
[InlineData("https://github.com")]
[InlineData("https://github.com/")]
[InlineData("https://github.com/owner")]
[InlineData("https://github.com/owner/repo")]
[InlineData("https://github.com/owner/repo/")]
[InlineData("https://github.com/owner/repo/commit")]
[InlineData("https://github.com/owner/repo/commit/")]
[InlineData("PATCH")]
[InlineData("PATCH-")]
[InlineData("PATCH-abc")]
[InlineData("patch12345")]
public void PatchLineageNormalizer_MalformedInputs_DoesNotThrow(string input)
{
var normalizer = PatchLineageNormalizer.Instance;
var exception = Record.Exception(() => normalizer.Normalize(input));
Assert.Null(exception);
}
#endregion
#region Full Hash Calculator Fuzzing
[Fact]
[Trait("Category", "Fuzzing")]
public void MergeHashCalculator_RandomInputs_AlwaysProducesValidHash()
{
for (var i = 0; i < FuzzIterations; i++)
{
var input = GenerateRandomMergeHashInput();
var hash = _calculator.ComputeMergeHash(input);
Assert.NotNull(hash);
Assert.StartsWith("sha256:", hash);
Assert.Equal(71, hash.Length); // sha256: + 64 hex chars
Assert.Matches(@"^sha256:[0-9a-f]{64}$", hash);
}
}
[Fact]
[Trait("Category", "Fuzzing")]
public void MergeHashCalculator_RandomInputs_IsDeterministic()
{
var inputs = new List<MergeHashInput>();
for (var i = 0; i < 100; i++)
{
inputs.Add(GenerateRandomMergeHashInput());
}
// First pass
var firstHashes = inputs.Select(i => _calculator.ComputeMergeHash(i)).ToList();
// Second pass
var secondHashes = inputs.Select(i => _calculator.ComputeMergeHash(i)).ToList();
// All should match
for (var i = 0; i < inputs.Count; i++)
{
Assert.Equal(firstHashes[i], secondHashes[i]);
}
}
#endregion
#region Random Input Generators
private string GenerateRandomPurl()
{
var types = new[] { "npm", "maven", "pypi", "nuget", "gem", "golang", "deb", "rpm", "apk", "cargo" };
var type = types[_random.Next(types.Length)];
var hasNamespace = _random.Next(2) == 1;
var hasVersion = _random.Next(2) == 1;
var hasQualifiers = _random.Next(2) == 1;
var hasSubpath = _random.Next(2) == 1;
var sb = new System.Text.StringBuilder();
sb.Append("pkg:");
sb.Append(type);
sb.Append('/');
if (hasNamespace)
{
sb.Append(GenerateRandomString(5));
sb.Append('/');
}
sb.Append(GenerateRandomString(8));
if (hasVersion)
{
sb.Append('@');
sb.Append(GenerateRandomVersion());
}
if (hasQualifiers)
{
sb.Append('?');
sb.Append(GenerateRandomString(3));
sb.Append('=');
sb.Append(GenerateRandomString(5));
}
if (hasSubpath)
{
sb.Append('#');
sb.Append(GenerateRandomString(10));
}
return sb.ToString();
}
private string GenerateRandomVersionRange()
{
var patterns = new Func<string>[]
{
() => $"[{GenerateRandomVersion()}, {GenerateRandomVersion()})",
() => $"({GenerateRandomVersion()}, {GenerateRandomVersion()}]",
() => $">={GenerateRandomVersion()}",
() => $"<{GenerateRandomVersion()}",
() => $"={GenerateRandomVersion()}",
() => $">={GenerateRandomVersion()},<{GenerateRandomVersion()}",
() => $"fixed:{GenerateRandomVersion()}",
() => "*",
() => GenerateRandomVersion(),
() => GenerateRandomString(10)
};
return patterns[_random.Next(patterns.Length)]();
}
private string GenerateRandomCpe()
{
if (_random.Next(2) == 0)
{
// CPE 2.3
var part = new[] { "a", "o", "h" }[_random.Next(3)];
return $"cpe:2.3:{part}:{GenerateRandomString(6)}:{GenerateRandomString(8)}:{GenerateRandomVersion()}:*:*:*:*:*:*:*";
}
else
{
// CPE 2.2
var part = new[] { "a", "o", "h" }[_random.Next(3)];
return $"cpe:/{part}:{GenerateRandomString(6)}:{GenerateRandomString(8)}:{GenerateRandomVersion()}";
}
}
private MergeHashInput GenerateRandomMergeHashInput()
{
return new MergeHashInput
{
Cve = $"CVE-{2020 + _random.Next(5)}-{_random.Next(10000, 99999)}",
AffectsKey = GenerateRandomPurl(),
VersionRange = _random.Next(3) > 0 ? GenerateRandomVersionRange() : null,
Weaknesses = Enumerable.Range(0, _random.Next(0, 5))
.Select(_ => $"CWE-{_random.Next(1, 1000)}")
.ToList(),
PatchLineage = _random.Next(3) > 0 ? GenerateRandomHex(40) : null
};
}
private string GenerateRandomVersion()
{
return $"{_random.Next(0, 20)}.{_random.Next(0, 50)}.{_random.Next(0, 100)}";
}
private string GenerateRandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789-_";
return new string(Enumerable.Range(0, length)
.Select(_ => chars[_random.Next(chars.Length)])
.ToArray());
}
private string GenerateRandomHex(int length)
{
const string hexChars = "0123456789abcdef";
return new string(Enumerable.Range(0, length)
.Select(_ => hexChars[_random.Next(hexChars.Length)])
.ToArray());
}
#endregion
}

View File

@@ -0,0 +1,313 @@
// -----------------------------------------------------------------------------
// MergeHashGoldenCorpusTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-016
// Description: Golden corpus tests for merge hash validation
// -----------------------------------------------------------------------------
using System.Text.Json;
using StellaOps.Concelier.Merge.Identity;
namespace StellaOps.Concelier.Merge.Tests.Identity;
/// <summary>
/// Tests that validate merge hash computations against golden corpus files.
/// Each corpus file contains pairs of advisory sources that should produce
/// the same or different merge hashes based on identity normalization.
/// </summary>
public sealed class MergeHashGoldenCorpusTests
{
private readonly MergeHashCalculator _calculator = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
private static string GetCorpusPath(string corpusName)
{
// Try multiple paths for test execution context
var paths = new[]
{
Path.Combine("Fixtures", "Golden", corpusName),
Path.Combine("..", "..", "..", "Fixtures", "Golden", corpusName),
Path.Combine(AppContext.BaseDirectory, "Fixtures", "Golden", corpusName)
};
foreach (var path in paths)
{
if (File.Exists(path))
{
return path;
}
}
throw new FileNotFoundException(string.Format("Corpus file not found: {0}", corpusName));
}
#region Debian-RHEL Corpus Tests
[Fact]
public void DeduplicateDebianRhelCorpus_AllItemsValidated()
{
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
var corpus = LoadCorpus(corpusPath);
Assert.NotNull(corpus);
Assert.NotEmpty(corpus.Items);
foreach (var item in corpus.Items)
{
ValidateCorpusItem(item);
}
}
[Fact]
public void DeduplicateDebianRhelCorpus_SameMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
var corpus = LoadCorpus(corpusPath);
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(sameHashItems);
foreach (var item in sameHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
}
}
[Fact]
public void DeduplicateDebianRhelCorpus_DifferentMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
var corpus = LoadCorpus(corpusPath);
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(differentHashItems);
foreach (var item in differentHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
}
}
#endregion
#region Backport Variants Corpus Tests
[Fact]
public void BackportVariantsCorpus_AllItemsValidated()
{
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
var corpus = LoadCorpus(corpusPath);
Assert.NotNull(corpus);
Assert.NotEmpty(corpus.Items);
foreach (var item in corpus.Items)
{
ValidateCorpusItem(item);
}
}
[Fact]
public void BackportVariantsCorpus_SameMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
var corpus = LoadCorpus(corpusPath);
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(sameHashItems);
foreach (var item in sameHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
}
}
[Fact]
public void BackportVariantsCorpus_DifferentMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
var corpus = LoadCorpus(corpusPath);
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(differentHashItems);
foreach (var item in differentHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
}
}
#endregion
#region Alias Collision Corpus Tests
[Fact]
public void AliasCollisionCorpus_AllItemsValidated()
{
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
var corpus = LoadCorpus(corpusPath);
Assert.NotNull(corpus);
Assert.NotEmpty(corpus.Items);
foreach (var item in corpus.Items)
{
ValidateCorpusItem(item);
}
}
[Fact]
public void AliasCollisionCorpus_SameMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
var corpus = LoadCorpus(corpusPath);
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(sameHashItems);
foreach (var item in sameHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
}
}
[Fact]
public void AliasCollisionCorpus_DifferentMergeHashPairs()
{
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
var corpus = LoadCorpus(corpusPath);
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
Assert.NotEmpty(differentHashItems);
foreach (var item in differentHashItems)
{
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
var hashes = item.Sources
.Select(s => ComputeHashFromSource(s))
.Distinct()
.ToList();
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
}
}
#endregion
#region Helper Methods
private GoldenCorpus LoadCorpus(string path)
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<GoldenCorpus>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize corpus: {path}");
}
private void ValidateCorpusItem(CorpusItem item)
{
Assert.False(string.IsNullOrEmpty(item.Id), "Corpus item must have an ID");
Assert.NotEmpty(item.Sources);
Assert.NotNull(item.Expected);
// Validate each source produces a valid hash
foreach (var source in item.Sources)
{
var hash = ComputeHashFromSource(source);
Assert.StartsWith("sha256:", hash);
Assert.Equal(71, hash.Length); // sha256: + 64 hex chars
}
}
private string ComputeHashFromSource(CorpusSource source)
{
var input = new MergeHashInput
{
Cve = source.Cve,
AffectsKey = source.AffectsKey,
VersionRange = source.VersionRange,
Weaknesses = source.Weaknesses ?? [],
PatchLineage = source.PatchLineage
};
return _calculator.ComputeMergeHash(input);
}
#endregion
#region Corpus Models
private sealed record GoldenCorpus
{
public string Corpus { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public IReadOnlyList<CorpusItem> Items { get; init; } = [];
}
private sealed record CorpusItem
{
public string Id { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public IReadOnlyList<CorpusSource> Sources { get; init; } = [];
public CorpusExpected Expected { get; init; } = new();
}
private sealed record CorpusSource
{
public string Source { get; init; } = string.Empty;
public string AdvisoryId { get; init; } = string.Empty;
public string Cve { get; init; } = string.Empty;
public string AffectsKey { get; init; } = string.Empty;
public string? VersionRange { get; init; }
public IReadOnlyList<string>? Weaknesses { get; init; }
public string? PatchLineage { get; init; }
}
private sealed record CorpusExpected
{
public bool SameMergeHash { get; init; }
public string Rationale { get; init; } = string.Empty;
}
#endregion
}

View File

@@ -0,0 +1,281 @@
// -----------------------------------------------------------------------------
// PatchLineageNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for PatchLineageNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class PatchLineageNormalizerTests
{
private readonly PatchLineageNormalizer _normalizer = PatchLineageNormalizer.Instance;
#region Full SHA Extraction
[Fact]
public void Normalize_FullSha_ReturnsLowercase()
{
var sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
var result = _normalizer.Normalize(sha);
Assert.Equal(sha.ToLowerInvariant(), result);
}
[Fact]
public void Normalize_FullShaUppercase_ReturnsLowercase()
{
var sha = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2";
var result = _normalizer.Normalize(sha);
Assert.Equal(sha.ToLowerInvariant(), result);
}
[Fact]
public void Normalize_FullShaMixedCase_ReturnsLowercase()
{
var sha = "A1b2C3d4E5f6A1b2C3d4E5f6A1b2C3d4E5f6A1b2";
var result = _normalizer.Normalize(sha);
Assert.Equal(sha.ToLowerInvariant(), result);
}
#endregion
#region Abbreviated SHA Extraction
[Fact]
public void Normalize_AbbrevShaWithContext_ExtractsSha()
{
var result = _normalizer.Normalize("fix: abc1234 addresses the issue");
Assert.Equal("abc1234", result);
}
[Fact]
public void Normalize_AbbrevShaWithCommitKeyword_ExtractsSha()
{
var result = _normalizer.Normalize("commit abc1234567");
Assert.Equal("abc1234567", result);
}
[Fact]
public void Normalize_AbbrevShaSeven_ExtractsSha()
{
var result = _normalizer.Normalize("patch: fix in abc1234");
Assert.Equal("abc1234", result);
}
[Fact]
public void Normalize_AbbrevShaTwelve_ExtractsSha()
{
var result = _normalizer.Normalize("backport of abc123456789");
Assert.Equal("abc123456789", result);
}
#endregion
#region GitHub/GitLab URL Extraction
[Fact]
public void Normalize_GitHubCommitUrl_ExtractsSha()
{
var url = "https://github.com/owner/repo/commit/abc123def456abc123def456abc123def456abc1";
var result = _normalizer.Normalize(url);
Assert.Equal("abc123def456abc123def456abc123def456abc1", result);
}
[Fact]
public void Normalize_GitLabCommitUrl_ExtractsSha()
{
var url = "https://gitlab.com/owner/repo/commit/abc123def456";
var result = _normalizer.Normalize(url);
Assert.Equal("abc123def456", result);
}
[Fact]
public void Normalize_GitHubUrlAbbrevSha_ExtractsSha()
{
var url = "https://github.com/apache/log4j/commit/abc1234";
var result = _normalizer.Normalize(url);
Assert.Equal("abc1234", result);
}
#endregion
#region Patch ID Extraction
[Fact]
public void Normalize_PatchIdUppercase_ReturnsUppercase()
{
var result = _normalizer.Normalize("PATCH-12345");
Assert.Equal("PATCH-12345", result);
}
[Fact]
public void Normalize_PatchIdLowercase_ReturnsUppercase()
{
var result = _normalizer.Normalize("patch-12345");
Assert.Equal("PATCH-12345", result);
}
[Fact]
public void Normalize_PatchIdInText_ExtractsPatchId()
{
var result = _normalizer.Normalize("Applied PATCH-67890 to fix issue");
Assert.Equal("PATCH-67890", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsNull()
{
var result = _normalizer.Normalize(null);
Assert.Null(result);
}
[Fact]
public void Normalize_EmptyString_ReturnsNull()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Null(result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsNull()
{
var result = _normalizer.Normalize(" ");
Assert.Null(result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var sha = " a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ";
var result = _normalizer.Normalize(sha);
Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", result);
}
#endregion
#region Edge Cases - Unrecognized Patterns
[Fact]
public void Normalize_NoRecognizablePattern_ReturnsNull()
{
var result = _normalizer.Normalize("some random text without sha or patch id");
Assert.Null(result);
}
[Fact]
public void Normalize_ShortHex_ReturnsNull()
{
// Less than 7 hex chars shouldn't match abbreviated SHA
var result = _normalizer.Normalize("abc12 is too short");
Assert.Null(result);
}
[Fact]
public void Normalize_NonHexChars_ReturnsNull()
{
var result = _normalizer.Normalize("ghijkl is not hex");
Assert.Null(result);
}
[Fact]
public void Normalize_PatchIdNoNumber_ReturnsNull()
{
var result = _normalizer.Normalize("PATCH-abc is invalid");
Assert.Null(result);
}
#endregion
#region Priority Testing
[Fact]
public void Normalize_UrlOverPlainSha_PrefersUrl()
{
// When URL contains SHA, should extract from URL pattern
var input = "https://github.com/owner/repo/commit/abcdef1234567890abcdef1234567890abcdef12";
var result = _normalizer.Normalize(input);
Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", result);
}
[Fact]
public void Normalize_FullShaOverAbbrev_PrefersFullSha()
{
// When both full and abbreviated SHA present, should prefer full
var input = "abc1234 mentioned in commit a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
var result = _normalizer.Normalize(input);
Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")]
[InlineData("https://github.com/owner/repo/commit/abc1234")]
[InlineData("PATCH-12345")]
[InlineData("commit abc1234567")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "https://github.com/apache/log4j/commit/abc123def456abc123def456abc123def456abc1";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
#endregion
#region Real-World Lineage Formats
[Theory]
[InlineData("https://github.com/apache/logging-log4j2/commit/7fe72d6", "7fe72d6")]
[InlineData("backport of abc123def456", "abc123def456")]
public void Normalize_RealWorldLineages_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
[Fact]
public void Normalize_PatchId_ExtractsAndUppercases()
{
// PATCH-NNNNN format is recognized and uppercased
var result = _normalizer.Normalize("Applied patch-12345 to fix issue");
Assert.Equal("PATCH-12345", result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = PatchLineageNormalizer.Instance;
var instance2 = PatchLineageNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,295 @@
// -----------------------------------------------------------------------------
// PurlNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for PurlNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class PurlNormalizerTests
{
private readonly PurlNormalizer _normalizer = PurlNormalizer.Instance;
#region Basic Normalization
[Fact]
public void Normalize_SimplePurl_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21");
Assert.Equal("pkg:npm/lodash@4.17.21", result);
}
[Fact]
public void Normalize_UppercaseType_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:NPM/lodash@4.17.21");
Assert.Equal("pkg:npm/lodash@4.17.21", result);
}
[Fact]
public void Normalize_WithNamespace_ReturnsNormalized()
{
var result = _normalizer.Normalize("pkg:maven/org.apache.commons/commons-lang3@3.12.0");
Assert.Equal("pkg:maven/org.apache.commons/commons-lang3@3.12.0", result);
}
#endregion
#region Scoped NPM Packages
[Fact]
public void Normalize_NpmScopedPackage_ReturnsLowercaseScope()
{
var result = _normalizer.Normalize("pkg:npm/@Angular/core@14.0.0");
Assert.StartsWith("pkg:npm/", result);
Assert.Contains("angular", result.ToLowerInvariant());
Assert.Contains("core", result.ToLowerInvariant());
}
[Fact]
public void Normalize_NpmScopedPackageEncoded_DecodesAndNormalizes()
{
var result = _normalizer.Normalize("pkg:npm/%40angular/core@14.0.0");
Assert.Contains("angular", result.ToLowerInvariant());
}
#endregion
#region Qualifier Stripping
[Fact]
public void Normalize_WithArchQualifier_StripsArch()
{
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?arch=amd64");
Assert.DoesNotContain("arch=", result);
}
[Fact]
public void Normalize_WithTypeQualifier_StripsType()
{
var result = _normalizer.Normalize("pkg:maven/org.apache/commons@1.0?type=jar");
Assert.DoesNotContain("type=", result);
}
[Fact]
public void Normalize_WithChecksumQualifier_StripsChecksum()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21?checksum=sha256:abc123");
Assert.DoesNotContain("checksum=", result);
}
[Fact]
public void Normalize_WithPlatformQualifier_StripsPlatform()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21?platform=linux");
Assert.DoesNotContain("platform=", result);
}
[Fact]
public void Normalize_WithMultipleQualifiers_StripsNonIdentity()
{
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?arch=amd64&distro=bullseye");
Assert.DoesNotContain("arch=", result);
Assert.Contains("distro=bullseye", result);
}
[Fact]
public void Normalize_WithIdentityQualifiers_KeepsIdentity()
{
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?distro=bullseye");
Assert.Contains("distro=bullseye", result);
}
#endregion
#region Qualifier Sorting
[Fact]
public void Normalize_UnsortedQualifiers_ReturnsSorted()
{
var result = _normalizer.Normalize("pkg:npm/pkg@1.0?z=1&a=2&m=3");
// Qualifiers should be sorted alphabetically
var queryStart = result.IndexOf('?');
if (queryStart > 0)
{
var qualifiers = result[(queryStart + 1)..].Split('&');
var sorted = qualifiers.OrderBy(q => q).ToArray();
Assert.Equal(sorted, qualifiers);
}
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null!);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" pkg:npm/lodash@4.17.21 ");
Assert.Equal("pkg:npm/lodash@4.17.21", result);
}
#endregion
#region Edge Cases - Non-PURL Input
[Fact]
public void Normalize_CpeInput_ReturnsAsIs()
{
var input = "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*";
var result = _normalizer.Normalize(input);
Assert.Equal(input, result);
}
[Fact]
public void Normalize_PlainPackageName_ReturnsLowercase()
{
var result = _normalizer.Normalize("SomePackage");
Assert.Equal("somepackage", result);
}
[Fact]
public void Normalize_InvalidPurlFormat_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:invalid");
Assert.Equal("pkg:invalid", result);
}
#endregion
#region Edge Cases - Special Characters
[Fact]
public void Normalize_WithSubpath_StripsSubpath()
{
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21#src/index.js");
Assert.DoesNotContain("#", result);
}
[Fact]
public void Normalize_UrlEncodedName_DecodesAndNormalizes()
{
var result = _normalizer.Normalize("pkg:npm/%40scope%2Fpkg@1.0.0");
// Should decode and normalize
Assert.StartsWith("pkg:npm/", result);
}
#endregion
#region Ecosystem-Specific Behavior
[Fact]
public void Normalize_GolangPackage_PreservesNameCase()
{
var result = _normalizer.Normalize("pkg:golang/github.com/User/Repo@v1.0.0");
// Go namespace is lowercased but name retains original chars
// The current normalizer lowercases everything except golang name
Assert.StartsWith("pkg:golang/", result);
Assert.Contains("repo", result.ToLowerInvariant());
}
[Fact]
public void Normalize_NugetPackage_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:nuget/Newtonsoft.Json@13.0.1");
Assert.Contains("newtonsoft.json", result.ToLowerInvariant());
}
[Fact]
public void Normalize_DebianPackage_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:deb/debian/CURL@7.68.0-1");
Assert.Contains("curl", result.ToLowerInvariant());
}
[Fact]
public void Normalize_RpmPackage_ReturnsLowercase()
{
var result = _normalizer.Normalize("pkg:rpm/redhat/OPENSSL@1.1.1");
Assert.Contains("openssl", result.ToLowerInvariant());
}
#endregion
#region Determinism
[Theory]
[InlineData("pkg:npm/lodash@4.17.21")]
[InlineData("pkg:NPM/LODASH@4.17.21")]
[InlineData("pkg:npm/@angular/core@14.0.0")]
[InlineData("pkg:maven/org.apache/commons@1.0")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "pkg:npm/@SCOPE/Package@1.0.0?arch=amd64&distro=bullseye";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
#endregion
#region Real-World PURL Formats
[Theory]
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21")]
[InlineData("pkg:pypi/requests@2.28.0", "pkg:pypi/requests@2.28.0")]
[InlineData("pkg:gem/rails@7.0.0", "pkg:gem/rails@7.0.0")]
public void Normalize_RealWorldPurls_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = PurlNormalizer.Instance;
var instance2 = PurlNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// VersionRangeNormalizerTests.cs
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
// Task: MHASH-8200-008
// Description: Unit tests for VersionRangeNormalizer
// -----------------------------------------------------------------------------
using StellaOps.Concelier.Merge.Identity.Normalizers;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class VersionRangeNormalizerTests
{
private readonly VersionRangeNormalizer _normalizer = VersionRangeNormalizer.Instance;
#region Interval Notation
[Fact]
public void Normalize_ClosedOpen_ConvertsToComparison()
{
var result = _normalizer.Normalize("[1.0.0, 2.0.0)");
Assert.Equal(">=1.0.0,<2.0.0", result);
}
[Fact]
public void Normalize_OpenClosed_ConvertsToComparison()
{
var result = _normalizer.Normalize("(1.0.0, 2.0.0]");
Assert.Equal(">1.0.0,<=2.0.0", result);
}
[Fact]
public void Normalize_ClosedClosed_ConvertsToComparison()
{
var result = _normalizer.Normalize("[1.0.0, 2.0.0]");
Assert.Equal(">=1.0.0,<=2.0.0", result);
}
[Fact]
public void Normalize_OpenOpen_ConvertsToComparison()
{
var result = _normalizer.Normalize("(1.0.0, 2.0.0)");
Assert.Equal(">1.0.0,<2.0.0", result);
}
[Fact]
public void Normalize_IntervalWithSpaces_ConvertsToComparison()
{
var result = _normalizer.Normalize("[ 1.0.0 , 2.0.0 )");
Assert.Equal(">=1.0.0,<2.0.0", result);
}
[Fact]
public void Normalize_LeftOpenInterval_ConvertsToUpperBound()
{
var result = _normalizer.Normalize("(, 2.0.0)");
Assert.Equal("<2.0.0", result);
}
[Fact]
public void Normalize_RightOpenInterval_ConvertsToLowerBound()
{
var result = _normalizer.Normalize("[1.0.0,)");
Assert.Equal(">=1.0.0", result);
}
#endregion
#region Comparison Operators
[Theory]
[InlineData(">= 1.0.0", ">=1.0.0")]
[InlineData(">=1.0.0", ">=1.0.0")]
[InlineData("> 1.0.0", ">1.0.0")]
[InlineData("<= 2.0.0", "<=2.0.0")]
[InlineData("< 2.0.0", "<2.0.0")]
[InlineData("= 1.0.0", "=1.0.0")]
[InlineData("!= 1.0.0", "!=1.0.0")]
public void Normalize_ComparisonOperators_NormalizesWhitespace(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("~= 1.0.0", "~=1.0.0")]
[InlineData("~> 1.0.0", "~=1.0.0")]
[InlineData("^ 1.0.0", "^1.0.0")]
public void Normalize_SemverOperators_Normalizes(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Multi-Constraint
[Fact]
public void Normalize_MultipleConstraints_SortsAndJoins()
{
var result = _normalizer.Normalize("<2.0.0,>=1.0.0");
// Should be sorted alphabetically
Assert.Contains("<2.0.0", result);
Assert.Contains(">=1.0.0", result);
}
[Fact]
public void Normalize_DuplicateConstraints_Deduplicates()
{
var result = _normalizer.Normalize(">= 1.0.0, >=1.0.0");
// Should deduplicate
var count = result.Split(',').Count(s => s == ">=1.0.0");
Assert.Equal(1, count);
}
#endregion
#region Fixed Version
[Fact]
public void Normalize_FixedNotation_ConvertsToGreaterOrEqual()
{
var result = _normalizer.Normalize("fixed: 1.5.1");
Assert.Equal(">=1.5.1", result);
}
[Fact]
public void Normalize_FixedNotationNoSpace_ConvertsToGreaterOrEqual()
{
var result = _normalizer.Normalize("fixed:1.5.1");
Assert.Equal(">=1.5.1", result);
}
#endregion
#region Wildcard
[Theory]
[InlineData("*", "*")]
[InlineData("all", "*")]
[InlineData("any", "*")]
public void Normalize_WildcardMarkers_ReturnsAsterisk(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Plain Version
[Fact]
public void Normalize_PlainVersion_ConvertsToExact()
{
var result = _normalizer.Normalize("1.0.0");
Assert.Equal("=1.0.0", result);
}
[Fact]
public void Normalize_PlainVersionWithPatch_ConvertsToExact()
{
var result = _normalizer.Normalize("1.2.3");
Assert.Equal("=1.2.3", result);
}
#endregion
#region Edge Cases - Empty and Null
[Fact]
public void Normalize_Null_ReturnsEmpty()
{
var result = _normalizer.Normalize(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_EmptyString_ReturnsEmpty()
{
var result = _normalizer.Normalize(string.Empty);
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WhitespaceOnly_ReturnsEmpty()
{
var result = _normalizer.Normalize(" ");
Assert.Equal(string.Empty, result);
}
[Fact]
public void Normalize_WithWhitespace_ReturnsTrimmed()
{
var result = _normalizer.Normalize(" >= 1.0.0 ");
Assert.Equal(">=1.0.0", result);
}
#endregion
#region Edge Cases - Malformed Input
[Fact]
public void Normalize_UnrecognizedFormat_ReturnsAsIs()
{
var result = _normalizer.Normalize("some-weird-format");
Assert.Equal("some-weird-format", result);
}
[Fact]
public void Normalize_MalformedInterval_ReturnsAsIs()
{
var result = _normalizer.Normalize("[1.0.0");
// Should return as-is if can't parse
Assert.Contains("1.0.0", result);
}
#endregion
#region Determinism
[Theory]
[InlineData("[1.0.0, 2.0.0)")]
[InlineData(">= 1.0.0")]
[InlineData("fixed: 1.5.1")]
[InlineData("*")]
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
{
var first = _normalizer.Normalize(input);
var second = _normalizer.Normalize(input);
var third = _normalizer.Normalize(input);
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void Normalize_Determinism_100Runs()
{
const string input = "[1.0.0, 2.0.0)";
var expected = _normalizer.Normalize(input);
for (var i = 0; i < 100; i++)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
}
[Fact]
public void Normalize_EquivalentFormats_ProduceSameOutput()
{
// Different ways to express the same range
var interval = _normalizer.Normalize("[1.0.0, 2.0.0)");
var comparison = _normalizer.Normalize(">=1.0.0,<2.0.0");
Assert.Equal(interval, comparison);
}
#endregion
#region Real-World Version Ranges
[Theory]
[InlineData("<7.68.0-1+deb10u2", "<7.68.0-1+deb10u2")]
[InlineData(">=0,<1.2.3", ">=0,<1.2.3")]
public void Normalize_RealWorldRanges_ReturnsExpected(string input, string expected)
{
var result = _normalizer.Normalize(input);
Assert.Equal(expected, result);
}
#endregion
#region Singleton Instance
[Fact]
public void Instance_ReturnsSameInstance()
{
var instance1 = VersionRangeNormalizer.Instance;
var instance2 = VersionRangeNormalizer.Instance;
Assert.Same(instance1, instance2);
}
#endregion
}

View File

@@ -302,9 +302,9 @@ public sealed class MergePropertyTests
// Assert - merge provenance trace should contain all original sources
var mergeProvenance = result.Provenance.FirstOrDefault(p => p.Source == "merge");
mergeProvenance.Should().NotBeNull();
mergeProvenance!.Value.Should().Contain("redhat", StringComparison.OrdinalIgnoreCase);
mergeProvenance.Value.Should().Contain("ghsa", StringComparison.OrdinalIgnoreCase);
mergeProvenance.Value.Should().Contain("osv", StringComparison.OrdinalIgnoreCase);
mergeProvenance!.Value.ToLowerInvariant().Should().Contain("redhat");
mergeProvenance.Value.ToLowerInvariant().Should().Contain("ghsa");
mergeProvenance.Value.ToLowerInvariant().Should().Contain("osv");
}
[Fact]

View File

@@ -4,6 +4,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />

View File

@@ -0,0 +1,770 @@
// -----------------------------------------------------------------------------
// AdvisoryCanonicalRepositoryTests.cs
// Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
// Task: SCHEMA-8200-011
// Description: Integration tests for AdvisoryCanonicalRepository
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for <see cref="AdvisoryCanonicalRepository"/>.
/// Tests CRUD operations, unique constraints, and cascade delete behavior.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class AdvisoryCanonicalRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryCanonicalRepository _repository;
private readonly SourceRepository _sourceRepository;
public AdvisoryCanonicalRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new AdvisoryCanonicalRepository(_dataSource, NullLogger<AdvisoryCanonicalRepository>.Instance);
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
#region GetByIdAsync Tests
[Fact]
public async Task GetByIdAsync_ShouldReturnEntity_WhenExists()
{
// Arrange
var canonical = CreateTestCanonical();
var id = await _repository.UpsertAsync(canonical);
// Act
var result = await _repository.GetByIdAsync(id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(id);
result.Cve.Should().Be(canonical.Cve);
result.AffectsKey.Should().Be(canonical.AffectsKey);
result.MergeHash.Should().Be(canonical.MergeHash);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
#endregion
#region GetByMergeHashAsync Tests
[Fact]
public async Task GetByMergeHashAsync_ShouldReturnEntity_WhenExists()
{
// Arrange
var canonical = CreateTestCanonical();
await _repository.UpsertAsync(canonical);
// Act
var result = await _repository.GetByMergeHashAsync(canonical.MergeHash);
// Assert
result.Should().NotBeNull();
result!.MergeHash.Should().Be(canonical.MergeHash);
result.Cve.Should().Be(canonical.Cve);
}
[Fact]
public async Task GetByMergeHashAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByMergeHashAsync("sha256:nonexistent");
// Assert
result.Should().BeNull();
}
#endregion
#region GetByCveAsync Tests
[Fact]
public async Task GetByCveAsync_ShouldReturnAllMatchingEntities()
{
// Arrange
var cve = "CVE-2024-12345";
var canonical1 = CreateTestCanonical(cve: cve, affectsKey: "pkg:npm/lodash@4.17.0");
var canonical2 = CreateTestCanonical(cve: cve, affectsKey: "pkg:npm/express@4.0.0");
var canonical3 = CreateTestCanonical(cve: "CVE-2024-99999");
await _repository.UpsertAsync(canonical1);
await _repository.UpsertAsync(canonical2);
await _repository.UpsertAsync(canonical3);
// Act
var results = await _repository.GetByCveAsync(cve);
// Assert
results.Should().HaveCount(2);
results.Should().AllSatisfy(r => r.Cve.Should().Be(cve));
}
[Fact]
public async Task GetByCveAsync_ShouldReturnEmptyList_WhenNoMatches()
{
// Act
var results = await _repository.GetByCveAsync("CVE-2099-00000");
// Assert
results.Should().BeEmpty();
}
#endregion
#region GetByAffectsKeyAsync Tests
[Fact]
public async Task GetByAffectsKeyAsync_ShouldReturnAllMatchingEntities()
{
// Arrange
var affectsKey = "pkg:npm/lodash@4.17.21";
var canonical1 = CreateTestCanonical(cve: "CVE-2024-11111", affectsKey: affectsKey);
var canonical2 = CreateTestCanonical(cve: "CVE-2024-22222", affectsKey: affectsKey);
var canonical3 = CreateTestCanonical(cve: "CVE-2024-33333", affectsKey: "pkg:npm/express@4.0.0");
await _repository.UpsertAsync(canonical1);
await _repository.UpsertAsync(canonical2);
await _repository.UpsertAsync(canonical3);
// Act
var results = await _repository.GetByAffectsKeyAsync(affectsKey);
// Assert
results.Should().HaveCount(2);
results.Should().AllSatisfy(r => r.AffectsKey.Should().Be(affectsKey));
}
#endregion
#region UpsertAsync Tests
[Fact]
public async Task UpsertAsync_ShouldInsertNewEntity()
{
// Arrange
var canonical = CreateTestCanonical();
// Act
var id = await _repository.UpsertAsync(canonical);
// Assert
id.Should().NotBeEmpty();
var retrieved = await _repository.GetByIdAsync(id);
retrieved.Should().NotBeNull();
retrieved!.Cve.Should().Be(canonical.Cve);
retrieved.AffectsKey.Should().Be(canonical.AffectsKey);
retrieved.MergeHash.Should().Be(canonical.MergeHash);
retrieved.Status.Should().Be("active");
retrieved.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingByMergeHash()
{
// Arrange
var mergeHash = $"sha256:{Guid.NewGuid():N}";
var original = CreateTestCanonical(mergeHash: mergeHash, severity: "high");
await _repository.UpsertAsync(original);
// Get original timestamps
var originalEntity = await _repository.GetByMergeHashAsync(mergeHash);
var originalCreatedAt = originalEntity!.CreatedAt;
// Create update with same merge_hash but different values
var updated = new AdvisoryCanonicalEntity
{
Id = Guid.NewGuid(), // Different ID
Cve = original.Cve,
AffectsKey = original.AffectsKey,
MergeHash = mergeHash, // Same merge_hash
Severity = "critical", // Updated severity
Title = "Updated Title"
};
// Act
var id = await _repository.UpsertAsync(updated);
// Assert - should return original ID, not new one
id.Should().Be(originalEntity.Id);
var result = await _repository.GetByMergeHashAsync(mergeHash);
result.Should().NotBeNull();
result!.Severity.Should().Be("critical");
result.Title.Should().Be("Updated Title");
result.CreatedAt.Should().BeCloseTo(originalCreatedAt, TimeSpan.FromSeconds(1)); // CreatedAt unchanged
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
}
[Fact]
public async Task UpsertAsync_ShouldPreserveExistingValues_WhenNewValuesAreNull()
{
// Arrange
var mergeHash = $"sha256:{Guid.NewGuid():N}";
var original = CreateTestCanonical(
mergeHash: mergeHash,
severity: "high",
title: "Original Title",
summary: "Original Summary");
await _repository.UpsertAsync(original);
// Create update with null values for severity, title, summary
var updated = new AdvisoryCanonicalEntity
{
Id = Guid.NewGuid(),
Cve = original.Cve,
AffectsKey = original.AffectsKey,
MergeHash = mergeHash,
Severity = null,
Title = null,
Summary = null
};
// Act
await _repository.UpsertAsync(updated);
// Assert - original values should be preserved
var result = await _repository.GetByMergeHashAsync(mergeHash);
result.Should().NotBeNull();
result!.Severity.Should().Be("high");
result.Title.Should().Be("Original Title");
result.Summary.Should().Be("Original Summary");
}
[Fact]
public async Task UpsertAsync_ShouldStoreWeaknessArray()
{
// Arrange
var canonical = CreateTestCanonical(weaknesses: ["CWE-79", "CWE-89", "CWE-120"]);
// Act
var id = await _repository.UpsertAsync(canonical);
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.Weakness.Should().BeEquivalentTo(["CWE-79", "CWE-89", "CWE-120"]);
}
[Fact]
public async Task UpsertAsync_ShouldStoreVersionRangeAsJson()
{
// Arrange
var versionRange = """{"introduced": "1.0.0", "fixed": "1.5.1"}""";
var canonical = CreateTestCanonical(versionRange: versionRange);
// Act
var id = await _repository.UpsertAsync(canonical);
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.VersionRange.Should().Contain("introduced");
result.VersionRange.Should().Contain("fixed");
}
#endregion
#region UpdateStatusAsync Tests
[Fact]
public async Task UpdateStatusAsync_ShouldUpdateStatus()
{
// Arrange
var canonical = CreateTestCanonical();
var id = await _repository.UpsertAsync(canonical);
// Act
await _repository.UpdateStatusAsync(id, "withdrawn");
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.Status.Should().Be("withdrawn");
}
[Fact]
public async Task UpdateStatusAsync_ShouldUpdateTimestamp()
{
// Arrange
var canonical = CreateTestCanonical();
var id = await _repository.UpsertAsync(canonical);
var original = await _repository.GetByIdAsync(id);
// Wait a bit to ensure timestamp difference
await Task.Delay(100);
// Act
await _repository.UpdateStatusAsync(id, "stub");
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.UpdatedAt.Should().BeAfter(original!.UpdatedAt);
}
#endregion
#region DeleteAsync Tests
[Fact]
public async Task DeleteAsync_ShouldRemoveEntity()
{
// Arrange
var canonical = CreateTestCanonical();
var id = await _repository.UpsertAsync(canonical);
// Verify exists
var exists = await _repository.GetByIdAsync(id);
exists.Should().NotBeNull();
// Act
await _repository.DeleteAsync(id);
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().BeNull();
}
[Fact]
public async Task DeleteAsync_ShouldCascadeDeleteSourceEdges()
{
// Arrange
var canonical = CreateTestCanonical();
var canonicalId = await _repository.UpsertAsync(canonical);
// Create a source first (required FK)
var source = CreateTestSource();
await _sourceRepository.UpsertAsync(source);
// Add source edge
var edge = CreateTestSourceEdge(canonicalId, source.Id);
var edgeId = await _repository.AddSourceEdgeAsync(edge);
// Verify edge exists
var edgeExists = await _repository.GetSourceEdgeByIdAsync(edgeId);
edgeExists.Should().NotBeNull();
// Act - delete canonical
await _repository.DeleteAsync(canonicalId);
// Assert - source edge should be deleted via cascade
var edgeAfterDelete = await _repository.GetSourceEdgeByIdAsync(edgeId);
edgeAfterDelete.Should().BeNull();
}
#endregion
#region CountAsync Tests
[Fact]
public async Task CountAsync_ShouldReturnActiveCount()
{
// Arrange
await _repository.UpsertAsync(CreateTestCanonical());
await _repository.UpsertAsync(CreateTestCanonical());
var withdrawnCanonical = CreateTestCanonical();
var withdrawnId = await _repository.UpsertAsync(withdrawnCanonical);
await _repository.UpdateStatusAsync(withdrawnId, "withdrawn");
// Act
var count = await _repository.CountAsync();
// Assert
count.Should().Be(2); // Only active ones
}
#endregion
#region StreamActiveAsync Tests
[Fact]
public async Task StreamActiveAsync_ShouldStreamOnlyActiveEntities()
{
// Arrange
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00001"));
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00002"));
var withdrawnId = await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00003"));
await _repository.UpdateStatusAsync(withdrawnId, "withdrawn");
// Act
var results = new List<AdvisoryCanonicalEntity>();
await foreach (var entity in _repository.StreamActiveAsync())
{
results.Add(entity);
}
// Assert
results.Should().HaveCount(2);
results.Should().AllSatisfy(e => e.Status.Should().Be("active"));
}
#endregion
#region Source Edge Tests
[Fact]
public async Task GetSourceEdgesAsync_ShouldReturnEdgesForCanonical()
{
// Arrange
var canonical = CreateTestCanonical();
var canonicalId = await _repository.UpsertAsync(canonical);
var source1 = CreateTestSource();
var source2 = CreateTestSource();
await _sourceRepository.UpsertAsync(source1);
await _sourceRepository.UpsertAsync(source2);
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source1.Id, precedence: 10));
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source2.Id, precedence: 20));
// Act
var edges = await _repository.GetSourceEdgesAsync(canonicalId);
// Assert
edges.Should().HaveCount(2);
edges.Should().BeInAscendingOrder(e => e.PrecedenceRank);
}
[Fact]
public async Task AddSourceEdgeAsync_ShouldInsertNewEdge()
{
// Arrange
var canonical = CreateTestCanonical();
var canonicalId = await _repository.UpsertAsync(canonical);
var source = CreateTestSource();
await _sourceRepository.UpsertAsync(source);
var edge = CreateTestSourceEdge(canonicalId, source.Id);
// Act
var edgeId = await _repository.AddSourceEdgeAsync(edge);
// Assert
edgeId.Should().NotBeEmpty();
var result = await _repository.GetSourceEdgeByIdAsync(edgeId);
result.Should().NotBeNull();
result!.CanonicalId.Should().Be(canonicalId);
result.SourceId.Should().Be(source.Id);
}
[Fact]
public async Task AddSourceEdgeAsync_ShouldUpsertOnConflict()
{
// Arrange
var canonical = CreateTestCanonical();
var canonicalId = await _repository.UpsertAsync(canonical);
var source = CreateTestSource();
await _sourceRepository.UpsertAsync(source);
var sourceDocHash = $"sha256:{Guid.NewGuid():N}";
var edge1 = CreateTestSourceEdge(canonicalId, source.Id, sourceDocHash: sourceDocHash, precedence: 100);
var id1 = await _repository.AddSourceEdgeAsync(edge1);
// Create edge with same (canonical_id, source_id, source_doc_hash) but different precedence
var edge2 = CreateTestSourceEdge(canonicalId, source.Id, sourceDocHash: sourceDocHash, precedence: 10);
// Act
var id2 = await _repository.AddSourceEdgeAsync(edge2);
// Assert - should return same ID
id2.Should().Be(id1);
var result = await _repository.GetSourceEdgeByIdAsync(id1);
result.Should().NotBeNull();
// Should use LEAST of precedence values
result!.PrecedenceRank.Should().Be(10);
}
[Fact]
public async Task AddSourceEdgeAsync_ShouldStoreDsseEnvelope()
{
// Arrange
var canonical = CreateTestCanonical();
var canonicalId = await _repository.UpsertAsync(canonical);
var source = CreateTestSource();
await _sourceRepository.UpsertAsync(source);
var dsseEnvelope = """{"payloadType": "application/vnd.in-toto+json", "payload": "eyJ0ZXN0IjogdHJ1ZX0=", "signatures": []}""";
var edge = CreateTestSourceEdge(canonicalId, source.Id, dsseEnvelope: dsseEnvelope);
// Act
var edgeId = await _repository.AddSourceEdgeAsync(edge);
// Assert
var result = await _repository.GetSourceEdgeByIdAsync(edgeId);
result.Should().NotBeNull();
result!.DsseEnvelope.Should().Contain("payloadType");
result.DsseEnvelope.Should().Contain("signatures");
}
[Fact]
public async Task GetSourceEdgesByAdvisoryIdAsync_ShouldReturnMatchingEdges()
{
// Arrange
var canonical = CreateTestCanonical();
var canonicalId = await _repository.UpsertAsync(canonical);
var source = CreateTestSource();
await _sourceRepository.UpsertAsync(source);
var advisoryId = "DSA-5678-1";
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source.Id, sourceAdvisoryId: advisoryId));
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source.Id, sourceAdvisoryId: "OTHER-123"));
// Act
var edges = await _repository.GetSourceEdgesByAdvisoryIdAsync(advisoryId);
// Assert
edges.Should().ContainSingle();
edges[0].SourceAdvisoryId.Should().Be(advisoryId);
}
#endregion
#region Statistics Tests
[Fact]
public async Task GetStatisticsAsync_ShouldReturnCorrectCounts()
{
// Arrange
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00001"));
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00002"));
var withdrawnId = await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00003"));
await _repository.UpdateStatusAsync(withdrawnId, "withdrawn");
var source = CreateTestSource();
await _sourceRepository.UpsertAsync(source);
var canonicals = await _repository.GetByCveAsync("CVE-2024-00001");
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicals[0].Id, source.Id));
// Act
var stats = await _repository.GetStatisticsAsync();
// Assert
stats.TotalCanonicals.Should().Be(3);
stats.ActiveCanonicals.Should().Be(2);
stats.TotalSourceEdges.Should().Be(1);
stats.LastUpdatedAt.Should().NotBeNull();
}
#endregion
#region Unique Constraint Tests
[Fact]
public async Task UpsertAsync_WithDuplicateMergeHash_ShouldUpdateNotInsert()
{
// Arrange
var mergeHash = $"sha256:{Guid.NewGuid():N}";
var canonical1 = CreateTestCanonical(mergeHash: mergeHash, title: "First");
var canonical2 = CreateTestCanonical(mergeHash: mergeHash, title: "Second");
await _repository.UpsertAsync(canonical1);
// Act - should update, not throw
await _repository.UpsertAsync(canonical2);
// Assert
var results = await _repository.GetByMergeHashAsync(mergeHash);
results.Should().NotBeNull();
// There should be exactly one record
var count = await _repository.CountAsync();
count.Should().Be(1);
}
#endregion
#region Edge Cases
[Fact]
public async Task UpsertAsync_WithEmptyWeaknessArray_ShouldSucceed()
{
// Arrange
var canonical = CreateTestCanonical(weaknesses: []);
// Act
var id = await _repository.UpsertAsync(canonical);
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.Weakness.Should().BeEmpty();
}
[Fact]
public async Task UpsertAsync_WithNullOptionalFields_ShouldSucceed()
{
// Arrange
var canonical = new AdvisoryCanonicalEntity
{
Id = Guid.NewGuid(),
Cve = "CVE-2024-99999",
AffectsKey = "pkg:npm/test@1.0.0",
MergeHash = $"sha256:{Guid.NewGuid():N}",
VersionRange = null,
Severity = null,
EpssScore = null,
Title = null,
Summary = null
};
// Act
var id = await _repository.UpsertAsync(canonical);
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.VersionRange.Should().BeNull();
result.Severity.Should().BeNull();
result.EpssScore.Should().BeNull();
}
[Fact]
public async Task UpsertAsync_WithEpssScore_ShouldStoreCorrectly()
{
// Arrange
var canonical = CreateTestCanonical(epssScore: 0.9754m);
// Act
var id = await _repository.UpsertAsync(canonical);
// Assert
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.EpssScore.Should().Be(0.9754m);
}
[Fact]
public async Task UpsertAsync_WithExploitKnown_ShouldOrWithExisting()
{
// Arrange
var mergeHash = $"sha256:{Guid.NewGuid():N}";
var canonical1 = CreateTestCanonical(mergeHash: mergeHash, exploitKnown: true);
await _repository.UpsertAsync(canonical1);
// Try to update with exploitKnown = false
var canonical2 = new AdvisoryCanonicalEntity
{
Id = Guid.NewGuid(),
Cve = canonical1.Cve,
AffectsKey = canonical1.AffectsKey,
MergeHash = mergeHash,
ExploitKnown = false // Trying to set to false
};
// Act
await _repository.UpsertAsync(canonical2);
// Assert - should remain true (OR semantics)
var result = await _repository.GetByMergeHashAsync(mergeHash);
result.Should().NotBeNull();
result!.ExploitKnown.Should().BeTrue();
}
#endregion
#region Test Helpers
private static AdvisoryCanonicalEntity CreateTestCanonical(
string? cve = null,
string? affectsKey = null,
string? mergeHash = null,
string? severity = null,
string? title = null,
string? summary = null,
string? versionRange = null,
string[]? weaknesses = null,
decimal? epssScore = null,
bool exploitKnown = false)
{
var id = Guid.NewGuid();
return new AdvisoryCanonicalEntity
{
Id = id,
Cve = cve ?? $"CVE-2024-{id.ToString("N")[..5]}",
AffectsKey = affectsKey ?? $"pkg:npm/{id:N}@1.0.0",
MergeHash = mergeHash ?? $"sha256:{id:N}",
Severity = severity,
Title = title,
Summary = summary,
VersionRange = versionRange,
Weakness = weaknesses ?? [],
EpssScore = epssScore,
ExploitKnown = exploitKnown
};
}
private static SourceEntity CreateTestSource()
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
return new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = "nvd",
Url = "https://example.com/feed",
Priority = 100,
Enabled = true,
Config = """{"apiKey": "test"}"""
};
}
private static AdvisorySourceEdgeEntity CreateTestSourceEdge(
Guid canonicalId,
Guid sourceId,
string? sourceAdvisoryId = null,
string? sourceDocHash = null,
int precedence = 100,
string? dsseEnvelope = null)
{
return new AdvisorySourceEdgeEntity
{
Id = Guid.NewGuid(),
CanonicalId = canonicalId,
SourceId = sourceId,
SourceAdvisoryId = sourceAdvisoryId ?? $"ADV-{Guid.NewGuid():N}"[..15],
SourceDocHash = sourceDocHash ?? $"sha256:{Guid.NewGuid():N}",
VendorStatus = "affected",
PrecedenceRank = precedence,
DsseEnvelope = dsseEnvelope,
FetchedAt = DateTimeOffset.UtcNow
};
}
#endregion
}

View File

@@ -1,90 +0,0 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Storage.Postgres.Converters;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
[Collection(ConcelierPostgresCollection.Name)]
public sealed class AdvisoryConversionServiceTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly AdvisoryConversionService _service;
private readonly AdvisoryRepository _advisories;
private readonly AdvisoryAliasRepository _aliases;
private readonly AdvisoryAffectedRepository _affected;
public AdvisoryConversionServiceTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_advisories = new AdvisoryRepository(dataSource, NullLogger<AdvisoryRepository>.Instance);
_aliases = new AdvisoryAliasRepository(dataSource, NullLogger<AdvisoryAliasRepository>.Instance);
_affected = new AdvisoryAffectedRepository(dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
_service = new AdvisoryConversionService(_advisories);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task ConvertAndUpsert_PersistsAdvisoryAndChildren()
{
var doc = CreateDoc();
var sourceId = Guid.NewGuid();
var stored = await _service.ConvertAndUpsertAsync(doc, "osv", sourceId);
var fetched = await _advisories.GetByKeyAsync(doc.AdvisoryKey);
var aliases = await _aliases.GetByAdvisoryAsync(stored.Id);
var affected = await _affected.GetByAdvisoryAsync(stored.Id);
fetched.Should().NotBeNull();
fetched!.PrimaryVulnId.Should().Be("CVE-2024-0002");
fetched.RawPayload.Should().NotBeNull();
fetched.Provenance.Should().Contain("osv");
aliases.Should().NotBeEmpty();
affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@2.0.0");
affected[0].VersionRange.Should().Contain("introduced");
}
private static AdvisoryDocument CreateDoc()
{
var payload = new DocumentObject
{
{ "primaryVulnId", "CVE-2024-0002" },
{ "title", "Another advisory" },
{ "severity", "medium" },
{ "aliases", new DocumentArray { "CVE-2024-0002" } },
{ "affected", new DocumentArray
{
new DocumentObject
{
{ "ecosystem", "npm" },
{ "packageName", "example" },
{ "purl", "pkg:npm/example@2.0.0" },
{ "range", "{\"introduced\":\"0\",\"fixed\":\"2.0.1\"}" },
{ "versionsAffected", new DocumentArray { "2.0.0" } },
{ "versionsFixed", new DocumentArray { "2.0.1" } }
}
}
}
};
return new AdvisoryDocument
{
AdvisoryKey = "ADV-2",
Payload = payload,
Modified = DateTime.UtcNow,
Published = DateTime.UtcNow.AddDays(-2)
};
}
}

View File

@@ -1,122 +0,0 @@
using FluentAssertions;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Postgres.Converters;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
public sealed class AdvisoryConverterTests
{
[Fact]
public void Convert_MapsCoreFieldsAndChildren()
{
var doc = CreateAdvisoryDocument();
var result = AdvisoryConverter.Convert(doc, sourceKey: "osv", sourceId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
result.Advisory.AdvisoryKey.Should().Be("ADV-1");
result.Advisory.PrimaryVulnId.Should().Be("CVE-2024-0001");
result.Advisory.Severity.Should().Be("high");
result.Aliases.Should().ContainSingle(a => a.AliasValue == "CVE-2024-0001");
result.Cvss.Should().ContainSingle(c => c.BaseScore == 9.8m && c.BaseSeverity == "critical");
result.Affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@1.0.0");
result.References.Should().ContainSingle(r => r.Url == "https://ref.example/test");
result.Credits.Should().ContainSingle(c => c.Name == "Researcher One");
result.Weaknesses.Should().ContainSingle(w => w.CweId == "CWE-79");
result.KevFlags.Should().ContainSingle(k => k.CveId == "CVE-2024-0001");
}
private static AdvisoryDocument CreateAdvisoryDocument()
{
var payload = new DocumentObject
{
{ "primaryVulnId", "CVE-2024-0001" },
{ "title", "Sample Advisory" },
{ "summary", "Summary" },
{ "description", "Description" },
{ "severity", "high" },
{ "aliases", new DocumentArray { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" } },
{ "cvss", new DocumentArray
{
new DocumentObject
{
{ "version", "3.1" },
{ "vector", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
{ "baseScore", 9.8 },
{ "baseSeverity", "critical" },
{ "exploitabilityScore", 3.9 },
{ "impactScore", 5.9 },
{ "source", "nvd" },
{ "isPrimary", true }
}
}
},
{ "affected", new DocumentArray
{
new DocumentObject
{
{ "ecosystem", "npm" },
{ "packageName", "example" },
{ "purl", "pkg:npm/example@1.0.0" },
{ "range", "{\"introduced\":\"0\",\"fixed\":\"1.0.1\"}" },
{ "versionsAffected", new DocumentArray { "1.0.0" } },
{ "versionsFixed", new DocumentArray { "1.0.1" } },
{ "databaseSpecific", "{\"severity\":\"high\"}" }
}
}
},
{ "references", new DocumentArray
{
new DocumentObject
{
{ "type", "advisory" },
{ "url", "https://ref.example/test" }
}
}
},
{ "credits", new DocumentArray
{
new DocumentObject
{
{ "name", "Researcher One" },
{ "contact", "r1@example.test" },
{ "type", "finder" }
}
}
},
{ "weaknesses", new DocumentArray
{
new DocumentObject
{
{ "cweId", "CWE-79" },
{ "description", "XSS" }
}
}
},
{ "kev", new DocumentArray
{
new DocumentObject
{
{ "cveId", "CVE-2024-0001" },
{ "vendorProject", "Example" },
{ "product", "Example Product" },
{ "name", "Critical vuln" },
{ "knownRansomwareUse", false },
{ "dateAdded", DateTime.UtcNow },
{ "dueDate", DateTime.UtcNow.AddDays(7) },
{ "notes", "note" }
}
}
}
};
return new AdvisoryDocument
{
AdvisoryKey = "ADV-1",
Payload = payload,
Modified = DateTime.UtcNow,
Published = DateTime.UtcNow.AddDays(-1)
};
}
}

View File

@@ -208,7 +208,7 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
// Assert - Should have updated the cursor
var retrieved = await _sourceStateRepository.GetBySourceIdAsync(source.Id);
retrieved.Should().NotBeNull();
retrieved!.LastCursor.Should().Be("cursor2");
retrieved!.Cursor.Should().Be("cursor2");
}
[Fact]
@@ -369,11 +369,9 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
{
Id = Guid.NewGuid(),
SourceId = sourceId,
LastCursor = cursor ?? "default-cursor",
LastFetchAt = DateTimeOffset.UtcNow,
LastSuccessAt = DateTimeOffset.UtcNow,
TotalAdvisoriesProcessed = 100,
Status = "active"
Cursor = cursor ?? "default-cursor",
LastSyncAt = DateTimeOffset.UtcNow,
LastSuccessAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -13,18 +13,9 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Update="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,508 @@
// -----------------------------------------------------------------------------
// CanonicalAdvisoryEndpointTests.cs
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
// Task: CANSVC-8200-020
// Description: Integration tests for canonical advisory API endpoints
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.WebService.Extensions;
using StellaOps.Concelier.WebService.Tests.Fixtures;
namespace StellaOps.Concelier.WebService.Tests.Canonical;
public sealed class CanonicalAdvisoryEndpointTests : IAsyncLifetime
{
private WebApplicationFactory<Program> _factory = null!;
private HttpClient _client = null!;
private readonly Mock<ICanonicalAdvisoryService> _serviceMock = new();
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private const string TestCve = "CVE-2025-0001";
private const string TestArtifactKey = "pkg:npm/lodash@4.17.21";
private const string TestMergeHash = "sha256:abc123def456789";
public Task InitializeAsync()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Remove existing ICanonicalAdvisoryService registration if any
var descriptor = services.FirstOrDefault(d =>
d.ServiceType == typeof(ICanonicalAdvisoryService));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Register mock service
services.AddSingleton(_serviceMock.Object);
});
});
_client = _factory.CreateClient();
return Task.CompletedTask;
}
public Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
return Task.CompletedTask;
}
#region GET /api/v1/canonical/{id}
[Fact]
public async Task GetById_ReturnsOk_WhenCanonicalExists()
{
// Arrange
var canonical = CreateTestCanonical(TestCanonicalId, TestCve);
_serviceMock
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
// Act
var response = await _client.GetAsync($"/api/v1/canonical/{TestCanonicalId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryResponse>();
content.Should().NotBeNull();
content!.Id.Should().Be(TestCanonicalId);
content.Cve.Should().Be(TestCve);
}
[Fact]
public async Task GetById_ReturnsNotFound_WhenCanonicalDoesNotExist()
{
// Arrange
var nonExistentId = Guid.NewGuid();
_serviceMock
.Setup(x => x.GetByIdAsync(nonExistentId, It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
// Act
var response = await _client.GetAsync($"/api/v1/canonical/{nonExistentId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region GET /api/v1/canonical?cve={cve}
[Fact]
public async Task QueryByCve_ReturnsCanonicals()
{
// Arrange
var canonicals = new List<CanonicalAdvisory>
{
CreateTestCanonical(TestCanonicalId, TestCve),
CreateTestCanonical(Guid.NewGuid(), TestCve)
};
_serviceMock
.Setup(x => x.GetByCveAsync(TestCve, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
// Act
var response = await _client.GetAsync($"/api/v1/canonical?cve={TestCve}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
content.Should().NotBeNull();
content!.Items.Should().HaveCount(2);
content.TotalCount.Should().Be(2);
}
[Fact]
public async Task QueryByCve_ReturnsEmptyList_WhenNoneFound()
{
// Arrange
_serviceMock
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CanonicalAdvisory>());
// Act
var response = await _client.GetAsync("/api/v1/canonical?cve=CVE-9999-9999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
content.Should().NotBeNull();
content!.Items.Should().BeEmpty();
content.TotalCount.Should().Be(0);
}
#endregion
#region GET /api/v1/canonical?artifact={artifact}
[Fact]
public async Task QueryByArtifact_ReturnsCanonicals()
{
// Arrange
var canonicals = new List<CanonicalAdvisory>
{
CreateTestCanonical(TestCanonicalId, TestCve, TestArtifactKey)
};
_serviceMock
.Setup(x => x.GetByArtifactAsync(TestArtifactKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonicals);
// Act
var response = await _client.GetAsync($"/api/v1/canonical?artifact={Uri.EscapeDataString(TestArtifactKey)}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
content.Should().NotBeNull();
content!.Items.Should().HaveCount(1);
content.Items[0].AffectsKey.Should().Be(TestArtifactKey);
}
#endregion
#region GET /api/v1/canonical?mergeHash={mergeHash}
[Fact]
public async Task QueryByMergeHash_ReturnsCanonical()
{
// Arrange
var canonical = CreateTestCanonical(TestCanonicalId, TestCve);
_serviceMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync(canonical);
// Act
var response = await _client.GetAsync($"/api/v1/canonical?mergeHash={Uri.EscapeDataString(TestMergeHash)}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
content.Should().NotBeNull();
content!.Items.Should().HaveCount(1);
content.TotalCount.Should().Be(1);
}
[Fact]
public async Task QueryByMergeHash_ReturnsEmpty_WhenNotFound()
{
// Arrange
_serviceMock
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
// Act
var response = await _client.GetAsync($"/api/v1/canonical?mergeHash=sha256:nonexistent");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
content.Should().NotBeNull();
content!.Items.Should().BeEmpty();
content.TotalCount.Should().Be(0);
}
#endregion
#region GET /api/v1/canonical (pagination)
[Fact]
public async Task Query_SupportsPagination()
{
// Arrange
var pagedResult = new PagedResult<CanonicalAdvisory>
{
Items = new List<CanonicalAdvisory> { CreateTestCanonical(TestCanonicalId, TestCve) },
TotalCount = 100,
Offset = 10,
Limit = 25
};
_serviceMock
.Setup(x => x.QueryAsync(It.Is<CanonicalQueryOptions>(o =>
o.Offset == 10 && o.Limit == 25), It.IsAny<CancellationToken>()))
.ReturnsAsync(pagedResult);
// Act
var response = await _client.GetAsync("/api/v1/canonical?offset=10&limit=25");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
content.Should().NotBeNull();
content!.TotalCount.Should().Be(100);
content.Offset.Should().Be(10);
content.Limit.Should().Be(25);
}
#endregion
#region POST /api/v1/canonical/ingest/{source}
[Fact]
public async Task Ingest_ReturnsOk_WhenCreated()
{
// Arrange
var ingestResult = IngestResult.Created(TestCanonicalId, TestMergeHash, Guid.NewGuid(), "nvd", "NVD-001");
_serviceMock
.Setup(x => x.IngestAsync(
"nvd",
It.Is<RawAdvisory>(a => a.Cve == TestCve),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ingestResult);
var request = new RawAdvisoryRequest
{
Cve = TestCve,
AffectsKey = TestArtifactKey,
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.0\"}",
Severity = "high",
Title = "Test vulnerability"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<IngestResultResponse>();
content.Should().NotBeNull();
content!.Decision.Should().Be("Created");
content.CanonicalId.Should().Be(TestCanonicalId);
content.MergeHash.Should().Be(TestMergeHash);
}
[Fact]
public async Task Ingest_ReturnsOk_WhenMerged()
{
// Arrange
var ingestResult = IngestResult.Merged(TestCanonicalId, TestMergeHash, Guid.NewGuid(), "ghsa", "GHSA-001");
_serviceMock
.Setup(x => x.IngestAsync(
"ghsa",
It.IsAny<RawAdvisory>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ingestResult);
var request = new RawAdvisoryRequest
{
Cve = TestCve,
AffectsKey = TestArtifactKey
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/ghsa", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<IngestResultResponse>();
content.Should().NotBeNull();
content!.Decision.Should().Be("Merged");
}
[Fact]
public async Task Ingest_ReturnsConflict_WhenConflict()
{
// Arrange
var ingestResult = IngestResult.Conflict(TestCanonicalId, TestMergeHash, "Version range mismatch", "nvd", "NVD-002");
_serviceMock
.Setup(x => x.IngestAsync(
"nvd",
It.IsAny<RawAdvisory>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ingestResult);
var request = new RawAdvisoryRequest
{
Cve = TestCve,
AffectsKey = TestArtifactKey
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var content = await response.Content.ReadFromJsonAsync<IngestResultResponse>();
content.Should().NotBeNull();
content!.Decision.Should().Be("Conflict");
content.ConflictReason.Should().Be("Version range mismatch");
}
[Fact]
public async Task Ingest_ReturnsBadRequest_WhenCveMissing()
{
// Arrange
var request = new RawAdvisoryRequest
{
AffectsKey = TestArtifactKey
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Ingest_ReturnsBadRequest_WhenAffectsKeyMissing()
{
// Arrange
var request = new RawAdvisoryRequest
{
Cve = TestCve
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
#region POST /api/v1/canonical/ingest/{source}/batch
[Fact]
public async Task IngestBatch_ReturnsOk_WithSummary()
{
// Arrange
var results = new List<IngestResult>
{
IngestResult.Created(Guid.NewGuid(), "hash1", Guid.NewGuid(), "nvd", "NVD-001"),
IngestResult.Merged(Guid.NewGuid(), "hash2", Guid.NewGuid(), "nvd", "NVD-002"),
IngestResult.Duplicate(Guid.NewGuid(), "hash3", "nvd", "NVD-003")
};
_serviceMock
.Setup(x => x.IngestBatchAsync(
"nvd",
It.IsAny<IEnumerable<RawAdvisory>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(results);
var requests = new[]
{
new RawAdvisoryRequest { Cve = "CVE-2025-0001", AffectsKey = "pkg:npm/a@1" },
new RawAdvisoryRequest { Cve = "CVE-2025-0002", AffectsKey = "pkg:npm/b@1" },
new RawAdvisoryRequest { Cve = "CVE-2025-0003", AffectsKey = "pkg:npm/c@1" }
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd/batch", requests);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<BatchIngestResultResponse>();
content.Should().NotBeNull();
content!.Results.Should().HaveCount(3);
content.Summary.Total.Should().Be(3);
content.Summary.Created.Should().Be(1);
content.Summary.Merged.Should().Be(1);
content.Summary.Duplicates.Should().Be(1);
content.Summary.Conflicts.Should().Be(0);
}
#endregion
#region PATCH /api/v1/canonical/{id}/status
[Fact]
public async Task UpdateStatus_ReturnsOk_WhenValid()
{
// Arrange
_serviceMock
.Setup(x => x.UpdateStatusAsync(TestCanonicalId, CanonicalStatus.Withdrawn, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var request = new UpdateStatusRequest { Status = "Withdrawn" };
// Act
var response = await _client.PatchAsJsonAsync($"/api/v1/canonical/{TestCanonicalId}/status", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
_serviceMock.Verify(x => x.UpdateStatusAsync(
TestCanonicalId,
CanonicalStatus.Withdrawn,
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpdateStatus_ReturnsBadRequest_WhenInvalidStatus()
{
// Arrange
var request = new UpdateStatusRequest { Status = "InvalidStatus" };
// Act
var response = await _client.PatchAsJsonAsync($"/api/v1/canonical/{TestCanonicalId}/status", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
#region Helpers
private static CanonicalAdvisory CreateTestCanonical(
Guid id,
string cve,
string affectsKey = "pkg:npm/example@1")
{
return new CanonicalAdvisory
{
Id = id,
Cve = cve,
AffectsKey = affectsKey,
MergeHash = TestMergeHash,
Status = CanonicalStatus.Active,
Severity = "high",
Title = $"Test advisory for {cve}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
SourceEdges = new List<SourceEdge>
{
new SourceEdge
{
Id = Guid.NewGuid(),
SourceName = "nvd",
SourceAdvisoryId = $"NVD-{cve}",
SourceDocHash = "sha256:doctest",
PrecedenceRank = 40,
FetchedAt = DateTimeOffset.UtcNow
}
}
};
}
#endregion
}

View File

@@ -13,6 +13,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Update="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />