428 lines
13 KiB
C#
428 lines
13 KiB
C#
// -----------------------------------------------------------------------------
|
|
// RichGraphBoundaryExtractorTests.cs
|
|
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
|
|
// Description: Unit tests for RichGraphBoundaryExtractor.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Scanner.Reachability.Boundary;
|
|
using StellaOps.Scanner.Reachability.Gates;
|
|
using Xunit;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Scanner.Reachability.Tests;
|
|
|
|
public class RichGraphBoundaryExtractorTests
|
|
{
|
|
private readonly RichGraphBoundaryExtractor _extractor;
|
|
|
|
public RichGraphBoundaryExtractorTests()
|
|
{
|
|
_extractor = new RichGraphBoundaryExtractor(
|
|
NullLogger<RichGraphBoundaryExtractor>.Instance);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_HttpRoot_ReturnsBoundaryWithApiSurface()
|
|
{
|
|
var root = new RichGraphRoot("root-http", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "com.example.Controller.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: "POST /api/users",
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("network", result.Kind);
|
|
Assert.NotNull(result.Surface);
|
|
Assert.Equal("api", result.Surface.Type);
|
|
Assert.Equal("https", result.Surface.Protocol);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_GrpcRoot_ReturnsBoundaryWithGrpcProtocol()
|
|
{
|
|
var root = new RichGraphRoot("root-grpc", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "com.example.UserService.getUser",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "grpc_method",
|
|
Display: "UserService.GetUser",
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.Surface);
|
|
Assert.Equal("grpc", result.Surface.Protocol);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_CliRoot_ReturnsProcessBoundary()
|
|
{
|
|
var root = new RichGraphRoot("root-cli", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "Main",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "csharp",
|
|
Kind: "cli_command",
|
|
Display: "stella scan",
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("process", result.Kind);
|
|
Assert.NotNull(result.Surface);
|
|
Assert.Equal("cli", result.Surface.Type);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_LibraryPhase_ReturnsLibraryBoundary()
|
|
{
|
|
var root = new RichGraphRoot("root-lib", "library", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "Utils.parseJson",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "javascript",
|
|
Kind: "function",
|
|
Display: "parseJson",
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("library", result.Kind);
|
|
Assert.NotNull(result.Surface);
|
|
Assert.Equal("library", result.Surface.Type);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_WithAuthGate_SetsAuthRequired()
|
|
{
|
|
var root = new RichGraphRoot("root-auth", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "Controller.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var context = BoundaryExtractionContext.FromGates(new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = GateType.AuthRequired,
|
|
Detail = "JWT token required",
|
|
GuardSymbol = "AuthFilter.doFilter",
|
|
Confidence = 0.9,
|
|
DetectionMethod = "pattern_match"
|
|
}
|
|
});
|
|
|
|
var result = _extractor.Extract(root, rootNode, context);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.Auth);
|
|
Assert.True(result.Auth.Required);
|
|
Assert.Equal("jwt", result.Auth.Type);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_WithAdminGate_SetsAdminRole()
|
|
{
|
|
var root = new RichGraphRoot("root-admin", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "AdminController.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var context = BoundaryExtractionContext.FromGates(new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = GateType.AdminOnly,
|
|
Detail = "Requires admin role",
|
|
GuardSymbol = "RoleFilter.check",
|
|
Confidence = 0.85,
|
|
DetectionMethod = "annotation"
|
|
}
|
|
});
|
|
|
|
var result = _extractor.Extract(root, rootNode, context);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.Auth);
|
|
Assert.True(result.Auth.Required);
|
|
Assert.NotNull(result.Auth.Roles);
|
|
Assert.Contains("admin", result.Auth.Roles);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_WithFeatureFlagGate_AddsControl()
|
|
{
|
|
var root = new RichGraphRoot("root-ff", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "BetaFeature.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var context = BoundaryExtractionContext.FromGates(new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = GateType.FeatureFlag,
|
|
Detail = "beta_users_only",
|
|
GuardSymbol = "FeatureFlags.isEnabled",
|
|
Confidence = 0.95,
|
|
DetectionMethod = "call_analysis"
|
|
}
|
|
});
|
|
|
|
var result = _extractor.Extract(root, rootNode, context);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.Controls);
|
|
Assert.Single(result.Controls);
|
|
Assert.Equal("feature_flag", result.Controls[0].Type);
|
|
Assert.True(result.Controls[0].Active);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_WithInternetFacingContext_SetsExposure()
|
|
{
|
|
var root = new RichGraphRoot("root-public", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "PublicApi.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var context = BoundaryExtractionContext.ForEnvironment(
|
|
"production",
|
|
isInternetFacing: true,
|
|
networkZone: "dmz");
|
|
|
|
var result = _extractor.Extract(root, rootNode, context);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.Exposure);
|
|
Assert.True(result.Exposure.InternetFacing);
|
|
Assert.Equal("dmz", result.Exposure.Zone);
|
|
Assert.Equal("public", result.Exposure.Level);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_InternalService_SetsInternalExposure()
|
|
{
|
|
var root = new RichGraphRoot("root-internal", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "InternalService.process",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "internal_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.Exposure);
|
|
Assert.False(result.Exposure.InternetFacing);
|
|
Assert.Equal("internal", result.Exposure.Level);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_SetsConfidenceBasedOnContext()
|
|
{
|
|
var root = new RichGraphRoot("root-1", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "Api.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
// Empty context should have lower confidence
|
|
var emptyResult = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
// Rich context should have higher confidence
|
|
var richContext = new BoundaryExtractionContext
|
|
{
|
|
IsInternetFacing = true,
|
|
NetworkZone = "dmz",
|
|
DetectedGates = new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = GateType.AuthRequired,
|
|
Detail = "auth",
|
|
GuardSymbol = "auth",
|
|
Confidence = 0.9,
|
|
DetectionMethod = "test"
|
|
}
|
|
}
|
|
};
|
|
var richResult = _extractor.Extract(root, rootNode, richContext);
|
|
|
|
Assert.NotNull(emptyResult);
|
|
Assert.NotNull(richResult);
|
|
Assert.True(richResult.Confidence > emptyResult.Confidence);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Extract_IsDeterministic()
|
|
{
|
|
var root = new RichGraphRoot("root-det", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "Api.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: "GET /api/test",
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var context = BoundaryExtractionContext.FromGates(new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = GateType.AuthRequired,
|
|
Detail = "JWT",
|
|
GuardSymbol = "Auth",
|
|
Confidence = 0.9,
|
|
DetectionMethod = "test"
|
|
}
|
|
});
|
|
|
|
var result1 = _extractor.Extract(root, rootNode, context);
|
|
var result2 = _extractor.Extract(root, rootNode, context);
|
|
|
|
Assert.NotNull(result1);
|
|
Assert.NotNull(result2);
|
|
Assert.Equal(result1.Kind, result2.Kind);
|
|
Assert.Equal(result1.Surface?.Type, result2.Surface?.Type);
|
|
Assert.Equal(result1.Auth?.Required, result2.Auth?.Required);
|
|
Assert.Equal(result1.Confidence, result2.Confidence);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void CanHandle_AlwaysReturnsTrue()
|
|
{
|
|
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.Empty));
|
|
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.ForEnvironment("test")));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void Priority_ReturnsBaseValue()
|
|
{
|
|
Assert.Equal(100, _extractor.Priority);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ExtractAsync_ReturnsResult()
|
|
{
|
|
var root = new RichGraphRoot("root-async", "runtime", null);
|
|
var rootNode = new RichGraphNode(
|
|
Id: "node-1",
|
|
SymbolId: "Api.handle",
|
|
CodeId: null,
|
|
Purl: null,
|
|
Lang: "java",
|
|
Kind: "http_handler",
|
|
Display: null,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: null,
|
|
SymbolDigest: null);
|
|
|
|
var result = await _extractor.ExtractAsync(root, rootNode, BoundaryExtractionContext.Empty);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("network", result.Kind);
|
|
}
|
|
}
|