306 lines
11 KiB
C#
306 lines
11 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the ReachGraph Store API.
|
|
/// Uses in-memory providers for quick testing without containers.
|
|
/// </summary>
|
|
public class ReachGraphApiIntegrationTests : IClassFixture<ReachGraphTestFactory>
|
|
{
|
|
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<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
|
|
}
|