Add Graph saved views persistence and compatibility endpoints
Introduce PostgresGraphSavedViewStore with SQL migration, in-memory fallback, CompatibilityEndpoints for UI contract alignment, and integration tests with a shared Postgres fixture. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Security;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
|
||||
namespace StellaOps.Graph.Api.Endpoints;
|
||||
|
||||
public static class CompatibilityEndpoints
|
||||
{
|
||||
public static void MapCompatibilityEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/graphs", async Task<IResult> (
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||
var graphId = InMemoryGraphRepository.BuildGraphId(tenantId);
|
||||
var snapshotAt = repository.GetSnapshotTimestamp();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
graphId,
|
||||
tenantId,
|
||||
name = $"{tenantId.ToUpperInvariant()} Security Graph",
|
||||
description = "Compatibility view over dependency, asset, and vulnerability relationships.",
|
||||
status = "ready",
|
||||
nodeCount = nodes.Count,
|
||||
edgeCount = edges.Count,
|
||||
snapshotAt,
|
||||
createdAt = snapshotAt,
|
||||
updatedAt = snapshotAt,
|
||||
etag = $"\"{graphId}:v1\""
|
||||
}
|
||||
},
|
||||
total = 1
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/graphs/{graphId}", async Task<IResult> (
|
||||
string graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||
var snapshotAt = repository.GetSnapshotTimestamp();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
graphId,
|
||||
tenantId,
|
||||
name = $"{tenantId.ToUpperInvariant()} Security Graph",
|
||||
description = "Compatibility view over dependency, asset, and vulnerability relationships.",
|
||||
status = "ready",
|
||||
nodeCount = nodes.Count,
|
||||
edgeCount = edges.Count,
|
||||
snapshotAt,
|
||||
createdAt = snapshotAt,
|
||||
updatedAt = snapshotAt,
|
||||
etag = $"\"{graphId}:v1\""
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/graphs/{graphId}/tiles", async Task<IResult> (
|
||||
string graphId,
|
||||
bool? includeOverlays,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IOverlayService overlayService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||
var overlays = includeOverlays == true
|
||||
? await overlayService.GetOverlaysAsync(tenantId, nodes.Select(node => node.Id), sampleExplain: true, ct)
|
||||
: null;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
version = "graph.tile.compat.v1",
|
||||
tenantId,
|
||||
tile = new
|
||||
{
|
||||
id = $"{graphId}:default",
|
||||
etag = $"\"{graphId}:tile:v1\""
|
||||
},
|
||||
nodes = nodes.Select(MapNode).ToArray(),
|
||||
edges = edges.Select(MapEdge).ToArray(),
|
||||
overlays = overlays is null ? null : new
|
||||
{
|
||||
policy = FlattenOverlays(overlays, "policy", MapPolicyOverlay),
|
||||
vex = FlattenOverlays(overlays, "vex", MapVexOverlay),
|
||||
aoc = FlattenOverlays(overlays, "aoc", MapAocOverlay)
|
||||
},
|
||||
telemetry = new
|
||||
{
|
||||
generationMs = Math.Max(1, (int)(DateTimeOffset.UtcNow - startedAt).TotalMilliseconds),
|
||||
cache = "miss",
|
||||
samples = nodes.Count
|
||||
},
|
||||
etag = $"\"{graphId}:tile:v1\""
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/search", async Task<IResult> (
|
||||
string? q,
|
||||
string? kinds,
|
||||
string? graphId,
|
||||
int? pageSize,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
return Results.BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_VALIDATION_FAILED",
|
||||
Message = "q is required."
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!string.IsNullOrWhiteSpace(graphId) && !repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var request = new GraphSearchRequest
|
||||
{
|
||||
Kinds = ParseKinds(kinds),
|
||||
Query = q.Trim(),
|
||||
Limit = Math.Clamp(pageSize ?? 50, 1, 200)
|
||||
};
|
||||
|
||||
var items = repository.Query(tenantId, request)
|
||||
.Where(node => IsCompatibilityKind(node.Kind))
|
||||
.Take(request.Limit!.Value)
|
||||
.Select(node => new
|
||||
{
|
||||
nodeId = node.Id,
|
||||
kind = NormalizeNodeKind(node.Kind),
|
||||
label = ResolveNodeLabel(node),
|
||||
score = ComputeSearchScore(node, q),
|
||||
severity = NormalizeSeverity(GetAttributeString(node, "severity")),
|
||||
reachability = GetAttributeString(node, "reachable")
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
total = items.Length
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/paths", async Task<IResult> (
|
||||
string source,
|
||||
string target,
|
||||
int? maxDepth,
|
||||
string? graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!string.IsNullOrWhiteSpace(graphId) && !repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var result = repository.FindCompatibilityPath(tenantId, source, target, Math.Clamp(maxDepth ?? 4, 1, 6));
|
||||
if (result is null)
|
||||
{
|
||||
return Results.Ok(new { paths = Array.Empty<object>(), shortestLength = 0, totalPaths = 0 });
|
||||
}
|
||||
|
||||
var path = result.Value;
|
||||
var steps = new List<object>();
|
||||
for (var index = 0; index < path.Nodes.Count; index++)
|
||||
{
|
||||
steps.Add(new
|
||||
{
|
||||
node = MapNode(path.Nodes[index]),
|
||||
edge = index == 0 ? null : MapEdge(path.Edges[index - 1]),
|
||||
depth = index
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
paths = new[] { steps },
|
||||
shortestLength = path.Edges.Count,
|
||||
totalPaths = 1
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/graphs/{graphId}/export", async Task<IResult> (
|
||||
string graphId,
|
||||
string? format,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphExportService exportService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.Export, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var job = await exportService.StartExportAsync(tenantId, new GraphExportRequest
|
||||
{
|
||||
Format = string.IsNullOrWhiteSpace(format) ? "ndjson" : format.Trim(),
|
||||
IncludeEdges = true,
|
||||
Kinds = ["artifact", "component", "vuln"]
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
exportId = job.JobId,
|
||||
format = job.Format,
|
||||
url = $"/graph/export/{job.JobId}",
|
||||
sha256 = job.Sha256,
|
||||
size = job.SizeBytes,
|
||||
expiresAt = job.CompletedAt.AddMinutes(30)
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/assets/{assetId}/snapshot", async Task<IResult> (
|
||||
string assetId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
var asset = repository.GetNode(tenantId, assetId);
|
||||
if (asset is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var adjacency = repository.GetAdjacency(tenantId, assetId);
|
||||
var componentIds = adjacency.Outgoing
|
||||
.Where(edge => edge.Kind.Equals("builds", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(edge => edge.Target)
|
||||
.ToArray();
|
||||
|
||||
var vulnerabilities = componentIds
|
||||
.SelectMany(componentId => repository.GetAdjacency(tenantId, componentId).Outgoing)
|
||||
.Where(edge => edge.Kind.Equals("affects", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(edge => edge.Target)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
assetId,
|
||||
name = ResolveNodeLabel(asset),
|
||||
kind = NormalizeNodeKind(asset.Kind),
|
||||
components = componentIds,
|
||||
vulnerabilities,
|
||||
snapshotAt = repository.GetSnapshotTimestamp()
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/nodes/{nodeId}/adjacency", async Task<IResult> (
|
||||
string nodeId,
|
||||
string? graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!string.IsNullOrWhiteSpace(graphId) && !repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (repository.GetNode(tenantId, nodeId) is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var adjacency = repository.GetAdjacency(tenantId, nodeId);
|
||||
return Results.Ok(new
|
||||
{
|
||||
nodeId,
|
||||
incoming = adjacency.Incoming.Select(edge => new
|
||||
{
|
||||
nodeId = edge.Source,
|
||||
edgeType = NormalizeEdgeType(edge.Kind)
|
||||
}).ToArray(),
|
||||
outgoing = adjacency.Outgoing.Select(edge => new
|
||||
{
|
||||
nodeId = edge.Target,
|
||||
edgeType = NormalizeEdgeType(edge.Kind)
|
||||
}).ToArray()
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/graphs/{graphId}/saved-views", async Task<IResult> (
|
||||
string graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphSavedViewStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(await store.ListAsync(tenantId, graphId, ct));
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapPost("/graphs/{graphId}/saved-views", async Task<IResult> (
|
||||
string graphId,
|
||||
CreateGraphSavedViewRequest request,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphSavedViewStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_VALIDATION_FAILED",
|
||||
Message = "name is required."
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var created = await store.CreateAsync(tenantId, graphId, request, ct);
|
||||
return Results.Created($"/graphs/{graphId}/saved-views/{created.ViewId}", created);
|
||||
})
|
||||
.RequireTenant();
|
||||
|
||||
app.MapDelete("/graphs/{graphId}/saved-views/{viewId}", async Task<IResult> (
|
||||
string graphId,
|
||||
string viewId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphSavedViewStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
var tenantId = auth.TenantId!;
|
||||
if (!repository.GraphExists(tenantId, graphId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return await store.DeleteAsync(tenantId, graphId, viewId, ct)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound();
|
||||
})
|
||||
.RequireTenant();
|
||||
}
|
||||
|
||||
private static async Task<(string? TenantId, IResult? Failure)> AuthorizeAsync(
|
||||
HttpContext context,
|
||||
string policyName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var tenantError))
|
||||
{
|
||||
return (null, Results.BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_VALIDATION_FAILED",
|
||||
Message = string.Equals(tenantError, "tenant_conflict", StringComparison.Ordinal)
|
||||
? "Conflicting tenant headers were supplied."
|
||||
: "Tenant header is required."
|
||||
}));
|
||||
}
|
||||
|
||||
var authResult = await context.AuthenticateAsync(GraphHeaderAuthenticationHandler.SchemeName);
|
||||
if (!authResult.Succeeded || authResult.Principal?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return (null, Results.Json(new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_UNAUTHORIZED",
|
||||
Message = "Authentication is required."
|
||||
}, statusCode: StatusCodes.Status401Unauthorized));
|
||||
}
|
||||
|
||||
var authorizationService = context.RequestServices.GetRequiredService<IAuthorizationService>();
|
||||
var authorized = await authorizationService.AuthorizeAsync(authResult.Principal, resource: null, policyName);
|
||||
if (!authorized.Succeeded)
|
||||
{
|
||||
return (null, Results.Json(new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_FORBIDDEN",
|
||||
Message = "The supplied scopes do not permit this operation."
|
||||
}, statusCode: StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
context.User = authResult.Principal;
|
||||
return (tenantId, null);
|
||||
}
|
||||
|
||||
private static object MapNode(NodeTile node)
|
||||
=> new
|
||||
{
|
||||
id = node.Id,
|
||||
kind = NormalizeNodeKind(node.Kind),
|
||||
label = ResolveNodeLabel(node),
|
||||
severity = NormalizeSeverity(GetAttributeString(node, "severity")),
|
||||
reachability = GetAttributeString(node, "reachable"),
|
||||
attributes = node.Attributes
|
||||
};
|
||||
|
||||
private static object MapEdge(EdgeTile edge)
|
||||
=> new
|
||||
{
|
||||
id = edge.Id,
|
||||
source = edge.Source,
|
||||
target = edge.Target,
|
||||
type = NormalizeEdgeType(edge.Kind),
|
||||
attributes = edge.Attributes
|
||||
};
|
||||
|
||||
private static object[] FlattenOverlays<T>(
|
||||
IDictionary<string, Dictionary<string, OverlayPayload>> overlays,
|
||||
string overlayKind,
|
||||
Func<string, OverlayPayload, T?> mapper)
|
||||
where T : class
|
||||
{
|
||||
return overlays
|
||||
.Where(item => item.Value.TryGetValue(overlayKind, out _))
|
||||
.Select(item =>
|
||||
{
|
||||
var payload = item.Value[overlayKind];
|
||||
return mapper(item.Key, payload);
|
||||
})
|
||||
.Where(item => item is not null)
|
||||
.Cast<object>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static object? MapPolicyOverlay(string nodeId, OverlayPayload payload)
|
||||
{
|
||||
var data = payload.Data as dynamic;
|
||||
var badge = ((string?)data?.decision ?? "warn").ToLowerInvariant();
|
||||
return new
|
||||
{
|
||||
nodeId,
|
||||
badge = badge switch
|
||||
{
|
||||
"fail" => "fail",
|
||||
"waived" => "waived",
|
||||
"pass" => "pass",
|
||||
_ => "warn"
|
||||
},
|
||||
policyId = (string?)data?.overlayId ?? $"policy://{nodeId}",
|
||||
verdictAt = (DateTimeOffset?)data?.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
private static object? MapVexOverlay(string nodeId, OverlayPayload payload)
|
||||
{
|
||||
var data = payload.Data as dynamic;
|
||||
return new
|
||||
{
|
||||
nodeId,
|
||||
state = ((string?)data?.status ?? "under_investigation").ToLowerInvariant(),
|
||||
statementId = (string?)data?.overlayId ?? $"vex://{nodeId}",
|
||||
lastUpdated = (DateTimeOffset?)data?.issued
|
||||
};
|
||||
}
|
||||
|
||||
private static object? MapAocOverlay(string nodeId, OverlayPayload payload)
|
||||
{
|
||||
var data = payload.Data as dynamic;
|
||||
return new
|
||||
{
|
||||
nodeId,
|
||||
status = ((string?)data?.status ?? "pending").ToLowerInvariant(),
|
||||
lastVerified = (DateTimeOffset?)data?.lastVerified
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] ParseKinds(string? kinds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kinds))
|
||||
{
|
||||
return ["artifact", "component", "vuln"];
|
||||
}
|
||||
|
||||
return kinds
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(kind => kind.Equals("asset", StringComparison.OrdinalIgnoreCase) ? "artifact" : kind)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeNodeKind(string kind)
|
||||
=> kind.Equals("artifact", StringComparison.OrdinalIgnoreCase)
|
||||
? "asset"
|
||||
: kind.ToLowerInvariant();
|
||||
|
||||
private static string NormalizeEdgeType(string kind)
|
||||
=> kind.Equals("builds", StringComparison.OrdinalIgnoreCase)
|
||||
? "contains"
|
||||
: kind.Equals("affects", StringComparison.OrdinalIgnoreCase)
|
||||
? "affects"
|
||||
: "depends_on";
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
=> severity?.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => "critical",
|
||||
"high" => "high",
|
||||
"medium" => "medium",
|
||||
"low" => "low",
|
||||
"info" => "info",
|
||||
"informational" => "info",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static bool IsCompatibilityKind(string kind)
|
||||
=> kind.Equals("artifact", StringComparison.OrdinalIgnoreCase)
|
||||
|| kind.Equals("component", StringComparison.OrdinalIgnoreCase)
|
||||
|| kind.Equals("vuln", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ResolveNodeLabel(NodeTile node)
|
||||
{
|
||||
var displayName = GetAttributeString(node, "displayName");
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
var purl = GetAttributeString(node, "purl");
|
||||
if (!string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
var namePart = purl.Split('/').LastOrDefault() ?? purl;
|
||||
return namePart.Split('@').FirstOrDefault() ?? namePart;
|
||||
}
|
||||
|
||||
var cveId = GetAttributeString(node, "cveId");
|
||||
if (!string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return cveId;
|
||||
}
|
||||
|
||||
return node.Id;
|
||||
}
|
||||
|
||||
private static string? GetAttributeString(NodeTile node, string key)
|
||||
=> node.Attributes.TryGetValue(key, out var value) && value is not null
|
||||
? value.ToString()
|
||||
: null;
|
||||
|
||||
private static double ComputeSearchScore(NodeTile node, string query)
|
||||
{
|
||||
var candidate = ResolveNodeLabel(node);
|
||||
if (candidate.Equals(query, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (candidate.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
return node.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ? 0.6 : 0.2;
|
||||
}
|
||||
}
|
||||
15
src/Graph/StellaOps.Graph.Api/Migrations/003_saved_views.sql
Normal file
15
src/Graph/StellaOps.Graph.Api/Migrations/003_saved_views.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS saved_views (
|
||||
tenant_id TEXT NOT NULL,
|
||||
graph_id TEXT NOT NULL,
|
||||
view_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
filters_json JSONB NULL,
|
||||
layout_json JSONB NULL,
|
||||
overlays_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, graph_id, view_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_views_tenant_graph_created_at
|
||||
ON saved_views (tenant_id, graph_id, created_at DESC, view_id);
|
||||
@@ -1,9 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Graph.Api.Endpoints;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Security;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
@@ -21,11 +25,27 @@ builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
|
||||
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
|
||||
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
|
||||
builder.Services.AddSingleton<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<InMemoryGraphSavedViewStore>();
|
||||
builder.Services.AddHostedService<GraphSavedViewsMigrationHostedService>();
|
||||
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.AddOptions<PostgresOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Postgres:Graph"))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.ConnectionString = ResolveGraphConnectionString(builder.Configuration) ?? string.Empty;
|
||||
options.SchemaName = ResolveGraphSchemaName(builder.Configuration);
|
||||
});
|
||||
builder.Services.AddSingleton<IGraphSavedViewStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
|
||||
return string.IsNullOrWhiteSpace(options.ConnectionString)
|
||||
? sp.GetRequiredService<InMemoryGraphSavedViewStore>()
|
||||
: ActivatorUtilities.CreateInstance<PostgresGraphSavedViewStore>(sp);
|
||||
});
|
||||
builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
@@ -495,6 +515,7 @@ app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string eviden
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapCompatibilityEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
@@ -600,4 +621,24 @@ static void LogAudit(HttpContext ctx, string route, int statusCode, long duratio
|
||||
DurationMs: durationMs));
|
||||
}
|
||||
|
||||
static string? ResolveGraphConnectionString(IConfiguration configuration)
|
||||
{
|
||||
var configured = configuration["Postgres:Graph:ConnectionString"];
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return configured.Trim();
|
||||
}
|
||||
|
||||
var fallback = configuration.GetConnectionString("Default");
|
||||
return string.IsNullOrWhiteSpace(fallback) ? null : fallback.Trim();
|
||||
}
|
||||
|
||||
static string ResolveGraphSchemaName(IConfiguration configuration)
|
||||
{
|
||||
var configured = configuration["Postgres:Graph:SchemaName"];
|
||||
return string.IsNullOrWhiteSpace(configured)
|
||||
? PostgresGraphSavedViewStore.DefaultSchemaName
|
||||
: configured.Trim();
|
||||
}
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed record GraphSavedViewRecord(
|
||||
string ViewId,
|
||||
string GraphId,
|
||||
string Name,
|
||||
string? Description,
|
||||
JsonObject? Filters,
|
||||
JsonObject? Layout,
|
||||
IReadOnlyList<string> Overlays,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record CreateGraphSavedViewRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public JsonObject? Filters { get; init; }
|
||||
public JsonObject? Layout { get; init; }
|
||||
public IReadOnlyList<string>? Overlays { get; init; }
|
||||
}
|
||||
|
||||
public interface IGraphSavedViewStore
|
||||
{
|
||||
Task<IReadOnlyList<GraphSavedViewRecord>> ListAsync(string tenant, string graphId, CancellationToken cancellationToken);
|
||||
|
||||
Task<GraphSavedViewRecord> CreateAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
CreateGraphSavedViewRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> DeleteAsync(string tenant, string graphId, string viewId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class GraphSavedViewsMigrationHostedService : IHostedService
|
||||
{
|
||||
private readonly IOptions<PostgresOptions> _options;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private StartupMigrationHost? _inner;
|
||||
|
||||
public GraphSavedViewsMigrationHostedService(
|
||||
IOptions<PostgresOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
IHostApplicationLifetime lifetime)
|
||||
{
|
||||
_options = options;
|
||||
_loggerFactory = loggerFactory;
|
||||
_lifetime = lifetime;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.Value;
|
||||
if (string.IsNullOrWhiteSpace(options.ConnectionString))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_inner = new GraphSavedViewsStartupMigrationHost(
|
||||
options.ConnectionString,
|
||||
string.IsNullOrWhiteSpace(options.SchemaName)
|
||||
? PostgresGraphSavedViewStore.DefaultSchemaName
|
||||
: options.SchemaName.Trim(),
|
||||
_loggerFactory.CreateLogger("Migration.Graph.Api"),
|
||||
_lifetime);
|
||||
return _inner.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
=> _inner?.StopAsync(cancellationToken) ?? Task.CompletedTask;
|
||||
|
||||
private sealed class GraphSavedViewsStartupMigrationHost : StartupMigrationHost
|
||||
{
|
||||
public GraphSavedViewsStartupMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime)
|
||||
: base(
|
||||
connectionString,
|
||||
schemaName,
|
||||
"Graph.Api",
|
||||
typeof(Program).Assembly,
|
||||
logger,
|
||||
lifetime)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphRepository
|
||||
{
|
||||
private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"];
|
||||
private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly List<NodeTile> _nodes;
|
||||
private readonly List<EdgeTile> _edges;
|
||||
private readonly Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> _snapshots;
|
||||
@@ -12,23 +14,30 @@ public sealed class InMemoryGraphRepository
|
||||
{
|
||||
_nodes = seed?.ToList() ?? new List<NodeTile>
|
||||
{
|
||||
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" } },
|
||||
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "example", ["version"] = "1.0.0" } },
|
||||
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } },
|
||||
new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container", ["displayName"] = "auth-service", ["version"] = "2026.04.04" } },
|
||||
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", ["displayName"] = "gamma", ["version"] = "3.1.4" } },
|
||||
new() { Id = "gn:acme:vuln:CVE-2026-1234", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-1234", ["severity"] = "critical", ["cveId"] = "CVE-2026-1234" } },
|
||||
new() { Id = "gn:acme:vuln:CVE-2026-5678", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-5678", ["severity"] = "high", ["cveId"] = "CVE-2026-5678" } },
|
||||
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } },
|
||||
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo", Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container", ["displayName"] = "transform-service", ["version"] = "2026.04.04" } },
|
||||
new() { Id = "gn:bravo:vuln:CVE-2026-9001", Kind = "vuln", Tenant = "bravo", Attributes = new() { ["displayName"] = "CVE-2026-9001", ["severity"] = "medium", ["cveId"] = "CVE-2026-9001" } },
|
||||
};
|
||||
|
||||
_edges = edges?.ToList() ?? new List<EdgeTile>
|
||||
{
|
||||
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:component->component:gamma", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:component:gamma", Attributes = new() { ["scope"] = "runtime" } },
|
||||
new() { Id = "ge:acme:component->vuln:widget", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:vuln:CVE-2026-1234", Attributes = new() { ["scope"] = "runtime" } },
|
||||
new() { Id = "ge:acme:component->vuln:gamma", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:gamma", Target = "gn:acme:vuln:CVE-2026-5678", 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" } },
|
||||
new() { Id = "ge:bravo:component->vuln:widget", Kind = "affects", Tenant = "bravo", Source = "gn:bravo:component:widget", Target = "gn:bravo:vuln:CVE-2026-9001", Attributes = new() { ["scope"] = "runtime" } },
|
||||
};
|
||||
|
||||
// Drop edges whose endpoints aren't present in the current node set to avoid invalid graph seeds in tests.
|
||||
@@ -195,6 +204,101 @@ public sealed class InMemoryGraphRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant)
|
||||
=> _nodes
|
||||
.Where(node => node.Tenant.Equals(tenant, StringComparison.Ordinal))
|
||||
.Where(node => CompatibilityKinds.Contains(node.Kind, StringComparer.OrdinalIgnoreCase))
|
||||
.OrderBy(node => node.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
public IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant)
|
||||
{
|
||||
var nodeIds = GetCompatibilityNodes(tenant).Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
|
||||
return _edges
|
||||
.Where(edge => edge.Tenant.Equals(tenant, StringComparison.Ordinal))
|
||||
.Where(edge => nodeIds.Contains(edge.Source) && nodeIds.Contains(edge.Target))
|
||||
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string BuildGraphId(string tenant)
|
||||
=> $"graph::{tenant}::main";
|
||||
|
||||
public bool GraphExists(string tenant, string graphId)
|
||||
=> string.Equals(BuildGraphId(tenant), graphId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public NodeTile? GetNode(string tenant, string nodeId)
|
||||
=> _nodes.FirstOrDefault(node =>
|
||||
node.Tenant.Equals(tenant, StringComparison.Ordinal)
|
||||
&& node.Id.Equals(nodeId, StringComparison.Ordinal));
|
||||
|
||||
public (IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId)
|
||||
{
|
||||
var edges = GetCompatibilityEdges(tenant);
|
||||
var incoming = edges.Where(edge => edge.Target.Equals(nodeId, StringComparison.Ordinal)).ToList();
|
||||
var outgoing = edges.Where(edge => edge.Source.Equals(nodeId, StringComparison.Ordinal)).ToList();
|
||||
return (incoming, outgoing);
|
||||
}
|
||||
|
||||
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth)
|
||||
{
|
||||
var nodes = GetCompatibilityNodes(tenant).ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
if (!nodes.ContainsKey(sourceId) || !nodes.ContainsKey(targetId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var edges = GetCompatibilityEdges(tenant);
|
||||
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!adjacency.TryGetValue(edge.Source, out var list))
|
||||
{
|
||||
list = new List<EdgeTile>();
|
||||
adjacency[edge.Source] = list;
|
||||
}
|
||||
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
var queue = new Queue<(string NodeId, List<EdgeTile> PathEdges)>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal) { sourceId };
|
||||
queue.Enqueue((sourceId, new List<EdgeTile>()));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (nodeId, pathEdges) = queue.Dequeue();
|
||||
if (nodeId.Equals(targetId, StringComparison.Ordinal))
|
||||
{
|
||||
var pathNodes = BuildNodeListFromEdges(nodes, sourceId, pathEdges);
|
||||
return (pathNodes, pathEdges);
|
||||
}
|
||||
|
||||
if (pathEdges.Count >= maxDepth || !adjacency.TryGetValue(nodeId, out var outgoing))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in outgoing.OrderBy(edge => edge.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!visited.Add(edge.Target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nextEdges = new List<EdgeTile>(pathEdges.Count + 1);
|
||||
nextEdges.AddRange(pathEdges);
|
||||
nextEdges.Add(edge);
|
||||
queue.Enqueue((edge.Target, nextEdges));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public DateTimeOffset GetSnapshotTimestamp()
|
||||
=> FixedSnapshotAt;
|
||||
|
||||
private Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> SeedSnapshots()
|
||||
{
|
||||
var dict = new Dictionary<string, (List<NodeTile>, List<EdgeTile>)>(StringComparer.Ordinal);
|
||||
@@ -214,13 +318,14 @@ public sealed class InMemoryGraphRepository
|
||||
}
|
||||
|
||||
widget.Attributes["purl"] = "pkg:npm/widget@2.1.0";
|
||||
widget.Attributes["version"] = "2.1.0";
|
||||
|
||||
updatedNodes.Add(new NodeTile
|
||||
{
|
||||
Id = "gn:acme:component:newlib",
|
||||
Kind = "component",
|
||||
Tenant = "acme",
|
||||
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm" }
|
||||
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "newlib", ["version"] = "1.0.0" }
|
||||
});
|
||||
|
||||
var updatedEdges = new List<EdgeTile>(_edges)
|
||||
@@ -329,6 +434,28 @@ public sealed class InMemoryGraphRepository
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NodeTile> BuildNodeListFromEdges(
|
||||
IDictionary<string, NodeTile> nodes,
|
||||
string sourceId,
|
||||
IReadOnlyList<EdgeTile> edges)
|
||||
{
|
||||
var list = new List<NodeTile>();
|
||||
if (nodes.TryGetValue(sourceId, out var firstNode))
|
||||
{
|
||||
list.Add(firstNode);
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (nodes.TryGetValue(edge.Target, out var node))
|
||||
{
|
||||
list.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CursorCodec
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphSavedViewStore : IGraphSavedViewStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<GraphSavedViewRecord>> _views = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryGraphSavedViewStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<GraphSavedViewRecord>> ListAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_views.TryGetValue(BuildKey(tenant, graphId), out var items))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<GraphSavedViewRecord>>([]);
|
||||
}
|
||||
|
||||
lock (items)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<GraphSavedViewRecord>>(items
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.ThenBy(item => item.ViewId, StringComparer.Ordinal)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<GraphSavedViewRecord> CreateAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
CreateGraphSavedViewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = BuildKey(tenant, graphId);
|
||||
var items = _views.GetOrAdd(key, _ => new List<GraphSavedViewRecord>());
|
||||
var record = new GraphSavedViewRecord(
|
||||
ViewId: $"view-{Guid.NewGuid():N}",
|
||||
GraphId: graphId,
|
||||
Name: request.Name.Trim(),
|
||||
Description: string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Filters: request.Filters?.DeepClone() as JsonObject,
|
||||
Layout: request.Layout?.DeepClone() as JsonObject,
|
||||
Overlays: (request.Overlays ?? []).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
lock (items)
|
||||
{
|
||||
items.Add(record);
|
||||
}
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
string viewId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_views.TryGetValue(BuildKey(tenant, graphId), out var items))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
lock (items)
|
||||
{
|
||||
var index = items.FindIndex(item => item.ViewId.Equals(viewId, StringComparison.Ordinal));
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
items.RemoveAt(index);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildKey(string tenant, string graphId)
|
||||
=> $"{tenant}|{graphId}";
|
||||
}
|
||||
@@ -30,7 +30,8 @@ namespace StellaOps.Graph.Api.Services;
|
||||
cachedBase = new Dictionary<string, OverlayPayload>(StringComparer.Ordinal)
|
||||
{
|
||||
["policy"] = BuildPolicyOverlay(tenant, nodeId, includeExplain: false),
|
||||
["vex"] = BuildVexOverlay(tenant, nodeId)
|
||||
["vex"] = BuildVexOverlay(tenant, nodeId),
|
||||
["aoc"] = BuildAocOverlay(tenant, nodeId),
|
||||
};
|
||||
|
||||
_cache.Set(cacheKey, cachedBase, new MemoryCacheEntryOptions
|
||||
@@ -62,6 +63,11 @@ namespace StellaOps.Graph.Api.Services;
|
||||
private static OverlayPayload BuildPolicyOverlay(string tenant, string nodeId, bool includeExplain)
|
||||
{
|
||||
var overlayId = ComputeOverlayId(tenant, nodeId, "policy");
|
||||
var decision = nodeId.Contains(":vuln:", StringComparison.OrdinalIgnoreCase)
|
||||
? "fail"
|
||||
: nodeId.Contains("widget", StringComparison.OrdinalIgnoreCase)
|
||||
? "warn"
|
||||
: "pass";
|
||||
return new OverlayPayload(
|
||||
Kind: "policy",
|
||||
Version: "policy.overlay.v1",
|
||||
@@ -69,8 +75,13 @@ namespace StellaOps.Graph.Api.Services;
|
||||
{
|
||||
overlayId,
|
||||
subject = nodeId,
|
||||
decision = "warn",
|
||||
rationale = new[] { "policy-default", "missing VEX waiver" },
|
||||
decision,
|
||||
rationale = decision switch
|
||||
{
|
||||
"fail" => new[] { "critical reachable dependency", "promotion gate blocked" },
|
||||
"warn" => new[] { "runtime dependency review", "waiver missing" },
|
||||
_ => new[] { "policy checks satisfied" },
|
||||
},
|
||||
inputs = new
|
||||
{
|
||||
sbomDigest = "sha256:demo-sbom",
|
||||
@@ -92,6 +103,11 @@ namespace StellaOps.Graph.Api.Services;
|
||||
private static OverlayPayload BuildVexOverlay(string tenant, string nodeId)
|
||||
{
|
||||
var overlayId = ComputeOverlayId(tenant, nodeId, "vex");
|
||||
var status = nodeId.Contains("1234", StringComparison.OrdinalIgnoreCase)
|
||||
? "affected"
|
||||
: nodeId.Contains("5678", StringComparison.OrdinalIgnoreCase)
|
||||
? "under_investigation"
|
||||
: "not_affected";
|
||||
return new OverlayPayload(
|
||||
Kind: "vex",
|
||||
Version: "openvex.v1",
|
||||
@@ -99,13 +115,45 @@ namespace StellaOps.Graph.Api.Services;
|
||||
{
|
||||
overlayId,
|
||||
subject = nodeId,
|
||||
status = "not_affected",
|
||||
justification = "component_not_present",
|
||||
status,
|
||||
justification = status switch
|
||||
{
|
||||
"affected" => "vulnerable_code_present",
|
||||
"under_investigation" => "requires_analysis",
|
||||
_ => "component_not_present",
|
||||
},
|
||||
issued = FixedTimestamp,
|
||||
impacts = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
|
||||
private static OverlayPayload BuildAocOverlay(string tenant, string nodeId)
|
||||
{
|
||||
var overlayId = ComputeOverlayId(tenant, nodeId, "aoc");
|
||||
var status = nodeId.Contains(":vuln:", StringComparison.OrdinalIgnoreCase)
|
||||
? "fail"
|
||||
: nodeId.Contains("artifact", StringComparison.OrdinalIgnoreCase)
|
||||
? "pass"
|
||||
: "warn";
|
||||
|
||||
return new OverlayPayload(
|
||||
Kind: "aoc",
|
||||
Version: "aoc.overlay.v1",
|
||||
Data: new
|
||||
{
|
||||
overlayId,
|
||||
subject = nodeId,
|
||||
status,
|
||||
lastVerified = FixedTimestamp,
|
||||
summary = status switch
|
||||
{
|
||||
"fail" => "Assurance controls are incomplete for the selected subject.",
|
||||
"warn" => "Assurance controls require operator review before promotion.",
|
||||
_ => "Assurance controls verified for the latest release cycle.",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class PostgresGraphSavedViewStore : IGraphSavedViewStore, IAsyncDisposable
|
||||
{
|
||||
public const string DefaultSchemaName = "graph";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly string _schemaName;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresGraphSavedViewStore(IOptions<PostgresOptions> options, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var connectionString = options.Value.ConnectionString
|
||||
?? throw new InvalidOperationException("Graph saved-view persistence requires a PostgreSQL connection string.");
|
||||
_dataSource = CreateDataSource(connectionString);
|
||||
_schemaName = string.IsNullOrWhiteSpace(options.Value.SchemaName)
|
||||
? DefaultSchemaName
|
||||
: options.Value.SchemaName.Trim();
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GraphSavedViewRecord>> ListAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql =
|
||||
$"""
|
||||
SELECT view_id, name, description, filters_json, layout_json, overlays_json, created_at
|
||||
FROM {QuoteIdentifier(_schemaName)}.saved_views
|
||||
WHERE tenant_id = @tenant AND graph_id = @graphId
|
||||
ORDER BY created_at DESC, view_id
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenant);
|
||||
command.Parameters.AddWithValue("graphId", graphId);
|
||||
|
||||
var items = new List<GraphSavedViewRecord>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new GraphSavedViewRecord(
|
||||
ViewId: reader.GetString(0),
|
||||
GraphId: graphId,
|
||||
Name: reader.GetString(1),
|
||||
Description: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Filters: ReadJsonObject(reader, 3),
|
||||
Layout: ReadJsonObject(reader, 4),
|
||||
Overlays: ReadStringArray(reader, 5),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(6)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<GraphSavedViewRecord> CreateAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
CreateGraphSavedViewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var record = new GraphSavedViewRecord(
|
||||
ViewId: $"view-{Guid.NewGuid():N}",
|
||||
GraphId: graphId,
|
||||
Name: request.Name.Trim(),
|
||||
Description: string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Filters: request.Filters?.DeepClone() as JsonObject,
|
||||
Layout: request.Layout?.DeepClone() as JsonObject,
|
||||
Overlays: NormalizeOverlays(request.Overlays),
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql =
|
||||
$"""
|
||||
INSERT INTO {QuoteIdentifier(_schemaName)}.saved_views
|
||||
(tenant_id, graph_id, view_id, name, description, filters_json, layout_json, overlays_json, created_at)
|
||||
VALUES
|
||||
(@tenant, @graphId, @viewId, @name, @description, CAST(@filtersJson AS jsonb), CAST(@layoutJson AS jsonb), CAST(@overlaysJson AS jsonb), @createdAt)
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenant);
|
||||
command.Parameters.AddWithValue("graphId", graphId);
|
||||
command.Parameters.AddWithValue("viewId", record.ViewId);
|
||||
command.Parameters.AddWithValue("name", record.Name);
|
||||
command.Parameters.AddWithValue("description", (object?)record.Description ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("filtersJson", SerializeJsonObject(record.Filters));
|
||||
command.Parameters.AddWithValue("layoutJson", SerializeJsonObject(record.Layout));
|
||||
command.Parameters.AddWithValue("overlaysJson", JsonSerializer.Serialize(record.Overlays, SerializerOptions));
|
||||
command.Parameters.AddWithValue("createdAt", record.CreatedAt);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
string tenant,
|
||||
string graphId,
|
||||
string viewId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql =
|
||||
$"""
|
||||
DELETE FROM {QuoteIdentifier(_schemaName)}.saved_views
|
||||
WHERE tenant_id = @tenant AND graph_id = @graphId AND view_id = @viewId
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenant", tenant);
|
||||
command.Parameters.AddWithValue("graphId", graphId);
|
||||
command.Parameters.AddWithValue("viewId", viewId);
|
||||
|
||||
var deleted = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private static string[] NormalizeOverlays(IReadOnlyList<string>? overlays)
|
||||
{
|
||||
return (overlays ?? [])
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static JsonObject? ReadJsonObject(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonNode.Parse(reader.GetString(ordinal)) as JsonObject;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringArray(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<string[]>(reader.GetString(ordinal), SerializerOptions)
|
||||
?? [];
|
||||
}
|
||||
|
||||
private static string SerializeJsonObject(JsonObject? value)
|
||||
=> value?.ToJsonString() ?? "null";
|
||||
|
||||
private static string QuoteIdentifier(string identifier)
|
||||
=> $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||
|
||||
private static NpgsqlDataSource CreateDataSource(string connectionString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-graph-saved-views"
|
||||
};
|
||||
|
||||
return new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => _dataSource.DisposeAsync();
|
||||
}
|
||||
@@ -15,8 +15,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the saved-view PostgreSQL runtime data source and aligned it with the shared transport guardrails. |
|
||||
| AUDIT-0350-M | DONE | Revalidated 2026-01-07; maintainability audit for Graph.Api. |
|
||||
| AUDIT-0350-T | DONE | Revalidated 2026-01-07; test coverage audit for Graph.Api. |
|
||||
| AUDIT-0350-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class GraphApiPostgresFixture : PostgresIntegrationFixture
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(Program).Assembly;
|
||||
|
||||
protected override string GetModuleName()
|
||||
=> "GraphApi";
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<GraphApiPostgresFixture>
|
||||
{
|
||||
private readonly GraphApiPostgresFixture _fixture;
|
||||
|
||||
public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Compatibility")]
|
||||
public async Task GraphList_ReturnsCompatibilityGraphMetadata()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var items = document.RootElement.GetProperty("items");
|
||||
Assert.True(items.GetArrayLength() > 0);
|
||||
Assert.Equal("ready", items[0].GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Compatibility")]
|
||||
public async Task TileEndpoint_WithIncludeOverlays_ReturnsPolicyVexAndAocOverlays()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var graphId = await GetGraphIdAsync(client);
|
||||
using var request = CreateRequest(
|
||||
HttpMethod.Get,
|
||||
$"/graphs/{Uri.EscapeDataString(graphId)}/tiles?includeOverlays=true",
|
||||
"graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var overlays = document.RootElement.GetProperty("overlays");
|
||||
Assert.True(overlays.TryGetProperty("policy", out var policy));
|
||||
Assert.True(policy.GetArrayLength() > 0);
|
||||
Assert.True(overlays.TryGetProperty("vex", out var vex));
|
||||
Assert.True(vex.GetArrayLength() > 0);
|
||||
Assert.True(overlays.TryGetProperty("aoc", out var aoc));
|
||||
Assert.True(aoc.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Compatibility")]
|
||||
public async Task SavedViews_Crud_PersistsAcrossHostRestart()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var graphId = await GetGraphIdAsync(client);
|
||||
var viewName = $"Priority view {Guid.NewGuid():N}";
|
||||
|
||||
using var createRequest = CreateRequest(
|
||||
HttpMethod.Post,
|
||||
$"/graphs/{Uri.EscapeDataString(graphId)}/saved-views",
|
||||
"graph:query");
|
||||
createRequest.Content = JsonContent.Create(new
|
||||
{
|
||||
name = viewName,
|
||||
description = "Compatibility test view",
|
||||
overlays = new[] { "policy", "vex" }
|
||||
});
|
||||
|
||||
var createResponse = await client.SendAsync(createRequest);
|
||||
var createdBody = await createResponse.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
using var createdDocument = JsonDocument.Parse(createdBody);
|
||||
var viewId = createdDocument.RootElement.GetProperty("viewId").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(viewId));
|
||||
|
||||
using var listRequest = CreateRequest(
|
||||
HttpMethod.Get,
|
||||
$"/graphs/{Uri.EscapeDataString(graphId)}/saved-views",
|
||||
"graph:read");
|
||||
var listResponse = await client.SendAsync(listRequest);
|
||||
var listBody = await listResponse.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
Assert.Contains(viewName, listBody, StringComparison.Ordinal);
|
||||
|
||||
using var restartFactory = CreateFactory();
|
||||
using var restartClient = restartFactory.CreateClient();
|
||||
using var restartListRequest = CreateRequest(
|
||||
HttpMethod.Get,
|
||||
$"/graphs/{Uri.EscapeDataString(graphId)}/saved-views",
|
||||
"graph:read");
|
||||
var restartListResponse = await restartClient.SendAsync(restartListRequest);
|
||||
var restartListBody = await restartListResponse.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, restartListResponse.StatusCode);
|
||||
Assert.Contains(viewName, restartListBody, StringComparison.Ordinal);
|
||||
|
||||
using var deleteRequest = CreateRequest(
|
||||
HttpMethod.Delete,
|
||||
$"/graphs/{Uri.EscapeDataString(graphId)}/saved-views/{Uri.EscapeDataString(viewId!)}",
|
||||
"graph:query");
|
||||
var deleteResponse = await restartClient.SendAsync(deleteRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateFactory()
|
||||
{
|
||||
return new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||
{
|
||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString,
|
||||
["Postgres:Graph:SchemaName"] = _fixture.SchemaName,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string scopes)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", scopes);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static async Task<string> GetGraphIdAsync(HttpClient client)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read");
|
||||
var response = await client.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return document.RootElement.GetProperty("items")[0].GetProperty("graphId").GetString()
|
||||
?? throw new InvalidOperationException("Compatibility graph id was not returned.");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
|
||||
Reference in New Issue
Block a user