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
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:
@@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Advisories;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.DualWrite;
|
||||
|
||||
/// <summary>
|
||||
/// Dual-write advisory store that writes to both MongoDB and PostgreSQL simultaneously.
|
||||
/// Used during migration to verify parity between backends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MongoDB is the primary store; PostgreSQL writes are best-effort with error logging.
|
||||
/// Read operations are always served from MongoDB.
|
||||
/// </remarks>
|
||||
public sealed class DualWriteAdvisoryStore : IAdvisoryStore
|
||||
{
|
||||
private readonly AdvisoryStore _mongoStore;
|
||||
private readonly IPostgresAdvisoryStore _postgresStore;
|
||||
private readonly ILogger<DualWriteAdvisoryStore> _logger;
|
||||
|
||||
public DualWriteAdvisoryStore(
|
||||
AdvisoryStore mongoStore,
|
||||
IPostgresAdvisoryStore postgresStore,
|
||||
ILogger<DualWriteAdvisoryStore> logger)
|
||||
{
|
||||
_mongoStore = mongoStore ?? throw new ArgumentNullException(nameof(mongoStore));
|
||||
_postgresStore = postgresStore ?? throw new ArgumentNullException(nameof(postgresStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
// Write to MongoDB (primary)
|
||||
await _mongoStore.UpsertAsync(advisory, cancellationToken, session).ConfigureAwait(false);
|
||||
|
||||
// Write to PostgreSQL (secondary, best-effort)
|
||||
try
|
||||
{
|
||||
await _postgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Dual-write success for advisory {AdvisoryKey}", advisory.AdvisoryKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't fail - MongoDB is primary during migration
|
||||
_logger.LogWarning(ex, "Dual-write to PostgreSQL failed for advisory {AdvisoryKey}. MongoDB write succeeded.", advisory.AdvisoryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
// Always read from MongoDB during dual-write mode
|
||||
return _mongoStore.FindAsync(advisoryKey, cancellationToken, session);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
// Always read from MongoDB during dual-write mode
|
||||
return _mongoStore.GetRecentAsync(limit, cancellationToken, session);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
// Always read from MongoDB during dual-write mode
|
||||
return _mongoStore.StreamAsync(cancellationToken, session);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Advisories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL advisory storage interface.
|
||||
/// This interface mirrors the MongoDB IAdvisoryStore but without MongoDB-specific parameters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by connectors when configured to write to PostgreSQL storage.
|
||||
/// </remarks>
|
||||
public interface IPostgresAdvisoryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Upserts an advisory and all its child entities.
|
||||
/// </summary>
|
||||
/// <param name="advisory">The advisory domain model to store.</param>
|
||||
/// <param name="sourceId">Optional source ID to associate with the advisory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task UpsertAsync(Advisory advisory, Guid? sourceId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an advisory by its key.
|
||||
/// </summary>
|
||||
/// <param name="advisoryKey">The advisory key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The advisory if found, null otherwise.</returns>
|
||||
Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recently modified advisories.
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of advisories to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of recent advisories.</returns>
|
||||
Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Streams all advisories for bulk operations.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of advisories.</returns>
|
||||
IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of advisories in the store.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Total count of advisories.</returns>
|
||||
Task<long> CountAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Conversion;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Advisories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of advisory storage.
|
||||
/// Uses the AdvisoryConverter to transform domain models to relational entities
|
||||
/// and the AdvisoryRepository to persist them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tasks: PG-T5b.2.1, PG-T5b.2.2, PG-T5b.2.3 - Enables importers to write to PostgreSQL.
|
||||
/// </remarks>
|
||||
public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore
|
||||
{
|
||||
private readonly IAdvisoryRepository _advisoryRepository;
|
||||
private readonly IAdvisoryAliasRepository _aliasRepository;
|
||||
private readonly IAdvisoryCvssRepository _cvssRepository;
|
||||
private readonly IAdvisoryAffectedRepository _affectedRepository;
|
||||
private readonly IAdvisoryReferenceRepository _referenceRepository;
|
||||
private readonly IAdvisoryCreditRepository _creditRepository;
|
||||
private readonly IAdvisoryWeaknessRepository _weaknessRepository;
|
||||
private readonly IKevFlagRepository _kevFlagRepository;
|
||||
private readonly AdvisoryConverter _converter;
|
||||
private readonly ILogger<PostgresAdvisoryStore> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public PostgresAdvisoryStore(
|
||||
IAdvisoryRepository advisoryRepository,
|
||||
IAdvisoryAliasRepository aliasRepository,
|
||||
IAdvisoryCvssRepository cvssRepository,
|
||||
IAdvisoryAffectedRepository affectedRepository,
|
||||
IAdvisoryReferenceRepository referenceRepository,
|
||||
IAdvisoryCreditRepository creditRepository,
|
||||
IAdvisoryWeaknessRepository weaknessRepository,
|
||||
IKevFlagRepository kevFlagRepository,
|
||||
ILogger<PostgresAdvisoryStore> logger)
|
||||
{
|
||||
_advisoryRepository = advisoryRepository ?? throw new ArgumentNullException(nameof(advisoryRepository));
|
||||
_aliasRepository = aliasRepository ?? throw new ArgumentNullException(nameof(aliasRepository));
|
||||
_cvssRepository = cvssRepository ?? throw new ArgumentNullException(nameof(cvssRepository));
|
||||
_affectedRepository = affectedRepository ?? throw new ArgumentNullException(nameof(affectedRepository));
|
||||
_referenceRepository = referenceRepository ?? throw new ArgumentNullException(nameof(referenceRepository));
|
||||
_creditRepository = creditRepository ?? throw new ArgumentNullException(nameof(creditRepository));
|
||||
_weaknessRepository = weaknessRepository ?? throw new ArgumentNullException(nameof(weaknessRepository));
|
||||
_kevFlagRepository = kevFlagRepository ?? throw new ArgumentNullException(nameof(kevFlagRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_converter = new AdvisoryConverter();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(Advisory advisory, Guid? sourceId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisory);
|
||||
|
||||
_logger.LogDebug("Upserting advisory {AdvisoryKey} to PostgreSQL", advisory.AdvisoryKey);
|
||||
|
||||
// Convert domain model to PostgreSQL entities
|
||||
var result = _converter.ConvertFromDomain(advisory, sourceId);
|
||||
|
||||
// Use the repository's atomic upsert which handles all child tables in a transaction
|
||||
await _advisoryRepository.UpsertAsync(
|
||||
result.Advisory,
|
||||
result.Aliases,
|
||||
result.Cvss,
|
||||
result.Affected,
|
||||
result.References,
|
||||
result.Credits,
|
||||
result.Weaknesses,
|
||||
result.KevFlags,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Upserted advisory {AdvisoryKey} with {ChildCount} child entities",
|
||||
advisory.AdvisoryKey,
|
||||
result.TotalChildEntities);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
|
||||
var entity = await _advisoryRepository.GetByKeyAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ReconstructAdvisoryAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var entities = await _advisoryRepository.GetModifiedSinceAsync(
|
||||
DateTimeOffset.MinValue,
|
||||
limit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var advisories = new List<Advisory>(entities.Count);
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var advisory = await ReconstructAdvisoryAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
advisories.Add(advisory);
|
||||
}
|
||||
|
||||
return advisories;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<Advisory> StreamAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
const int batchSize = 100;
|
||||
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var entities = await _advisoryRepository.GetModifiedSinceAsync(
|
||||
DateTimeOffset.MinValue,
|
||||
batchSize,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return await ReconstructAdvisoryAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (entities.Count < batchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
offset += batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> CountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _advisoryRepository.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs an Advisory domain model from a PostgreSQL entity.
|
||||
/// </summary>
|
||||
private async Task<Advisory> ReconstructAdvisoryAsync(AdvisoryEntity entity, CancellationToken cancellationToken)
|
||||
{
|
||||
// If raw payload is available, deserialize from it for full fidelity
|
||||
if (!string.IsNullOrEmpty(entity.RawPayload))
|
||||
{
|
||||
try
|
||||
{
|
||||
var advisory = JsonSerializer.Deserialize<Advisory>(entity.RawPayload, JsonOptions);
|
||||
if (advisory is not null)
|
||||
{
|
||||
return advisory;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize raw payload for advisory {AdvisoryKey}, reconstructing from entities", entity.AdvisoryKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct from child entities
|
||||
var aliases = await _aliasRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
var cvss = await _cvssRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
var affected = await _affectedRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
var references = await _referenceRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
var credits = await _creditRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
var weaknesses = await _weaknessRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert entities back to domain models
|
||||
var aliasStrings = aliases.Select(a => a.AliasValue).ToArray();
|
||||
var creditModels = credits.Select(c => new AdvisoryCredit(
|
||||
c.Name,
|
||||
c.CreditType,
|
||||
c.Contact is not null ? new[] { c.Contact } : Array.Empty<string>(),
|
||||
AdvisoryProvenance.Empty)).ToArray();
|
||||
var referenceModels = references.Select(r => new AdvisoryReference(
|
||||
r.Url,
|
||||
r.RefType,
|
||||
null,
|
||||
null,
|
||||
AdvisoryProvenance.Empty)).ToArray();
|
||||
var cvssModels = cvss.Select(c => new CvssMetric(
|
||||
c.CvssVersion,
|
||||
c.VectorString,
|
||||
(double)c.BaseScore,
|
||||
c.BaseSeverity ?? "unknown",
|
||||
new AdvisoryProvenance(c.Source ?? "unknown", "cvss", c.VectorString, c.CreatedAt))).ToArray();
|
||||
var weaknessModels = weaknesses.Select(w => new AdvisoryWeakness(
|
||||
"CWE",
|
||||
w.CweId,
|
||||
w.Description,
|
||||
null,
|
||||
w.Source is not null ? new[] { new AdvisoryProvenance(w.Source, "cwe", w.CweId, w.CreatedAt) } : Array.Empty<AdvisoryProvenance>())).ToArray();
|
||||
|
||||
// Convert affected packages
|
||||
var affectedModels = affected.Select(a =>
|
||||
{
|
||||
IEnumerable<AffectedVersionRange> versionRanges = Array.Empty<AffectedVersionRange>();
|
||||
if (!string.IsNullOrEmpty(a.VersionRange) && a.VersionRange != "{}")
|
||||
{
|
||||
try
|
||||
{
|
||||
versionRanges = JsonSerializer.Deserialize<AffectedVersionRange[]>(a.VersionRange, JsonOptions)
|
||||
?? Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fallback to empty
|
||||
}
|
||||
}
|
||||
|
||||
return new AffectedPackage(
|
||||
MapEcosystemToType(a.Ecosystem),
|
||||
a.PackageName,
|
||||
null,
|
||||
versionRanges);
|
||||
}).ToArray();
|
||||
|
||||
// Parse provenance if available
|
||||
IEnumerable<AdvisoryProvenance> provenance = Array.Empty<AdvisoryProvenance>();
|
||||
if (!string.IsNullOrEmpty(entity.Provenance) && entity.Provenance != "[]" && entity.Provenance != "{}")
|
||||
{
|
||||
try
|
||||
{
|
||||
provenance = JsonSerializer.Deserialize<AdvisoryProvenance[]>(entity.Provenance, JsonOptions)
|
||||
?? Array.Empty<AdvisoryProvenance>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fallback to empty
|
||||
}
|
||||
}
|
||||
|
||||
return new Advisory(
|
||||
entity.AdvisoryKey,
|
||||
entity.Title ?? entity.AdvisoryKey,
|
||||
entity.Summary,
|
||||
null,
|
||||
entity.PublishedAt,
|
||||
entity.ModifiedAt,
|
||||
entity.Severity,
|
||||
false,
|
||||
aliasStrings,
|
||||
creditModels,
|
||||
referenceModels,
|
||||
affectedModels,
|
||||
cvssModels,
|
||||
provenance,
|
||||
entity.Description,
|
||||
weaknessModels,
|
||||
null);
|
||||
}
|
||||
|
||||
private static string MapEcosystemToType(string ecosystem)
|
||||
{
|
||||
return ecosystem.ToLowerInvariant() switch
|
||||
{
|
||||
"npm" => "semver",
|
||||
"pypi" => "semver",
|
||||
"maven" => "semver",
|
||||
"nuget" => "semver",
|
||||
"cargo" => "semver",
|
||||
"go" => "semver",
|
||||
"rubygems" => "semver",
|
||||
"composer" => "semver",
|
||||
"hex" => "semver",
|
||||
"pub" => "semver",
|
||||
"rpm" => "rpm",
|
||||
"deb" => "deb",
|
||||
"apk" => "semver",
|
||||
"cpe" => "cpe",
|
||||
"vendor" => "vendor",
|
||||
"ics" => "ics-vendor",
|
||||
"generic" => "semver",
|
||||
_ => "semver"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Conversion;
|
||||
|
||||
/// <summary>
|
||||
/// Result of converting a MongoDB advisory document to PostgreSQL entities.
|
||||
/// Contains the main advisory entity and all related child entities.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryConversionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The main advisory entity.
|
||||
/// </summary>
|
||||
public required AdvisoryEntity Advisory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alias entities (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryAliasEntity> Aliases { get; init; } = Array.Empty<AdvisoryAliasEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryCvssEntity> Cvss { get; init; } = Array.Empty<AdvisoryCvssEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Affected package entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryAffectedEntity> Affected { get; init; } = Array.Empty<AdvisoryAffectedEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Reference URL entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryReferenceEntity> References { get; init; } = Array.Empty<AdvisoryReferenceEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Credit entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryCreditEntity> Credits { get; init; } = Array.Empty<AdvisoryCreditEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Weakness (CWE) entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryWeaknessEntity> Weaknesses { get; init; } = Array.Empty<AdvisoryWeaknessEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Known Exploited Vulnerabilities (KEV) flag entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<KevFlagEntity> KevFlags { get; init; } = Array.Empty<KevFlagEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Total number of child entities.
|
||||
/// </summary>
|
||||
public int TotalChildEntities =>
|
||||
Aliases.Count + Cvss.Count + Affected.Count + References.Count + Credits.Count + Weaknesses.Count + KevFlags.Count;
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Conversion;
|
||||
|
||||
/// <summary>
|
||||
/// Converts MongoDB advisory documents to PostgreSQL entity structures.
|
||||
/// This converter handles the transformation from MongoDB's document-based storage
|
||||
/// to PostgreSQL's relational structure with normalized child tables.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Task: PG-T5b.1.1 - Build AdvisoryConverter to parse MongoDB documents
|
||||
/// Task: PG-T5b.1.2 - Map to relational structure with child tables
|
||||
/// Task: PG-T5b.1.3 - Preserve provenance JSONB
|
||||
/// Task: PG-T5b.1.4 - Handle version ranges (keep as JSONB)
|
||||
/// </remarks>
|
||||
public sealed class AdvisoryConverter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MongoDB BsonDocument payload to PostgreSQL entities.
|
||||
/// </summary>
|
||||
/// <param name="payload">The MongoDB advisory payload (BsonDocument).</param>
|
||||
/// <param name="sourceId">Optional source ID to associate with the advisory.</param>
|
||||
/// <returns>A conversion result containing the main entity and all child entities.</returns>
|
||||
public AdvisoryConversionResult Convert(BsonDocument payload, Guid? sourceId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var advisoryKey = payload.GetValue("advisoryKey", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("advisoryKey missing from payload.");
|
||||
|
||||
var title = payload.GetValue("title", defaultValue: null)?.AsString ?? advisoryKey;
|
||||
var summary = TryGetString(payload, "summary");
|
||||
var description = TryGetString(payload, "description");
|
||||
var severity = TryGetString(payload, "severity");
|
||||
var published = TryReadDateTime(payload, "published");
|
||||
var modified = TryReadDateTime(payload, "modified");
|
||||
|
||||
// Extract primary vulnerability ID from aliases (first CVE if available)
|
||||
var aliases = ExtractAliases(payload);
|
||||
var cveAlias = aliases.FirstOrDefault(a => a.AliasType == "cve");
|
||||
var firstAlias = aliases.FirstOrDefault();
|
||||
var primaryVulnId = cveAlias != default ? cveAlias.AliasValue
|
||||
: (firstAlias != default ? firstAlias.AliasValue : advisoryKey);
|
||||
|
||||
// Extract provenance and serialize to JSONB
|
||||
var provenanceJson = ExtractProvenanceJson(payload);
|
||||
|
||||
// Create the main advisory entity
|
||||
var advisoryId = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var advisory = new AdvisoryEntity
|
||||
{
|
||||
Id = advisoryId,
|
||||
AdvisoryKey = advisoryKey,
|
||||
PrimaryVulnId = primaryVulnId,
|
||||
SourceId = sourceId,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Description = description,
|
||||
Severity = severity,
|
||||
PublishedAt = published,
|
||||
ModifiedAt = modified,
|
||||
WithdrawnAt = null,
|
||||
Provenance = provenanceJson,
|
||||
RawPayload = payload.ToJson(),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
// Convert all child entities
|
||||
var aliasEntities = ConvertAliases(advisoryId, aliases, now);
|
||||
var cvssEntities = ConvertCvss(advisoryId, payload, now);
|
||||
var affectedEntities = ConvertAffected(advisoryId, payload, now);
|
||||
var referenceEntities = ConvertReferences(advisoryId, payload, now);
|
||||
var creditEntities = ConvertCredits(advisoryId, payload, now);
|
||||
var weaknessEntities = ConvertWeaknesses(advisoryId, payload, now);
|
||||
var kevFlags = ConvertKevFlags(advisoryId, payload, now);
|
||||
|
||||
return new AdvisoryConversionResult
|
||||
{
|
||||
Advisory = advisory,
|
||||
Aliases = aliasEntities,
|
||||
Cvss = cvssEntities,
|
||||
Affected = affectedEntities,
|
||||
References = referenceEntities,
|
||||
Credits = creditEntities,
|
||||
Weaknesses = weaknessEntities,
|
||||
KevFlags = kevFlags
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an Advisory domain model directly to PostgreSQL entities.
|
||||
/// </summary>
|
||||
/// <param name="advisory">The Advisory domain model.</param>
|
||||
/// <param name="sourceId">Optional source ID.</param>
|
||||
/// <returns>A conversion result containing all entities.</returns>
|
||||
public AdvisoryConversionResult ConvertFromDomain(Advisory advisory, Guid? sourceId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisory);
|
||||
|
||||
var advisoryId = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Determine primary vulnerability ID
|
||||
var primaryVulnId = advisory.Aliases
|
||||
.FirstOrDefault(a => a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
?? advisory.Aliases.FirstOrDefault()
|
||||
?? advisory.AdvisoryKey;
|
||||
|
||||
// Serialize provenance to JSON
|
||||
var provenanceJson = JsonSerializer.Serialize(advisory.Provenance, JsonOptions);
|
||||
|
||||
var entity = new AdvisoryEntity
|
||||
{
|
||||
Id = advisoryId,
|
||||
AdvisoryKey = advisory.AdvisoryKey,
|
||||
PrimaryVulnId = primaryVulnId,
|
||||
SourceId = sourceId,
|
||||
Title = advisory.Title,
|
||||
Summary = advisory.Summary,
|
||||
Description = advisory.Description,
|
||||
Severity = advisory.Severity,
|
||||
PublishedAt = advisory.Published,
|
||||
ModifiedAt = advisory.Modified,
|
||||
WithdrawnAt = null,
|
||||
Provenance = provenanceJson,
|
||||
RawPayload = CanonicalJsonSerializer.Serialize(advisory),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
// Convert aliases
|
||||
var aliasEntities = new List<AdvisoryAliasEntity>();
|
||||
var isPrimarySet = false;
|
||||
foreach (var alias in advisory.Aliases)
|
||||
{
|
||||
var aliasType = DetermineAliasType(alias);
|
||||
var isPrimary = !isPrimarySet && aliasType == "cve";
|
||||
if (isPrimary) isPrimarySet = true;
|
||||
|
||||
aliasEntities.Add(new AdvisoryAliasEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
AliasType = aliasType,
|
||||
AliasValue = alias,
|
||||
IsPrimary = isPrimary,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Convert CVSS metrics
|
||||
var cvssEntities = new List<AdvisoryCvssEntity>();
|
||||
var isPrimaryCvss = true;
|
||||
foreach (var metric in advisory.CvssMetrics)
|
||||
{
|
||||
cvssEntities.Add(new AdvisoryCvssEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CvssVersion = metric.Version,
|
||||
VectorString = metric.Vector,
|
||||
BaseScore = (decimal)metric.BaseScore,
|
||||
BaseSeverity = metric.BaseSeverity,
|
||||
ExploitabilityScore = null,
|
||||
ImpactScore = null,
|
||||
Source = metric.Provenance.Source,
|
||||
IsPrimary = isPrimaryCvss,
|
||||
CreatedAt = now
|
||||
});
|
||||
isPrimaryCvss = false;
|
||||
}
|
||||
|
||||
// Convert affected packages
|
||||
var affectedEntities = new List<AdvisoryAffectedEntity>();
|
||||
foreach (var pkg in advisory.AffectedPackages)
|
||||
{
|
||||
var ecosystem = MapTypeToEcosystem(pkg.Type);
|
||||
var versionRangeJson = JsonSerializer.Serialize(pkg.VersionRanges, JsonOptions);
|
||||
|
||||
affectedEntities.Add(new AdvisoryAffectedEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Ecosystem = ecosystem,
|
||||
PackageName = pkg.Identifier,
|
||||
Purl = BuildPurl(ecosystem, pkg.Identifier),
|
||||
VersionRange = versionRangeJson,
|
||||
VersionsAffected = null,
|
||||
VersionsFixed = ExtractFixedVersions(pkg.VersionRanges),
|
||||
DatabaseSpecific = null,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Convert references
|
||||
var referenceEntities = new List<AdvisoryReferenceEntity>();
|
||||
foreach (var reference in advisory.References)
|
||||
{
|
||||
referenceEntities.Add(new AdvisoryReferenceEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
RefType = reference.Kind ?? "web",
|
||||
Url = reference.Url,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Convert credits
|
||||
var creditEntities = new List<AdvisoryCreditEntity>();
|
||||
foreach (var credit in advisory.Credits)
|
||||
{
|
||||
creditEntities.Add(new AdvisoryCreditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Name = credit.DisplayName,
|
||||
Contact = credit.Contacts.FirstOrDefault(),
|
||||
CreditType = credit.Role,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Convert weaknesses
|
||||
var weaknessEntities = new List<AdvisoryWeaknessEntity>();
|
||||
foreach (var weakness in advisory.Cwes)
|
||||
{
|
||||
weaknessEntities.Add(new AdvisoryWeaknessEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CweId = weakness.Identifier,
|
||||
Description = weakness.Name,
|
||||
Source = weakness.Provenance.FirstOrDefault()?.Source,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return new AdvisoryConversionResult
|
||||
{
|
||||
Advisory = entity,
|
||||
Aliases = aliasEntities,
|
||||
Cvss = cvssEntities,
|
||||
Affected = affectedEntities,
|
||||
References = referenceEntities,
|
||||
Credits = creditEntities,
|
||||
Weaknesses = weaknessEntities,
|
||||
KevFlags = new List<KevFlagEntity>()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<(string AliasType, string AliasValue, bool IsPrimary)> ExtractAliases(BsonDocument payload)
|
||||
{
|
||||
var result = new List<(string AliasType, string AliasValue, bool IsPrimary)>();
|
||||
|
||||
if (!payload.TryGetValue("aliases", out var aliasValue) || aliasValue is not BsonArray aliasArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var isPrimarySet = false;
|
||||
foreach (var alias in aliasArray.OfType<BsonValue>().Where(x => x.IsString).Select(x => x.AsString))
|
||||
{
|
||||
var aliasType = DetermineAliasType(alias);
|
||||
var isPrimary = !isPrimarySet && aliasType == "cve";
|
||||
if (isPrimary) isPrimarySet = true;
|
||||
|
||||
result.Add((aliasType, alias, isPrimary));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string DetermineAliasType(string alias)
|
||||
{
|
||||
if (alias.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
return "cve";
|
||||
if (alias.StartsWith("GHSA-", StringComparison.OrdinalIgnoreCase))
|
||||
return "ghsa";
|
||||
if (alias.StartsWith("RUSTSEC-", StringComparison.OrdinalIgnoreCase))
|
||||
return "rustsec";
|
||||
if (alias.StartsWith("GO-", StringComparison.OrdinalIgnoreCase))
|
||||
return "go";
|
||||
if (alias.StartsWith("PYSEC-", StringComparison.OrdinalIgnoreCase))
|
||||
return "pysec";
|
||||
if (alias.StartsWith("DSA-", StringComparison.OrdinalIgnoreCase))
|
||||
return "dsa";
|
||||
if (alias.StartsWith("RHSA-", StringComparison.OrdinalIgnoreCase))
|
||||
return "rhsa";
|
||||
if (alias.StartsWith("USN-", StringComparison.OrdinalIgnoreCase))
|
||||
return "usn";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
private static string ExtractProvenanceJson(BsonDocument payload)
|
||||
{
|
||||
if (!payload.TryGetValue("provenance", out var provenanceValue) || provenanceValue is not BsonArray provenanceArray)
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
return provenanceArray.ToJson();
|
||||
}
|
||||
|
||||
private static List<AdvisoryAliasEntity> ConvertAliases(
|
||||
Guid advisoryId,
|
||||
List<(string AliasType, string AliasValue, bool IsPrimary)> aliases,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
return aliases.Select(a => new AdvisoryAliasEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
AliasType = a.AliasType,
|
||||
AliasValue = a.AliasValue,
|
||||
IsPrimary = a.IsPrimary,
|
||||
CreatedAt = now
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<AdvisoryCvssEntity> ConvertCvss(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
|
||||
{
|
||||
var result = new List<AdvisoryCvssEntity>();
|
||||
|
||||
if (!payload.TryGetValue("cvssMetrics", out var cvssValue) || cvssValue is not BsonArray cvssArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var isPrimary = true;
|
||||
foreach (var doc in cvssArray.OfType<BsonDocument>())
|
||||
{
|
||||
var version = doc.GetValue("version", defaultValue: null)?.AsString;
|
||||
var vector = doc.GetValue("vector", defaultValue: null)?.AsString;
|
||||
var baseScore = doc.TryGetValue("baseScore", out var scoreValue) && scoreValue.IsNumeric
|
||||
? (decimal)scoreValue.ToDouble()
|
||||
: 0m;
|
||||
var baseSeverity = TryGetString(doc, "baseSeverity");
|
||||
var source = doc.TryGetValue("provenance", out var provValue) && provValue.IsBsonDocument
|
||||
? TryGetString(provValue.AsBsonDocument, "source")
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrEmpty(version) || string.IsNullOrEmpty(vector))
|
||||
continue;
|
||||
|
||||
result.Add(new AdvisoryCvssEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CvssVersion = version,
|
||||
VectorString = vector,
|
||||
BaseScore = baseScore,
|
||||
BaseSeverity = baseSeverity,
|
||||
ExploitabilityScore = null,
|
||||
ImpactScore = null,
|
||||
Source = source,
|
||||
IsPrimary = isPrimary,
|
||||
CreatedAt = now
|
||||
});
|
||||
isPrimary = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AdvisoryAffectedEntity> ConvertAffected(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
|
||||
{
|
||||
var result = new List<AdvisoryAffectedEntity>();
|
||||
|
||||
if (!payload.TryGetValue("affectedPackages", out var affectedValue) || affectedValue is not BsonArray affectedArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var doc in affectedArray.OfType<BsonDocument>())
|
||||
{
|
||||
var type = doc.GetValue("type", defaultValue: null)?.AsString ?? "semver";
|
||||
var identifier = doc.GetValue("identifier", defaultValue: null)?.AsString;
|
||||
|
||||
if (string.IsNullOrEmpty(identifier))
|
||||
continue;
|
||||
|
||||
var ecosystem = MapTypeToEcosystem(type);
|
||||
|
||||
// Version ranges kept as JSONB (PG-T5b.1.4)
|
||||
var versionRangeJson = "{}";
|
||||
if (doc.TryGetValue("versionRanges", out var rangesValue) && rangesValue is BsonArray)
|
||||
{
|
||||
versionRangeJson = rangesValue.ToJson();
|
||||
}
|
||||
|
||||
string[]? versionsFixed = null;
|
||||
if (doc.TryGetValue("versionRanges", out var rangesForFixed) && rangesForFixed is BsonArray rangesArr)
|
||||
{
|
||||
versionsFixed = rangesArr.OfType<BsonDocument>()
|
||||
.Select(r => TryGetString(r, "fixedVersion"))
|
||||
.Where(v => !string.IsNullOrEmpty(v))
|
||||
.Select(v => v!)
|
||||
.ToArray();
|
||||
if (versionsFixed.Length == 0) versionsFixed = null;
|
||||
}
|
||||
|
||||
result.Add(new AdvisoryAffectedEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Ecosystem = ecosystem,
|
||||
PackageName = identifier,
|
||||
Purl = BuildPurl(ecosystem, identifier),
|
||||
VersionRange = versionRangeJson,
|
||||
VersionsAffected = null,
|
||||
VersionsFixed = versionsFixed,
|
||||
DatabaseSpecific = null,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AdvisoryReferenceEntity> ConvertReferences(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
|
||||
{
|
||||
var result = new List<AdvisoryReferenceEntity>();
|
||||
|
||||
if (!payload.TryGetValue("references", out var referencesValue) || referencesValue is not BsonArray referencesArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var doc in referencesArray.OfType<BsonDocument>())
|
||||
{
|
||||
var url = doc.GetValue("url", defaultValue: null)?.AsString;
|
||||
if (string.IsNullOrEmpty(url))
|
||||
continue;
|
||||
|
||||
var kind = TryGetString(doc, "kind") ?? "web";
|
||||
|
||||
result.Add(new AdvisoryReferenceEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
RefType = kind,
|
||||
Url = url,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AdvisoryCreditEntity> ConvertCredits(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
|
||||
{
|
||||
var result = new List<AdvisoryCreditEntity>();
|
||||
|
||||
if (!payload.TryGetValue("credits", out var creditsValue) || creditsValue is not BsonArray creditsArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var doc in creditsArray.OfType<BsonDocument>())
|
||||
{
|
||||
var displayName = doc.GetValue("displayName", defaultValue: null)?.AsString;
|
||||
if (string.IsNullOrEmpty(displayName))
|
||||
continue;
|
||||
|
||||
var role = TryGetString(doc, "role");
|
||||
string? contact = null;
|
||||
if (doc.TryGetValue("contacts", out var contactsValue) && contactsValue is BsonArray contactsArray)
|
||||
{
|
||||
contact = contactsArray.OfType<BsonValue>()
|
||||
.Where(v => v.IsString)
|
||||
.Select(v => v.AsString)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
result.Add(new AdvisoryCreditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Name = displayName,
|
||||
Contact = contact,
|
||||
CreditType = role,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AdvisoryWeaknessEntity> ConvertWeaknesses(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
|
||||
{
|
||||
var result = new List<AdvisoryWeaknessEntity>();
|
||||
|
||||
if (!payload.TryGetValue("cwes", out var cwesValue) || cwesValue is not BsonArray cwesArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var doc in cwesArray.OfType<BsonDocument>())
|
||||
{
|
||||
var identifier = doc.GetValue("identifier", defaultValue: null)?.AsString;
|
||||
if (string.IsNullOrEmpty(identifier))
|
||||
continue;
|
||||
|
||||
var name = TryGetString(doc, "name");
|
||||
string? source = null;
|
||||
if (doc.TryGetValue("provenance", out var provValue) && provValue.IsBsonDocument)
|
||||
{
|
||||
source = TryGetString(provValue.AsBsonDocument, "source");
|
||||
}
|
||||
|
||||
result.Add(new AdvisoryWeaknessEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CweId = identifier,
|
||||
Description = name,
|
||||
Source = source,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<KevFlagEntity> ConvertKevFlags(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
|
||||
{
|
||||
// KEV flags are typically stored separately; this handles inline KEV data if present
|
||||
var result = new List<KevFlagEntity>();
|
||||
|
||||
// Check for exploitKnown flag
|
||||
var exploitKnown = payload.TryGetValue("exploitKnown", out var exploitValue)
|
||||
&& exploitValue.IsBoolean
|
||||
&& exploitValue.AsBoolean;
|
||||
|
||||
if (!exploitKnown)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract CVE ID for KEV flag
|
||||
string? cveId = null;
|
||||
if (payload.TryGetValue("aliases", out var aliasValue) && aliasValue is BsonArray aliasArray)
|
||||
{
|
||||
cveId = aliasArray.OfType<BsonValue>()
|
||||
.Where(v => v.IsString && v.AsString.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(v => v.AsString)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Add(new KevFlagEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CveId = cveId,
|
||||
VendorProject = null,
|
||||
Product = null,
|
||||
VulnerabilityName = TryGetString(payload, "title"),
|
||||
DateAdded = DateOnly.FromDateTime(now.UtcDateTime),
|
||||
DueDate = null,
|
||||
KnownRansomwareUse = false,
|
||||
Notes = null,
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string MapTypeToEcosystem(string type)
|
||||
{
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"npm" => "npm",
|
||||
"pypi" => "pypi",
|
||||
"maven" => "maven",
|
||||
"nuget" => "nuget",
|
||||
"cargo" => "cargo",
|
||||
"go" => "go",
|
||||
"rubygems" => "rubygems",
|
||||
"composer" => "composer",
|
||||
"hex" => "hex",
|
||||
"pub" => "pub",
|
||||
"rpm" => "rpm",
|
||||
"deb" => "deb",
|
||||
"apk" => "apk",
|
||||
"cpe" => "cpe",
|
||||
"semver" => "generic",
|
||||
"vendor" => "vendor",
|
||||
"ics-vendor" => "ics",
|
||||
_ => "generic"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? BuildPurl(string ecosystem, string identifier)
|
||||
{
|
||||
// Only build PURL for supported ecosystems
|
||||
return ecosystem switch
|
||||
{
|
||||
"npm" => $"pkg:npm/{identifier}",
|
||||
"pypi" => $"pkg:pypi/{identifier}",
|
||||
"maven" => identifier.Contains(':') ? $"pkg:maven/{identifier.Replace(':', '/')}" : null,
|
||||
"nuget" => $"pkg:nuget/{identifier}",
|
||||
"cargo" => $"pkg:cargo/{identifier}",
|
||||
"go" => $"pkg:golang/{identifier}",
|
||||
"rubygems" => $"pkg:gem/{identifier}",
|
||||
"composer" => $"pkg:composer/{identifier}",
|
||||
"hex" => $"pkg:hex/{identifier}",
|
||||
"pub" => $"pkg:pub/{identifier}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string[]? ExtractFixedVersions(IEnumerable<AffectedVersionRange> ranges)
|
||||
{
|
||||
var fixedVersions = ranges
|
||||
.Where(r => !string.IsNullOrEmpty(r.FixedVersion))
|
||||
.Select(r => r.FixedVersion!)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
return fixedVersions.Length > 0 ? fixedVersions : null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(BsonDocument doc, string field)
|
||||
{
|
||||
return doc.TryGetValue(field, out var value) && value.IsString ? value.AsString : null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryReadDateTime(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value))
|
||||
return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
BsonDateTime dateTime => DateTime.SpecifyKind(dateTime.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonString stringValue when DateTimeOffset.TryParse(stringValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"}"""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"}"""
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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"}"""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user