Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace StellaOps.Graph.Api.Contracts;
public sealed record GraphLineageRequest
{
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
[JsonPropertyName("sbomDigest")]
public string? SbomDigest { get; init; }
[JsonPropertyName("maxDepth")]
public int? MaxDepth { get; init; }
[JsonPropertyName("relationshipKinds")]
public string[]? RelationshipKinds { get; init; }
}
public sealed record GraphLineageResponse
{
[JsonPropertyName("nodes")]
public IReadOnlyList<NodeTile> Nodes { get; init; } = Array.Empty<NodeTile>();
[JsonPropertyName("edges")]
public IReadOnlyList<EdgeTile> Edges { get; init; } = Array.Empty<EdgeTile>();
}
public static class LineageValidator
{
public static string? Validate(GraphLineageRequest request)
{
if (string.IsNullOrWhiteSpace(request.ArtifactDigest) && string.IsNullOrWhiteSpace(request.SbomDigest))
{
return "artifactDigest or sbomDigest is required";
}
if (request.MaxDepth.HasValue && (request.MaxDepth.Value < 1 || request.MaxDepth.Value > 6))
{
return "maxDepth must be between 1 and 6";
}
return null;
}
}

View File

@@ -9,6 +9,7 @@ builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
@@ -238,6 +239,53 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
return Results.Empty;
});
app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest request, IGraphLineageService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/lineage"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
var validation = LineageValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
var response = await service.GetLineageAsync(tenantId, request, ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public interface IGraphLineageService
{
Task<GraphLineageResponse> GetLineageAsync(string tenant, GraphLineageRequest request, CancellationToken ct);
}

View File

@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphLineageService : IGraphLineageService
{
private readonly InMemoryGraphRepository _repository;
public InMemoryGraphLineageService(InMemoryGraphRepository repository)
{
_repository = repository;
}
public Task<GraphLineageResponse> GetLineageAsync(string tenant, GraphLineageRequest request, CancellationToken ct)
{
var (nodes, edges) = _repository.GetLineage(tenant, request);
return Task.FromResult(new GraphLineageResponse { Nodes = nodes, Edges = edges });
}
}

View File

@@ -15,6 +15,8 @@ public sealed class InMemoryGraphRepository
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm" } },
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container" } },
new() { Id = "gn:acme:sbom:sha256:sbom-a", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-a", ["artifact_digest"] = "sha256:abc", ["format"] = "cyclonedx" } },
new() { Id = "gn:acme:sbom:sha256:sbom-b", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-b", ["artifact_digest"] = "sha256:abc", ["format"] = "spdx" } },
new() { Id = "gn:acme:component:gamma", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:nuget/Gamma@3.1.4", ["ecosystem"] = "nuget" } },
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo",Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo",Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container" } },
@@ -24,6 +26,8 @@ public sealed class InMemoryGraphRepository
{
new() { Id = "ge:acme:artifact->component", Kind = "builds", Tenant = "acme", Source = "gn:acme:artifact:sha256:abc", Target = "gn:acme:component:example", Attributes = new() { ["reason"] = "sbom" } },
new() { Id = "ge:acme:component->component", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:example", Target = "gn:acme:component:widget", Attributes = new() { ["scope"] = "runtime" } },
new() { Id = "ge:acme:sbom->artifact", Kind = "SBOM_VERSION_OF", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:artifact:sha256:abc", Attributes = new() { ["relationship"] = "version_of" } },
new() { Id = "ge:acme:sbom->sbom", Kind = "SBOM_LINEAGE_PARENT", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:sbom:sha256:sbom-a", Attributes = new() { ["relationship"] = "parent" } },
new() { Id = "ge:bravo:artifact->component", Kind = "builds", Tenant = "bravo", Source = "gn:bravo:artifact:sha256:def", Target = "gn:bravo:component:widget", Attributes = new() { ["reason"] = "sbom" } },
};
@@ -74,6 +78,114 @@ public sealed class InMemoryGraphRepository
return (nodes, edges);
}
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var maxDepth = request.MaxDepth ?? 3;
if (maxDepth < 1)
{
maxDepth = 1;
}
var allowedKinds = BuildLineageKindFilter(request.RelationshipKinds);
var tenantNodes = _nodes
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
.ToList();
if (tenantNodes.Count == 0)
{
return (Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
}
var nodeById = tenantNodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var seedIds = new HashSet<string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
var digest = request.ArtifactDigest.Trim();
foreach (var node in tenantNodes.Where(n => HasAttribute(n, "artifact_digest", digest)))
{
seedIds.Add(node.Id);
}
}
if (!string.IsNullOrWhiteSpace(request.SbomDigest))
{
var digest = request.SbomDigest.Trim();
foreach (var node in tenantNodes.Where(n => HasAttribute(n, "sbom_digest", digest)))
{
seedIds.Add(node.Id);
}
}
if (seedIds.Count == 0)
{
return (Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
}
var tenantEdges = _edges
.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal))
.Where(e => IsLineageEdgeAllowed(e, allowedKinds))
.ToList();
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
foreach (var edge in tenantEdges)
{
AddAdjacency(adjacency, edge.Source, edge);
AddAdjacency(adjacency, edge.Target, edge);
}
var visitedNodes = new HashSet<string>(seedIds, StringComparer.Ordinal);
var visitedEdges = new HashSet<string>(StringComparer.Ordinal);
var frontier = new HashSet<string>(seedIds, StringComparer.Ordinal);
for (var depth = 0; depth < maxDepth && frontier.Count > 0; depth++)
{
var next = new HashSet<string>(StringComparer.Ordinal);
foreach (var nodeId in frontier)
{
if (!adjacency.TryGetValue(nodeId, out var edges))
{
continue;
}
foreach (var edge in edges)
{
if (visitedEdges.Add(edge.Id))
{
var other = string.Equals(edge.Source, nodeId, StringComparison.Ordinal)
? edge.Target
: edge.Source;
if (visitedNodes.Add(other))
{
next.Add(other);
}
}
}
}
frontier = next;
}
var resultNodes = new List<NodeTile>();
foreach (var nodeId in visitedNodes.OrderBy(id => id, StringComparer.Ordinal))
{
if (nodeById.TryGetValue(nodeId, out var node))
{
resultNodes.Add(node);
}
}
var resultEdges = tenantEdges
.Where(edge => visitedEdges.Contains(edge.Id))
.Where(edge => visitedNodes.Contains(edge.Source) && visitedNodes.Contains(edge.Target))
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
.ToList();
return (resultNodes, resultEdges);
}
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
{
if (_snapshots.TryGetValue($"{tenant}:{snapshotId}", out var snap))
@@ -128,6 +240,69 @@ public sealed class InMemoryGraphRepository
return dict;
}
private static bool HasAttribute(NodeTile node, string key, string expected)
{
if (!node.Attributes.TryGetValue(key, out var value) || value is null)
{
return false;
}
return string.Equals(value.ToString(), expected, StringComparison.OrdinalIgnoreCase);
}
private static HashSet<string> BuildLineageKindFilter(string[]? relationshipKinds)
{
if (relationshipKinds is null || relationshipKinds.Length == 0)
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in relationshipKinds)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
set.Add(value.Trim());
}
return set;
}
private static bool IsLineageEdgeAllowed(EdgeTile edge, HashSet<string> allowedKinds)
{
if (allowedKinds.Count == 0)
{
return edge.Kind.StartsWith("SBOM_LINEAGE_", StringComparison.OrdinalIgnoreCase)
|| string.Equals(edge.Kind, "SBOM_VERSION_OF", StringComparison.OrdinalIgnoreCase);
}
if (allowedKinds.Contains(edge.Kind))
{
return true;
}
if (edge.Attributes.TryGetValue("relationship", out var relationship) && relationship is not null)
{
return allowedKinds.Contains(relationship.ToString() ?? string.Empty);
}
return false;
}
private static void AddAdjacency(Dictionary<string, List<EdgeTile>> adjacency, string nodeId, EdgeTile edge)
{
if (!adjacency.TryGetValue(nodeId, out var list))
{
list = new List<EdgeTile>();
adjacency[nodeId] = list;
}
list.Add(edge);
}
private static bool MatchesQuery(NodeTile node, string query)
{
var q = query.ToLowerInvariant();

View File

@@ -10,6 +10,9 @@ public sealed class SbomIngestTransformer
private const string DependsOnEdgeKind = "DEPENDS_ON";
private const string DeclaredInEdgeKind = "DECLARED_IN";
private const string BuiltFromEdgeKind = "BUILT_FROM";
private const string SbomNodeKind = "sbom";
private const string SbomVersionOfEdgeKind = "SBOM_VERSION_OF";
private const string SbomLineageEdgePrefix = "SBOM_LINEAGE_";
public GraphBuildBatch Transform(SbomSnapshot snapshot)
{
@@ -19,6 +22,7 @@ public sealed class SbomIngestTransformer
var edges = new List<JsonObject>();
var artifactNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
var sbomNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
var componentNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
var fileNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
var licenseCandidates = new Dictionary<(string License, string SourceDigest), LicenseCandidate>(LicenseKeyComparer.Instance);
@@ -30,6 +34,16 @@ public sealed class SbomIngestTransformer
nodes.Add(artifactNode);
artifactNodes[GetArtifactKey(snapshot.ArtifactDigest, snapshot.SbomDigest)] = artifactNode;
var sbomNode = CreateSbomNode(snapshot);
if (sbomNode is not null)
{
nodes.Add(sbomNode);
sbomNodes[snapshot.SbomDigest] = sbomNode;
var sbomEdge = CreateSbomVersionOfEdge(snapshot, sbomNode, artifactNode, NextEdgeOffset());
edges.Add(sbomEdge);
}
foreach (var component in snapshot.Components)
{
var componentNode = CreateComponentNode(snapshot, component);
@@ -91,6 +105,27 @@ public sealed class SbomIngestTransformer
edges.Add(edge);
}
if (sbomNode is not null && snapshot.Lineage.Count > 0)
{
foreach (var lineage in snapshot.Lineage)
{
if (string.IsNullOrWhiteSpace(lineage.SbomDigest))
{
continue;
}
if (!sbomNodes.TryGetValue(lineage.SbomDigest, out var relatedNode))
{
relatedNode = CreateLineageSbomNode(snapshot, lineage);
nodes.Add(relatedNode);
sbomNodes[lineage.SbomDigest] = relatedNode;
}
var edge = CreateLineageEdge(snapshot, sbomNode, relatedNode, lineage, NextEdgeOffset());
edges.Add(edge);
}
}
var orderedNodes = nodes
.OrderBy(node => node["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
@@ -168,6 +203,76 @@ public sealed class SbomIngestTransformer
ValidTo: null));
}
private static JsonObject? CreateSbomNode(SbomSnapshot snapshot)
{
if (string.IsNullOrWhiteSpace(snapshot.SbomDigest))
{
return null;
}
var attributes = new JsonObject
{
["sbom_digest"] = snapshot.SbomDigest,
["artifact_digest"] = snapshot.ArtifactDigest,
["format"] = snapshot.SbomFormat,
["format_version"] = snapshot.SbomFormatVersion
};
if (!string.IsNullOrWhiteSpace(snapshot.SbomVersionId))
{
attributes["version_id"] = snapshot.SbomVersionId;
}
if (snapshot.SbomSequence > 0)
{
attributes["sequence"] = snapshot.SbomSequence;
}
if (!string.IsNullOrWhiteSpace(snapshot.ChainId))
{
attributes["chain_id"] = snapshot.ChainId;
}
return GraphDocumentFactory.CreateNode(new GraphNodeSpec(
Tenant: snapshot.Tenant,
Kind: SbomNodeKind,
CanonicalKey: new Dictionary<string, string>
{
["tenant"] = snapshot.Tenant,
["sbom_digest"] = snapshot.SbomDigest
},
Attributes: attributes,
Provenance: new GraphProvenanceSpec(snapshot.Source, snapshot.CollectedAt, snapshot.SbomDigest, snapshot.EventOffset + 1),
ValidFrom: snapshot.CollectedAt,
ValidTo: null));
}
private static JsonObject CreateLineageSbomNode(SbomSnapshot snapshot, SbomLineageReference lineage)
{
var attributes = new JsonObject
{
["sbom_digest"] = lineage.SbomDigest,
["artifact_digest"] = lineage.ArtifactDigest
};
return GraphDocumentFactory.CreateNode(new GraphNodeSpec(
Tenant: snapshot.Tenant,
Kind: SbomNodeKind,
CanonicalKey: new Dictionary<string, string>
{
["tenant"] = snapshot.Tenant,
["sbom_digest"] = lineage.SbomDigest
},
Attributes: attributes,
Provenance: new GraphProvenanceSpec(
ResolveSource(lineage.Source, snapshot.Source),
lineage.CollectedAt,
lineage.SbomDigest,
lineage.EventOffset),
ValidFrom: lineage.CollectedAt,
ValidTo: null));
}
private static JsonObject CreateComponentNode(SbomSnapshot snapshot, SbomComponent component)
{
var attributes = new JsonObject
@@ -199,6 +304,59 @@ public sealed class SbomIngestTransformer
ValidTo: null));
}
private static JsonObject CreateSbomVersionOfEdge(SbomSnapshot snapshot, JsonObject sbomNode, JsonObject artifactNode, long eventOffset)
{
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
Tenant: snapshot.Tenant,
Kind: SbomVersionOfEdgeKind,
CanonicalKey: new Dictionary<string, string>
{
["tenant"] = snapshot.Tenant,
["sbom_node_id"] = sbomNode["id"]!.GetValue<string>(),
["artifact_node_id"] = artifactNode["id"]!.GetValue<string>()
},
Attributes: new JsonObject
{
["sbom_digest"] = snapshot.SbomDigest,
["artifact_digest"] = snapshot.ArtifactDigest,
["chain_id"] = snapshot.ChainId,
["sequence"] = snapshot.SbomSequence
},
Provenance: new GraphProvenanceSpec(snapshot.Source, snapshot.CollectedAt, snapshot.SbomDigest, eventOffset),
ValidFrom: snapshot.CollectedAt,
ValidTo: null));
}
private static JsonObject CreateLineageEdge(SbomSnapshot snapshot, JsonObject fromNode, JsonObject toNode, SbomLineageReference lineage, long fallbackOffset)
{
var offset = lineage.EventOffset > 0 ? lineage.EventOffset : fallbackOffset;
var kind = NormalizeLineageKind(lineage.Relationship);
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
Tenant: snapshot.Tenant,
Kind: kind,
CanonicalKey: new Dictionary<string, string>
{
["tenant"] = snapshot.Tenant,
["from_sbom_node_id"] = fromNode["id"]!.GetValue<string>(),
["to_sbom_node_id"] = toNode["id"]!.GetValue<string>(),
["relationship"] = lineage.Relationship
},
Attributes: new JsonObject
{
["relationship"] = lineage.Relationship,
["sbom_digest"] = lineage.SbomDigest,
["artifact_digest"] = lineage.ArtifactDigest
},
Provenance: new GraphProvenanceSpec(
ResolveSource(lineage.Source, snapshot.Source),
lineage.CollectedAt,
snapshot.SbomDigest,
offset),
ValidFrom: lineage.CollectedAt,
ValidTo: null));
}
private static JsonObject CreateFileNode(SbomSnapshot snapshot, SbomComponentFile file)
{
var attributes = new JsonObject
@@ -390,6 +548,17 @@ public sealed class SbomIngestTransformer
return array;
}
private static string NormalizeLineageKind(string relationship)
{
if (string.IsNullOrWhiteSpace(relationship))
{
return SbomLineageEdgePrefix + "UNKNOWN";
}
var normalized = relationship.Trim().Replace('-', '_').Replace(' ', '_').ToUpperInvariant();
return SbomLineageEdgePrefix + normalized;
}
private static LicenseCandidate CreateLicenseCandidate(SbomSnapshot snapshot, SbomComponent component)
{
var collectedAt = component.CollectedAt.AddSeconds(2);

View File

@@ -16,6 +16,21 @@ public sealed class SbomSnapshot
[JsonPropertyName("sbomDigest")]
public string SbomDigest { get; init; } = string.Empty;
[JsonPropertyName("sbomVersionId")]
public string SbomVersionId { get; init; } = string.Empty;
[JsonPropertyName("sbomSequence")]
public int SbomSequence { get; init; }
[JsonPropertyName("sbomFormat")]
public string SbomFormat { get; init; } = string.Empty;
[JsonPropertyName("sbomFormatVersion")]
public string SbomFormatVersion { get; init; } = string.Empty;
[JsonPropertyName("chainId")]
public string ChainId { get; init; } = string.Empty;
[JsonPropertyName("collectedAt")]
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch;
@@ -33,6 +48,9 @@ public sealed class SbomSnapshot
[JsonPropertyName("baseArtifacts")]
public IReadOnlyList<SbomBaseArtifact> BaseArtifacts { get; init; } = Array.Empty<SbomBaseArtifact>();
[JsonPropertyName("lineage")]
public IReadOnlyList<SbomLineageReference> Lineage { get; init; } = Array.Empty<SbomLineageReference>();
}
public sealed class SbomArtifactMetadata
@@ -229,3 +247,24 @@ public sealed class SbomBaseArtifact
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
}
public sealed class SbomLineageReference
{
[JsonPropertyName("relationship")]
public string Relationship { get; init; } = string.Empty;
[JsonPropertyName("sbomDigest")]
public string SbomDigest { get; init; } = string.Empty;
[JsonPropertyName("artifactDigest")]
public string ArtifactDigest { get; init; } = string.Empty;
[JsonPropertyName("eventOffset")]
public long EventOffset { get; init; }
[JsonPropertyName("collectedAt")]
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch;
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,31 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public sealed class LineageServiceTests
{
[Fact]
public async Task GetLineageAsync_ReturnsSbomAndArtifactChain()
{
var repository = new InMemoryGraphRepository();
var service = new InMemoryGraphLineageService(repository);
var request = new GraphLineageRequest
{
SbomDigest = "sha256:sbom-b",
MaxDepth = 3
};
var response = await service.GetLineageAsync("acme", request, CancellationToken.None);
Assert.Contains(response.Nodes, node => node.Id == "gn:acme:sbom:sha256:sbom-b");
Assert.Contains(response.Nodes, node => node.Id == "gn:acme:sbom:sha256:sbom-a");
Assert.Contains(response.Nodes, node => node.Id == "gn:acme:artifact:sha256:abc");
Assert.Contains(response.Edges, edge => edge.Kind == "SBOM_LINEAGE_PARENT");
Assert.Contains(response.Edges, edge => edge.Kind == "SBOM_VERSION_OF");
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Linq;
using System.Text.Json.Nodes;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using Xunit;
namespace StellaOps.Graph.Indexer.Tests;
public sealed class SbomLineageTransformerTests
{
[Fact]
public void Transform_adds_lineage_edges_when_present()
{
var snapshot = new SbomSnapshot
{
Tenant = "tenant-a",
ArtifactDigest = "sha256:artifact",
SbomDigest = "sha256:sbom",
SbomFormat = "cyclonedx",
SbomFormatVersion = "1.6",
Lineage = new[]
{
new SbomLineageReference
{
Relationship = "parent",
SbomDigest = "sha256:parent",
ArtifactDigest = "sha256:parent-artifact",
CollectedAt = DateTimeOffset.Parse("2025-12-01T00:00:00Z")
}
}
};
var transformer = new SbomIngestTransformer();
var batch = transformer.Transform(snapshot);
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "sbom");
var edgeKinds = batch.Edges
.Select(e => e["kind"]!.GetValue<string>())
.ToArray();
Assert.Contains("SBOM_VERSION_OF", edgeKinds);
Assert.Contains("SBOM_LINEAGE_PARENT", edgeKinds);
}
}