Refactor code structure and optimize performance across multiple modules

This commit is contained in:
StellaOps Bot
2025-12-26 20:03:22 +02:00
parent c786faae84
commit b4fc66feb6
3353 changed files with 88254 additions and 1590657 deletions

View File

@@ -10,13 +10,16 @@ using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.BinaryIndex.Core.Tests;
public class ElfFeatureExtractorTests
{
private readonly ElfFeatureExtractor _extractor = new();
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithElfMagic_ReturnsTrue()
{
// Arrange: ELF magic bytes
@@ -30,7 +33,8 @@ public class ElfFeatureExtractorTests
result.Should().BeTrue();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithNonElfMagic_ReturnsFalse()
{
// Arrange: Not ELF
@@ -44,7 +48,8 @@ public class ElfFeatureExtractorTests
result.Should().BeFalse();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithEmptyStream_ReturnsFalse()
{
// Arrange
@@ -57,7 +62,8 @@ public class ElfFeatureExtractorTests
result.Should().BeFalse();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithValidElf64_ReturnsCorrectMetadata()
{
// Arrange: Minimal ELF64 header (little-endian, x86_64, executable)
@@ -77,7 +83,8 @@ public class ElfFeatureExtractorTests
metadata.Type.Should().Be(BinaryType.Executable);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithElf64SharedLib_ReturnsSharedLibrary()
{
// Arrange: ELF64 shared library
@@ -95,7 +102,8 @@ public class ElfFeatureExtractorTests
metadata.Type.Should().Be(BinaryType.SharedLibrary);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithAarch64_ReturnsCorrectArchitecture()
{
// Arrange: ELF64 aarch64
@@ -113,7 +121,8 @@ public class ElfFeatureExtractorTests
metadata.Architecture.Should().Be("aarch64");
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractIdentityAsync_ProducesConsistentBinaryKey()
{
// Arrange: Same ELF content
@@ -163,7 +172,8 @@ public class PeFeatureExtractorTests
{
private readonly PeFeatureExtractor _extractor = new();
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithDosMagic_ReturnsTrue()
{
// Arrange: DOS/PE magic bytes
@@ -177,7 +187,8 @@ public class PeFeatureExtractorTests
result.Should().BeTrue();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithElfMagic_ReturnsFalse()
{
// Arrange: ELF magic
@@ -191,7 +202,8 @@ public class PeFeatureExtractorTests
result.Should().BeFalse();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithPe64_ReturnsCorrectMetadata()
{
// Arrange: PE32+ x86_64 executable
@@ -207,7 +219,8 @@ public class PeFeatureExtractorTests
metadata.Type.Should().Be(BinaryType.Executable);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithDll_ReturnsSharedLibrary()
{
// Arrange: PE DLL
@@ -224,7 +237,8 @@ public class PeFeatureExtractorTests
metadata.Type.Should().Be(BinaryType.SharedLibrary);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithX86_ReturnsCorrectArchitecture()
{
// Arrange: PE32 x86
@@ -238,7 +252,8 @@ public class PeFeatureExtractorTests
metadata.Architecture.Should().Be("x86");
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractIdentityAsync_ProducesConsistentBinaryKey()
{
// Arrange: Same PE content
@@ -293,7 +308,8 @@ public class MachoFeatureExtractorTests
{
private readonly MachoFeatureExtractor _extractor = new();
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithMacho64Magic_ReturnsTrue()
{
// Arrange: Mach-O 64-bit magic
@@ -307,7 +323,8 @@ public class MachoFeatureExtractorTests
result.Should().BeTrue();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithFatBinaryMagic_ReturnsTrue()
{
// Arrange: Universal binary magic
@@ -321,7 +338,8 @@ public class MachoFeatureExtractorTests
result.Should().BeTrue();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanExtract_WithElfMagic_ReturnsFalse()
{
// Arrange: ELF magic
@@ -335,7 +353,8 @@ public class MachoFeatureExtractorTests
result.Should().BeFalse();
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithMacho64Executable_ReturnsCorrectMetadata()
{
// Arrange: Mach-O 64-bit x86_64 executable
@@ -354,7 +373,8 @@ public class MachoFeatureExtractorTests
metadata.Type.Should().Be(BinaryType.Executable);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithDylib_ReturnsSharedLibrary()
{
// Arrange: Mach-O dylib
@@ -371,7 +391,8 @@ public class MachoFeatureExtractorTests
metadata.Type.Should().Be(BinaryType.SharedLibrary);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractMetadataAsync_WithArm64_ReturnsCorrectArchitecture()
{
// Arrange: Mach-O arm64
@@ -388,7 +409,8 @@ public class MachoFeatureExtractorTests
metadata.Architecture.Should().Be("aarch64");
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractIdentityAsync_ProducesConsistentBinaryKey()
{
// Arrange: Same Mach-O content
@@ -437,7 +459,8 @@ public class MachoFeatureExtractorTests
public class BinaryIdentityDeterminismTests
{
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AllExtractors_SameContent_ProduceSameHash()
{
// Arrange: Create identical binary content
@@ -472,7 +495,8 @@ public class BinaryIdentityDeterminismTests
identity2.BinaryKey.Should().Be(identity3.BinaryKey);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DifferentContent_ProducesDifferentHash()
{
// Arrange

View File

@@ -25,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.FixIndex\StellaOps.BinaryIndex.FixIndex.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,160 @@
// -----------------------------------------------------------------------------
// BasicBlockFingerprintGeneratorTests.cs
// Sprint: SPRINT_20251226_013_BINIDX_fingerprint_factory
// Task: FPRINT-21 — Add unit tests for fingerprint generation
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Fingerprints.Generators;
using StellaOps.BinaryIndex.Fingerprints.Models;
using Xunit;
namespace StellaOps.BinaryIndex.Fingerprints.Tests.Generators;
public class BasicBlockFingerprintGeneratorTests
{
private readonly BasicBlockFingerprintGenerator _generator;
public BasicBlockFingerprintGeneratorTests()
{
_generator = new BasicBlockFingerprintGenerator(
NullLogger<BasicBlockFingerprintGenerator>.Instance);
}
[Fact]
public void Algorithm_ReturnsBasicBlock()
{
_generator.Algorithm.Should().Be(FingerprintAlgorithm.BasicBlock);
}
[Theory]
[InlineData(15, false)] // Too small
[InlineData(16, true)] // Minimum size
[InlineData(100, true)] // Normal size
[InlineData(1000, true)] // Large
public void CanProcess_ChecksMinimumSize(int size, bool expected)
{
var input = CreateInput(new byte[size]);
_generator.CanProcess(input).Should().Be(expected);
}
[Fact]
public async Task GenerateAsync_ProducesValidFingerprint()
{
// Sample x86_64 code (simplified)
var binaryData = new byte[]
{
0x55, // push rbp
0x48, 0x89, 0xe5, // mov rbp, rsp
0x48, 0x83, 0xec, 0x10, // sub rsp, 0x10
0x89, 0x7d, 0xfc, // mov [rbp-4], edi
0x8b, 0x45, 0xfc, // mov eax, [rbp-4]
0x83, 0xc0, 0x01, // add eax, 1
0x89, 0xc7, // mov edi, eax
0xe8, 0x00, 0x00, 0x00, 0x00, // call (placeholder)
0xc9, // leave
0xc3 // ret
};
var input = CreateInput(binaryData);
var result = await _generator.GenerateAsync(input);
result.Should().NotBeNull();
result.Hash.Should().HaveCount(16); // 16-byte fingerprint
result.FingerprintId.Should().HaveLength(32); // Hex-encoded
result.Algorithm.Should().Be(FingerprintAlgorithm.BasicBlock);
result.Confidence.Should().BeGreaterThan(0);
result.Metadata.Should().NotBeNull();
}
[Fact]
public async Task GenerateAsync_ProducesDeterministicFingerprints()
{
var binaryData = new byte[]
{
0x55, 0x48, 0x89, 0xe5, // Function prologue
0x48, 0x83, 0xec, 0x20, // sub rsp, 0x20
0xc3 // ret
};
var input = CreateInput(binaryData);
var result1 = await _generator.GenerateAsync(input);
var result2 = await _generator.GenerateAsync(input);
result1.Hash.Should().BeEquivalentTo(result2.Hash);
result1.FingerprintId.Should().Be(result2.FingerprintId);
}
[Fact]
public async Task GenerateAsync_DifferentCodeProducesDifferentFingerprints()
{
var binaryData1 = new byte[]
{
0x55, 0x48, 0x89, 0xe5, 0x48, 0x83, 0xec, 0x10,
0x89, 0x7d, 0xfc, 0x8b, 0x45, 0xfc, 0xc3
};
var binaryData2 = new byte[]
{
0x55, 0x48, 0x89, 0xe5, 0x48, 0x83, 0xec, 0x20,
0x89, 0x7d, 0xec, 0x8b, 0x45, 0xec, 0xc3
};
var result1 = await _generator.GenerateAsync(CreateInput(binaryData1));
var result2 = await _generator.GenerateAsync(CreateInput(binaryData2));
result1.FingerprintId.Should().NotBe(result2.FingerprintId);
}
[Fact]
public async Task GenerateAsync_IncludesMetadata()
{
var binaryData = new byte[]
{
0x55, 0x48, 0x89, 0xe5, // Block 1
0xe8, 0x00, 0x00, 0x00, 0x00, // call
0x48, 0x85, 0xc0, // Block 2
0x74, 0x05, // je (conditional)
0xb8, 0x01, 0x00, 0x00, 0x00, // Block 3: mov eax, 1
0xc3, // ret
0xb8, 0x00, 0x00, 0x00, 0x00, // Block 4: mov eax, 0
0xc3 // ret
};
var result = await _generator.GenerateAsync(CreateInput(binaryData));
result.Metadata.Should().NotBeNull();
result.Metadata!.BasicBlockCount.Should().BeGreaterThan(0);
result.Metadata!.FunctionSize.Should().Be(binaryData.Length);
}
[Theory]
[InlineData("x86_64")]
[InlineData("amd64")]
[InlineData("aarch64")]
public async Task GenerateAsync_SupportsMultipleArchitectures(string arch)
{
var input = CreateInput(new byte[32], architecture: arch);
var result = await _generator.GenerateAsync(input);
result.Should().NotBeNull();
result.Hash.Should().NotBeEmpty();
}
private static FingerprintInput CreateInput(
byte[] binaryData,
string architecture = "x86_64")
{
return new FingerprintInput
{
BinaryData = binaryData,
Architecture = architecture,
CveId = "CVE-2024-TEST",
Component = "test-component"
};
}
}

View File

@@ -0,0 +1,228 @@
// -----------------------------------------------------------------------------
// FingerprintMatcherTests.cs
// Sprint: SPRINT_20251226_013_BINIDX_fingerprint_factory
// Task: FPRINT-22 — Add integration tests for matching pipeline
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.BinaryIndex.Fingerprints.Matching;
using StellaOps.BinaryIndex.Fingerprints.Models;
using Xunit;
namespace StellaOps.BinaryIndex.Fingerprints.Tests.Matching;
public class FingerprintMatcherTests
{
private readonly Mock<IFingerprintRepository> _repositoryMock;
private readonly FingerprintMatcher _matcher;
public FingerprintMatcherTests()
{
_repositoryMock = new Mock<IFingerprintRepository>();
_matcher = new FingerprintMatcher(
NullLogger<FingerprintMatcher>.Instance,
_repositoryMock.Object);
}
[Fact]
public async Task MatchAsync_NoCandidate_ReturnsNoMatch()
{
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<VulnFingerprint>.Empty);
var fingerprint = new byte[16];
var result = await _matcher.MatchAsync(fingerprint);
result.IsMatch.Should().BeFalse();
result.Similarity.Should().Be(0);
result.MatchedFingerprint.Should().BeNull();
}
[Fact]
public async Task MatchAsync_ExactMatch_ReturnsMatch()
{
var testFingerprint = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
var storedFingerprint = CreateStoredFingerprint(testFingerprint);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint);
result.IsMatch.Should().BeTrue();
result.Similarity.Should().Be(1.0m);
result.MatchedFingerprint.Should().NotBeNull();
result.MatchedFingerprint!.CveId.Should().Be("CVE-2024-TEST");
}
[Fact]
public async Task MatchAsync_SimilarMatch_ReturnsMatchAboveThreshold()
{
var testFingerprint = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
// Change one byte - should still match with high similarity
var storedHash = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17 };
var storedFingerprint = CreateStoredFingerprint(storedHash);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint, new MatchOptions { MinSimilarity = 0.9m });
result.IsMatch.Should().BeTrue();
result.Similarity.Should().BeGreaterThan(0.9m);
}
[Fact]
public async Task MatchAsync_DissimilarFingerprint_ReturnsNoMatch()
{
var testFingerprint = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
var storedHash = new byte[] { 255, 254, 253, 252, 251, 250, 249, 248, 247, 246, 245, 244, 243, 242, 241, 240 };
var storedFingerprint = CreateStoredFingerprint(storedHash);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint);
result.IsMatch.Should().BeFalse();
result.Similarity.Should().BeLessThan(0.5m);
}
[Fact]
public async Task MatchAsync_RespectsThreshold()
{
var testFingerprint = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
var storedHash = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 20, 20 };
var storedFingerprint = CreateStoredFingerprint(storedHash);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
// With high threshold - no match
var highThresholdResult = await _matcher.MatchAsync(testFingerprint, new MatchOptions { MinSimilarity = 0.99m });
highThresholdResult.IsMatch.Should().BeFalse();
// With lower threshold - match
var lowThresholdResult = await _matcher.MatchAsync(testFingerprint, new MatchOptions { MinSimilarity = 0.8m });
lowThresholdResult.IsMatch.Should().BeTrue();
}
[Fact]
public async Task MatchAsync_RespectsValidatedFilter()
{
var testFingerprint = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
var storedFingerprint = CreateStoredFingerprint(testFingerprint, validated: false);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint, new MatchOptions { RequireValidated = true });
result.IsMatch.Should().BeFalse(); // Filtered out because not validated
}
[Fact]
public async Task MatchBatchAsync_ProcessesAllFingerprints()
{
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray<VulnFingerprint>.Empty);
var fingerprints = new List<byte[]>
{
new byte[16],
new byte[16],
new byte[16]
};
var results = await _matcher.MatchBatchAsync(fingerprints);
results.Should().HaveCount(3);
}
[Theory]
[InlineData(new byte[] { 1, 2, 3, 4 }, new byte[] { 1, 2, 3, 4 }, 1.0)]
[InlineData(new byte[] { 1, 2, 3, 4 }, new byte[] { 1, 2, 3, 5 }, 0.9)] // 1 byte different
[InlineData(new byte[] { 0, 0, 0, 0 }, new byte[] { 255, 255, 255, 255 }, 0.0)] // Completely different
public void CalculateSimilarity_ReturnsExpectedValues(byte[] fp1, byte[] fp2, double expectedApprox)
{
var similarity = _matcher.CalculateSimilarity(fp1, fp2, FingerprintAlgorithm.BasicBlock);
similarity.Should().BeApproximately((decimal)expectedApprox, 0.15m);
}
[Fact]
public async Task MatchAsync_IncludesMatchDetails()
{
var testFingerprint = new byte[16];
var storedFingerprint = CreateStoredFingerprint(testFingerprint);
_repositoryMock
.Setup(r => r.SearchByHashAsync(
It.IsAny<byte[]>(),
It.IsAny<FingerprintAlgorithm>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create(storedFingerprint));
var result = await _matcher.MatchAsync(testFingerprint);
result.Details.Should().NotBeNull();
result.Details!.MatchingAlgorithm.Should().Be(FingerprintAlgorithm.BasicBlock);
result.Details.CandidatesEvaluated.Should().Be(1);
result.Details.MatchTimeMs.Should().BeGreaterOrEqualTo(0);
}
private static VulnFingerprint CreateStoredFingerprint(byte[] hash, bool validated = false)
{
return new VulnFingerprint
{
Id = Guid.NewGuid(),
CveId = "CVE-2024-TEST",
Component = "test-component",
Algorithm = FingerprintAlgorithm.BasicBlock,
FingerprintId = Convert.ToHexString(hash).ToLowerInvariant(),
FingerprintHash = hash,
Architecture = "x86_64",
Validated = validated,
IndexedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,337 @@
// -----------------------------------------------------------------------------
// BinaryIdentityRepositoryTests.cs
// Sprint: SPRINT_20251226_011_BINIDX
// Task: BINCAT-18 - Integration tests with Testcontainers PostgreSQL
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Persistence.Repositories;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
/// <summary>
/// Integration tests for BinaryIdentityRepository using real PostgreSQL.
/// </summary>
[Collection(nameof(BinaryIndexDatabaseCollection))]
public sealed class BinaryIdentityRepositoryTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
using StellaOps.TestKit;
public BinaryIdentityRepositoryTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_InsertsNewIdentity_ReturnsWithId()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var identity = new BinaryIdentity
{
BinaryKey = $"elf:x86_64:{Guid.NewGuid():N}",
BuildId = "abc123def456",
BuildIdType = "gnu-build-id",
FileSha256 = "sha256:abcd1234567890abcdef1234567890abcdef12345678",
TextSha256 = "sha256:text1234",
Format = BinaryFormat.Elf,
Architecture = "x86_64",
OsAbi = "linux",
Type = BinaryType.SharedLibrary,
IsStripped = false
};
// Act
var result = await repository.UpsertAsync(identity, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().NotBe(Guid.Empty);
result.BinaryKey.Should().Be(identity.BinaryKey);
result.BuildId.Should().Be(identity.BuildId);
result.BuildIdType.Should().Be(identity.BuildIdType);
result.FileSha256.Should().Be(identity.FileSha256);
result.Format.Should().Be(BinaryFormat.Elf);
result.Architecture.Should().Be("x86_64");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_UpdatesExistingIdentity_PreservesOriginalId()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var binaryKey = $"elf:x86_64:{Guid.NewGuid():N}";
var original = new BinaryIdentity
{
BinaryKey = binaryKey,
BuildId = "original-build-id",
BuildIdType = "gnu-build-id",
FileSha256 = "sha256:original",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
var inserted = await repository.UpsertAsync(original, CancellationToken.None);
var updated = new BinaryIdentity
{
BinaryKey = binaryKey,
BuildId = "original-build-id",
BuildIdType = "gnu-build-id",
FileSha256 = "sha256:original",
Format = BinaryFormat.Elf,
Architecture = "x86_64",
LastSeenSnapshotId = Guid.NewGuid()
};
// Act
var result = await repository.UpsertAsync(updated, CancellationToken.None);
// Assert
result.Id.Should().Be(inserted.Id, "upsert should preserve original row ID");
result.LastSeenSnapshotId.Should().Be(updated.LastSeenSnapshotId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByBuildIdAsync_ExistingBuildId_ReturnsIdentity()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var buildId = $"build-{Guid.NewGuid():N}";
var identity = new BinaryIdentity
{
BinaryKey = $"elf:x86_64:{Guid.NewGuid():N}",
BuildId = buildId,
BuildIdType = "gnu-build-id",
FileSha256 = "sha256:test",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
await repository.UpsertAsync(identity, CancellationToken.None);
// Act
var result = await repository.GetByBuildIdAsync(buildId, "gnu-build-id", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.BuildId.Should().Be(buildId);
result.BinaryKey.Should().Be(identity.BinaryKey);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByBuildIdAsync_NonExistentBuildId_ReturnsNull()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
// Act
var result = await repository.GetByBuildIdAsync("non-existent-build-id", "gnu-build-id", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByKeyAsync_ExistingKey_ReturnsIdentity()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var binaryKey = $"pe:x86_64:{Guid.NewGuid():N}";
var identity = new BinaryIdentity
{
BinaryKey = binaryKey,
FileSha256 = "sha256:pe-test",
Format = BinaryFormat.Pe,
Architecture = "x86_64"
};
await repository.UpsertAsync(identity, CancellationToken.None);
// Act
var result = await repository.GetByKeyAsync(binaryKey, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.BinaryKey.Should().Be(binaryKey);
result.Format.Should().Be(BinaryFormat.Pe);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByKeyAsync_NonExistentKey_ReturnsNull()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
// Act
var result = await repository.GetByKeyAsync("non-existent-key", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetBatchAsync_MultipleExistingKeys_ReturnsAllMatches()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var identities = Enumerable.Range(0, 5)
.Select(i => new BinaryIdentity
{
BinaryKey = $"macho:arm64:{Guid.NewGuid():N}",
FileSha256 = $"sha256:batch-test-{i}",
Format = BinaryFormat.Macho,
Architecture = "arm64"
})
.ToList();
foreach (var identity in identities)
{
await repository.UpsertAsync(identity, CancellationToken.None);
}
var keys = identities.Select(i => i.BinaryKey).ToList();
// Act
var results = await repository.GetBatchAsync(keys, CancellationToken.None);
// Assert
results.Should().HaveCount(5);
results.Select(r => r.BinaryKey).Should().BeEquivalentTo(keys);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetBatchAsync_MixedExistentAndNonExistentKeys_ReturnsOnlyExisting()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var existingKey = $"elf:x86_64:{Guid.NewGuid():N}";
var nonExistentKey = $"elf:x86_64:non-existent-{Guid.NewGuid():N}";
var identity = new BinaryIdentity
{
BinaryKey = existingKey,
FileSha256 = "sha256:mixed-test",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
await repository.UpsertAsync(identity, CancellationToken.None);
// Act
var results = await repository.GetBatchAsync([existingKey, nonExistentKey], CancellationToken.None);
// Assert
results.Should().HaveCount(1);
results[0].BinaryKey.Should().Be(existingKey);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetBatchAsync_EmptyKeysList_ReturnsEmpty()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
// Act
var results = await repository.GetBatchAsync([], CancellationToken.None);
// Assert
results.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_AllBinaryFormats_PersistsCorrectly()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
var testCases = new[]
{
(Format: BinaryFormat.Elf, Arch: "x86_64"),
(Format: BinaryFormat.Pe, Arch: "x86"),
(Format: BinaryFormat.Macho, Arch: "arm64")
};
foreach (var (format, arch) in testCases)
{
var identity = new BinaryIdentity
{
BinaryKey = $"{format.ToString().ToLowerInvariant()}:{arch}:{Guid.NewGuid():N}",
FileSha256 = $"sha256:format-test-{format}",
Format = format,
Architecture = arch
};
// Act
var result = await repository.UpsertAsync(identity, CancellationToken.None);
// Assert
result.Format.Should().Be(format, $"format {format} should be persisted correctly");
result.Architecture.Should().Be(arch);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_AllBinaryTypes_PersistsCorrectly()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new BinaryIdentityRepository(dbContext);
foreach (var binaryType in Enum.GetValues<BinaryType>())
{
var identity = new BinaryIdentity
{
BinaryKey = $"elf:x86_64:{Guid.NewGuid():N}",
FileSha256 = $"sha256:type-test-{binaryType}",
Format = BinaryFormat.Elf,
Architecture = "x86_64",
Type = binaryType
};
// Act
var result = await repository.UpsertAsync(identity, CancellationToken.None);
// Assert
result.Type.Should().Be(binaryType, $"binary type {binaryType} should be persisted correctly");
}
}
}
/// <summary>
/// Collection definition for sharing the PostgreSQL container across tests.
/// </summary>
[CollectionDefinition(nameof(BinaryIndexDatabaseCollection))]
public sealed class BinaryIndexDatabaseCollection : ICollectionFixture<BinaryIndexIntegrationFixture>
{
}

View File

@@ -0,0 +1,77 @@
// -----------------------------------------------------------------------------
// BinaryIndexIntegrationFixture.cs
// Sprint: SPRINT_20251226_011_BINIDX
// Task: BINCAT-18 - Integration tests with Testcontainers PostgreSQL
// -----------------------------------------------------------------------------
using System.Reflection;
using Dapper;
using Npgsql;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.Infrastructure.Postgres.Testing;
namespace StellaOps.BinaryIndex.Persistence.Tests;
/// <summary>
/// PostgreSQL integration test fixture for BinaryIndex module.
/// Spins up a real PostgreSQL container and runs schema migrations.
/// </summary>
public sealed class BinaryIndexIntegrationFixture : PostgresIntegrationFixture
{
private const string TestTenantId = "00000000-0000-0000-0000-000000000001";
private NpgsqlDataSource? _dataSource;
protected override Assembly? GetMigrationAssembly()
=> typeof(BinaryIndexDbContext).Assembly;
protected override string GetModuleName() => "BinaryIndex";
protected override string? GetResourcePrefix() => "StellaOps.BinaryIndex.Persistence.Migrations";
public override async Task InitializeAsync()
{
await base.InitializeAsync();
_dataSource = NpgsqlDataSource.Create(ConnectionString);
}
public override async Task DisposeAsync()
{
if (_dataSource != null)
{
await _dataSource.DisposeAsync();
}
await base.DisposeAsync();
}
/// <summary>
/// Creates a new database context with the test tenant configured.
/// </summary>
public BinaryIndexDbContext CreateDbContext(string? tenantId = null)
{
if (_dataSource == null)
{
throw new InvalidOperationException("Fixture not initialized. Call InitializeAsync first.");
}
var tenant = new TestTenantContext(tenantId ?? TestTenantId);
return new BinaryIndexDbContext(_dataSource, tenant);
}
/// <summary>
/// Gets the default test tenant ID.
/// </summary>
public string GetTestTenantId() => TestTenantId;
}
/// <summary>
/// Simple tenant context implementation for testing.
/// </summary>
internal sealed class TestTenantContext : ITenantContext
{
public TestTenantContext(string tenantId)
{
TenantId = tenantId;
}
public string TenantId { get; }
}

View File

@@ -0,0 +1,189 @@
// -----------------------------------------------------------------------------
// CorpusSnapshotRepositoryTests.cs
// Sprint: SPRINT_20251226_011_BINIDX
// Task: BINCAT-18 - Integration tests with Testcontainers PostgreSQL
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Corpus;
using StellaOps.BinaryIndex.Persistence.Repositories;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
/// <summary>
/// Integration tests for CorpusSnapshotRepository using real PostgreSQL.
/// </summary>
[Collection(nameof(BinaryIndexDatabaseCollection))]
public sealed class CorpusSnapshotRepositoryTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
using StellaOps.TestKit;
public CorpusSnapshotRepositoryTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAsync_NewSnapshot_ReturnsWithId()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var snapshot = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: "debian",
Release: "bookworm",
Architecture: "amd64",
MetadataDigest: $"sha256:{Guid.NewGuid():N}",
CapturedAt: DateTimeOffset.UtcNow);
// Act
var result = await repository.CreateAsync(snapshot, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(snapshot.Id);
result.Distro.Should().Be("debian");
result.Release.Should().Be("bookworm");
result.Architecture.Should().Be("amd64");
result.MetadataDigest.Should().Be(snapshot.MetadataDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByIdAsync_ExistingSnapshot_ReturnsSnapshot()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var snapshot = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: "ubuntu",
Release: "noble",
Architecture: "arm64",
MetadataDigest: $"sha256:{Guid.NewGuid():N}",
CapturedAt: DateTimeOffset.UtcNow);
await repository.CreateAsync(snapshot, CancellationToken.None);
// Act
var result = await repository.GetByIdAsync(snapshot.Id, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(snapshot.Id);
result.Distro.Should().Be("ubuntu");
result.Release.Should().Be("noble");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByIdAsync_NonExistentId_ReturnsNull()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
// Act
var result = await repository.GetByIdAsync(Guid.NewGuid(), CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindByKeyAsync_ExistingKey_ReturnsLatestSnapshot()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var distro = $"testdistro-{Guid.NewGuid():N}";
var release = "v1.0";
var architecture = "x86_64";
// Create older snapshot
var older = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: distro,
Release: release,
Architecture: architecture,
MetadataDigest: "sha256:older",
CapturedAt: DateTimeOffset.UtcNow.AddDays(-1));
await repository.CreateAsync(older, CancellationToken.None);
// Create newer snapshot
var newer = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: distro,
Release: release,
Architecture: architecture,
MetadataDigest: "sha256:newer",
CapturedAt: DateTimeOffset.UtcNow);
await repository.CreateAsync(newer, CancellationToken.None);
// Act
var result = await repository.FindByKeyAsync(distro, release, architecture, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(newer.Id, "should return most recent snapshot");
result.MetadataDigest.Should().Be("sha256:newer");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindByKeyAsync_NonExistentKey_ReturnsNull()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
// Act
var result = await repository.FindByKeyAsync("nonexistent", "v1.0", "x86_64", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateAsync_MultipleSnapshots_AllPersisted()
{
// Arrange
var dbContext = _fixture.CreateDbContext();
var repository = new CorpusSnapshotRepository(dbContext, NullLogger<CorpusSnapshotRepository>.Instance);
var distros = new[] { "debian", "ubuntu", "alpine" };
var snapshots = distros.Select(d => new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: $"{d}-{Guid.NewGuid():N}",
Release: "latest",
Architecture: "amd64",
MetadataDigest: $"sha256:{d}",
CapturedAt: DateTimeOffset.UtcNow)).ToList();
// Act
foreach (var snapshot in snapshots)
{
await repository.CreateAsync(snapshot, CancellationToken.None);
}
// Assert
foreach (var snapshot in snapshots)
{
var result = await repository.GetByIdAsync(snapshot.Id, CancellationToken.None);
result.Should().NotBeNull();
result!.Id.Should().Be(snapshot.Id);
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.BinaryIndex.Persistence.Tests</RootNamespace>
<AssemblyName>StellaOps.BinaryIndex.Persistence.Tests</AssemblyName>
<Description>PostgreSQL integration tests for BinaryIndex module using Testcontainers</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Persistence\StellaOps.BinaryIndex.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>