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