save checkpoint
This commit is contained in:
@@ -5,14 +5,14 @@ using StellaOps.Graph.Api.Services;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<InMemoryGraphRepository>();
|
||||
builder.Services.AddSingleton(_ => new InMemoryGraphRepository());
|
||||
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
||||
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
||||
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
||||
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
|
||||
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
|
||||
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
|
||||
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
|
||||
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
|
||||
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
|
||||
@@ -351,16 +351,52 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
|
||||
return Results.Ok(manifest);
|
||||
});
|
||||
|
||||
app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphExportService service) =>
|
||||
app.MapGet("/graph/export/{jobId}", async (string jobId, HttpContext context, IGraphExportService service, CancellationToken ct) =>
|
||||
{
|
||||
var job = service.Get(jobId);
|
||||
if (job is null)
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status401Unauthorized, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:export"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status403Forbidden, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/export/download"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var job = service.Get(jobId);
|
||||
if (job is null || !string.Equals(job.Tenant, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
|
||||
return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" });
|
||||
}
|
||||
|
||||
context.Response.Headers.ContentLength = job.Payload.Length;
|
||||
context.Response.Headers["X-Content-SHA256"] = job.Sha256;
|
||||
LogAudit(context, "/graph/export/download", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
|
||||
});
|
||||
|
||||
@@ -371,15 +407,37 @@ app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphEx
|
||||
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var response = await service.GetEdgeMetadataAsync(tenant, request, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var response = await service.GetEdgeMetadataAsync(tenant!, request, ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
@@ -387,15 +445,37 @@ app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpCon
|
||||
app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var result = await service.GetSingleEdgeMetadataAsync(tenant, edgeId, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/metadata"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var result = await service.GetSingleEdgeMetadataAsync(tenant!, edgeId, ct);
|
||||
if (result is null)
|
||||
{
|
||||
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
|
||||
@@ -409,15 +489,37 @@ app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext c
|
||||
app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/path"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.GetPathEdgesWithMetadataAsync(tenant, sourceNodeId, targetNodeId, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/path"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.GetPathEdgesWithMetadataAsync(tenant!, sourceNodeId, targetNodeId, ct);
|
||||
LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() });
|
||||
});
|
||||
@@ -425,12 +527,34 @@ app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sour
|
||||
app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-reason"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<EdgeReason>(reason, ignoreCase: true, out var edgeReason))
|
||||
@@ -439,7 +563,7 @@ app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit,
|
||||
return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" });
|
||||
}
|
||||
|
||||
var response = await service.QueryByReasonAsync(tenant, edgeReason, limit ?? 100, cursor, ct);
|
||||
var response = await service.QueryByReasonAsync(tenant!, edgeReason, limit ?? 100, cursor, ct);
|
||||
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
@@ -447,15 +571,37 @@ app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit,
|
||||
app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-evidence"))
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.QueryByEvidenceAsync(tenant, evidenceType, evidenceRef, ct);
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/edges/by-evidence"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var edges = await service.QueryByEvidenceAsync(tenant!, evidenceType, evidenceRef, ct);
|
||||
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(edges);
|
||||
});
|
||||
@@ -501,3 +647,5 @@ static void LogAudit(HttpContext ctx, string route, int statusCode, long duratio
|
||||
StatusCode: statusCode,
|
||||
DurationMs: durationMs));
|
||||
}
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class EdgeMetadataEndpointsAuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public EdgeMetadataEndpointsAuthorizationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataPost_MissingAuthorization_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/edges/metadata")
|
||||
{
|
||||
Content = JsonContent.Create(new { edgeIds = new[] { "ge:acme:artifact->component" } })
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("GRAPH_UNAUTHORIZED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataPost_MissingTenant_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/edges/metadata")
|
||||
{
|
||||
Content = JsonContent.Create(new { edgeIds = new[] { "ge:acme:artifact->component" } })
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("GRAPH_VALIDATION_FAILED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataPost_MissingReadOrQueryScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/edges/metadata")
|
||||
{
|
||||
Content = JsonContent.Create(new { edgeIds = new[] { "ge:acme:artifact->component" } })
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("GRAPH_FORBIDDEN", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeByReason_MissingAuthorization_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/graph/edges/by-reason/SbomDependency");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("GRAPH_UNAUTHORIZED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgePath_ReadOnlyScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/graph/edges/path/gn:acme:artifact:sha256:abc/gn:acme:component:widget");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("GRAPH_FORBIDDEN", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeByReason_WithReadScope_ReturnsOk()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/graph/edges/by-reason/SbomDependency");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataGet_WithValidAuthUnknownEdge_ReturnsNotFound()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/graph/edges/ge:acme:missing/metadata");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Contains("EDGE_NOT_FOUND", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task EdgeMetadataGet_WithValidAuthKnownEdge_ReturnsOk()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/graph/edges/ge:acme:component-%3Ecomponent/metadata");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("depends_on", payload, StringComparison.Ordinal);
|
||||
Assert.Contains("explanation", payload, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class ExportEndpointsAuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ExportEndpointsAuthorizationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_WithValidAuthAndTenant_ReturnsFile()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (jobId, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(payload);
|
||||
Assert.True(response.Headers.Contains("X-Content-SHA256"));
|
||||
Assert.StartsWith("application/x-ndjson", response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(jobId, response.Content.Headers.ContentDisposition?.FileName ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_MissingAuthorization_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (_, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("GRAPH_UNAUTHORIZED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_WrongTenant_ReturnsNotFound()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (_, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "bravo");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Contains("GRAPH_EXPORT_NOT_FOUND", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Safety")]
|
||||
public async Task ExportDownload_MissingTenant_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var (_, downloadUrl) = await CreateExportJobAsync(client, "acme");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("GRAPH_VALIDATION_FAILED", payload, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<(string JobId, string DownloadUrl)> CreateExportJobAsync(HttpClient client, string tenant)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/export")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
format = "ndjson",
|
||||
includeEdges = true
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", tenant);
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:export");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
var root = doc.RootElement;
|
||||
var jobId = root.GetProperty("jobId").GetString();
|
||||
var downloadUrl = root.GetProperty("downloadUrl").GetString();
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(jobId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(downloadUrl));
|
||||
|
||||
return (jobId!, downloadUrl!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class QueryOverlayEndpointsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public QueryOverlayEndpointsIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task Query_WithIncludeOverlays_ReturnsPolicyAndVexOverlays()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
kinds = new[] { "component" },
|
||||
query = "widget",
|
||||
includeOverlays = true,
|
||||
includeEdges = false,
|
||||
includeStats = false,
|
||||
limit = 5
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:query");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var nodeLines = body
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(line => line.Contains("\"type\":\"node\"", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.NotEmpty(nodeLines);
|
||||
|
||||
using var nodeDoc = JsonDocument.Parse(nodeLines[0]);
|
||||
var overlays = nodeDoc.RootElement.GetProperty("data").GetProperty("overlays");
|
||||
Assert.True(overlays.TryGetProperty("policy", out _));
|
||||
Assert.True(overlays.TryGetProperty("vex", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task Query_WithIncludeOverlays_SamplesExplainTraceOncePerResponse()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
kinds = new[] { "component" },
|
||||
query = "component",
|
||||
includeOverlays = true,
|
||||
includeEdges = false,
|
||||
includeStats = false,
|
||||
limit = 10
|
||||
})
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", "graph:query");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var explainTraceCount = 0;
|
||||
var nodeLines = body
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(line => line.Contains("\"type\":\"node\"", StringComparison.Ordinal));
|
||||
|
||||
foreach (var line in nodeLines)
|
||||
{
|
||||
using var nodeDoc = JsonDocument.Parse(line);
|
||||
var data = nodeDoc.RootElement.GetProperty("data");
|
||||
if (!data.TryGetProperty("overlays", out var overlays))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var overlay in overlays.EnumerateObject())
|
||||
{
|
||||
if (!overlay.Value.TryGetProperty("data", out var payload))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!payload.TryGetProperty("explainTrace", out var trace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trace.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
explainTraceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, explainTraceCount);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Update="xunit.v3" />
|
||||
<PackageReference Update="xunit.runner.visualstudio" />
|
||||
|
||||
@@ -9,3 +9,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0351-T | DONE | Revalidated 2026-01-07; test coverage audit for Graph.Api.Tests. |
|
||||
| AUDIT-0351-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| QA-GRAPH-RECHECK-002 | DONE | SPRINT_20260210_005: endpoint auth/scope/tenant regression tests for edge metadata API added and passing. |
|
||||
| QA-GRAPH-RECHECK-004 | DONE | SPRINT_20260210_005: export download round-trip/authorization regression tests added and passing. |
|
||||
| QA-GRAPH-RECHECK-005 | DONE | SPRINT_20260210_005: query/overlay API integration tests added to validate runtime data and explain-trace behavior. |
|
||||
| QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. |
|
||||
|
||||
Reference in New Issue
Block a user