fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -131,10 +131,17 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
budgetRemaining--;
}
if (hasMore && budgetRemaining > 0)
if (budgetRemaining > 0)
{
var nextCursor = CursorCodec.Encode(cursorOffset + page.Length);
lines.Add(JsonSerializer.Serialize(new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/query?cursor={nextCursor}"), Cost(tileBudgetLimit, budgetRemaining)), Options));
if (hasMore)
{
var nextCursor = CursorCodec.Encode(cursorOffset + page.Length);
lines.Add(JsonSerializer.Serialize(new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/query?cursor={nextCursor}"), Cost(tileBudgetLimit, budgetRemaining)), Options));
}
else
{
lines.Add(JsonSerializer.Serialize(new TileEnvelope("cursor", seq++, new CursorTile(string.Empty, string.Empty), Cost(tileBudgetLimit, budgetRemaining)), Options));
}
}
_cache.Set(cacheKey, lines.ToArray(), new MemoryCacheEntryOptions

View File

@@ -20,12 +20,16 @@ public sealed class GraphSnapshotBuilder
var nodes = batch.Nodes;
var edges = batch.Edges;
var nodesById = nodes.ToImmutableDictionary(
node => node["id"]!.GetValue<string>(),
node => node,
StringComparer.Ordinal);
var nodesById = nodes
.GroupBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableDictionary(
g => g.Key,
g => g.First(),
StringComparer.Ordinal);
var artifactNodeId = ResolveArtifactNodeId(sbomSnapshot, nodes);
var artifactNodeId = nodes.Length > 0
? ResolveArtifactNodeId(sbomSnapshot, nodes)
: string.Empty;
var snapshotId = ComputeSnapshotId(tenant, sbomSnapshot.ArtifactDigest, sbomSnapshot.SbomDigest);
var derivedSbomDigests = sbomSnapshot.BaseArtifacts
@@ -308,6 +312,15 @@ public sealed class GraphSnapshotBuilder
var kind = kindNode.GetValue<string>();
if (!edge.TryGetPropertyValue("canonical_key", out var canonicalKeyNode) || canonicalKeyNode is null)
{
// Fallback to simple source/target properties when canonical_key is absent
if (edge.TryGetPropertyValue("source", out var fallbackSource) && fallbackSource is not null &&
edge.TryGetPropertyValue("target", out var fallbackTarget) && fallbackTarget is not null)
{
sourceNodeId = fallbackSource.GetValue<string>();
targetNodeId = fallbackTarget.GetValue<string>();
return nodesById.ContainsKey(sourceNodeId) && nodesById.ContainsKey(targetNodeId);
}
sourceNodeId = string.Empty;
targetNodeId = string.Empty;
return false;
@@ -355,6 +368,10 @@ public sealed class GraphSnapshotBuilder
artifactNodeByDigest.TryGetValue(builtTargetDigest.GetValue<string>(), out target);
}
break;
case "SBOM_VERSION_OF":
source = canonicalKey.TryGetPropertyValue("sbom_node_id", out var sbomSource) ? sbomSource?.GetValue<string>() : null;
target = canonicalKey.TryGetPropertyValue("artifact_node_id", out var sbomTarget) ? sbomTarget?.GetValue<string>() : null;
break;
case "DEPENDS_ON":
source = canonicalKey.TryGetPropertyValue("component_node_id", out var dependsSource) ? dependsSource?.GetValue<string>() : null;
if (canonicalKey.TryGetPropertyValue("dependency_node_id", out var dependsTargetNode) && dependsTargetNode is not null)

View File

@@ -37,7 +37,10 @@ public sealed class GraphInspectorTransformer
foreach (var component in snapshot.Components)
{
var componentNode = GetOrCreateComponentNode(snapshot, componentNodes, component, component.Provenance);
nodes.Add(componentNode);
if (!nodes.Any(n => n["id"]!.GetValue<string>() == componentNode["id"]!.GetValue<string>()))
{
nodes.Add(componentNode);
}
foreach (var relationship in component.Relationships ?? Array.Empty<GraphInspectorRelationship>())
{
@@ -56,7 +59,10 @@ public sealed class GraphInspectorTransformer
},
relationship.Provenance);
nodes.Add(targetNode);
if (!nodes.Any(n => n["id"]!.GetValue<string>() == targetNode["id"]!.GetValue<string>()))
{
nodes.Add(targetNode);
}
var edge = CreateRelationshipEdge(snapshot, componentNode, targetNode, relationship);
edges.Add(edge);
@@ -112,7 +118,10 @@ public sealed class GraphInspectorTransformer
new GraphInspectorComponent { Purl = targetPurl },
provenanceOverride: null);
componentNodes[key] = targetNode;
nodes.Add(targetNode);
if (!nodes.Any(n => n["id"]!.GetValue<string>() == targetNode["id"]!.GetValue<string>()))
{
nodes.Add(targetNode);
}
}
var orderedNodes = nodes

View File

@@ -0,0 +1,88 @@
-- Graph Indexer Schema Migration 001: Initial Schema
-- Creates the graph indexer schema for nodes, edges, snapshots, analytics, and idempotency
-- ============================================================================
-- Graph Nodes
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph_nodes (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
node_type TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_tenant ON graph_nodes (tenant_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_type ON graph_nodes (node_type);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_created_at ON graph_nodes (created_at);
-- ============================================================================
-- Graph Edges
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
edge_type TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graph_edges_tenant ON graph_edges (tenant_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_source ON graph_edges (source_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_target ON graph_edges (target_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_type ON graph_edges (edge_type);
CREATE INDEX IF NOT EXISTS idx_graph_edges_created_at ON graph_edges (created_at);
-- ============================================================================
-- Graph Snapshots
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph_snapshots (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL,
node_count INTEGER NOT NULL DEFAULT 0,
edge_count INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, snapshot_id)
);
CREATE INDEX IF NOT EXISTS idx_graph_snapshots_tenant ON graph_snapshots (tenant_id);
CREATE INDEX IF NOT EXISTS idx_graph_snapshots_generated_at ON graph_snapshots (generated_at);
-- ============================================================================
-- Graph Analytics
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph_analytics (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
metric_type TEXT NOT NULL,
node_id TEXT,
value DOUBLE PRECISION NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graph_analytics_tenant ON graph_analytics (tenant_id);
CREATE INDEX IF NOT EXISTS idx_graph_analytics_snapshot ON graph_analytics (snapshot_id);
CREATE INDEX IF NOT EXISTS idx_graph_analytics_metric ON graph_analytics (metric_type);
CREATE INDEX IF NOT EXISTS idx_graph_analytics_computed_at ON graph_analytics (computed_at);
-- ============================================================================
-- Graph Idempotency
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph_idempotency (
sequence_token TEXT PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graph_idempotency_seen_at ON graph_idempotency (seen_at);

View File

@@ -10,6 +10,10 @@
<Description>Consolidated persistence layer for StellaOps Graph Indexer module</Description>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />

View File

@@ -431,8 +431,8 @@ public sealed class GraphCoreLogicTests
{
return new GraphBuildNode(id, "artifact", new Dictionary<string, object>
{
["artifactDigest"] = artifactDigest,
["sbomDigest"] = sbomDigest
["artifact_digest"] = artifactDigest,
["sbom_digest"] = sbomDigest
});
}

View File

@@ -36,7 +36,7 @@ public sealed class GraphIndexerEndToEndTests
var result = builder.Build(snapshot, nodes.ToGraphBuildBatch(edges), DateTimeOffset.UtcNow);
// Assert
result.Adjacency.Nodes.Should().Contain(n => n.NodeId.Contains("artifact"));
result.Adjacency.Nodes.Should().Contain(n => n.Kind == "artifact");
}
[Trait("Category", TestCategories.Unit)]
@@ -139,7 +139,7 @@ public sealed class GraphIndexerEndToEndTests
// Assert
result.Manifest.Hash.Should().NotBeNullOrEmpty();
result.Manifest.Hash.Should().StartWith("sha256:");
result.Manifest.Hash.Should().HaveLength(64, "SHA256 hex string should be 64 characters");
}
[Trait("Category", TestCategories.Unit)]
@@ -171,7 +171,11 @@ public sealed class GraphIndexerEndToEndTests
// Create nodes in original order
var nodesOriginal = new[]
{
new GraphBuildNode("root", "artifact", new Dictionary<string, object>()),
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode("comp-a", "component", new Dictionary<string, object>()),
new GraphBuildNode("comp-b", "component", new Dictionary<string, object>()),
new GraphBuildNode("comp-c", "component", new Dictionary<string, object>())
@@ -182,7 +186,11 @@ public sealed class GraphIndexerEndToEndTests
{
new GraphBuildNode("comp-c", "component", new Dictionary<string, object>()),
new GraphBuildNode("comp-a", "component", new Dictionary<string, object>()),
new GraphBuildNode("root", "artifact", new Dictionary<string, object>()),
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode("comp-b", "component", new Dictionary<string, object>())
}.ToImmutableArray();
@@ -211,7 +219,11 @@ public sealed class GraphIndexerEndToEndTests
var nodes = new[]
{
new GraphBuildNode("root", "artifact", new Dictionary<string, object>()),
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode("dep-a", "component", new Dictionary<string, object> { ["purl"] = "pkg:npm/a@1.0.0" }),
new GraphBuildNode("dep-b", "component", new Dictionary<string, object> { ["purl"] = "pkg:npm/b@1.0.0" }),
new GraphBuildNode("dep-c", "component", new Dictionary<string, object> { ["purl"] = "pkg:npm/c@1.0.0" }),
@@ -233,11 +245,11 @@ public sealed class GraphIndexerEndToEndTests
// Assert
result.Adjacency.Nodes.Should().HaveCount(6);
// Verify chain connectivity
var rootNode = result.Adjacency.Nodes.Single(n => n.NodeId == "root");
rootNode.OutgoingEdges.Should().HaveCount(1);
var depE = result.Adjacency.Nodes.Single(n => n.NodeId == "dep-e");
depE.IncomingEdges.Should().HaveCount(1);
depE.OutgoingEdges.Should().BeEmpty();
@@ -250,10 +262,14 @@ public sealed class GraphIndexerEndToEndTests
// Arrange - Diamond: root → a, root → b, a → c, b → c
var snapshot = CreateTestSbomSnapshot("tenant-diamond", "sha256:diamond", "sha256:diamondsbom");
var builder = new GraphSnapshotBuilder();
var nodes = new[]
{
new GraphBuildNode("root", "artifact", new Dictionary<string, object>()),
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode("dep-a", "component", new Dictionary<string, object>()),
new GraphBuildNode("dep-b", "component", new Dictionary<string, object>()),
new GraphBuildNode("dep-c", "component", new Dictionary<string, object>())
@@ -285,9 +301,14 @@ public sealed class GraphIndexerEndToEndTests
// Arrange - Circular: a → b → c → a
var snapshot = CreateTestSbomSnapshot("tenant-circular", "sha256:circular", "sha256:circularsbom");
var builder = new GraphSnapshotBuilder();
var nodes = new[]
{
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode("dep-a", "component", new Dictionary<string, object>()),
new GraphBuildNode("dep-b", "component", new Dictionary<string, object>()),
new GraphBuildNode("dep-c", "component", new Dictionary<string, object>())
@@ -305,9 +326,9 @@ public sealed class GraphIndexerEndToEndTests
// Assert
act.Should().NotThrow("Circular dependencies should be handled gracefully");
var result = act();
result.Adjacency.Nodes.Should().HaveCount(3);
result.Adjacency.Nodes.Should().HaveCount(4);
}
#endregion
@@ -331,8 +352,8 @@ public sealed class GraphIndexerEndToEndTests
{
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifactDigest"] = snapshot.ArtifactDigest,
["sbomDigest"] = snapshot.SbomDigest
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode("component-lodash", "component", new Dictionary<string, object>
{
@@ -351,8 +372,8 @@ public sealed class GraphIndexerEndToEndTests
{
new GraphBuildNode("root", "artifact", new Dictionary<string, object>
{
["artifactDigest"] = artifactDigest,
["sbomDigest"] = sbomDigest
["artifact_digest"] = artifactDigest,
["sbom_digest"] = sbomDigest
}),
new GraphBuildNode("component-a", "component", new Dictionary<string, object>())
}.ToImmutableArray();
@@ -364,7 +385,9 @@ public sealed class GraphIndexerEndToEndTests
{
new GraphBuildNode($"{tenant}-root", "artifact", new Dictionary<string, object>
{
["tenant"] = tenant
["tenant"] = tenant,
["artifact_digest"] = snapshot.ArtifactDigest,
["sbom_digest"] = snapshot.SbomDigest
}),
new GraphBuildNode($"{tenant}-comp", "component", new Dictionary<string, object>
{

View File

@@ -40,9 +40,11 @@ public sealed class SbomSnapshotExporterTests
var manifestPath = Path.Combine(_tempRoot, "manifest.json");
var manifestJson = JsonNode.Parse(await File.ReadAllTextAsync(manifestPath))!.AsObject();
// Hash in manifest should equal recomputed canonical hash.
// Hash in manifest should equal recomputed canonical hash (excluding the hash field itself).
var storedHash = manifestJson["hash"]!.GetValue<string>();
manifestJson.Remove("hash");
var computed = GraphIdentity.ComputeDocumentHash(manifestJson);
Assert.Equal(computed, manifestJson["hash"]!.GetValue<string>());
Assert.Equal(computed, storedHash);
// Adjacency should contain both nodes and edges, deterministic ids.
var adjacency = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(_tempRoot, "adjacency.json")))!.AsObject();