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
};
}
}