Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
348
src/Graph/StellaOps.Graph.Api/Contracts/ReachabilityContracts.cs
Normal file
348
src/Graph/StellaOps.Graph.Api/Contracts/ReachabilityContracts.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityContracts.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-027)
|
||||
// Task: Implement IReachabilityDeltaService
|
||||
// Description: Contracts for reachability delta computation between artifacts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Graph.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for computing reachability delta between two artifact versions.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDeltaRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the source artifact (the "from" version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target artifact (the "to" version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CVE filter. If provided, only compute delta for this CVE.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of paths to include per vulnerability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxPathsPerVuln")]
|
||||
public int MaxPathsPerVuln { get; init; } = 5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates reachability delta requests.
|
||||
/// </summary>
|
||||
public static class ReachabilityDeltaValidator
|
||||
{
|
||||
public static string? Validate(ReachabilityDeltaRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FromDigest))
|
||||
{
|
||||
return "fromDigest is required";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ToDigest))
|
||||
{
|
||||
return "toDigest is required";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
return "tenantId is required";
|
||||
}
|
||||
|
||||
if (request.MaxPathsPerVuln < 1 || request.MaxPathsPerVuln > 20)
|
||||
{
|
||||
return "maxPathsPerVuln must be between 1 and 20";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing reachability delta between two artifacts.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDeltaResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the source artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required ReachabilityDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual reachability changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entries")]
|
||||
public ImmutableArray<ReachabilityDeltaEntry> Entries { get; init; } = ImmutableArray<ReachabilityDeltaEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the delta was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of reachability changes.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of reachability changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalChanges")]
|
||||
public int TotalChanges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities that became reachable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("newlyReachable")]
|
||||
public int NewlyReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities that became unreachable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("newlyUnreachable")]
|
||||
public int NewlyUnreachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities with path count changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("pathCountChanges")]
|
||||
public int PathCountChanges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities with gate changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gateChanges")]
|
||||
public int GateChanges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities with confidence changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceChanges")]
|
||||
public int ConfidenceChanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual reachability change entry.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDeltaEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public required ReachabilityChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status in source artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromStatus")]
|
||||
public required ReachabilityStatus FromStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status in target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toStatus")]
|
||||
public required ReachabilityStatus ToStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path count in source artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromPathCount")]
|
||||
public int FromPathCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path count in target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toPathCount")]
|
||||
public int ToPathCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample paths that changed (limited by maxPathsPerVuln).
|
||||
/// </summary>
|
||||
[JsonPropertyName("changedPaths")]
|
||||
public ImmutableArray<ReachabilityPath> ChangedPaths { get; init; } = ImmutableArray<ReachabilityPath>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanation")]
|
||||
public string? Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate that blocked the path (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("blockingGate")]
|
||||
public string? BlockingGate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of reachability change.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReachabilityChangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability became reachable.
|
||||
/// </summary>
|
||||
BecameReachable,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability became unreachable.
|
||||
/// </summary>
|
||||
BecameUnreachable,
|
||||
|
||||
/// <summary>
|
||||
/// Path count increased.
|
||||
/// </summary>
|
||||
PathCountIncreased,
|
||||
|
||||
/// <summary>
|
||||
/// Path count decreased.
|
||||
/// </summary>
|
||||
PathCountDecreased,
|
||||
|
||||
/// <summary>
|
||||
/// Gate status changed.
|
||||
/// </summary>
|
||||
GateChanged,
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level changed.
|
||||
/// </summary>
|
||||
ConfidenceChanged,
|
||||
|
||||
/// <summary>
|
||||
/// Paths changed but overall status same.
|
||||
/// </summary>
|
||||
PathsChanged
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status for a vulnerability.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown reachability.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Definitely reachable.
|
||||
/// </summary>
|
||||
Reachable,
|
||||
|
||||
/// <summary>
|
||||
/// Probably reachable.
|
||||
/// </summary>
|
||||
ProbablyReachable,
|
||||
|
||||
/// <summary>
|
||||
/// Probably unreachable.
|
||||
/// </summary>
|
||||
ProbablyUnreachable,
|
||||
|
||||
/// <summary>
|
||||
/// Definitely unreachable.
|
||||
/// </summary>
|
||||
Unreachable,
|
||||
|
||||
/// <summary>
|
||||
/// Blocked by gate.
|
||||
/// </summary>
|
||||
GateBlocked,
|
||||
|
||||
/// <summary>
|
||||
/// Not applicable (vulnerability not present).
|
||||
/// </summary>
|
||||
NotApplicable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability path from entry point to vulnerable component.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityPath
|
||||
{
|
||||
/// <summary>
|
||||
/// Path status (added, removed, modified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point node (where the path starts).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public required string EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable component (where the path ends).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerableComponent")]
|
||||
public required string VulnerableComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hops in the path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hopCount")]
|
||||
public int HopCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Components in the path (ordered).
|
||||
/// </summary>
|
||||
[JsonPropertyName("components")]
|
||||
public ImmutableArray<string> Components { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for this path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate that applies to this path (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gate")]
|
||||
public string? Gate { get; init; }
|
||||
}
|
||||
@@ -44,7 +44,7 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
@@ -99,7 +99,7 @@ app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest reques
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
@@ -154,7 +154,7 @@ app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
@@ -209,7 +209,7 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
@@ -263,7 +263,7 @@ app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest re
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
@@ -304,7 +304,7 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:export"))
|
||||
@@ -385,7 +385,7 @@ static void LogAudit(HttpContext ctx, string route, int statusCode, long duratio
|
||||
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
|
||||
var actor = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "anonymous";
|
||||
var scopes = ctx.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToArray();
|
||||
|
||||
logger.Log(new AuditEvent(
|
||||
|
||||
12
src/Graph/StellaOps.Graph.Api/Properties/launchSettings.json
Normal file
12
src/Graph/StellaOps.Graph.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Graph.Api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62517;http://localhost:62518"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IReachabilityDeltaService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-027)
|
||||
// Task: Implement IReachabilityDeltaService
|
||||
// Description: Service interface for computing reachability deltas between artifacts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing reachability deltas between artifact versions.
|
||||
/// Compares reachability status, path counts, and gate changes.
|
||||
/// </summary>
|
||||
public interface IReachabilityDeltaService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the reachability delta between two artifact versions.
|
||||
/// </summary>
|
||||
/// <param name="request">The delta computation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The reachability delta response.</returns>
|
||||
Task<ReachabilityDeltaResponse> ComputeDeltaAsync(
|
||||
ReachabilityDeltaRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the reachability delta for a specific CVE between two artifacts.
|
||||
/// </summary>
|
||||
/// <param name="fromDigest">Source artifact digest.</param>
|
||||
/// <param name="toDigest">Target artifact digest.</param>
|
||||
/// <param name="cve">CVE identifier.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The reachability delta entry for the CVE, or null if no change.</returns>
|
||||
Task<ReachabilityDeltaEntry?> ComputeDeltaForCveAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
string cve,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current reachability status for a CVE in an artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest.</param>
|
||||
/// <param name="cve">CVE identifier.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The reachability status and path count.</returns>
|
||||
Task<(ReachabilityStatus Status, int PathCount)> GetReachabilityStatusAsync(
|
||||
string artifactDigest,
|
||||
string cve,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
|
||||
var cacheKey = BuildCacheKey(tenant, request, limit, tileBudgetLimit, nodeBudgetLimit, edgeBudgetLimit);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out string[]? cached))
|
||||
if (_cache.TryGetValue(cacheKey, out string[]? cached) && cached is not null)
|
||||
{
|
||||
foreach (var line in cached)
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class InMemoryGraphSearchService : IGraphSearchService
|
||||
{
|
||||
var limit = Math.Clamp(request.Limit ?? 50, 1, 500);
|
||||
var cacheKey = BuildCacheKey(tenant, request, limit);
|
||||
if (_cache.TryGetValue(cacheKey, out string[]? cachedLines))
|
||||
if (_cache.TryGetValue(cacheKey, out string[]? cachedLines) && cachedLines is not null)
|
||||
{
|
||||
foreach (var cached in cachedLines)
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace StellaOps.Graph.Api.Services;
|
||||
}
|
||||
|
||||
// Always return a fresh copy so we can inject a single explain trace without polluting cache.
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase, StringComparer.Ordinal);
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase ?? new Dictionary<string, OverlayPayload>(), StringComparer.Ordinal);
|
||||
|
||||
if (sampleExplain && !explainEmitted)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryReachabilityDeltaService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-027)
|
||||
// Task: Implement IReachabilityDeltaService
|
||||
// Description: In-memory implementation for computing reachability deltas.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IReachabilityDeltaService"/>.
|
||||
/// Uses mock data for development; to be replaced with real graph queries.
|
||||
/// </summary>
|
||||
public sealed class InMemoryReachabilityDeltaService : IReachabilityDeltaService
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Graph.ReachabilityDelta");
|
||||
|
||||
private readonly ILogger<InMemoryReachabilityDeltaService> _logger;
|
||||
|
||||
// In-memory store for reachability data (artifact -> cve -> status/paths)
|
||||
private readonly Dictionary<string, Dictionary<string, ArtifactReachability>> _reachabilityData = new();
|
||||
|
||||
public InMemoryReachabilityDeltaService(ILogger<InMemoryReachabilityDeltaService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachabilityDeltaResponse> ComputeDeltaAsync(
|
||||
ReachabilityDeltaRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("ComputeReachabilityDelta");
|
||||
activity?.SetTag("from_digest", request.FromDigest);
|
||||
activity?.SetTag("to_digest", request.ToDigest);
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Computing reachability delta from {FromDigest} to {ToDigest}",
|
||||
TruncateDigest(request.FromDigest),
|
||||
TruncateDigest(request.ToDigest));
|
||||
|
||||
var entries = new List<ReachabilityDeltaEntry>();
|
||||
|
||||
// Get reachability data for both artifacts
|
||||
var fromData = GetOrCreateArtifactReachability(request.FromDigest, request.TenantId);
|
||||
var toData = GetOrCreateArtifactReachability(request.ToDigest, request.TenantId);
|
||||
|
||||
// Collect all CVEs from both artifacts
|
||||
var allCves = new HashSet<string>(fromData.Keys);
|
||||
allCves.UnionWith(toData.Keys);
|
||||
|
||||
// Filter by specific CVE if requested
|
||||
if (!string.IsNullOrWhiteSpace(request.Cve))
|
||||
{
|
||||
allCves = allCves.Where(c => c.Equals(request.Cve, StringComparison.OrdinalIgnoreCase)).ToHashSet();
|
||||
}
|
||||
|
||||
foreach (var cve in allCves.OrderBy(c => c))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var fromReach = fromData.GetValueOrDefault(cve);
|
||||
var toReach = toData.GetValueOrDefault(cve);
|
||||
|
||||
var entry = ComputeEntryDelta(cve, fromReach, toReach, request.MaxPathsPerVuln);
|
||||
if (entry is not null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute summary
|
||||
var summary = new ReachabilityDeltaSummary
|
||||
{
|
||||
TotalChanges = entries.Count,
|
||||
NewlyReachable = entries.Count(e => e.ChangeType == ReachabilityChangeType.BecameReachable),
|
||||
NewlyUnreachable = entries.Count(e => e.ChangeType == ReachabilityChangeType.BecameUnreachable),
|
||||
PathCountChanges = entries.Count(e => e.ChangeType is ReachabilityChangeType.PathCountIncreased
|
||||
or ReachabilityChangeType.PathCountDecreased),
|
||||
GateChanges = entries.Count(e => e.ChangeType == ReachabilityChangeType.GateChanged),
|
||||
ConfidenceChanges = entries.Count(e => e.ChangeType == ReachabilityChangeType.ConfidenceChanged)
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Computed reachability delta: {TotalChanges} changes ({NewlyReachable} newly reachable, {NewlyUnreachable} newly unreachable)",
|
||||
summary.TotalChanges, summary.NewlyReachable, summary.NewlyUnreachable);
|
||||
|
||||
return new ReachabilityDeltaResponse
|
||||
{
|
||||
FromDigest = request.FromDigest,
|
||||
ToDigest = request.ToDigest,
|
||||
Summary = summary,
|
||||
Entries = entries.ToImmutableArray(),
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachabilityDeltaEntry?> ComputeDeltaForCveAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
string cve,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("ComputeReachabilityDeltaForCve");
|
||||
activity?.SetTag("cve", cve);
|
||||
|
||||
var fromData = GetOrCreateArtifactReachability(fromDigest, tenantId);
|
||||
var toData = GetOrCreateArtifactReachability(toDigest, tenantId);
|
||||
|
||||
var fromReach = fromData.GetValueOrDefault(cve);
|
||||
var toReach = toData.GetValueOrDefault(cve);
|
||||
|
||||
return ComputeEntryDelta(cve, fromReach, toReach, maxPaths: 5);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(ReachabilityStatus Status, int PathCount)> GetReachabilityStatusAsync(
|
||||
string artifactDigest,
|
||||
string cve,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var data = GetOrCreateArtifactReachability(artifactDigest, tenantId);
|
||||
|
||||
if (data.TryGetValue(cve, out var reach))
|
||||
{
|
||||
return (reach.Status, reach.Paths.Count);
|
||||
}
|
||||
|
||||
return (ReachabilityStatus.NotApplicable, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds reachability data for testing purposes.
|
||||
/// </summary>
|
||||
public void SeedReachabilityData(string artifactDigest, string cve, ReachabilityStatus status, List<ReachabilityPathData>? paths = null)
|
||||
{
|
||||
var key = artifactDigest;
|
||||
if (!_reachabilityData.TryGetValue(key, out var cveMap))
|
||||
{
|
||||
cveMap = new Dictionary<string, ArtifactReachability>(StringComparer.OrdinalIgnoreCase);
|
||||
_reachabilityData[key] = cveMap;
|
||||
}
|
||||
|
||||
cveMap[cve] = new ArtifactReachability
|
||||
{
|
||||
Status = status,
|
||||
Paths = paths ?? new List<ReachabilityPathData>(),
|
||||
Gate = null,
|
||||
Confidence = status == ReachabilityStatus.Reachable ? 0.95 : 0.1
|
||||
};
|
||||
}
|
||||
|
||||
private Dictionary<string, ArtifactReachability> GetOrCreateArtifactReachability(string artifactDigest, string tenantId)
|
||||
{
|
||||
var key = artifactDigest;
|
||||
if (_reachabilityData.TryGetValue(key, out var data))
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
// Return empty for unknown artifacts
|
||||
return new Dictionary<string, ArtifactReachability>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ReachabilityDeltaEntry? ComputeEntryDelta(
|
||||
string cve,
|
||||
ArtifactReachability? from,
|
||||
ArtifactReachability? to,
|
||||
int maxPaths)
|
||||
{
|
||||
var fromStatus = from?.Status ?? ReachabilityStatus.NotApplicable;
|
||||
var toStatus = to?.Status ?? ReachabilityStatus.NotApplicable;
|
||||
var fromPathCount = from?.Paths.Count ?? 0;
|
||||
var toPathCount = to?.Paths.Count ?? 0;
|
||||
|
||||
// Determine change type
|
||||
ReachabilityChangeType? changeType = null;
|
||||
string? explanation = null;
|
||||
|
||||
if (fromStatus == toStatus && fromPathCount == toPathCount)
|
||||
{
|
||||
// No change
|
||||
return null;
|
||||
}
|
||||
|
||||
// Status transitions
|
||||
if (IsReachable(toStatus) && !IsReachable(fromStatus))
|
||||
{
|
||||
changeType = ReachabilityChangeType.BecameReachable;
|
||||
explanation = $"Vulnerability became reachable (was {fromStatus}, now {toStatus})";
|
||||
}
|
||||
else if (!IsReachable(toStatus) && IsReachable(fromStatus))
|
||||
{
|
||||
changeType = ReachabilityChangeType.BecameUnreachable;
|
||||
explanation = $"Vulnerability became unreachable (was {fromStatus}, now {toStatus})";
|
||||
}
|
||||
else if (toStatus == ReachabilityStatus.GateBlocked && fromStatus != ReachabilityStatus.GateBlocked)
|
||||
{
|
||||
changeType = ReachabilityChangeType.GateChanged;
|
||||
explanation = $"Gate now blocks vulnerability (gate: {to?.Gate})";
|
||||
}
|
||||
else if (fromStatus == ReachabilityStatus.GateBlocked && toStatus != ReachabilityStatus.GateBlocked)
|
||||
{
|
||||
changeType = ReachabilityChangeType.GateChanged;
|
||||
explanation = $"Gate no longer blocks vulnerability";
|
||||
}
|
||||
else if (toPathCount > fromPathCount)
|
||||
{
|
||||
changeType = ReachabilityChangeType.PathCountIncreased;
|
||||
explanation = $"Path count increased from {fromPathCount} to {toPathCount}";
|
||||
}
|
||||
else if (toPathCount < fromPathCount)
|
||||
{
|
||||
changeType = ReachabilityChangeType.PathCountDecreased;
|
||||
explanation = $"Path count decreased from {fromPathCount} to {toPathCount}";
|
||||
}
|
||||
else
|
||||
{
|
||||
changeType = ReachabilityChangeType.PathsChanged;
|
||||
explanation = "Reachability paths changed";
|
||||
}
|
||||
|
||||
// Compute changed paths
|
||||
var changedPaths = ComputeChangedPaths(from?.Paths, to?.Paths, maxPaths);
|
||||
|
||||
return new ReachabilityDeltaEntry
|
||||
{
|
||||
Cve = cve,
|
||||
ChangeType = changeType.Value,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = toStatus,
|
||||
FromPathCount = fromPathCount,
|
||||
ToPathCount = toPathCount,
|
||||
ChangedPaths = changedPaths,
|
||||
Explanation = explanation,
|
||||
BlockingGate = toStatus == ReachabilityStatus.GateBlocked ? to?.Gate : null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsReachable(ReachabilityStatus status)
|
||||
{
|
||||
return status is ReachabilityStatus.Reachable or ReachabilityStatus.ProbablyReachable;
|
||||
}
|
||||
|
||||
private static ImmutableArray<ReachabilityPath> ComputeChangedPaths(
|
||||
List<ReachabilityPathData>? fromPaths,
|
||||
List<ReachabilityPathData>? toPaths,
|
||||
int maxPaths)
|
||||
{
|
||||
var result = new List<ReachabilityPath>();
|
||||
var fromSet = fromPaths?.ToHashSet() ?? new HashSet<ReachabilityPathData>();
|
||||
var toSet = toPaths?.ToHashSet() ?? new HashSet<ReachabilityPathData>();
|
||||
|
||||
// Find removed paths
|
||||
foreach (var path in fromSet.Except(toSet).Take(maxPaths / 2))
|
||||
{
|
||||
result.Add(new ReachabilityPath
|
||||
{
|
||||
Status = "removed",
|
||||
EntryPoint = path.EntryPoint,
|
||||
VulnerableComponent = path.VulnerableComponent,
|
||||
HopCount = path.Components.Count,
|
||||
Components = path.Components.ToImmutableArray(),
|
||||
Confidence = path.Confidence,
|
||||
Gate = path.Gate
|
||||
});
|
||||
}
|
||||
|
||||
// Find added paths
|
||||
foreach (var path in toSet.Except(fromSet).Take(maxPaths - result.Count))
|
||||
{
|
||||
result.Add(new ReachabilityPath
|
||||
{
|
||||
Status = "added",
|
||||
EntryPoint = path.EntryPoint,
|
||||
VulnerableComponent = path.VulnerableComponent,
|
||||
HopCount = path.Components.Count,
|
||||
Components = path.Components.ToImmutableArray(),
|
||||
Confidence = path.Confidence,
|
||||
Gate = path.Gate
|
||||
});
|
||||
}
|
||||
|
||||
return result.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
|
||||
{
|
||||
return $"{digest[..(colonIndex + 13)]}...";
|
||||
}
|
||||
|
||||
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal reachability data for an artifact/CVE.
|
||||
/// </summary>
|
||||
private sealed class ArtifactReachability
|
||||
{
|
||||
public ReachabilityStatus Status { get; init; }
|
||||
public List<ReachabilityPathData> Paths { get; init; } = new();
|
||||
public string? Gate { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path data for reachability computation.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityPathData
|
||||
{
|
||||
public required string EntryPoint { get; init; }
|
||||
public required string VulnerableComponent { get; init; }
|
||||
public List<string> Components { get; init; } = new();
|
||||
public double Confidence { get; init; }
|
||||
public string? Gate { get; init; }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Graph.Indexer module.
|
||||
/// </summary>
|
||||
public sealed class GraphIndexerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<GraphIndexerPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(GraphIndexerDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "GraphIndexer";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Graph.Indexer PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class GraphIndexerPostgresCollection : ICollectionFixture<GraphIndexerPostgresFixture>
|
||||
{
|
||||
public const string Name = "GraphIndexerPostgres";
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Graph.Indexer.Storage.Postgres\StellaOps.Graph.Indexer.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.Graph.Indexer.Storage.Postgres</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Graph.Indexer/StellaOps.Graph.Indexer.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -26,7 +26,7 @@ public sealed class GraphSnapshotBuilder
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var artifactNodeId = ResolveArtifactNodeId(sbomSnapshot, nodes);
|
||||
var snapshotId = ComputeSnapshotId(sbomSnapshot.Tenant, sbomSnapshot.ArtifactDigest, sbomSnapshot.SbomDigest);
|
||||
var snapshotId = ComputeSnapshotId(tenant, sbomSnapshot.ArtifactDigest, sbomSnapshot.SbomDigest);
|
||||
|
||||
var derivedSbomDigests = sbomSnapshot.BaseArtifacts
|
||||
.Select(baseArtifact => baseArtifact.SbomDigest)
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
143
src/Graph/StellaOps.Graph.sln
Normal file
143
src/Graph/StellaOps.Graph.sln
Normal file
@@ -0,0 +1,143 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Graph.Api", "StellaOps.Graph.Api", "{CFE227F1-1E50-8E34-0063-BB47F2602854}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Graph.Indexer", "StellaOps.Graph.Indexer", "{641F541A-A83B-8EC9-1EEE-0877B8C12E3A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Graph.Indexer.Persistence", "StellaOps.Graph.Indexer.Persistence", "{852C3E5B-F62A-BE80-F8C6-EC5C86E7A96F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Graph.Api.Tests", "StellaOps.Graph.Api.Tests", "{7CBE63C6-F62C-EAA5-9C68-FC43ED9EC9F8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Graph.Indexer.Persistence.Tests", "StellaOps.Graph.Indexer.Persistence.Tests", "{BEF141FE-B1E6-B291-97AA-23EF5C5C064A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Graph.Indexer.Tests", "StellaOps.Graph.Indexer.Tests", "{C06505EB-A731-B6EC-1B9A-73C168FE5627}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Graph.Api", "StellaOps.Graph.Api\StellaOps.Graph.Api.csproj", "{A56FF19F-0F1A-3EEF-E971-D2787209FD68}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Graph.Api.Tests", "__Tests\StellaOps.Graph.Api.Tests\StellaOps.Graph.Api.Tests.csproj", "{BABDA638-636A-085C-9D44-4BD9485265F4}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Graph.Indexer", "StellaOps.Graph.Indexer\StellaOps.Graph.Indexer.csproj", "{B284972A-8E22-BC42-828A-C93D26852AAF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Graph.Indexer.Persistence", "__Libraries\StellaOps.Graph.Indexer.Persistence\StellaOps.Graph.Indexer.Persistence.csproj", "{9FD001FA-4ACC-F531-DE95-9A2271B40876}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Graph.Indexer.Persistence.Tests", "__Tests\StellaOps.Graph.Indexer.Persistence.Tests\StellaOps.Graph.Indexer.Persistence.Tests.csproj", "{C22E1240-2BF8-EBA1-F533-A9A8DA7F155A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Graph.Indexer.Tests", "__Tests\StellaOps.Graph.Indexer.Tests\StellaOps.Graph.Indexer.Tests.csproj", "{FDAC412D-92ED-B6E3-5E61-608A4EB5C2D8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Global
|
||||
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
||||
Release|Any CPU = Release|Any CPU
|
||||
|
||||
EndGlobalSection
|
||||
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
|
||||
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{A56FF19F-0F1A-3EEF-E971-D2787209FD68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{A56FF19F-0F1A-3EEF-E971-D2787209FD68}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{A56FF19F-0F1A-3EEF-E971-D2787209FD68}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Graph Indexer module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// </summary>
|
||||
public class GraphIndexerDbContext : DbContext
|
||||
{
|
||||
public GraphIndexerDbContext(DbContextOptions<GraphIndexerDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("graph");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,21 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Graph.Indexer.Analytics;
|
||||
using StellaOps.Graph.Indexer.Incremental;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Graph.Indexer PostgreSQL storage services.
|
||||
/// Extension methods for configuring Graph.Indexer persistence services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
public static class GraphIndexerPersistenceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Graph.Indexer PostgreSQL storage services.
|
||||
/// Adds Graph.Indexer PostgreSQL persistence services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration root.</param>
|
||||
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGraphIndexerPostgresStorage(
|
||||
public static IServiceCollection AddGraphIndexerPersistence(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "Postgres:Graph")
|
||||
@@ -38,12 +35,9 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Graph.Indexer PostgreSQL storage services with explicit options.
|
||||
/// Adds Graph.Indexer PostgreSQL persistence services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGraphIndexerPostgresStorage(
|
||||
public static IServiceCollection AddGraphIndexerPersistence(
|
||||
this IServiceCollection services,
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
@@ -4,7 +4,7 @@ using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for Graph.Indexer module.
|
||||
@@ -4,7 +4,7 @@ using Npgsql;
|
||||
using StellaOps.Graph.Indexer.Analytics;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IGraphAnalyticsWriter"/>.
|
||||
@@ -5,7 +5,7 @@ using Npgsql;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IGraphDocumentWriter"/>.
|
||||
@@ -6,7 +6,7 @@ using Npgsql;
|
||||
using StellaOps.Graph.Indexer.Analytics;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IGraphSnapshotProvider"/>.
|
||||
@@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Graph.Indexer.Incremental;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IIdempotencyStore"/>.
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Graph.Indexer.Persistence</RootNamespace>
|
||||
<AssemblyName>StellaOps.Graph.Indexer.Persistence</AssemblyName>
|
||||
<Description>Consolidated persistence layer for StellaOps Graph Indexer module</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Graph.Indexer\StellaOps.Graph.Indexer.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
@@ -28,7 +29,6 @@ public class AuditLoggerTests
|
||||
Assert.True(recent.Count <= 100);
|
||||
// First entry is the most recent (minute 509). Verify using total minutes from epoch.
|
||||
var minutesFromEpoch = (int)(recent.First().Timestamp - DateTimeOffset.UnixEpoch).TotalMinutes;
|
||||
using StellaOps.TestKit;
|
||||
Assert.Equal(509, minutesFromEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// GraphApiContractTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
|
||||
// Tasks: GRAPH-5100-006, GRAPH-5100-007, GRAPH-5100-008
|
||||
@@ -22,7 +22,7 @@ namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// W1 API Layer Tests: Contract Tests, Auth Tests, OTel Trace Assertions
|
||||
/// Task GRAPH-5100-006: Contract tests (GET /graphs/{tenantId}/query → 200 + NDJSON)
|
||||
/// Task GRAPH-5100-006: Contract tests (GET /graphs/{tenantId}/query → 200 + NDJSON)
|
||||
/// Task GRAPH-5100-007: Auth tests (scopes: graph:read, graph:write)
|
||||
/// Task GRAPH-5100-008: OTel trace assertions (spans include tenant_id, query_type)
|
||||
/// </summary>
|
||||
@@ -414,7 +414,6 @@ public sealed class GraphApiContractTests : IDisposable
|
||||
// Arrange
|
||||
using var metrics = new GraphMetrics();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
// Assert - Verify meter is correctly configured
|
||||
metrics.Meter.Should().NotBeNull();
|
||||
metrics.Meter.Name.Should().Be("StellaOps.Graph.Api");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -80,7 +80,6 @@ public class MetricsTests
|
||||
// Now create metrics after listener is started
|
||||
using var metrics = new GraphMetrics();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
@@ -92,7 +92,6 @@ public class QueryServiceTests
|
||||
{
|
||||
if (!line.Contains("\"type\":\"node\"")) continue;
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
using StellaOps.TestKit;
|
||||
var data = doc.RootElement.GetProperty("data");
|
||||
if (data.TryGetProperty("overlays", out var overlaysElement) && overlaysElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
@@ -71,7 +71,7 @@ public class SearchServiceTests
|
||||
results.Add(line);
|
||||
}
|
||||
|
||||
Assert.True(results.Any(r => r.Contains("\"type\":\"node\"")));
|
||||
Assert.Contains(results, r => r.Contains("\"type\":\"node\""));
|
||||
|
||||
var cursorLine = results.FirstOrDefault(r => r.Contains("\"type\":\"cursor\""));
|
||||
if (!string.IsNullOrEmpty(cursorLine))
|
||||
@@ -204,7 +204,6 @@ public class SearchServiceTests
|
||||
private static string ExtractNodeId(string nodeJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(nodeJson);
|
||||
using StellaOps.TestKit;
|
||||
return doc.RootElement.GetProperty("data").GetProperty("id").GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
<PackageReference Update="xunit" />
|
||||
<PackageReference Update="xunit.runner.visualstudio" />
|
||||
<PackageReference Update="Microsoft.NET.Test.Sdk" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Reflection;
|
||||
using Npgsql;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Graph.Indexer module.
|
||||
/// </summary>
|
||||
public sealed class GraphIndexerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<GraphIndexerPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(GraphIndexerDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "GraphIndexer";
|
||||
|
||||
/// <summary>
|
||||
/// Gets table names in the current schema.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetTableNamesAsync()
|
||||
{
|
||||
var tables = new List<string>();
|
||||
var sql = @"
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema
|
||||
ORDER BY table_name";
|
||||
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("schema", SchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets column names for a specific table.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetColumnNamesAsync(string tableName)
|
||||
{
|
||||
var columns = new List<string>();
|
||||
var sql = @"
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema AND table_name = @table
|
||||
ORDER BY ordinal_position";
|
||||
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("schema", SchemaName);
|
||||
command.Parameters.AddWithValue("table", tableName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
columns.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets index names for a specific table.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetIndexNamesAsync(string tableName)
|
||||
{
|
||||
var indexes = new List<string>();
|
||||
var sql = @"
|
||||
SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = @schema AND tablename = @table
|
||||
ORDER BY indexname";
|
||||
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("schema", SchemaName);
|
||||
command.Parameters.AddWithValue("table", tableName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
indexes.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures migrations have been run (this is already done in InitializeAsync).
|
||||
/// </summary>
|
||||
public Task EnsureMigrationsRunAsync() => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Graph.Indexer PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class GraphIndexerPostgresCollection : ICollectionFixture<GraphIndexerPostgresFixture>
|
||||
{
|
||||
public const string Name = "GraphIndexerPostgres";
|
||||
}
|
||||
@@ -10,11 +10,12 @@ using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MicrosoftOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Tests;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// S1 Storage Layer Tests: Query Determinism Tests
|
||||
@@ -8,10 +8,11 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MicrosoftOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Tests;
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// S1 Storage Layer Tests: Migration Tests
|
||||
@@ -1,11 +1,12 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MicrosoftOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.Graph.Indexer.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Storage.Postgres.Tests;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Persistence.Tests;
|
||||
|
||||
[Collection(GraphIndexerPostgresCollection.Name)]
|
||||
public sealed class PostgresIdempotencyStoreTests : IAsyncLifetime
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Graph.Indexer.Persistence.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Graph.Indexer.Persistence\StellaOps.Graph.Indexer.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Graph.Indexer.Analytics;
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed class GraphAnalyticsPipelineTests
|
||||
provider.Enqueue(snapshot);
|
||||
|
||||
using var metrics = new GraphAnalyticsMetrics();
|
||||
using StellaOps.TestKit;
|
||||
var writer = new InMemoryGraphAnalyticsWriter();
|
||||
var pipeline = new GraphAnalyticsPipeline(
|
||||
new GraphAnalyticsEngine(new GraphAnalyticsOptions()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -34,7 +34,6 @@ public sealed class GraphChangeStreamProcessorTests
|
||||
var writer = new FlakyWriter(failFirst: true);
|
||||
using var metrics = new GraphBackfillMetrics();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var options = Options.Create(new GraphChangeStreamOptions
|
||||
{
|
||||
MaxRetryAttempts = 3,
|
||||
@@ -99,7 +98,7 @@ using StellaOps.TestKit;
|
||||
public int BatchCount { get; private set; }
|
||||
public bool SucceededAfterRetry => _attempts > 1 && BatchCount > 0;
|
||||
|
||||
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
public Task WriteAsync(Ingestion.Sbom.GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
{
|
||||
_attempts++;
|
||||
if (_failFirst && _attempts == 1)
|
||||
|
||||
@@ -47,7 +47,7 @@ public sealed class GraphCoreLogicTests
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Adjacency.Nodes.Should().HaveCount(4);
|
||||
@@ -76,7 +76,7 @@ public sealed class GraphCoreLogicTests
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert - Each node should have correct edge counts
|
||||
var rootNode = result.Adjacency.Nodes.Single(n => n.NodeId == "root");
|
||||
@@ -108,7 +108,7 @@ public sealed class GraphCoreLogicTests
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
var axiosNode = result.Adjacency.Nodes.Single(n => n.NodeId == "axios-node");
|
||||
@@ -136,11 +136,11 @@ public sealed class GraphCoreLogicTests
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert - Should handle duplicates deterministically
|
||||
var compNodes = result.Adjacency.Nodes.Where(n => n.NodeId == "comp").ToList();
|
||||
compNodes.Should().HaveCountGreaterOrEqualTo(1);
|
||||
compNodes.Should().HaveCountGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -155,7 +155,7 @@ public sealed class GraphCoreLogicTests
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Adjacency.Nodes.Should().BeEmpty();
|
||||
@@ -175,7 +175,7 @@ public sealed class GraphCoreLogicTests
|
||||
var edges = CreateLinearGraphEdges(3);
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Traverse from node-0 to node-2
|
||||
var path = TraversePath(result.Adjacency, "node-0", "node-2");
|
||||
@@ -198,7 +198,7 @@ public sealed class GraphCoreLogicTests
|
||||
var edges = ImmutableArray<GraphBuildEdge>.Empty; // No edges
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var path = TraversePath(result.Adjacency, "isolated-a", "isolated-b");
|
||||
@@ -223,7 +223,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Path from self to self
|
||||
var path = TraversePath(result.Adjacency, "self", "self");
|
||||
@@ -254,7 +254,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var path = TraversePath(result.Adjacency, "A", "D");
|
||||
@@ -290,7 +290,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Filter to only component nodes
|
||||
var componentNodes = FilterNodes(result.Adjacency, n => n.NodeId.StartsWith("comp-"));
|
||||
@@ -320,7 +320,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Get nodes with "depends-on" edges only
|
||||
var dependencyNodes = FilterNodesWithEdge(result.Adjacency, "depends-on");
|
||||
@@ -348,7 +348,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Filter nodes containing "critical" in ID
|
||||
var criticalNodes = FilterNodes(result.Adjacency, n => n.NodeId.Contains("critical"));
|
||||
@@ -377,7 +377,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Filter with always-true predicate
|
||||
var allNodes = FilterNodes(result.Adjacency, _ => true);
|
||||
@@ -403,7 +403,7 @@ public sealed class GraphCoreLogicTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Act - Filter for non-existent pattern
|
||||
var noMatches = FilterNodes(result.Adjacency, n => n.NodeId.Contains("nonexistent"));
|
||||
@@ -485,7 +485,7 @@ public sealed class GraphCoreLogicTests
|
||||
/// <summary>
|
||||
/// Simple BFS path finding for testing.
|
||||
/// </summary>
|
||||
private static List<string> TraversePath(GraphAdjacency adjacency, string from, string to)
|
||||
private static List<string> TraversePath(GraphAdjacencyManifest adjacency, string from, string to)
|
||||
{
|
||||
if (from == to)
|
||||
return new List<string> { from };
|
||||
@@ -527,44 +527,15 @@ public sealed class GraphCoreLogicTests
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
private static List<AdjacencyNode> FilterNodes(GraphAdjacency adjacency, Func<AdjacencyNode, bool> predicate)
|
||||
private static List<GraphAdjacencyNode> FilterNodes(GraphAdjacencyManifest adjacency, Func<GraphAdjacencyNode, bool> predicate)
|
||||
{
|
||||
return adjacency.Nodes.Where(predicate).ToList();
|
||||
}
|
||||
|
||||
private static List<AdjacencyNode> FilterNodesWithEdge(GraphAdjacency adjacency, string edgeId)
|
||||
private static List<GraphAdjacencyNode> FilterNodesWithEdge(GraphAdjacencyManifest adjacency, string edgeId)
|
||||
{
|
||||
return adjacency.Nodes.Where(n => n.OutgoingEdges.Contains(edgeId) || n.IncomingEdges.Contains(edgeId)).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Supporting Types (if not present in the project)
|
||||
|
||||
/// <summary>
|
||||
/// Graph build node for testing.
|
||||
/// </summary>
|
||||
internal record GraphBuildNode(string Id, string Type, IDictionary<string, object> Attributes);
|
||||
|
||||
/// <summary>
|
||||
/// Graph build edge for testing.
|
||||
/// </summary>
|
||||
internal record GraphBuildEdge(string Id, string Source, string Target, string EdgeType, IDictionary<string, object> Attributes);
|
||||
|
||||
/// <summary>
|
||||
/// Graph build batch for testing.
|
||||
/// </summary>
|
||||
internal record GraphBuildBatch(ImmutableArray<GraphBuildNode> Nodes, ImmutableArray<GraphBuildEdge> Edges);
|
||||
|
||||
/// <summary>
|
||||
/// Graph adjacency structure for testing.
|
||||
/// </summary>
|
||||
internal record GraphAdjacency(ImmutableArray<AdjacencyNode> Nodes);
|
||||
|
||||
/// <summary>
|
||||
/// Adjacency node for testing.
|
||||
/// </summary>
|
||||
internal record AdjacencyNode(string NodeId, ImmutableArray<string> OutgoingEdges, ImmutableArray<string> IncomingEdges);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Adjacency.Nodes.Should().Contain(n => n.NodeId.Contains("artifact"));
|
||||
@@ -50,7 +50,7 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Adjacency.Nodes.Should().Contain(n => n.NodeId.Contains("component"));
|
||||
@@ -67,7 +67,7 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert - Root should have outgoing edges to components
|
||||
var rootNode = result.Adjacency.Nodes.FirstOrDefault(n => n.NodeId == "root");
|
||||
@@ -88,11 +88,11 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.ArtifactDigest.Should().Be(artifactDigest);
|
||||
result.SbomDigest.Should().Be(sbomDigest);
|
||||
result.Manifest.ArtifactDigest.Should().Be(artifactDigest);
|
||||
result.Manifest.SbomDigest.Should().Be(sbomDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -112,12 +112,12 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act
|
||||
var result1 = builder.Build(snapshot1, new GraphBuildBatch(nodes1, edges), DateTimeOffset.UtcNow);
|
||||
var result2 = builder.Build(snapshot2, new GraphBuildBatch(nodes2, edges), DateTimeOffset.UtcNow);
|
||||
var result1 = builder.Build(snapshot1, nodes1.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
var result2 = builder.Build(snapshot2, nodes2.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert - Each result should contain only its tenant's data
|
||||
result1.Tenant.Should().Be(tenant1);
|
||||
result2.Tenant.Should().Be(tenant2);
|
||||
result1.Manifest.Tenant.Should().Be(tenant1);
|
||||
result2.Manifest.Tenant.Should().Be(tenant2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -135,11 +135,11 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.ManifestHash.Should().NotBeNullOrEmpty();
|
||||
result.ManifestHash.Should().StartWith("sha256:");
|
||||
result.Manifest.Hash.Should().NotBeNullOrEmpty();
|
||||
result.Manifest.Hash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -153,11 +153,11 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var edges = CreateSbomEdges();
|
||||
|
||||
// Act - Build twice with same input
|
||||
var result1 = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var result2 = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var result1 = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var result2 = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
|
||||
// Assert
|
||||
result1.ManifestHash.Should().Be(result2.ManifestHash);
|
||||
result1.Manifest.Hash.Should().Be(result2.Manifest.Hash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -190,11 +190,11 @@ public sealed class GraphIndexerEndToEndTests
|
||||
var timestamp = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
|
||||
|
||||
// Act
|
||||
var result1 = builder.Build(snapshot, new GraphBuildBatch(nodesOriginal, edges), timestamp);
|
||||
var result2 = builder.Build(snapshot, new GraphBuildBatch(nodesShuffled, edges), timestamp);
|
||||
var result1 = builder.Build(snapshot, nodesOriginal.ToGraphBuildBatch(edges), timestamp);
|
||||
var result2 = builder.Build(snapshot, nodesShuffled.ToGraphBuildBatch(edges), timestamp);
|
||||
|
||||
// Assert
|
||||
result1.ManifestHash.Should().Be(result2.ManifestHash, "Shuffled inputs should produce same hash");
|
||||
result1.Manifest.Hash.Should().Be(result2.Manifest.Hash, "Shuffled inputs should produce same hash");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -229,7 +229,7 @@ public sealed class GraphIndexerEndToEndTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Adjacency.Nodes.Should().HaveCount(6);
|
||||
@@ -268,7 +268,7 @@ public sealed class GraphIndexerEndToEndTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act
|
||||
var result = builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Adjacency.Nodes.Should().HaveCount(4);
|
||||
@@ -301,7 +301,7 @@ public sealed class GraphIndexerEndToEndTests
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act - Should not throw
|
||||
var act = () => builder.Build(snapshot, new GraphBuildBatch(nodes, edges), DateTimeOffset.UtcNow);
|
||||
var act = () => builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
act.Should().NotThrow("Circular dependencies should be handled gracefully");
|
||||
@@ -385,10 +385,5 @@ public sealed class GraphIndexerEndToEndTests
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Supporting Types
|
||||
|
||||
internal record GraphBuildNode(string Id, string Type, IDictionary<string, object> Attributes);
|
||||
internal record GraphBuildEdge(string Id, string Source, string Target, string EdgeType, IDictionary<string, object> Attributes);
|
||||
internal record GraphBuildBatch(ImmutableArray<GraphBuildNode> Nodes, ImmutableArray<GraphBuildEdge> Edges);
|
||||
|
||||
#endregion
|
||||
// Note: These test types shadow the production types in Ingestion.Sbom namespace which use JsonObject
|
||||
// Tests in this file use these simplified versions for easier test data construction
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GraphTestHelpers.cs
|
||||
// Description: Shared test helpers for Graph.Indexer tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Graph.Indexer.Documents;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test helper record for creating graph nodes with simplified syntax.
|
||||
/// Converts to JsonObject format expected by GraphSnapshotBuilder.
|
||||
/// </summary>
|
||||
internal sealed record GraphBuildNode(string NodeId, string Kind, Dictionary<string, object> Attributes)
|
||||
{
|
||||
public JsonObject ToJsonObject()
|
||||
{
|
||||
var json = new JsonObject
|
||||
{
|
||||
["id"] = NodeId,
|
||||
["kind"] = Kind
|
||||
};
|
||||
|
||||
if (Attributes.Count > 0)
|
||||
{
|
||||
var attributesJson = new JsonObject();
|
||||
foreach (var (key, value) in Attributes)
|
||||
{
|
||||
attributesJson[key] = JsonValue.Create(value);
|
||||
}
|
||||
json["attributes"] = attributesJson;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
public static implicit operator JsonObject(GraphBuildNode node) => node.ToJsonObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test helper record for creating graph edges with simplified syntax.
|
||||
/// Converts to JsonObject format expected by GraphSnapshotBuilder.
|
||||
/// </summary>
|
||||
internal sealed record GraphBuildEdge(string Id, string Source, string Target, string EdgeType, Dictionary<string, object> Attributes)
|
||||
{
|
||||
public JsonObject ToJsonObject()
|
||||
{
|
||||
var json = new JsonObject
|
||||
{
|
||||
["id"] = Id,
|
||||
["source"] = Source,
|
||||
["target"] = Target,
|
||||
["kind"] = EdgeType
|
||||
};
|
||||
|
||||
if (Attributes.Count > 0)
|
||||
{
|
||||
var attributesJson = new JsonObject();
|
||||
foreach (var (key, value) in Attributes)
|
||||
{
|
||||
attributesJson[key] = JsonValue.Create(value);
|
||||
}
|
||||
json["attributes"] = attributesJson;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
public static implicit operator JsonObject(GraphBuildEdge edge) => edge.ToJsonObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for converting test helper types to production types.
|
||||
/// </summary>
|
||||
internal static class GraphTestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an array of test GraphBuildNodes to JsonObjects.
|
||||
/// </summary>
|
||||
public static ImmutableArray<JsonObject> ToJsonObjects(this ImmutableArray<GraphBuildNode> nodes)
|
||||
{
|
||||
return nodes.Select(n => n.ToJsonObject()).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an array of test GraphBuildEdges to JsonObjects.
|
||||
/// </summary>
|
||||
public static ImmutableArray<JsonObject> ToJsonObjects(this ImmutableArray<GraphBuildEdge> edges)
|
||||
{
|
||||
return edges.Select(e => e.ToJsonObject()).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a production GraphBuildBatch from test node and edge arrays.
|
||||
/// </summary>
|
||||
public static GraphBuildBatch ToGraphBuildBatch(this ImmutableArray<GraphBuildNode> nodes, ImmutableArray<GraphBuildEdge> edges)
|
||||
{
|
||||
return new GraphBuildBatch(nodes.ToJsonObjects(), edges.ToJsonObjects());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-friendly wrapper for GraphBuildBatch that accepts simplified node/edge types.
|
||||
/// </summary>
|
||||
internal sealed record TestGraphBuildBatch
|
||||
{
|
||||
public ImmutableArray<GraphBuildNode> Nodes { get; }
|
||||
public ImmutableArray<GraphBuildEdge> Edges { get; }
|
||||
|
||||
public TestGraphBuildBatch(ImmutableArray<GraphBuildNode> nodes, ImmutableArray<GraphBuildEdge> edges)
|
||||
{
|
||||
Nodes = nodes;
|
||||
Edges = edges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts this test helper to the production GraphBuildBatch type.
|
||||
/// </summary>
|
||||
public GraphBuildBatch ToProduction()
|
||||
{
|
||||
return new GraphBuildBatch(
|
||||
Nodes.ToJsonObjects(),
|
||||
Edges.ToJsonObjects());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implicit conversion to production GraphBuildBatch.
|
||||
/// </summary>
|
||||
public static implicit operator GraphBuildBatch(TestGraphBuildBatch test)
|
||||
{
|
||||
return test.ToProduction();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for GraphSnapshot to provide convenient accessors.
|
||||
/// </summary>
|
||||
internal static class GraphSnapshotExtensions
|
||||
{
|
||||
public static string GetArtifactDigest(this GraphSnapshot snapshot) => snapshot.Manifest.ArtifactDigest;
|
||||
public static string GetSbomDigest(this GraphSnapshot snapshot) => snapshot.Manifest.SbomDigest;
|
||||
public static string GetTenant(this GraphSnapshot snapshot) => snapshot.Manifest.Tenant;
|
||||
public static string GetManifestHash(this GraphSnapshot snapshot) => snapshot.Manifest.Hash;
|
||||
}
|
||||
@@ -3,8 +3,8 @@ using System.Text.Json.Nodes;
|
||||
using StellaOps.Graph.Indexer.Documents;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomSnapshotExporterTests
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Graph.Indexer/StellaOps.Graph.Indexer.csproj" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user