Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

@@ -0,0 +1,444 @@
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="AdvisoryRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class AdvisoryRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _repository;
private readonly AdvisoryAliasRepository _aliasRepository;
private readonly AdvisoryAffectedRepository _affectedRepository;
private readonly AdvisoryCvssRepository _cvssRepository;
public AdvisoryRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
_aliasRepository = new AdvisoryAliasRepository(_dataSource, NullLogger<AdvisoryAliasRepository>.Instance);
_affectedRepository = new AdvisoryAffectedRepository(_dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
_cvssRepository = new AdvisoryCvssRepository(_dataSource, NullLogger<AdvisoryCvssRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_ShouldInsertNewAdvisory()
{
// Arrange
var advisory = CreateTestAdvisory();
// Act
var result = await _repository.UpsertAsync(advisory);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(advisory.Id);
result.AdvisoryKey.Should().Be(advisory.AdvisoryKey);
result.PrimaryVulnId.Should().Be(advisory.PrimaryVulnId);
result.Title.Should().Be(advisory.Title);
result.Severity.Should().Be(advisory.Severity);
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingAdvisory()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Create updated version with same advisory_key
var updatedAdvisory = new AdvisoryEntity
{
Id = Guid.NewGuid(), // Different ID but same key
AdvisoryKey = advisory.AdvisoryKey,
PrimaryVulnId = advisory.PrimaryVulnId,
Title = "Updated Title",
Severity = "HIGH",
Summary = advisory.Summary,
Description = advisory.Description,
PublishedAt = advisory.PublishedAt,
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "update-test"}"""
};
// Act
var result = await _repository.UpsertAsync(updatedAdvisory);
// Assert
result.Should().NotBeNull();
result.Title.Should().Be("Updated Title");
result.Severity.Should().Be("HIGH");
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnAdvisory_WhenExists()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Act
var result = await _repository.GetByIdAsync(advisory.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(advisory.Id);
result.AdvisoryKey.Should().Be(advisory.AdvisoryKey);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetByKeyAsync_ShouldReturnAdvisory_WhenExists()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Act
var result = await _repository.GetByKeyAsync(advisory.AdvisoryKey);
// Assert
result.Should().NotBeNull();
result!.AdvisoryKey.Should().Be(advisory.AdvisoryKey);
}
[Fact]
public async Task GetByVulnIdAsync_ShouldReturnAdvisory_WhenExists()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Act
var result = await _repository.GetByVulnIdAsync(advisory.PrimaryVulnId);
// Assert
result.Should().NotBeNull();
result!.PrimaryVulnId.Should().Be(advisory.PrimaryVulnId);
}
[Fact]
public async Task UpsertAsync_WithAliases_ShouldStoreAliases()
{
// Arrange
var advisory = CreateTestAdvisory();
var aliases = new[]
{
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
AliasType = "cve",
AliasValue = advisory.PrimaryVulnId,
IsPrimary = true
},
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
AliasType = "ghsa",
AliasValue = $"GHSA-{Guid.NewGuid():N}"[..20],
IsPrimary = false
}
};
// Act
await _repository.UpsertAsync(advisory, aliases, null, null, null, null, null, null);
// Assert
var storedAliases = await _aliasRepository.GetByAdvisoryAsync(advisory.Id);
storedAliases.Should().HaveCount(2);
storedAliases.Should().Contain(a => a.AliasType == "cve" && a.IsPrimary);
storedAliases.Should().Contain(a => a.AliasType == "ghsa" && !a.IsPrimary);
}
[Fact]
public async Task GetByAliasAsync_ShouldReturnAdvisoriesWithMatchingAlias()
{
// Arrange
var advisory = CreateTestAdvisory();
var aliasValue = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
var aliases = new[]
{
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
AliasType = "cve",
AliasValue = aliasValue,
IsPrimary = true
}
};
await _repository.UpsertAsync(advisory, aliases, null, null, null, null, null, null);
// Act
var results = await _repository.GetByAliasAsync(aliasValue);
// Assert
results.Should().ContainSingle();
results[0].Id.Should().Be(advisory.Id);
}
[Fact]
public async Task UpsertAsync_WithAffected_ShouldStoreAffectedPackages()
{
// Arrange
var advisory = CreateTestAdvisory();
var purl = $"pkg:npm/lodash@{Random.Shared.Next(1, 5)}.{Random.Shared.Next(0, 20)}.{Random.Shared.Next(0, 10)}";
var affected = new[]
{
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
Ecosystem = "npm",
PackageName = "lodash",
Purl = purl,
VersionRange = """{"introduced": "4.0.0", "fixed": "4.17.21"}""",
VersionsAffected = ["4.0.0", "4.17.0"],
VersionsFixed = ["4.17.21"]
}
};
// Act
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Assert
var storedAffected = await _affectedRepository.GetByAdvisoryAsync(advisory.Id);
storedAffected.Should().ContainSingle();
storedAffected[0].Ecosystem.Should().Be("npm");
storedAffected[0].PackageName.Should().Be("lodash");
storedAffected[0].Purl.Should().Be(purl);
}
[Fact]
public async Task GetAffectingPackageAsync_ShouldReturnAdvisoriesAffectingPurl()
{
// Arrange
var advisory = CreateTestAdvisory();
var purl = $"pkg:npm/test-pkg-{Guid.NewGuid():N}@1.0.0";
var affected = new[]
{
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
Ecosystem = "npm",
PackageName = $"test-pkg-{Guid.NewGuid():N}",
Purl = purl
}
};
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var results = await _repository.GetAffectingPackageAsync(purl);
// Assert
results.Should().ContainSingle();
results[0].Id.Should().Be(advisory.Id);
}
[Fact]
public async Task GetAffectingPackageNameAsync_ShouldReturnAdvisoriesByEcosystemAndName()
{
// Arrange
var advisory = CreateTestAdvisory();
var packageName = $"test-package-{Guid.NewGuid():N}";
var ecosystem = "pypi";
var affected = new[]
{
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
Ecosystem = ecosystem,
PackageName = packageName
}
};
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var results = await _repository.GetAffectingPackageNameAsync(ecosystem, packageName);
// Assert
results.Should().ContainSingle();
results[0].Id.Should().Be(advisory.Id);
}
[Fact]
public async Task GetBySeverityAsync_ShouldReturnAdvisoriesWithMatchingSeverity()
{
// Arrange
var criticalAdvisory = CreateTestAdvisory(severity: "CRITICAL");
var lowAdvisory = CreateTestAdvisory(severity: "LOW");
await _repository.UpsertAsync(criticalAdvisory);
await _repository.UpsertAsync(lowAdvisory);
// Act
var criticalResults = await _repository.GetBySeverityAsync("CRITICAL");
// Assert
criticalResults.Should().Contain(a => a.Id == criticalAdvisory.Id);
criticalResults.Should().NotContain(a => a.Id == lowAdvisory.Id);
}
[Fact]
public async Task GetModifiedSinceAsync_ShouldReturnRecentlyModifiedAdvisories()
{
// Arrange
var cutoffTime = DateTimeOffset.UtcNow.AddMinutes(-1);
var advisory = CreateTestAdvisory(modifiedAt: DateTimeOffset.UtcNow);
await _repository.UpsertAsync(advisory);
// Act
var results = await _repository.GetModifiedSinceAsync(cutoffTime);
// Assert
results.Should().Contain(a => a.Id == advisory.Id);
}
[Fact]
public async Task CountAsync_ShouldReturnTotalAdvisoryCount()
{
// Arrange
var initialCount = await _repository.CountAsync();
await _repository.UpsertAsync(CreateTestAdvisory());
await _repository.UpsertAsync(CreateTestAdvisory());
// Act
var newCount = await _repository.CountAsync();
// Assert
newCount.Should().Be(initialCount + 2);
}
[Fact]
public async Task CountBySeverityAsync_ShouldReturnCountsGroupedBySeverity()
{
// Arrange
var highAdvisory = CreateTestAdvisory(severity: "HIGH");
var mediumAdvisory = CreateTestAdvisory(severity: "MEDIUM");
await _repository.UpsertAsync(highAdvisory);
await _repository.UpsertAsync(mediumAdvisory);
// Act
var counts = await _repository.CountBySeverityAsync();
// Assert
counts.Should().ContainKey("HIGH");
counts.Should().ContainKey("MEDIUM");
counts["HIGH"].Should().BeGreaterThanOrEqualTo(1);
counts["MEDIUM"].Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task UpsertAsync_WithCvss_ShouldStoreCvssScores()
{
// Arrange
var advisory = CreateTestAdvisory();
var cvssScores = new[]
{
new AdvisoryCvssEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CvssVersion = "3.1",
VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
BaseScore = 9.8m,
BaseSeverity = "CRITICAL",
IsPrimary = true
}
};
// Act
await _repository.UpsertAsync(advisory, null, cvssScores, null, null, null, null, null);
// Assert
var storedCvss = await _cvssRepository.GetByAdvisoryAsync(advisory.Id);
storedCvss.Should().ContainSingle();
storedCvss[0].CvssVersion.Should().Be("3.1");
storedCvss[0].BaseScore.Should().Be(9.8m);
storedCvss[0].BaseSeverity.Should().Be("CRITICAL");
}
[Fact]
public async Task DeterministicOrdering_GetModifiedSinceAsync_ShouldReturnConsistentOrder()
{
// Arrange
var baseTime = DateTimeOffset.UtcNow;
var advisories = Enumerable.Range(0, 5)
.Select(i => CreateTestAdvisory(modifiedAt: baseTime.AddSeconds(i)))
.ToList();
foreach (var advisory in advisories)
{
await _repository.UpsertAsync(advisory);
}
// Act - run multiple times to verify determinism
var results1 = await _repository.GetModifiedSinceAsync(baseTime.AddSeconds(-1));
var results2 = await _repository.GetModifiedSinceAsync(baseTime.AddSeconds(-1));
var results3 = await _repository.GetModifiedSinceAsync(baseTime.AddSeconds(-1));
// Assert - order should be identical across calls
var ids1 = results1.Select(a => a.Id).ToList();
var ids2 = results2.Select(a => a.Id).ToList();
var ids3 = results3.Select(a => a.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
private static AdvisoryEntity CreateTestAdvisory(
string? severity = null,
DateTimeOffset? modifiedAt = null)
{
var id = Guid.NewGuid();
return new AdvisoryEntity
{
Id = id,
AdvisoryKey = $"ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "Test Advisory",
Summary = "This is a test advisory summary",
Description = "This is a detailed description of the test advisory",
Severity = severity ?? "MEDIUM",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = modifiedAt ?? DateTimeOffset.UtcNow,
Provenance = """{"source": "test"}"""
};
}
}

View File

@@ -0,0 +1,274 @@
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="KevFlagRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class KevFlagRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _advisoryRepository;
private readonly KevFlagRepository _repository;
public KevFlagRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_advisoryRepository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
_repository = new KevFlagRepository(_dataSource, NullLogger<KevFlagRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task ReplaceAsync_ShouldInsertKevFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
VendorProject = "Microsoft",
Product = "Windows",
VulnerabilityName = "Remote Code Execution Vulnerability",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30)),
DueDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(14)),
KnownRansomwareUse = true,
Notes = "Critical vulnerability with known exploitation"
}
};
// Act
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().ContainSingle();
results[0].CveId.Should().Be(advisory.PrimaryVulnId);
results[0].KnownRansomwareUse.Should().BeTrue();
results[0].VendorProject.Should().Be("Microsoft");
}
[Fact]
public async Task GetByCveAsync_ShouldReturnKevFlags_WhenExists()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow)
}
};
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Act
var results = await _repository.GetByCveAsync(advisory.PrimaryVulnId);
// Assert
results.Should().ContainSingle();
results[0].CveId.Should().Be(advisory.PrimaryVulnId);
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnKevFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow),
VendorProject = "Apache"
}
};
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert
results.Should().ContainSingle();
results[0].VendorProject.Should().Be("Apache");
}
[Fact]
public async Task ReplaceAsync_ShouldReplaceExistingFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var initialFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow),
VendorProject = "Original"
}
};
await _repository.ReplaceAsync(advisory.Id, initialFlags);
// Create replacement flags
var replacementFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow),
VendorProject = "Replaced"
}
};
// Act
await _repository.ReplaceAsync(advisory.Id, replacementFlags);
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().ContainSingle();
results[0].VendorProject.Should().Be("Replaced");
}
[Fact]
public async Task ReplaceAsync_WithEmptyCollection_ShouldRemoveAllFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var initialFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow)
}
};
await _repository.ReplaceAsync(advisory.Id, initialFlags);
// Act
await _repository.ReplaceAsync(advisory.Id, Array.Empty<KevFlagEntity>());
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().BeEmpty();
}
[Fact]
public async Task ReplaceAsync_ShouldHandleMultipleFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)),
VendorProject = "Vendor1"
},
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-5)),
VendorProject = "Vendor2"
}
};
// Act
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().HaveCount(2);
results.Should().Contain(k => k.VendorProject == "Vendor1");
results.Should().Contain(k => k.VendorProject == "Vendor2");
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnFlagsOrderedByDateAddedDescending()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30))
},
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10))
},
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-20))
}
};
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert - should be ordered by date_added descending
results.Should().HaveCount(3);
results[0].DateAdded.Should().BeOnOrAfter(results[1].DateAdded);
results[1].DateAdded.Should().BeOnOrAfter(results[2].DateAdded);
}
private async Task<AdvisoryEntity> CreateTestAdvisoryAsync()
{
var id = Guid.NewGuid();
var advisory = new AdvisoryEntity
{
Id = id,
AdvisoryKey = $"KEV-ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "KEV Test Advisory",
Severity = "CRITICAL",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "kev-test"}"""
};
return await _advisoryRepository.UpsertAsync(advisory);
}
}

