using System.Collections.Immutable; using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scanner.Reachability.Slices; using StellaOps.Scanner.WebService.Endpoints; using StellaOps.Scanner.WebService.Services; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.WebService.Tests; /// /// Integration tests for slice query and replay endpoints. /// public sealed class SliceEndpointsTests : IClassFixture { private readonly ScannerApplicationFixture _fixture; private readonly HttpClient _client; public SliceEndpointsTests(ScannerApplicationFixture fixture) { _fixture = fixture; _client = fixture.Factory.CreateClient(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task QuerySlice_WithValidCve_ReturnsSlice() { // Arrange var request = new SliceQueryRequestDto { ScanId = "test-scan-001", CveId = "CVE-2024-1234", Symbols = new List { "vulnerable_function" } }; // Act var response = await _client.PostAsJsonAsync("/api/slices/query", request); // Assert // Note: May return 404 if no test data, but validates endpoint registration Assert.True( response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Unauthorized, $"Unexpected status: {response.StatusCode}"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task QuerySlice_WithoutScanId_ReturnsBadRequest() { // Arrange var request = new SliceQueryRequestDto { CveId = "CVE-2024-1234" }; // Act var response = await _client.PostAsJsonAsync("/api/slices/query", request); // Assert Assert.True( response.StatusCode == HttpStatusCode.BadRequest || response.StatusCode == HttpStatusCode.Unauthorized, $"Expected BadRequest or Unauthorized, got {response.StatusCode}"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task QuerySlice_WithoutCveOrSymbols_ReturnsBadRequest() { // Arrange var request = new SliceQueryRequestDto { ScanId = "test-scan-001" }; // Act var response = await _client.PostAsJsonAsync("/api/slices/query", request); // Assert Assert.True( response.StatusCode == HttpStatusCode.BadRequest || response.StatusCode == HttpStatusCode.Unauthorized, $"Expected BadRequest or Unauthorized, got {response.StatusCode}"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetSlice_WithValidDigest_ReturnsSlice() { // Arrange var digest = "sha256:abc123"; // Act var response = await _client.GetAsync($"/api/slices/{digest}"); // Assert Assert.True( response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Unauthorized, $"Unexpected status: {response.StatusCode}"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetSlice_WithDsseAccept_ReturnsDsseEnvelope() { // Arrange var digest = "sha256:abc123"; var request = new HttpRequestMessage(HttpMethod.Get, $"/api/slices/{digest}"); request.Headers.Add("Accept", "application/dsse+json"); // Act var response = await _client.SendAsync(request); // Assert Assert.True( response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Unauthorized, $"Unexpected status: {response.StatusCode}"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReplaySlice_WithValidDigest_ReturnsReplayResult() { // Arrange var request = new SliceReplayRequestDto { SliceDigest = "sha256:abc123" }; // Act var response = await _client.PostAsJsonAsync("/api/slices/replay", request); // Assert Assert.True( response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Unauthorized, $"Unexpected status: {response.StatusCode}"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReplaySlice_WithoutDigest_ReturnsBadRequest() { // Arrange var request = new SliceReplayRequestDto(); // Act var response = await _client.PostAsJsonAsync("/api/slices/replay", request); // Assert Assert.True( response.StatusCode == HttpStatusCode.BadRequest || response.StatusCode == HttpStatusCode.Unauthorized, $"Expected BadRequest or Unauthorized, got {response.StatusCode}"); } } /// /// Unit tests for SliceDiffComputer. /// public sealed class SliceDiffComputerTests { private readonly SliceDiffComputer _computer = new(); [Trait("Category", TestCategories.Unit)] [Fact] public void Compare_IdenticalSlices_ReturnsMatch() { // Arrange var slice = CreateTestSlice(); // Act var result = _computer.Compare(slice, slice); // Assert Assert.True(result.Match); Assert.Empty(result.MissingNodes); Assert.Empty(result.ExtraNodes); Assert.Empty(result.MissingEdges); Assert.Empty(result.ExtraEdges); Assert.Null(result.VerdictDiff); } [Trait("Category", TestCategories.Unit)] [Fact] public void Compare_DifferentNodes_ReturnsDiff() { // Arrange var original = CreateTestSlice(); var modified = original with { Subgraph = original.Subgraph with { Nodes = original.Subgraph.Nodes.Add(new SliceNode { Id = "extra-node", Symbol = "extra_func", Kind = SliceNodeKind.Intermediate }) } }; // Act var result = _computer.Compare(original, modified); // Assert Assert.False(result.Match); Assert.Empty(result.MissingNodes); Assert.Single(result.ExtraNodes); Assert.Contains("extra-node", result.ExtraNodes); } [Trait("Category", TestCategories.Unit)] [Fact] public void Compare_DifferentEdges_ReturnsDiff() { // Arrange var original = CreateTestSlice(); var modified = original with { Subgraph = original.Subgraph with { Edges = original.Subgraph.Edges.RemoveAt(0) } }; // Act var result = _computer.Compare(original, modified); // Assert Assert.False(result.Match); Assert.Single(result.MissingEdges); } [Trait("Category", TestCategories.Unit)] [Fact] public void Compare_DifferentVerdict_ReturnsDiff() { // Arrange var original = CreateTestSlice(); var modified = original with { Verdict = original.Verdict with { Status = SliceVerdictStatus.Unreachable } }; // Act var result = _computer.Compare(original, modified); // Assert Assert.False(result.Match); Assert.NotNull(result.VerdictDiff); Assert.Contains("Status:", result.VerdictDiff); } [Trait("Category", TestCategories.Unit)] [Fact] public void ComputeCacheKey_SameInputs_ReturnsSameKey() { // Arrange var symbols = new[] { "func_a", "func_b" }; var entrypoints = new[] { "main" }; // Act var key1 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, entrypoints, null); var key2 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, entrypoints, null); // Assert Assert.Equal(key1, key2); } [Trait("Category", TestCategories.Unit)] [Fact] public void ComputeCacheKey_DifferentInputs_ReturnsDifferentKey() { // Arrange var symbols = new[] { "func_a", "func_b" }; // Act var key1 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, null, null); var key2 = SliceDiffComputer.ComputeCacheKey("scan2", "CVE-2024-1234", symbols, null, null); // Assert Assert.NotEqual(key1, key2); } [Trait("Category", TestCategories.Unit)] [Fact] public void ToSummary_MatchingSlices_ReturnsMatchMessage() { // Arrange var result = new SliceDiffResult { Match = true }; // Act var summary = result.ToSummary(); // Assert Assert.Contains("match exactly", summary); } [Trait("Category", TestCategories.Unit)] [Fact] public void ToSummary_DifferingSlices_ReturnsDetailedDiff() { // Arrange var result = new SliceDiffResult { Match = false, MissingNodes = ImmutableArray.Create("node1", "node2"), ExtraEdges = ImmutableArray.Create("edge1"), VerdictDiff = "Status: reachable -> unreachable" }; // Act var summary = result.ToSummary(); // Assert Assert.Contains("Missing nodes", summary); Assert.Contains("Extra edges", summary); Assert.Contains("Verdict changed", summary); } private static ReachabilitySlice CreateTestSlice() { return new ReachabilitySlice { Inputs = new SliceInputs { GraphDigest = "sha256:graph123" }, Query = new SliceQuery { CveId = "CVE-2024-1234", TargetSymbols = ImmutableArray.Create("vulnerable_func"), Entrypoints = ImmutableArray.Create("main") }, Subgraph = new SliceSubgraph { Nodes = ImmutableArray.Create( new SliceNode { Id = "main", Symbol = "main", Kind = SliceNodeKind.Entrypoint }, new SliceNode { Id = "vuln", Symbol = "vulnerable_func", Kind = SliceNodeKind.Target } ), Edges = ImmutableArray.Create( new SliceEdge { From = "main", To = "vuln", Kind = SliceEdgeKind.Direct, Confidence = 1.0 } ) }, Verdict = new SliceVerdict { Status = SliceVerdictStatus.Reachable, Confidence = 0.95 }, Manifest = Scanner.Core.ScanManifest.CreateBuilder("test-scan", "sha256:test") .WithConcelierSnapshot("sha256:concel") .WithExcititorSnapshot("sha256:excititor") .WithLatticePolicyHash("sha256:policy") .Build() }; } } /// /// Unit tests for SliceCache. /// public sealed class SliceCacheTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task TryGetAsync_EmptyCache_ReturnsNull() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions()); using var cache = new SliceCache(options); // Act var result = await cache.TryGetAsync("nonexistent"); // Assert Assert.Null(result); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SetAsync_ThenTryGetAsync_ReturnsEntry() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions()); using var cache = new SliceCache(options); var cacheResult = CreateTestCacheResult(); // Act await cache.SetAsync("key1", cacheResult, TimeSpan.FromMinutes(5)); var result = await cache.TryGetAsync("key1"); // Assert Assert.NotNull(result); Assert.Equal("sha256:abc123", result!.SliceDigest); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task TryGetAsync_IncrementsCacheStats() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions()); using var cache = new SliceCache(options); var cacheResult = CreateTestCacheResult(); await cache.SetAsync("key1", cacheResult, TimeSpan.FromMinutes(5)); // Act await cache.TryGetAsync("key1"); // hit await cache.TryGetAsync("missing"); // miss var stats = cache.GetStatistics(); // Assert Assert.Equal(1, stats.HitCount); Assert.Equal(1, stats.MissCount); Assert.Equal(0.5, stats.HitRate, 2); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ClearAsync_RemovesAllEntries() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions()); using var cache = new SliceCache(options); var cacheResult = CreateTestCacheResult(); await cache.SetAsync("key1", cacheResult, TimeSpan.FromMinutes(5)); await cache.SetAsync("key2", cacheResult, TimeSpan.FromMinutes(5)); // Act await cache.ClearAsync(); var stats = cache.GetStatistics(); // Assert Assert.Equal(0, stats.EntryCount); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task RemoveAsync_RemovesSpecificEntry() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions()); using var cache = new SliceCache(options); var cacheResult = CreateTestCacheResult(); await cache.SetAsync("key1", cacheResult, TimeSpan.FromMinutes(5)); await cache.SetAsync("key2", cacheResult, TimeSpan.FromMinutes(5)); // Act await cache.RemoveAsync("key1"); // Assert Assert.Null(await cache.TryGetAsync("key1")); Assert.NotNull(await cache.TryGetAsync("key2")); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Disabled_NeverCaches() { // Arrange var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions { Enabled = false }); using var cache = new SliceCache(options); var cacheResult = CreateTestCacheResult(); // Act await cache.SetAsync("key1", cacheResult, TimeSpan.FromMinutes(5)); var result = await cache.TryGetAsync("key1"); // Assert Assert.Null(result); } private static CachedSliceResult CreateTestCacheResult() { return new CachedSliceResult { SliceDigest = "sha256:abc123", Verdict = "Reachable", Confidence = 0.95, PathWitnesses = new List { "main->vuln" }, CachedAt = DateTimeOffset.UtcNow }; } }