Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View 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; }
}

View File

@@ -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(

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Graph.Api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62517;http://localhost:62518"
}
}
}

View File

@@ -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);
}

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View 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

View File

@@ -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);
}
}

View File

@@ -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)
{

View File

@@ -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.

View File

@@ -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"/>.

View File

@@ -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"/>.

View File

@@ -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"/>.

View File

@@ -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"/>.

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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" }

View File

@@ -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)
{

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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()),

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>