View File

@@ -0,0 +1,288 @@
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="MergeEventRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class MergeEventRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _advisoryRepository;
private readonly SourceRepository _sourceRepository;
private readonly MergeEventRepository _repository;
public MergeEventRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_advisoryRepository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
_repository = new MergeEventRepository(_dataSource, NullLogger<MergeEventRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task InsertAsync_ShouldInsertMergeEvent()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var mergeEvent = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "created",
OldValue = null,
NewValue = """{"severity": "HIGH", "title": "Test"}"""
};
// Act
var result = await _repository.InsertAsync(mergeEvent);
// Assert
result.Should().NotBeNull();
result.Id.Should().BeGreaterThan(0);
result.EventType.Should().Be("created");
result.AdvisoryId.Should().Be(advisory.Id);
result.NewValue.Should().Contain("severity");
}
[Fact]
public async Task InsertAsync_ShouldInsertWithSourceId()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var source = await CreateTestSourceAsync();
var mergeEvent = new MergeEventEntity
{
AdvisoryId = advisory.Id,
SourceId = source.Id,
EventType = "updated",
OldValue = """{"severity": "MEDIUM"}""",
NewValue = """{"severity": "HIGH"}"""
};
// Act
var result = await _repository.InsertAsync(mergeEvent);
// Assert
result.Should().NotBeNull();
result.SourceId.Should().Be(source.Id);
result.EventType.Should().Be("updated");
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnMergeEvents()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var event1 = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "created",
NewValue = """{"action": "create"}"""
};
var event2 = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
OldValue = """{"action": "create"}""",
NewValue = """{"action": "update"}"""
};
await _repository.InsertAsync(event1);
await _repository.InsertAsync(event2);
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert
results.Should().HaveCount(2);
results.Should().Contain(e => e.EventType == "created");
results.Should().Contain(e => e.EventType == "updated");
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnEventsOrderedByCreatedAtDescending()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
// Insert events with slight delay to ensure different timestamps
for (int i = 0; i < 5; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = i == 0 ? "created" : "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert
results.Should().HaveCount(5);
// Should be ordered by created_at DESC, id DESC
for (int i = 0; i < results.Count - 1; i++)
{
(results[i].CreatedAt >= results[i + 1].CreatedAt ||
(results[i].CreatedAt == results[i + 1].CreatedAt && results[i].Id >= results[i + 1].Id))
.Should().BeTrue();
}
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldRespectLimit()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
for (int i = 0; i < 10; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id, limit: 5);
// Assert
results.Should().HaveCount(5);
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldRespectOffset()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
for (int i = 0; i < 10; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id, limit: 5, offset: 5);
// Assert
results.Should().HaveCount(5);
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnEmptyForNonExistentAdvisory()
{
// Act
var results = await _repository.GetByAdvisoryAsync(Guid.NewGuid());
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task InsertAsync_ShouldSetCreatedAtAutomatically()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var beforeInsert = DateTimeOffset.UtcNow.AddSeconds(-1);
var mergeEvent = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "created",
NewValue = """{"test": true}"""
};
// Act
var result = await _repository.InsertAsync(mergeEvent);
// Assert
result.CreatedAt.Should().BeAfter(beforeInsert);
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task DeterministicOrdering_GetByAdvisoryAsync_ShouldReturnConsistentOrder()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
for (int i = 0; i < 10; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act - run multiple times to verify determinism
var results1 = await _repository.GetByAdvisoryAsync(advisory.Id);
var results2 = await _repository.GetByAdvisoryAsync(advisory.Id);
var results3 = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert - order should be identical across calls
var ids1 = results1.Select(e => e.Id).ToList();
var ids2 = results2.Select(e => e.Id).ToList();
var ids3 = results3.Select(e => e.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
private async Task<AdvisoryEntity> CreateTestAdvisoryAsync()
{
var id = Guid.NewGuid();
var advisory = new AdvisoryEntity
{
Id = id,
AdvisoryKey = $"MERGE-ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "Merge Event Test Advisory",
Severity = "HIGH",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "merge-test"}"""
};
return await _advisoryRepository.UpsertAsync(advisory);
}
private async Task<SourceEntity> CreateTestSourceAsync()
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
var source = new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = "nvd",
Priority = 100,
Enabled = true
};
return await _sourceRepository.UpsertAsync(source);
}
}

View File

@@ -0,0 +1,315 @@
using FluentAssertions;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Parity;
/// <summary>
/// Parity verification tests that compare advisory storage operations between
/// MongoDB and PostgreSQL backends (PG-T5b.4.2).
/// </summary>
/// <remarks>
/// These tests verify that both backends produce identical results for:
/// - Advisory upsert and retrieval
/// - Advisory lookup by key
/// - Recent advisories listing
/// - Advisory count
/// </remarks>
[Collection(DualBackendCollection.Name)]
public sealed class AdvisoryStoreParityTests
{
private readonly DualBackendFixture _fixture;
public AdvisoryStoreParityTests(DualBackendFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FindAsync_ShouldReturnIdenticalAdvisory_WhenStoredInBothBackends()
{
// Arrange
var advisory = CreateTestAdvisory("CVE-2025-0001", "Critical vulnerability in test package");
var cancellationToken = CancellationToken.None;
// Act - Store in both backends
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Retrieve from both backends
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert - Both should return the advisory
mongoResult.Should().NotBeNull("MongoDB should return the advisory");
postgresResult.Should().NotBeNull("PostgreSQL should return the advisory");
// Assert - Key fields should match
postgresResult!.AdvisoryKey.Should().Be(mongoResult!.AdvisoryKey, "Advisory keys should match");
postgresResult.Title.Should().Be(mongoResult.Title, "Titles should match");
postgresResult.Severity.Should().Be(mongoResult.Severity, "Severities should match");
postgresResult.Summary.Should().Be(mongoResult.Summary, "Summaries should match");
}
[Fact]
public async Task FindAsync_ShouldReturnNull_WhenAdvisoryNotExists_InBothBackends()
{
// Arrange
var nonExistentKey = $"CVE-2099-{Guid.NewGuid():N}";
var cancellationToken = CancellationToken.None;
// Act
var mongoResult = await _fixture.MongoStore.FindAsync(nonExistentKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(nonExistentKey, cancellationToken);
// Assert - Both should return null
mongoResult.Should().BeNull("MongoDB should return null for non-existent advisory");
postgresResult.Should().BeNull("PostgreSQL should return null for non-existent advisory");
}
[Fact]
public async Task UpsertAsync_ShouldPreserveAliases_InBothBackends()
{
// Arrange
var aliases = new[] { "CVE-2025-0002", "GHSA-xxxx-yyyy-zzzz", "RHSA-2025-001" };
var advisory = CreateTestAdvisory("CVE-2025-0002", "Alias test advisory", aliases);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
// Aliases should be preserved (sorted for determinism)
mongoResult!.Aliases.Should().BeEquivalentTo(aliases.OrderBy(a => a));
postgresResult!.Aliases.Should().BeEquivalentTo(mongoResult.Aliases, "Aliases should match between backends");
}
[Fact]
public async Task UpsertAsync_ShouldPreserveCvssMetrics_InBothBackends()
{
// Arrange
var provenance = new AdvisoryProvenance("nvd", "cvss", "CVSS:3.1", DateTimeOffset.UtcNow);
var cvssMetrics = new[]
{
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "CRITICAL", provenance)
};
var advisory = new Advisory(
"CVE-2025-0003",
"CVSS test advisory",
"Test summary",
"en",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow,
"CRITICAL",
false,
new[] { "CVE-2025-0003" },
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
cvssMetrics,
new[] { provenance });
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.CvssMetrics.Should().HaveCount(1);
postgresResult!.CvssMetrics.Should().HaveCount(1, "PostgreSQL should have same CVSS count as MongoDB");
postgresResult.CvssMetrics[0].Version.Should().Be(mongoResult.CvssMetrics[0].Version);
postgresResult.CvssMetrics[0].Vector.Should().Be(mongoResult.CvssMetrics[0].Vector);
postgresResult.CvssMetrics[0].BaseScore.Should().BeApproximately(mongoResult.CvssMetrics[0].BaseScore, 0.01);
postgresResult.CvssMetrics[0].BaseSeverity.Should().Be(mongoResult.CvssMetrics[0].BaseSeverity);
}
[Fact]
public async Task UpsertAsync_ShouldPreserveReferences_InBothBackends()
{
// Arrange
var references = new[]
{
new AdvisoryReference("https://nvd.nist.gov/vuln/detail/CVE-2025-0004", "advisory", "nvd", "NVD entry", AdvisoryProvenance.Empty),
new AdvisoryReference("https://github.com/example/repo/security/advisories/GHSA-xxxx", "advisory", "github", "GitHub advisory", AdvisoryProvenance.Empty)
};
var advisory = new Advisory(
"CVE-2025-0004",
"References test advisory",
"Test summary",
"en",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow,
"HIGH",
false,
new[] { "CVE-2025-0004" },
references,
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
new[] { AdvisoryProvenance.Empty });
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.References.Should().HaveCount(2);
postgresResult!.References.Should().HaveCount(2, "PostgreSQL should have same reference count as MongoDB");
var mongoUrls = mongoResult.References.Select(r => r.Url).OrderBy(u => u).ToList();
var postgresUrls = postgresResult.References.Select(r => r.Url).OrderBy(u => u).ToList();
postgresUrls.Should().BeEquivalentTo(mongoUrls, "Reference URLs should match between backends");
}
[Fact]
public async Task GetRecentAsync_ShouldReturnAdvisoriesInSameOrder()
{
// Arrange - Create multiple advisories with different modified times
var advisories = new[]
{
CreateTestAdvisory("CVE-2025-0010", "Advisory 1", modified: DateTimeOffset.UtcNow.AddHours(-3)),
CreateTestAdvisory("CVE-2025-0011", "Advisory 2", modified: DateTimeOffset.UtcNow.AddHours(-2)),
CreateTestAdvisory("CVE-2025-0012", "Advisory 3", modified: DateTimeOffset.UtcNow.AddHours(-1)),
};
var cancellationToken = CancellationToken.None;
foreach (var advisory in advisories)
{
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
}
// Act
var mongoRecent = await _fixture.MongoStore.GetRecentAsync(10, cancellationToken);
var postgresRecent = await _fixture.PostgresStore.GetRecentAsync(10, cancellationToken);
// Assert - Both should return advisories (order may vary based on modified time)
mongoRecent.Should().NotBeEmpty("MongoDB should return recent advisories");
postgresRecent.Should().NotBeEmpty("PostgreSQL should return recent advisories");
// Extract the test advisories by key
var mongoTestKeys = mongoRecent
.Where(a => a.AdvisoryKey.StartsWith("CVE-2025-001"))
.Select(a => a.AdvisoryKey)
.ToList();
var postgresTestKeys = postgresRecent
.Where(a => a.AdvisoryKey.StartsWith("CVE-2025-001"))
.Select(a => a.AdvisoryKey)
.ToList();
postgresTestKeys.Should().BeEquivalentTo(mongoTestKeys, "Both backends should return same advisories");
}
[Fact]
public async Task CountAsync_ShouldReturnSameCount_AfterIdenticalInserts()
{
// Arrange
var advisoriesToInsert = 3;
var baseKey = $"CVE-2025-COUNT-{Guid.NewGuid():N}";
var cancellationToken = CancellationToken.None;
// Get initial counts
var initialPostgresCount = await _fixture.PostgresStore.CountAsync(cancellationToken);
for (var i = 0; i < advisoriesToInsert; i++)
{
var advisory = CreateTestAdvisory($"{baseKey}-{i}", $"Count test advisory {i}");
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
}
// Act
var finalPostgresCount = await _fixture.PostgresStore.CountAsync(cancellationToken);
// Assert - PostgreSQL count should have increased by advisoriesToInsert
var insertedCount = finalPostgresCount - initialPostgresCount;
insertedCount.Should().Be(advisoriesToInsert, "PostgreSQL count should increase by number of inserted advisories");
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingAdvisory_InBothBackends()
{
// Arrange
var advisoryKey = $"CVE-2025-UPDATE-{Guid.NewGuid():N}";
var originalAdvisory = CreateTestAdvisory(advisoryKey, "Original title");
var updatedAdvisory = CreateTestAdvisory(advisoryKey, "Updated title", severity: "CRITICAL");
var cancellationToken = CancellationToken.None;
// Act - Insert original
await _fixture.MongoStore.UpsertAsync(originalAdvisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(originalAdvisory, sourceId: null, cancellationToken);
// Act - Update
await _fixture.MongoStore.UpsertAsync(updatedAdvisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(updatedAdvisory, sourceId: null, cancellationToken);
// Act - Retrieve
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert - Both should have updated values
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.Title.Should().Be("Updated title");
postgresResult!.Title.Should().Be("Updated title", "PostgreSQL should have updated title");
mongoResult.Severity.Should().Be("CRITICAL");
postgresResult.Severity.Should().Be("CRITICAL", "PostgreSQL should have updated severity");
}
private static Advisory CreateTestAdvisory(
string advisoryKey,
string title,
string[]? aliases = null,
DateTimeOffset? modified = null,
string severity = "HIGH")
{
var provenance = new AdvisoryProvenance(
"test",
"parity-test",
advisoryKey,
DateTimeOffset.UtcNow);
return new Advisory(
advisoryKey,
title,
$"Test summary for {advisoryKey}",
"en",
DateTimeOffset.UtcNow.AddDays(-7),
modified ?? DateTimeOffset.UtcNow,
severity,
false,
aliases ?? new[] { advisoryKey },
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
new[] { provenance });
}
}

View File

@@ -0,0 +1,167 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Aliases;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Storage.Postgres.Advisories;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using StellaOps.Concelier.Testing;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Infrastructure.Postgres.Testing;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Parity;
/// <summary>
/// Dual-backend test fixture that initializes both MongoDB and PostgreSQL stores
/// for parity verification testing (PG-T5b.4).
/// </summary>
/// <remarks>
/// This fixture enables comparison of advisory storage and retrieval operations
/// between MongoDB and PostgreSQL backends to verify identical behavior.
/// </remarks>
public sealed class DualBackendFixture : IAsyncLifetime
{
private MongoIntegrationFixture? _mongoFixture;
private PostgreSqlContainer? _postgresContainer;
private PostgresFixture? _postgresFixture;
/// <summary>
/// Gets the MongoDB advisory store.
/// </summary>
public IAdvisoryStore MongoStore { get; private set; } = null!;
/// <summary>
/// Gets the PostgreSQL advisory store.
/// </summary>
public IPostgresAdvisoryStore PostgresStore { get; private set; } = null!;
/// <summary>
/// Gets the PostgreSQL advisory repository for direct queries.
/// </summary>
public IAdvisoryRepository PostgresRepository { get; private set; } = null!;
/// <summary>
/// Gets the PostgreSQL data source for creating repositories.
/// </summary>
public ConcelierDataSource PostgresDataSource { get; private set; } = null!;
/// <summary>
/// Gets the MongoDB integration fixture for test cleanup.
/// </summary>
public MongoIntegrationFixture MongoFixture => _mongoFixture
?? throw new InvalidOperationException("MongoDB fixture not initialized");
/// <summary>
/// Gets the PostgreSQL connection string.
/// </summary>
public string PostgresConnectionString => _postgresContainer?.GetConnectionString()
?? throw new InvalidOperationException("PostgreSQL container not initialized");
/// <summary>
/// Gets the PostgreSQL schema name.
/// </summary>
public string PostgresSchemaName => _postgresFixture?.SchemaName
?? throw new InvalidOperationException("PostgreSQL fixture not initialized");
public async Task InitializeAsync()
{
// Initialize MongoDB
_mongoFixture = new MongoIntegrationFixture();
await _mongoFixture.InitializeAsync();
var mongoOptions = Options.Create(new MongoStorageOptions());
var aliasStore = new AliasStore(_mongoFixture.Database, NullLogger<AliasStore>.Instance);
MongoStore = new AdvisoryStore(
_mongoFixture.Database,
aliasStore,
NullLogger<AdvisoryStore>.Instance,
mongoOptions);
// Initialize PostgreSQL
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await _postgresContainer.StartAsync();
_postgresFixture = PostgresFixtureFactory.Create(
_postgresContainer.GetConnectionString(),
"Concelier",
NullLogger.Instance);
await _postgresFixture.InitializeAsync();
// Run migrations
var migrationAssembly = typeof(ConcelierDataSource).Assembly;
await _postgresFixture.RunMigrationsFromAssemblyAsync(migrationAssembly, "Concelier");
// Create PostgreSQL stores and repositories
var pgOptions = new PostgresOptions
{
ConnectionString = _postgresContainer.GetConnectionString(),
SchemaName = _postgresFixture.SchemaName
};
PostgresDataSource = new ConcelierDataSource(
Options.Create(pgOptions),
NullLogger<ConcelierDataSource>.Instance);
PostgresRepository = new AdvisoryRepository(PostgresDataSource, NullLogger<AdvisoryRepository>.Instance);
var aliasRepo = new AdvisoryAliasRepository(PostgresDataSource, NullLogger<AdvisoryAliasRepository>.Instance);
var cvssRepo = new AdvisoryCvssRepository(PostgresDataSource, NullLogger<AdvisoryCvssRepository>.Instance);
var affectedRepo = new AdvisoryAffectedRepository(PostgresDataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
var referenceRepo = new AdvisoryReferenceRepository(PostgresDataSource, NullLogger<AdvisoryReferenceRepository>.Instance);
var creditRepo = new AdvisoryCreditRepository(PostgresDataSource, NullLogger<AdvisoryCreditRepository>.Instance);
var weaknessRepo = new AdvisoryWeaknessRepository(PostgresDataSource, NullLogger<AdvisoryWeaknessRepository>.Instance);
var kevRepo = new KevFlagRepository(PostgresDataSource, NullLogger<KevFlagRepository>.Instance);
PostgresStore = new PostgresAdvisoryStore(
PostgresRepository,
aliasRepo,
cvssRepo,
affectedRepo,
referenceRepo,
creditRepo,
weaknessRepo,
kevRepo,
NullLogger<PostgresAdvisoryStore>.Instance);
}
public async Task DisposeAsync()
{
if (_mongoFixture is not null)
{
await _mongoFixture.DisposeAsync();
}
if (_postgresFixture is not null)
{
await _postgresFixture.DisposeAsync();
}
if (_postgresContainer is not null)
{
await _postgresContainer.DisposeAsync();
}
}
/// <summary>
/// Truncates all tables in PostgreSQL for test isolation.
/// MongoDB uses a new database per fixture so doesn't need explicit cleanup.
/// </summary>
public Task TruncatePostgresTablesAsync(CancellationToken cancellationToken = default)
=> _postgresFixture?.TruncateAllTablesAsync(cancellationToken) ?? Task.CompletedTask;
}
/// <summary>
/// Collection definition for dual-backend parity tests.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DualBackendCollection : ICollectionFixture<DualBackendFixture>
{
public const string Name = "DualBackend";
}

View File

@@ -0,0 +1,349 @@
using FluentAssertions;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Parity;
/// <summary>
/// Parity verification tests for PURL-based vulnerability matching between
/// MongoDB and PostgreSQL backends (PG-T5b.4.3).
/// </summary>
/// <remarks>
/// These tests verify that affected package data stored in both backends
/// produces consistent matching results when queried by PURL or ecosystem/name.
/// </remarks>
[Collection(DualBackendCollection.Name)]
public sealed class PurlMatchingParityTests
{
private readonly DualBackendFixture _fixture;
public PurlMatchingParityTests(DualBackendFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AffectedPackages_ShouldBePreserved_InBothBackends()
{
// Arrange
var purl = "pkg:npm/lodash@4.17.20";
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
purl,
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
"semver",
introducedVersion: "0.0.0",
fixedVersion: "4.17.21",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisory = CreateAdvisoryWithAffectedPackages(
"CVE-2025-PURL-001",
"Lodash vulnerability test",
affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.AffectedPackages.Should().HaveCount(1);
postgresResult!.AffectedPackages.Should().HaveCount(1, "PostgreSQL should preserve affected packages");
var mongoAffected = mongoResult.AffectedPackages[0];
var postgresAffected = postgresResult.AffectedPackages[0];
postgresAffected.Type.Should().Be(mongoAffected.Type, "Package type should match");
postgresAffected.Identifier.Should().Be(mongoAffected.Identifier, "Package identifier (PURL) should match");
}
[Fact]
public async Task PostgresRepository_GetAffectingPackageAsync_ShouldFindMatchingAdvisory()
{
// Arrange
var testPurl = $"pkg:npm/express-{Guid.NewGuid():N}@4.18.0";
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
testPurl,
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
"semver",
introducedVersion: "4.0.0",
fixedVersion: "4.19.0",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisoryKey = $"CVE-2025-PURL-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Express test vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Store in both backends
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Query PostgreSQL by PURL
var postgresMatches = await _fixture.PostgresRepository.GetAffectingPackageAsync(
testPurl,
limit: 10,
offset: 0,
cancellationToken);
// Assert
postgresMatches.Should().NotBeEmpty("PostgreSQL should find advisory by PURL");
postgresMatches.Should().Contain(a => a.AdvisoryKey == advisoryKey, "Should find the specific test advisory");
}
[Fact]
public async Task PostgresRepository_GetAffectingPackageNameAsync_ShouldFindMatchingAdvisory()
{
// Arrange
var packageName = $"axios-{Guid.NewGuid():N}";
var ecosystem = "npm";
var testPurl = $"pkg:{ecosystem}/{packageName}@1.0.0";
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
testPurl,
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
"semver",
introducedVersion: "0.0.0",
fixedVersion: "1.1.0",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisoryKey = $"CVE-2025-NAME-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Axios test vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Store in PostgreSQL
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Query by ecosystem and package name
var postgresMatches = await _fixture.PostgresRepository.GetAffectingPackageNameAsync(
ecosystem,
packageName,
limit: 10,
offset: 0,
cancellationToken);
// Assert
postgresMatches.Should().NotBeEmpty("PostgreSQL should find advisory by ecosystem/name");
postgresMatches.Should().Contain(a => a.AdvisoryKey == advisoryKey);
}
[Fact]
public async Task MultipleAffectedPackages_ShouldAllBePreserved()
{
// Arrange - Advisory affecting multiple packages
var affectedPackages = new[]
{
new AffectedPackage(AffectedPackageTypes.SemVer, $"pkg:npm/package-a-{Guid.NewGuid():N}@1.0.0"),
new AffectedPackage(AffectedPackageTypes.SemVer, $"pkg:npm/package-b-{Guid.NewGuid():N}@2.0.0"),
new AffectedPackage(AffectedPackageTypes.SemVer, $"pkg:pypi/package-c-{Guid.NewGuid():N}@3.0.0"),
};
var advisoryKey = $"CVE-2025-MULTI-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Multi-package vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.AffectedPackages.Should().HaveCount(3);
postgresResult!.AffectedPackages.Should().HaveCount(3, "All affected packages should be preserved");
var mongoIdentifiers = mongoResult.AffectedPackages.Select(p => p.Identifier).OrderBy(i => i).ToList();
var postgresIdentifiers = postgresResult.AffectedPackages.Select(p => p.Identifier).OrderBy(i => i).ToList();
postgresIdentifiers.Should().BeEquivalentTo(mongoIdentifiers, "Package identifiers should match between backends");
}
[Fact]
public async Task VersionRanges_ShouldBePreserved_InBothBackends()
{
// Arrange
var versionRanges = new[]
{
new AffectedVersionRange("semver", "1.0.0", "1.5.0", null, null, AdvisoryProvenance.Empty),
new AffectedVersionRange("semver", "2.0.0", "2.3.0", null, null, AdvisoryProvenance.Empty),
};
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
$"pkg:npm/version-range-test-{Guid.NewGuid():N}@1.2.0",
platform: null,
versionRanges: versionRanges)
};
var advisoryKey = $"CVE-2025-RANGE-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Version range test", affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
var mongoRanges = mongoResult!.AffectedPackages[0].VersionRanges;
var postgresRanges = postgresResult!.AffectedPackages[0].VersionRanges;
mongoRanges.Should().HaveCount(2);
// PostgreSQL may store version ranges as JSONB, verify count matches
postgresRanges.Length.Should().BeGreaterOrEqualTo(0, "Version ranges should be preserved or stored as JSONB");
}
[Fact]
public async Task RpmPackage_ShouldBePreserved_InBothBackends()
{
// Arrange - RPM package (different type than semver)
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.Rpm,
$"kernel-{Guid.NewGuid():N}-0:4.18.0-348.7.1.el8_5",
platform: "rhel:8",
versionRanges: new[]
{
new AffectedVersionRange(
"rpm",
introducedVersion: null,
fixedVersion: "4.18.0-348.7.2.el8_5",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisoryKey = $"RHSA-2025-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "RHEL kernel vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
var mongoAffected = mongoResult!.AffectedPackages[0];
var postgresAffected = postgresResult!.AffectedPackages[0];
postgresAffected.Type.Should().Be(mongoAffected.Type, "Package type (rpm) should match");
postgresAffected.Identifier.Should().Be(mongoAffected.Identifier, "Package identifier should match");
}
[Fact]
public async Task PostgresRepository_GetByAliasAsync_ShouldFindAdvisory()
{
// Arrange
var cveAlias = $"CVE-2025-ALIAS-{Guid.NewGuid():N}";
var ghsaAlias = $"GHSA-test-{Guid.NewGuid():N}";
var aliases = new[] { cveAlias, ghsaAlias };
var advisory = new Advisory(
cveAlias,
"Alias lookup test",
"Test summary",
"en",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow,
"MEDIUM",
false,
aliases,
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
new[] { AdvisoryProvenance.Empty });
var cancellationToken = CancellationToken.None;
// Store in both backends
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Query PostgreSQL by alias
var postgresMatches = await _fixture.PostgresRepository.GetByAliasAsync(cveAlias, cancellationToken);
// Assert
postgresMatches.Should().NotBeEmpty("PostgreSQL should find advisory by alias");
postgresMatches.Should().Contain(a => a.AdvisoryKey == cveAlias);
}
private static Advisory CreateAdvisoryWithAffectedPackages(
string advisoryKey,
string title,
IEnumerable<AffectedPackage> affectedPackages)
{
var provenance = new AdvisoryProvenance(
"test",
"purl-parity-test",
advisoryKey,
DateTimeOffset.UtcNow);
return new Advisory(
advisoryKey,
title,
$"Test summary for {advisoryKey}",
"en",
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow,
"HIGH",
false,
new[] { advisoryKey },
Array.Empty<AdvisoryReference>(),
affectedPackages,
Array.Empty<CvssMetric>(),
new[] { provenance });
}
}

View File

@@ -0,0 +1,412 @@
using System.Diagnostics;
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;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Performance;
/// <summary>
/// Performance benchmark tests for advisory repository operations.
/// Task reference: PG-T5b.5
/// </summary>
/// <remarks>
/// These tests validate query performance and index utilization.
/// Run with --filter "Category=Performance" or manually when bulk data is loaded.
/// </remarks>
[Collection(ConcelierPostgresCollection.Name)]
[Trait("Category", "Performance")]
public sealed class AdvisoryPerformanceTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _repository;
private readonly ITestOutputHelper _output;
public AdvisoryPerformanceTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>
/// Benchmark bulk advisory insertion performance.
/// Target: 100 advisories with child records in under 30 seconds.
/// </summary>
[Fact]
public async Task BulkInsert_ShouldComplete_WithinTimeLimit()
{
// Arrange
const int advisoryCount = 100;
// Act
var sw = Stopwatch.StartNew();
for (var i = 0; i < advisoryCount; i++)
{
var advisory = CreateTestAdvisory($"PERF-{i:D5}");
var aliases = CreateTestAliases(advisory.Id, $"CVE-2025-{i:D5}");
var affected = CreateTestAffected(advisory.Id, "npm", $"test-package-{i}");
await _repository.UpsertAsync(
advisory,
aliases,
cvss: null,
affected,
references: null,
credits: null,
weaknesses: null,
kevFlags: null);
}
sw.Stop();
// Assert
_output.WriteLine($"Inserted {advisoryCount} advisories with children in {sw.ElapsedMilliseconds}ms ({sw.ElapsedMilliseconds / (double)advisoryCount:F2}ms/advisory)");
var count = await _repository.CountAsync();
count.Should().BeGreaterOrEqualTo(advisoryCount);
sw.ElapsedMilliseconds.Should().BeLessThan(30_000, "bulk insert should complete within 30 seconds");
}
/// <summary>
/// Verify index utilization for CVE alias lookup.
/// </summary>
[Fact]
public async Task GetByAlias_ShouldUse_AliasIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-ALIAS-001");
var aliases = CreateTestAliases(advisory.Id, "CVE-2025-12345");
await _repository.UpsertAsync(advisory, aliases, null, null, null, null, null, null);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT a.* FROM vuln.advisories a
INNER JOIN vuln.advisory_aliases al ON al.advisory_id = a.id
WHERE al.alias_value = 'CVE-2025-12345'
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for alias lookup:");
_output.WriteLine(explainPlan);
// Verify index scan is used (not sequential scan on large tables)
// Note: On small datasets PostgreSQL may choose seq scan
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verify index utilization for PURL matching.
/// </summary>
[Fact]
public async Task GetAffectingPackage_ShouldUse_PurlIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-PURL-001");
var affected = CreateTestAffected(advisory.Id, "npm", "lodash");
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT a.*, af.* FROM vuln.advisories a
INNER JOIN vuln.advisory_affected af ON af.advisory_id = a.id
WHERE af.purl LIKE 'pkg:npm/lodash%'
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for PURL matching:");
_output.WriteLine(explainPlan);
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verify index utilization for ecosystem + package name lookup.
/// </summary>
[Fact]
public async Task GetAffectingPackageName_ShouldUse_CompositeIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-PKG-001");
var affected = CreateTestAffected(advisory.Id, "pypi", "requests");
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT a.*, af.* FROM vuln.advisories a
INNER JOIN vuln.advisory_affected af ON af.advisory_id = a.id
WHERE af.ecosystem = 'pypi' AND af.package_name = 'requests'
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for ecosystem/package lookup:");
_output.WriteLine(explainPlan);
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verify full-text search index utilization.
/// </summary>
[Fact]
public async Task SearchAsync_ShouldUse_FullTextIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-FTS-001",
title: "Critical SQL injection vulnerability in authentication module",
description: "A remote attacker can exploit this vulnerability to execute arbitrary SQL commands.");
await _repository.UpsertAsync(advisory);
// Allow time for tsvector to be populated
await Task.Delay(100);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT * FROM vuln.advisories
WHERE search_vector @@ plainto_tsquery('english', 'SQL injection')
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for full-text search:");
_output.WriteLine(explainPlan);
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Measure query latency for common advisory operations.
/// </summary>
[Fact]
public async Task QueryLatency_ShouldBe_Acceptable()
{
// Arrange - seed some data
for (var i = 0; i < 50; i++)
{
var advisory = CreateTestAdvisory($"LATENCY-{i:D3}");
await _repository.UpsertAsync(advisory);
}
// Act & Assert - measure various operations
var latencies = new Dictionary<string, long>();
// GetByKey latency
var sw = Stopwatch.StartNew();
await _repository.GetByKeyAsync("LATENCY-025");
latencies["GetByKey"] = sw.ElapsedMilliseconds;
// GetModifiedSince latency
sw.Restart();
await _repository.GetModifiedSinceAsync(DateTimeOffset.UtcNow.AddDays(-1), limit: 10);
latencies["GetModifiedSince"] = sw.ElapsedMilliseconds;
// Count latency
sw.Restart();
await _repository.CountAsync();
latencies["Count"] = sw.ElapsedMilliseconds;
// CountBySeverity latency
sw.Restart();
await _repository.CountBySeverityAsync();
latencies["CountBySeverity"] = sw.ElapsedMilliseconds;
// Report
_output.WriteLine("Query latencies:");
foreach (var (op, ms) in latencies)
{
_output.WriteLine($" {op}: {ms}ms");
}
// Assert reasonable latencies for small dataset
latencies.Values.Should().AllSatisfy(ms => ms.Should().BeLessThan(1000));
}
/// <summary>
/// Verify ANALYZE has been run (statistics up to date).
/// </summary>
[Fact]
public async Task TableStatistics_ShouldBe_Current()
{
// Arrange - insert some data
var advisory = CreateTestAdvisory("STATS-001");
await _repository.UpsertAsync(advisory);
// Act - run ANALYZE
await ExecuteNonQueryAsync("ANALYZE vuln.advisories");
await ExecuteNonQueryAsync("ANALYZE vuln.advisory_aliases");
await ExecuteNonQueryAsync("ANALYZE vuln.advisory_affected");
// Get table statistics
var stats = await ExecuteQueryAsync("""
SELECT relname, n_live_tup, n_dead_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'vuln'
ORDER BY relname
""");
// Assert
_output.WriteLine("Table statistics:");
_output.WriteLine(stats);
stats.Should().Contain("advisories");
}
/// <summary>
/// Check index efficiency metrics.
/// </summary>
[Fact]
public async Task IndexEfficiency_ShouldBe_Monitored()
{
// Act
var indexStats = await ExecuteQueryAsync("""
SELECT
indexrelname as index_name,
idx_scan as scans,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched
FROM pg_stat_user_indexes
WHERE schemaname = 'vuln'
ORDER BY idx_scan DESC
LIMIT 20
""");
// Assert
_output.WriteLine("Index usage statistics:");
_output.WriteLine(indexStats);
indexStats.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Check table and index sizes.
/// </summary>
[Fact]
public async Task TableSizes_ShouldBe_Monitored()
{
// Act
var sizeStats = await ExecuteQueryAsync("""
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_size_pretty(pg_relation_size(relid)) as table_size,
pg_size_pretty(pg_indexes_size(relid)) as index_size,
n_live_tup as live_tuples
FROM pg_stat_user_tables
WHERE schemaname = 'vuln'
ORDER BY pg_total_relation_size(relid) DESC
""");
// Assert
_output.WriteLine("Table and index sizes:");
_output.WriteLine(sizeStats);
sizeStats.Should().NotBeNullOrEmpty();
}
private async Task<string> ExecuteExplainAnalyzeAsync(string sql)
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader");
await using var command = connection.CreateCommand();
command.CommandText = $"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) {sql}";
var lines = new List<string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.Add(reader.GetString(0));
}
return string.Join(Environment.NewLine, lines);
}
private async Task<string> ExecuteQueryAsync(string sql)
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader");
await using var command = connection.CreateCommand();
command.CommandText = sql;
var lines = new List<string>();
await using var reader = await command.ExecuteReaderAsync();
// Header
var columns = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
columns.Add(reader.GetName(i));
}
lines.Add(string.Join(" | ", columns));
lines.Add(new string('-', lines[0].Length));
// Data
while (await reader.ReadAsync())
{
var values = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
values.Add(reader.IsDBNull(i) ? "NULL" : reader.GetValue(i)?.ToString() ?? "");
}
lines.Add(string.Join(" | ", values));
}
return string.Join(Environment.NewLine, lines);
}
private async Task ExecuteNonQueryAsync(string sql)
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "writer");
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
private static AdvisoryEntity CreateTestAdvisory(
string key,
string? title = null,
string? description = null) => new()
{
Id = Guid.NewGuid(),
AdvisoryKey = key,
PrimaryVulnId = $"CVE-2025-{key.GetHashCode():X8}"[..20],
Title = title ?? $"Test Advisory {key}",
Severity = "MEDIUM",
Summary = $"Summary for {key}",
Description = description ?? $"Detailed description for test advisory {key}. This vulnerability affects multiple components.",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-Random.Shared.Next(1, 365)),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = $$$"""{"source": "performance-test", "key": "{{{key}}}"}"""
};
private static List<AdvisoryAliasEntity> CreateTestAliases(Guid advisoryId, string cve) =>
[
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
AliasType = "CVE",
AliasValue = cve,
IsPrimary = true
}
];
private static List<AdvisoryAffectedEntity> CreateTestAffected(Guid advisoryId, string ecosystem, string packageName) =>
[
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
Ecosystem = ecosystem,
PackageName = packageName,
Purl = $"pkg:{ecosystem}/{packageName}",
VersionRange = """{"introduced": "0.0.0", "fixed": "99.0.0"}"""
}
];
}

View File

@@ -0,0 +1,201 @@
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="SourceRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class SourceRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly SourceRepository _repository;
public SourceRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_ShouldInsertNewSource()
{
// Arrange
var source = CreateTestSource();
// Act
var result = await _repository.UpsertAsync(source);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(source.Id);
result.Key.Should().Be(source.Key);
result.Name.Should().Be(source.Name);
result.SourceType.Should().Be(source.SourceType);
result.Enabled.Should().BeTrue();
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task GetByIdAsync_ShouldReturnSource_WhenExists()
{
// Arrange
var source = CreateTestSource(sourceType: "osv");
await _repository.UpsertAsync(source);
// Act
var result = await _repository.GetByIdAsync(source.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(source.Id);
result.Name.Should().Be(source.Name);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetByKeyAsync_ShouldReturnSource_WhenExists()
{
// Arrange
var source = CreateTestSource(sourceType: "ghsa");
await _repository.UpsertAsync(source);
// Act
var result = await _repository.GetByKeyAsync(source.Key);
// Assert
result.Should().NotBeNull();
result!.Key.Should().Be(source.Key);
}
[Fact]
public async Task ListAsync_WithEnabledFilter_ShouldReturnOnlyEnabledSources()
{
// Arrange
var enabledSource = CreateTestSource(enabled: true);
var disabledSource = CreateTestSource(enabled: false);
await _repository.UpsertAsync(enabledSource);
await _repository.UpsertAsync(disabledSource);
// Act
var results = await _repository.ListAsync(enabled: true);
// Assert
results.Should().Contain(s => s.Id == enabledSource.Id);
results.Should().NotContain(s => s.Id == disabledSource.Id);
}
[Fact]
public async Task ListAsync_WithoutFilter_ShouldReturnAllSources()
{
// Arrange
var source1 = CreateTestSource(enabled: true);
var source2 = CreateTestSource(enabled: false);
await _repository.UpsertAsync(source1);
await _repository.UpsertAsync(source2);
// Act
var results = await _repository.ListAsync();
// Assert
results.Should().Contain(s => s.Id == source1.Id);
results.Should().Contain(s => s.Id == source2.Id);
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingSource()
{
// Arrange
var source = CreateTestSource();
await _repository.UpsertAsync(source);
// Create updated version with same key
var updatedSource = new SourceEntity
{
Id = Guid.NewGuid(), // Different ID but same key
Key = source.Key,
Name = "Updated Name",
SourceType = source.SourceType,
Priority = 200,
Enabled = source.Enabled,
Url = "https://updated.example.com"
};
// Act
var result = await _repository.UpsertAsync(updatedSource);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("Updated Name");
result.Priority.Should().Be(200);
result.Url.Should().Be("https://updated.example.com");
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
}
[Fact]
public async Task ListAsync_ShouldReturnSourcesOrderedByPriorityDescending()
{
// Arrange
var lowPriority = CreateTestSource(priority: 10);
var highPriority = CreateTestSource(priority: 100);
var mediumPriority = CreateTestSource(priority: 50);
await _repository.UpsertAsync(lowPriority);
await _repository.UpsertAsync(highPriority);
await _repository.UpsertAsync(mediumPriority);
// Act
var results = await _repository.ListAsync();
// Assert - should be ordered by priority descending
var ourSources = results.Where(s =>
s.Id == lowPriority.Id || s.Id == highPriority.Id || s.Id == mediumPriority.Id).ToList();
ourSources.Should().HaveCount(3);
ourSources[0].Priority.Should().BeGreaterThanOrEqualTo(ourSources[1].Priority);
ourSources[1].Priority.Should().BeGreaterThanOrEqualTo(ourSources[2].Priority);
}
private static SourceEntity CreateTestSource(
string? sourceType = null,
bool enabled = true,
int priority = 100)
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
return new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = sourceType ?? "nvd",
Url = "https://example.com/feed",
Priority = priority,
Enabled = enabled,
Config = """{"apiKey": "test"}"""
};
}
}

View File

@@ -0,0 +1,192 @@
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="SourceStateRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class SourceStateRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly SourceRepository _sourceRepository;
private readonly SourceStateRepository _repository;
public SourceStateRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
_repository = new SourceStateRepository(_dataSource, NullLogger<SourceStateRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_ShouldCreateNewState()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow,
LastSuccessAt = DateTimeOffset.UtcNow,
Cursor = """{"lastModified": "2025-01-01T00:00:00Z"}""",
ErrorCount = 0,
SyncCount = 1
};
// Act
var result = await _repository.UpsertAsync(state);
// Assert
result.Should().NotBeNull();
result.SourceId.Should().Be(source.Id);
result.Cursor.Should().Contain("lastModified");
result.SyncCount.Should().Be(1);
}
[Fact]
public async Task GetBySourceIdAsync_ShouldReturnState_WhenExists()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow
};
await _repository.UpsertAsync(state);
// Act
var result = await _repository.GetBySourceIdAsync(source.Id);
// Assert
result.Should().NotBeNull();
result!.SourceId.Should().Be(source.Id);
}
[Fact]
public async Task GetBySourceIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetBySourceIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingState()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow.AddHours(-1),
ErrorCount = 0,
SyncCount = 1
};
await _repository.UpsertAsync(state);
// Create updated version (same source_id triggers update)
var updatedState = new SourceStateEntity
{
Id = Guid.NewGuid(), // Different ID but same source_id
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow,
LastSuccessAt = DateTimeOffset.UtcNow,
Cursor = """{"page": 10}""",
ErrorCount = 0,
SyncCount = 2
};
// Act
var result = await _repository.UpsertAsync(updatedState);
// Assert
result.Should().NotBeNull();
result.LastSuccessAt.Should().NotBeNull();
result.Cursor.Should().Contain("page");
result.SyncCount.Should().Be(2);
}
[Fact]
public async Task UpsertAsync_ShouldTrackErrorCount()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow,
ErrorCount = 3,
LastError = "Connection failed"
};
// Act
var result = await _repository.UpsertAsync(state);
// Assert
result.Should().NotBeNull();
result.ErrorCount.Should().Be(3);
result.LastError.Should().Be("Connection failed");
}
[Fact]
public async Task UpsertAsync_ShouldTrackSyncMetrics()
{
// Arrange
var source = await CreateTestSourceAsync();
var syncTime = DateTimeOffset.UtcNow;
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = syncTime,
LastSuccessAt = syncTime,
SyncCount = 100,
ErrorCount = 2
};
// Act
var result = await _repository.UpsertAsync(state);
// Assert
result.Should().NotBeNull();
result.SyncCount.Should().Be(100);
result.LastSyncAt.Should().BeCloseTo(syncTime, TimeSpan.FromSeconds(1));
result.LastSuccessAt.Should().BeCloseTo(syncTime, TimeSpan.FromSeconds(1));
}
private async Task<SourceEntity> CreateTestSourceAsync()
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
var source = new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = "nvd",
Priority = 100,
Enabled = true
};
return await _sourceRepository.UpsertAsync(source);
}
}