502 lines
15 KiB
C#
502 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Integration tests for slice query and replay endpoints.
|
|
/// </summary>
|
|
public sealed class SliceEndpointsTests : IClassFixture<ScannerApplicationFixture>
|
|
{
|
|
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<string> { "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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unit tests for SliceDiffComputer.
|
|
/// </summary>
|
|
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()
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unit tests for SliceCache.
|
|
/// </summary>
|
|
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<string> { "main->vuln" },
|
|
CachedAt = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
}
|