partly or unimplemented features - now implemented
This commit is contained in:
422
src/Graph/StellaOps.Graph.Api/Contracts/EdgeMetadataContracts.cs
Normal file
422
src/Graph/StellaOps.Graph.Api/Contracts/EdgeMetadataContracts.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EdgeMetadataContracts.cs
|
||||
// Sprint: SPRINT_20260208_039_Graph_graph_edge_metadata_with_reason_evidence_provenance
|
||||
// Description: Contracts for edge metadata including reason, evidence, and provenance.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Graph.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Enumeration of reasons why an edge exists in the graph.
|
||||
/// Provides human-readable explanations for graph relationships.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EdgeReason
|
||||
{
|
||||
/// <summary>Reason is unknown or not yet determined.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Edge from SBOM component dependency declaration.</summary>
|
||||
SbomDependency = 1,
|
||||
|
||||
/// <summary>Edge from static analysis symbol resolution.</summary>
|
||||
StaticSymbol = 2,
|
||||
|
||||
/// <summary>Edge from dynamic runtime call trace.</summary>
|
||||
RuntimeTrace = 3,
|
||||
|
||||
/// <summary>Edge from package manifest (package.json, Cargo.toml, etc.).</summary>
|
||||
PackageManifest = 4,
|
||||
|
||||
/// <summary>Edge from lockfile (package-lock.json, Cargo.lock, etc.).</summary>
|
||||
Lockfile = 5,
|
||||
|
||||
/// <summary>Edge from build system output.</summary>
|
||||
BuildArtifact = 6,
|
||||
|
||||
/// <summary>Edge from container image layer analysis.</summary>
|
||||
ImageLayer = 7,
|
||||
|
||||
/// <summary>Edge from advisory/vulnerability affecting relationship.</summary>
|
||||
AdvisoryAffects = 8,
|
||||
|
||||
/// <summary>Edge from VEX statement relationship.</summary>
|
||||
VexStatement = 9,
|
||||
|
||||
/// <summary>Edge from policy overlay.</summary>
|
||||
PolicyOverlay = 10,
|
||||
|
||||
/// <summary>Edge from attestation reference.</summary>
|
||||
AttestationRef = 11,
|
||||
|
||||
/// <summary>Edge from manual operator annotation.</summary>
|
||||
OperatorAnnotation = 12,
|
||||
|
||||
/// <summary>Edge inferred from transitive dependency analysis.</summary>
|
||||
TransitiveInference = 13,
|
||||
|
||||
/// <summary>Edge from provenance/SLSA relationship.</summary>
|
||||
Provenance = 14
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes how an edge was discovered or established.
|
||||
/// Captures the method/tool used to identify the relationship.
|
||||
/// </summary>
|
||||
public sealed record EdgeVia
|
||||
{
|
||||
/// <summary>
|
||||
/// The mechanism or tool that established this edge.
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the tool/mechanism if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the edge was established.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0-10000 basis points) in the edge validity.
|
||||
/// 10000 = 100% confidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceBps")]
|
||||
public int ConfidenceBps { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the source evidence (e.g., SBOM digest, attestation ID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceRef")]
|
||||
public string? EvidenceRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete explanation payload for why an edge exists.
|
||||
/// Combines reason, method, and evidence provenance.
|
||||
/// </summary>
|
||||
public sealed record EdgeExplanationPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// The primary reason for this edge's existence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public EdgeReason Reason { get; init; } = EdgeReason.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// How the edge was discovered/established.
|
||||
/// </summary>
|
||||
[JsonPropertyName("via")]
|
||||
public EdgeVia? Via { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of why this edge exists.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional evidence references that support this edge.
|
||||
/// Maps evidence type to reference ID (e.g., "sbom" -> "sha256:abc").
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public IReadOnlyDictionary<string, string>? Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance metadata linking back to source artifacts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("provenance")]
|
||||
public EdgeProvenanceRef? Provenance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization and filtering.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance reference for an edge, linking back to source evidence.
|
||||
/// </summary>
|
||||
public sealed record EdgeProvenanceRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Source system that generated this edge.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the evidence was collected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset CollectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the SBOM that sourced this edge, if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scan digest that identified this edge, if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanDigest")]
|
||||
public string? ScanDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attestation bundle ID containing evidence, if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationId")]
|
||||
public string? AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event offset in the event log for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventOffset")]
|
||||
public long? EventOffset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended edge tile with metadata for API responses.
|
||||
/// Augments EdgeTile with explanation and provenance.
|
||||
/// </summary>
|
||||
public sealed record EdgeTileWithMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge unique identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Edge kind/type (e.g., "depends_on", "affects", "vex_applies").
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "depends_on";
|
||||
|
||||
/// <summary>
|
||||
/// Tenant owning this edge.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source node ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string Target { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Edge attributes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attributes")]
|
||||
public Dictionary<string, object?> Attributes { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of why this edge exists.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanation")]
|
||||
public EdgeExplanationPayload? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to query edge metadata for specific edges.
|
||||
/// </summary>
|
||||
public sealed record EdgeMetadataRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge IDs to retrieve metadata for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edgeIds")]
|
||||
public IReadOnlyList<string> EdgeIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include full provenance details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeProvenance")]
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include evidence references.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeEvidence")]
|
||||
public bool IncludeEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing edge metadata.
|
||||
/// </summary>
|
||||
public sealed record EdgeMetadataResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Edges with their metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edges")]
|
||||
public IReadOnlyList<EdgeTileWithMetadata> Edges { get; init; } = Array.Empty<EdgeTileWithMetadata>();
|
||||
|
||||
/// <summary>
|
||||
/// Total count of edges returned.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for creating edge explanations with common patterns.
|
||||
/// </summary>
|
||||
public static class EdgeExplanationFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an explanation for an SBOM dependency edge.
|
||||
/// </summary>
|
||||
public static EdgeExplanationPayload FromSbomDependency(
|
||||
string sbomDigest,
|
||||
string source,
|
||||
DateTimeOffset collectedAt,
|
||||
string? summary = null)
|
||||
{
|
||||
return new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.SbomDependency,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "sbom-parser",
|
||||
Timestamp = collectedAt,
|
||||
ConfidenceBps = 10000,
|
||||
EvidenceRef = sbomDigest
|
||||
},
|
||||
Summary = summary ?? "Dependency declared in SBOM",
|
||||
Evidence = new Dictionary<string, string> { ["sbom"] = sbomDigest },
|
||||
Provenance = new EdgeProvenanceRef
|
||||
{
|
||||
Source = source,
|
||||
CollectedAt = collectedAt,
|
||||
SbomDigest = sbomDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an explanation for an advisory affects edge.
|
||||
/// </summary>
|
||||
public static EdgeExplanationPayload FromAdvisory(
|
||||
string advisoryId,
|
||||
string source,
|
||||
DateTimeOffset collectedAt,
|
||||
string? scanDigest = null)
|
||||
{
|
||||
return new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.AdvisoryAffects,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "advisory-matcher",
|
||||
Timestamp = collectedAt,
|
||||
ConfidenceBps = 10000,
|
||||
EvidenceRef = advisoryId
|
||||
},
|
||||
Summary = $"Affected by advisory {advisoryId}",
|
||||
Evidence = new Dictionary<string, string> { ["advisory"] = advisoryId },
|
||||
Provenance = new EdgeProvenanceRef
|
||||
{
|
||||
Source = source,
|
||||
CollectedAt = collectedAt,
|
||||
ScanDigest = scanDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an explanation for a VEX statement edge.
|
||||
/// </summary>
|
||||
public static EdgeExplanationPayload FromVex(
|
||||
string vexDocumentId,
|
||||
string statement,
|
||||
string source,
|
||||
DateTimeOffset collectedAt,
|
||||
string? attestationId = null)
|
||||
{
|
||||
return new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.VexStatement,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "vex-processor",
|
||||
Timestamp = collectedAt,
|
||||
ConfidenceBps = 10000,
|
||||
EvidenceRef = vexDocumentId
|
||||
},
|
||||
Summary = statement,
|
||||
Evidence = new Dictionary<string, string> { ["vex"] = vexDocumentId },
|
||||
Provenance = new EdgeProvenanceRef
|
||||
{
|
||||
Source = source,
|
||||
CollectedAt = collectedAt,
|
||||
AttestationId = attestationId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an explanation for a static analysis edge.
|
||||
/// </summary>
|
||||
public static EdgeExplanationPayload FromStaticAnalysis(
|
||||
string symbol,
|
||||
string analysisToolVersion,
|
||||
DateTimeOffset analysisTime,
|
||||
int confidenceBps = 9000)
|
||||
{
|
||||
return new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.StaticSymbol,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "static-analysis",
|
||||
Version = analysisToolVersion,
|
||||
Timestamp = analysisTime,
|
||||
ConfidenceBps = confidenceBps
|
||||
},
|
||||
Summary = $"Static analysis resolved symbol: {symbol}",
|
||||
Tags = ["static-analysis", "symbol-resolution"]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an explanation for a runtime trace edge.
|
||||
/// </summary>
|
||||
public static EdgeExplanationPayload FromRuntimeTrace(
|
||||
string traceId,
|
||||
DateTimeOffset traceTime,
|
||||
int callCount)
|
||||
{
|
||||
return new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.RuntimeTrace,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "runtime-instrumentation",
|
||||
Timestamp = traceTime,
|
||||
ConfidenceBps = 10000,
|
||||
EvidenceRef = traceId
|
||||
},
|
||||
Summary = $"Observed {callCount} runtime call(s)",
|
||||
Evidence = new Dictionary<string, string> { ["trace"] = traceId },
|
||||
Tags = ["runtime", "dynamic-analysis"]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
|
||||
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
|
||||
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<IEdgeMetadataService, InMemoryEdgeMetadataService>();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.TryAddStellaOpsLocalBinding("graph");
|
||||
var app = builder.Build();
|
||||
@@ -362,6 +364,102 @@ app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphEx
|
||||
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Edge Metadata API
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
var response = await service.GetEdgeMetadataAsync(tenant, request, ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
var result = await service.GetSingleEdgeMetadataAsync(tenant, edgeId, ct);
|
||||
if (result is null)
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
|
||||
return Results.NotFound(new ErrorResponse { Error = "EDGE_NOT_FOUND", Message = $"Edge '{edgeId}' not found" });
|
||||
}
|
||||
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/path"))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
var edges = await service.GetPathEdgesWithMetadataAsync(tenant, sourceNodeId, targetNodeId, ct);
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() });
|
||||
});
|
||||
|
||||
app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-reason"))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<EdgeReason>(reason, ignoreCase: true, out var edgeReason))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" });
|
||||
}
|
||||
|
||||
var response = await service.QueryByReasonAsync(tenant, edgeReason, limit ?? 100, cursor, ct);
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-evidence"))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
var edges = await service.QueryByEvidenceAsync(tenant, evidenceType, evidenceRef, ct);
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(new { evidenceType, evidenceRef, edges = edges.ToList() });
|
||||
});
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IEdgeMetadataService.cs
|
||||
// Sprint: SPRINT_20260208_039_Graph_graph_edge_metadata_with_reason_evidence_provenance
|
||||
// Description: Service interface for edge metadata queries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying and managing edge metadata (reason, evidence, provenance).
|
||||
/// </summary>
|
||||
public interface IEdgeMetadataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets edge metadata for specified edge IDs.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="request">Request specifying edge IDs and options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Response containing edge metadata.</returns>
|
||||
Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
|
||||
string tenant,
|
||||
EdgeMetadataRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for a single edge.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="edgeId">Edge identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Edge with metadata, or null if not found.</returns>
|
||||
Task<EdgeTileWithMetadata?> GetSingleEdgeMetadataAsync(
|
||||
string tenant,
|
||||
string edgeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all edges with metadata for a path between nodes.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="sourceNodeId">Source node ID.</param>
|
||||
/// <param name="targetNodeId">Target node ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Edges along the path with their metadata.</returns>
|
||||
Task<IReadOnlyList<EdgeTileWithMetadata>> GetPathEdgesWithMetadataAsync(
|
||||
string tenant,
|
||||
string sourceNodeId,
|
||||
string targetNodeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries edges by reason type.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="reason">Edge reason to filter by.</param>
|
||||
/// <param name="limit">Maximum results to return.</param>
|
||||
/// <param name="cursor">Pagination cursor.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Edges matching the reason filter.</returns>
|
||||
Task<EdgeMetadataResponse> QueryByReasonAsync(
|
||||
string tenant,
|
||||
EdgeReason reason,
|
||||
int limit = 100,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries edges by evidence reference.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="evidenceType">Type of evidence (e.g., "sbom", "advisory", "vex").</param>
|
||||
/// <param name="evidenceRef">Reference ID of the evidence.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Edges linked to the specified evidence.</returns>
|
||||
Task<EdgeMetadataResponse> QueryByEvidenceAsync(
|
||||
string tenant,
|
||||
string evidenceType,
|
||||
string evidenceRef,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryEdgeMetadataService.cs
|
||||
// Sprint: SPRINT_20260208_039_Graph_graph_edge_metadata_with_reason_evidence_provenance
|
||||
// Description: In-memory implementation of edge metadata service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of edge metadata service.
|
||||
/// Stores edge explanations alongside the graph repository.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly ILogger<InMemoryEdgeMetadataService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Cache of edge explanations keyed by edge ID
|
||||
private readonly Dictionary<string, EdgeExplanationPayload> _explanations;
|
||||
|
||||
public InMemoryEdgeMetadataService(
|
||||
InMemoryGraphRepository repository,
|
||||
ILogger<InMemoryEdgeMetadataService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
|
||||
// Seed with default explanations for demo/test data
|
||||
_explanations = SeedDefaultExplanations();
|
||||
}
|
||||
|
||||
public Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
|
||||
string tenant,
|
||||
EdgeMetadataRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var edges = new List<EdgeTileWithMetadata>();
|
||||
|
||||
foreach (var edgeId in request.EdgeIds)
|
||||
{
|
||||
var edgeTile = _repository.GetEdge(tenant, edgeId);
|
||||
if (edgeTile is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var explanation = GetOrCreateExplanation(edgeTile, request.IncludeProvenance, request.IncludeEvidence);
|
||||
edges.Add(ToEdgeTileWithMetadata(edgeTile, explanation));
|
||||
}
|
||||
|
||||
_logger.LogDebug("Retrieved metadata for {Count} edges in tenant {Tenant}", edges.Count, tenant);
|
||||
|
||||
return Task.FromResult(new EdgeMetadataResponse
|
||||
{
|
||||
Edges = edges,
|
||||
Total = edges.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<EdgeTileWithMetadata?> GetSingleEdgeMetadataAsync(
|
||||
string tenant,
|
||||
string edgeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(edgeId);
|
||||
|
||||
var edgeTile = _repository.GetEdge(tenant, edgeId);
|
||||
if (edgeTile is null)
|
||||
{
|
||||
return Task.FromResult<EdgeTileWithMetadata?>(null);
|
||||
}
|
||||
|
||||
var explanation = GetOrCreateExplanation(edgeTile, includeProvenance: true, includeEvidence: true);
|
||||
return Task.FromResult<EdgeTileWithMetadata?>(ToEdgeTileWithMetadata(edgeTile, explanation));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EdgeTileWithMetadata>> GetPathEdgesWithMetadataAsync(
|
||||
string tenant,
|
||||
string sourceNodeId,
|
||||
string targetNodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceNodeId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetNodeId);
|
||||
|
||||
// Get edges along the path (simplified BFS/DFS for in-memory)
|
||||
var pathEdges = FindPathEdges(tenant, sourceNodeId, targetNodeId);
|
||||
var result = new List<EdgeTileWithMetadata>();
|
||||
|
||||
foreach (var edge in pathEdges)
|
||||
{
|
||||
var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
|
||||
result.Add(ToEdgeTileWithMetadata(edge, explanation));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EdgeTileWithMetadata>>(result);
|
||||
}
|
||||
|
||||
public Task<EdgeMetadataResponse> QueryByReasonAsync(
|
||||
string tenant,
|
||||
EdgeReason reason,
|
||||
int limit = 100,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
|
||||
var allEdges = _repository.GetAllEdges(tenant);
|
||||
var matchingEdges = new List<EdgeTileWithMetadata>();
|
||||
|
||||
foreach (var edge in allEdges)
|
||||
{
|
||||
if (!_explanations.TryGetValue(edge.Id, out var explanation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (explanation.Reason == reason)
|
||||
{
|
||||
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
|
||||
|
||||
if (matchingEdges.Count >= limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new EdgeMetadataResponse
|
||||
{
|
||||
Edges = matchingEdges,
|
||||
Total = matchingEdges.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<EdgeMetadataResponse> QueryByEvidenceAsync(
|
||||
string tenant,
|
||||
string evidenceType,
|
||||
string evidenceRef,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceRef);
|
||||
|
||||
var allEdges = _repository.GetAllEdges(tenant);
|
||||
var matchingEdges = new List<EdgeTileWithMetadata>();
|
||||
|
||||
foreach (var edge in allEdges)
|
||||
{
|
||||
if (!_explanations.TryGetValue(edge.Id, out var explanation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (explanation.Evidence is not null &&
|
||||
explanation.Evidence.TryGetValue(evidenceType, out var refValue) &&
|
||||
string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new EdgeMetadataResponse
|
||||
{
|
||||
Edges = matchingEdges,
|
||||
Total = matchingEdges.Count
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates an edge explanation.
|
||||
/// </summary>
|
||||
public void SetExplanation(string edgeId, EdgeExplanationPayload explanation)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(edgeId);
|
||||
ArgumentNullException.ThrowIfNull(explanation);
|
||||
|
||||
_explanations[edgeId] = explanation;
|
||||
}
|
||||
|
||||
private EdgeExplanationPayload GetOrCreateExplanation(
|
||||
EdgeTile edge,
|
||||
bool includeProvenance,
|
||||
bool includeEvidence)
|
||||
{
|
||||
if (_explanations.TryGetValue(edge.Id, out var existing))
|
||||
{
|
||||
return FilterExplanation(existing, includeProvenance, includeEvidence);
|
||||
}
|
||||
|
||||
// Generate default explanation based on edge kind
|
||||
var generated = GenerateDefaultExplanation(edge);
|
||||
return FilterExplanation(generated, includeProvenance, includeEvidence);
|
||||
}
|
||||
|
||||
private static EdgeExplanationPayload FilterExplanation(
|
||||
EdgeExplanationPayload explanation,
|
||||
bool includeProvenance,
|
||||
bool includeEvidence)
|
||||
{
|
||||
if (includeProvenance && includeEvidence)
|
||||
{
|
||||
return explanation;
|
||||
}
|
||||
|
||||
return explanation with
|
||||
{
|
||||
Provenance = includeProvenance ? explanation.Provenance : null,
|
||||
Evidence = includeEvidence ? explanation.Evidence : null
|
||||
};
|
||||
}
|
||||
|
||||
private EdgeExplanationPayload GenerateDefaultExplanation(EdgeTile edge)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var reason = InferReasonFromKind(edge.Kind);
|
||||
var summary = GenerateSummary(edge, reason);
|
||||
|
||||
return new EdgeExplanationPayload
|
||||
{
|
||||
Reason = reason,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "graph-indexer",
|
||||
Timestamp = now,
|
||||
ConfidenceBps = 10000
|
||||
},
|
||||
Summary = summary,
|
||||
Tags = [edge.Kind]
|
||||
};
|
||||
}
|
||||
|
||||
private static EdgeReason InferReasonFromKind(string kind)
|
||||
{
|
||||
return kind.ToLowerInvariant() switch
|
||||
{
|
||||
"depends_on" => EdgeReason.SbomDependency,
|
||||
"builds" => EdgeReason.BuildArtifact,
|
||||
"affects" => EdgeReason.AdvisoryAffects,
|
||||
"vex_applies" => EdgeReason.VexStatement,
|
||||
"sbom_version_of" => EdgeReason.SbomDependency,
|
||||
"sbom_lineage_parent" => EdgeReason.Provenance,
|
||||
"policy_overlay" => EdgeReason.PolicyOverlay,
|
||||
"calls" => EdgeReason.StaticSymbol,
|
||||
"runtime_calls" => EdgeReason.RuntimeTrace,
|
||||
"contains" => EdgeReason.ImageLayer,
|
||||
_ => EdgeReason.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSummary(EdgeTile edge, EdgeReason reason)
|
||||
{
|
||||
return reason switch
|
||||
{
|
||||
EdgeReason.SbomDependency => $"Dependency relationship: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.BuildArtifact => $"Build produced: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.AdvisoryAffects => $"Advisory affects: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.VexStatement => $"VEX applies: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.Provenance => $"Provenance chain: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.StaticSymbol => $"Symbol reference: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.RuntimeTrace => $"Runtime call: {edge.Source} -> {edge.Target}",
|
||||
EdgeReason.ImageLayer => $"Container contains: {edge.Source} -> {edge.Target}",
|
||||
_ => $"Relationship: {edge.Source} -> {edge.Target}"
|
||||
};
|
||||
}
|
||||
|
||||
private static EdgeTileWithMetadata ToEdgeTileWithMetadata(EdgeTile edge, EdgeExplanationPayload explanation)
|
||||
{
|
||||
return new EdgeTileWithMetadata
|
||||
{
|
||||
Id = edge.Id,
|
||||
Kind = edge.Kind,
|
||||
Tenant = edge.Tenant,
|
||||
Source = edge.Source,
|
||||
Target = edge.Target,
|
||||
Attributes = edge.Attributes,
|
||||
Explanation = explanation
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<EdgeTile> FindPathEdges(string tenant, string sourceNodeId, string targetNodeId)
|
||||
{
|
||||
// Simple BFS for path finding
|
||||
var allEdges = _repository.GetAllEdges(tenant);
|
||||
var edgesBySource = allEdges
|
||||
.GroupBy(e => e.Source)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<(string NodeId, List<EdgeTile> Path)>();
|
||||
queue.Enqueue((sourceNodeId, new List<EdgeTile>()));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, path) = queue.Dequeue();
|
||||
|
||||
if (current == targetNodeId)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!edgesBySource.TryGetValue(current, out var outEdges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in outEdges)
|
||||
{
|
||||
if (!visited.Contains(edge.Target))
|
||||
{
|
||||
var newPath = new List<EdgeTile>(path) { edge };
|
||||
queue.Enqueue((edge.Target, newPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<EdgeTile>();
|
||||
}
|
||||
|
||||
private Dictionary<string, EdgeExplanationPayload> SeedDefaultExplanations()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal)
|
||||
{
|
||||
["ge:acme:artifact->component"] = EdgeExplanationFactory.FromSbomDependency(
|
||||
"sha256:sbom-a",
|
||||
"sbom-parser",
|
||||
now.AddHours(-1),
|
||||
"Build artifact produces component"),
|
||||
|
||||
["ge:acme:component->component"] = new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.SbomDependency,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "sbom-parser",
|
||||
Timestamp = now.AddHours(-1),
|
||||
ConfidenceBps = 10000,
|
||||
EvidenceRef = "sha256:sbom-a"
|
||||
},
|
||||
Summary = "example@1.0.0 depends on widget@2.0.0 for runtime",
|
||||
Evidence = new Dictionary<string, string> { ["sbom"] = "sha256:sbom-a" },
|
||||
Provenance = new EdgeProvenanceRef
|
||||
{
|
||||
Source = "sbom-parser",
|
||||
CollectedAt = now.AddHours(-1),
|
||||
SbomDigest = "sha256:sbom-a"
|
||||
},
|
||||
Tags = ["runtime", "dependency"]
|
||||
},
|
||||
|
||||
["ge:acme:sbom->artifact"] = new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.SbomDependency,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "sbom-linker",
|
||||
Timestamp = now.AddHours(-2),
|
||||
ConfidenceBps = 10000
|
||||
},
|
||||
Summary = "SBOM describes artifact sha256:abc"
|
||||
},
|
||||
|
||||
["ge:acme:sbom->sbom"] = new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.Provenance,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
Method = "lineage-tracker",
|
||||
Timestamp = now.AddDays(-1),
|
||||
ConfidenceBps = 10000
|
||||
},
|
||||
Summary = "SBOM lineage: sbom-b derives from sbom-a",
|
||||
Tags = ["lineage", "provenance"]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for InMemoryGraphRepository to support edge metadata queries.
|
||||
/// </summary>
|
||||
public static class InMemoryGraphRepositoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a single edge by ID.
|
||||
/// </summary>
|
||||
public static EdgeTile? GetEdge(this InMemoryGraphRepository repository, string tenant, string edgeId)
|
||||
{
|
||||
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
|
||||
{
|
||||
Kinds = Array.Empty<string>(),
|
||||
Query = null,
|
||||
IncludeEdges = true
|
||||
});
|
||||
|
||||
return edges.FirstOrDefault(e => e.Id.Equals(edgeId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all edges for a tenant.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<EdgeTile> GetAllEdges(this InMemoryGraphRepository repository, string tenant)
|
||||
{
|
||||
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
|
||||
{
|
||||
Kinds = Array.Empty<string>(),
|
||||
Query = null,
|
||||
IncludeEdges = true
|
||||
});
|
||||
|
||||
return edges;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class EdgeMetadataServiceTests
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repo;
|
||||
private readonly InMemoryEdgeMetadataService _service;
|
||||
private readonly FakeTimeProvider _time;
|
||||
|
||||
public EdgeMetadataServiceTests()
|
||||
{
|
||||
_repo = new InMemoryGraphRepository();
|
||||
_time = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_service = new InMemoryEdgeMetadataService(_repo, _time);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetEdgeMetadataAsync_WithValidEdgeIds_ReturnsEdgesWithExplanations()
|
||||
{
|
||||
// Arrange - default repo has some seeded edges
|
||||
var request = new EdgeMetadataRequest
|
||||
{
|
||||
EdgeIds = new[] { "ge:acme:builds:1" } // Seeded edge
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEdgeMetadataAsync("acme", request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Edges);
|
||||
var edge = result.Edges.First();
|
||||
Assert.Equal("ge:acme:builds:1", edge.Id);
|
||||
Assert.NotNull(edge.Explanation);
|
||||
Assert.NotEqual(EdgeReason.Unknown, edge.Explanation.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetEdgeMetadataAsync_WithNonExistentEdgeIds_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var request = new EdgeMetadataRequest
|
||||
{
|
||||
EdgeIds = new[] { "ge:acme:nonexistent:9999" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetEdgeMetadataAsync("acme", request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Edges);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetSingleEdgeMetadataAsync_WithValidEdgeId_ReturnsEdgeWithMetadata()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ge:acme:builds:1", result.Id);
|
||||
Assert.Equal("acme", result.Tenant);
|
||||
Assert.NotNull(result.Explanation);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetSingleEdgeMetadataAsync_WithNonExistentEdgeId_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:missing:999", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPathEdgesWithMetadataAsync_WithConnectedNodes_ReturnsPathEdges()
|
||||
{
|
||||
// The seeded data has: artifact:sha256:abc --builds--> component:widget
|
||||
// Act
|
||||
var result = await _service.GetPathEdgesWithMetadataAsync(
|
||||
"acme",
|
||||
"gn:acme:artifact:sha256:abc",
|
||||
"gn:acme:component:widget",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var edges = result.ToList();
|
||||
Assert.NotEmpty(edges);
|
||||
Assert.All(edges, e => Assert.NotNull(e.Explanation));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPathEdgesWithMetadataAsync_WithDisconnectedNodes_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetPathEdgesWithMetadataAsync(
|
||||
"acme",
|
||||
"gn:acme:artifact:sha256:abc",
|
||||
"gn:othertenant:component:missing",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryByReasonAsync_WithMatchingReason_ReturnsFilteredEdges()
|
||||
{
|
||||
// Arrange - seeded edges include "builds" which maps to SbomDependency
|
||||
var reason = EdgeReason.SbomDependency;
|
||||
|
||||
// Act
|
||||
var result = await _service.QueryByReasonAsync("acme", reason, 100, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.All(result.Edges, e => Assert.Equal(reason, e.Explanation?.Reason ?? EdgeReason.Unknown));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryByReasonAsync_RespectsLimitParameter()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.QueryByReasonAsync("acme", EdgeReason.SbomDependency, 1, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Edges.Count <= 1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryByEvidenceAsync_WithMatchingEvidenceRef_ReturnsEdges()
|
||||
{
|
||||
// Act - query by sbom evidence type
|
||||
var result = await _service.QueryByEvidenceAsync(
|
||||
"acme",
|
||||
"sbom",
|
||||
"sbom:build:acme:1234:sha256",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var edges = result.ToList();
|
||||
// May or may not find matches depending on seeded data, but should not throw
|
||||
Assert.NotNull(edges);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryByEvidenceAsync_WithNoMatchingEvidence_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.QueryByEvidenceAsync(
|
||||
"acme",
|
||||
"nonexistent",
|
||||
"evidence:ref:that:does:not:exist",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EdgeExplanation_IncludesProvenanceInformation()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Explanation);
|
||||
Assert.NotNull(result.Explanation.Provenance);
|
||||
Assert.NotEmpty(result.Explanation.Provenance.Source);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EdgeExplanation_IncludesViaInformation()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Explanation);
|
||||
Assert.NotNull(result.Explanation.Via);
|
||||
Assert.NotEmpty(result.Explanation.Via.Method);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InferReasonFromKind_MapsCorrectly()
|
||||
{
|
||||
// Test by checking edges with different kinds return appropriate reasons
|
||||
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("builds", result.Kind);
|
||||
// "builds" kind should map to SbomDependency
|
||||
Assert.Equal(EdgeReason.SbomDependency, result.Explanation?.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TenantIsolation_OnlyReturnsEdgesForRequestedTenant()
|
||||
{
|
||||
// Act - query with a different tenant
|
||||
var result = await _service.GetEdgeMetadataAsync(
|
||||
"other-tenant",
|
||||
new EdgeMetadataRequest { EdgeIds = new[] { "ge:acme:builds:1" } },
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert - should not find acme's edges
|
||||
Assert.Empty(result.Edges);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user