fix tests. new product advisories enhancements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user