// Licensed to StellaOps under the BUSL-1.1 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; /// /// Integration tests for the ReachGraph Store API. /// Uses in-memory providers for quick testing without containers. /// public class ReachGraphApiIntegrationTests : IClassFixture { private readonly HttpClient _client; private const string TenantHeader = "X-Tenant-ID"; private const string TestTenant = "test-tenant"; public ReachGraphApiIntegrationTests(ReachGraphTestFactory 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(); 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(); var result2 = await response2.Content.ReadFromJsonAsync(); 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(); // Act var response = await _client.GetAsync($"/v1/reachgraphs/{upsertResult!.Digest}"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(); 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(); // 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(); 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(); // 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(); 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(); 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(); 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(); 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(); // 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 }