Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.WebService.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the ReachGraph Store API.
|
||||
/// Uses in-memory providers for quick testing without containers.
|
||||
/// </summary>
|
||||
public class ReachGraphApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private const string TenantHeader = "X-Tenant-ID";
|
||||
private const string TestTenant = "test-tenant";
|
||||
|
||||
public ReachGraphApiIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add(TenantHeader, TestTenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ValidGraph_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var request = new UpsertReachGraphRequest { Graph = graph };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/v1/reachgraphs", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Created);
|
||||
Assert.StartsWith("blake3:", result.Digest);
|
||||
Assert.Equal(graph.Nodes.Length, result.NodeCount);
|
||||
Assert.Equal(graph.Edges.Length, result.EdgeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_SameGraph_ReturnsOk()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var request = new UpsertReachGraphRequest { Graph = graph };
|
||||
|
||||
// Act - upsert twice
|
||||
var response1 = await _client.PostAsJsonAsync("/v1/reachgraphs", request);
|
||||
var response2 = await _client.PostAsJsonAsync("/v1/reachgraphs", request);
|
||||
|
||||
// Assert - first is Created, second is OK (idempotent)
|
||||
Assert.Equal(HttpStatusCode.Created, response1.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
|
||||
|
||||
var result1 = await response1.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
var result2 = await response2.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
|
||||
Assert.True(result1!.Created);
|
||||
Assert.False(result2!.Created);
|
||||
Assert.Equal(result1.Digest, result2.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigest_ExistingGraph_ReturnsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
|
||||
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
|
||||
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/reachgraphs/{upsertResult!.Digest}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ReachGraphMinimal>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(graph.SchemaVersion, result.SchemaVersion);
|
||||
Assert.Equal(graph.Artifact.Name, result.Artifact.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigest_NonExisting_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistingDigest = "blake3:0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/reachgraphs/{nonExistingDigest}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SliceByCve_ReturnsRelevantNodes()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateGraphWithCve();
|
||||
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
|
||||
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
|
||||
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/reachgraphs/{upsertResult!.Digest}/slice?cve=CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<CveSliceResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cve", result.SliceQuery.Type);
|
||||
Assert.Equal("CVE-2024-1234", result.SliceQuery.Cve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SliceByPackage_ReturnsConnectedNodes()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
|
||||
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
|
||||
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(
|
||||
$"/v1/reachgraphs/{upsertResult!.Digest}/slice?q=pkg:npm/*&depth=2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<SliceQueryResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("package", result.SliceQuery.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replay_MatchingInputs_ReturnsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
|
||||
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
|
||||
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
|
||||
var replayRequest = new ReplayRequest
|
||||
{
|
||||
ExpectedDigest = upsertResult!.Digest,
|
||||
Inputs = new ReplayInputs
|
||||
{
|
||||
Sbom = graph.Provenance.Inputs.Sbom,
|
||||
Vex = graph.Provenance.Inputs.Vex,
|
||||
Callgraph = graph.Provenance.Inputs.Callgraph
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/v1/reachgraphs/replay", replayRequest);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ReplayResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Match);
|
||||
Assert.Equal(upsertResult.Digest, result.ComputedDigest);
|
||||
Assert.True(result.InputsVerified?.Sbom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByArtifact_ReturnsSubgraphs()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
|
||||
await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(
|
||||
$"/v1/reachgraphs/by-artifact/{Uri.EscapeDataString(graph.Artifact.Digest)}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<ListByArtifactResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.TotalCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ExistingGraph_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSampleGraph();
|
||||
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
|
||||
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
|
||||
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync($"/v1/reachgraphs/{upsertResult!.Digest}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
|
||||
// Verify it's gone
|
||||
var getResponse = await _client.GetAsync($"/v1/reachgraphs/{upsertResult.Digest}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ReachGraphMinimal CreateSampleGraph() => new()
|
||||
{
|
||||
SchemaVersion = "reachgraph.min@v1",
|
||||
Artifact = new ReachGraphArtifact(
|
||||
"test-app:v1.0.0",
|
||||
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abc1",
|
||||
["linux/amd64"]),
|
||||
Scope = new ReachGraphScope(
|
||||
["/app/main"],
|
||||
["prod"]),
|
||||
Nodes =
|
||||
[
|
||||
new ReachGraphNode
|
||||
{
|
||||
Id = "sha256:entry1",
|
||||
Kind = ReachGraphNodeKind.Function,
|
||||
Ref = "main()",
|
||||
File = "src/main.ts",
|
||||
Line = 1,
|
||||
IsEntrypoint = true
|
||||
},
|
||||
new ReachGraphNode
|
||||
{
|
||||
Id = "sha256:pkg1",
|
||||
Kind = ReachGraphNodeKind.Package,
|
||||
Ref = "pkg:npm/lodash@4.17.21"
|
||||
},
|
||||
new ReachGraphNode
|
||||
{
|
||||
Id = "sha256:sink1",
|
||||
Kind = ReachGraphNodeKind.Function,
|
||||
Ref = "lodash.template()",
|
||||
File = "node_modules/lodash/template.js",
|
||||
Line = 100,
|
||||
IsSink = true
|
||||
}
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new ReachGraphEdge
|
||||
{
|
||||
From = "sha256:entry1",
|
||||
To = "sha256:pkg1",
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.Import,
|
||||
Loc = "src/main.ts:3",
|
||||
Confidence = 1.0
|
||||
}
|
||||
},
|
||||
new ReachGraphEdge
|
||||
{
|
||||
From = "sha256:pkg1",
|
||||
To = "sha256:sink1",
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = EdgeExplanationType.Import,
|
||||
Confidence = 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Inputs = new ReachGraphInputs
|
||||
{
|
||||
Sbom = "sha256:sbom123abc456def789",
|
||||
Vex = "sha256:vex456def789abc123",
|
||||
Callgraph = "sha256:cg789abc123def456"
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
Analyzer = new ReachGraphAnalyzer(
|
||||
"stellaops-scanner",
|
||||
"1.0.0",
|
||||
"sha256:toolchain123456789")
|
||||
}
|
||||
};
|
||||
|
||||
private static ReachGraphMinimal CreateGraphWithCve() => CreateSampleGraph() with
|
||||
{
|
||||
Scope = new ReachGraphScope(
|
||||
["/app/main"],
|
||||
["prod"],
|
||||
["CVE-2024-1234"])
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user