// -----------------------------------------------------------------------------
// JavaCallGraphExtractorTests.cs
// Sprint: SPRINT_3610_0001_0001_java_callgraph (JCG-018)
// Description: Unit tests for the Java bytecode call graph extractor.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Java;
using StellaOps.Scanner.Reachability;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
///
/// Unit tests for .
/// Tests entrypoint detection, sink matching, and call graph extraction from Java bytecode.
///
public class JavaCallGraphExtractorTests
{
private readonly JavaCallGraphExtractor _extractor;
private readonly DateTimeOffset _fixedTime = DateTimeOffset.Parse("2025-12-19T00:00:00Z");
public JavaCallGraphExtractorTests()
{
var timeProvider = new FixedTimeProvider(_fixedTime);
_extractor = new JavaCallGraphExtractor(
NullLogger.Instance,
timeProvider);
}
#region JavaEntrypointClassifier Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_SpringRequestMapping_DetectedAsHttpHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserController",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "UserController.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "getUser",
Descriptor = "(Ljava/lang/Long;)Lcom/example/User;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 42,
Annotations = ["org.springframework.web.bind.annotation.GetMapping"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_SpringRestController_PublicMethodDetectedAsHttpHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserController",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["org.springframework.web.bind.annotation.RestController"],
SourceFile = "UserController.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "getAllUsers",
Descriptor = "()Ljava/util/List;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 25,
Annotations = [],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_JaxRsPath_DetectedAsHttpHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserResource",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["jakarta.ws.rs.Path"],
SourceFile = "UserResource.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "getUser",
Descriptor = "(Ljava/lang/Long;)Lcom/example/User;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 30,
Annotations = ["jakarta.ws.rs.GET"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_SpringScheduled_DetectedAsScheduledJob()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.SchedulerService",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "SchedulerService.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "processExpiredTokens",
Descriptor = "()V",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 45,
Annotations = ["org.springframework.scheduling.annotation.Scheduled"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.ScheduledJob, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_KafkaListener_DetectedAsMessageHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.OrderEventListener",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "OrderEventListener.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "handleOrderCreated",
Descriptor = "(Lcom/example/OrderEvent;)V",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 20,
Annotations = ["org.springframework.kafka.annotation.KafkaListener"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.MessageHandler, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_GrpcService_DetectedAsGrpcMethod()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserServiceGrpc",
SuperClassName = "io.grpc.stub.AbstractStub",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "UserServiceGrpc.java",
Methods = []
};
// The gRPC service annotation on method level triggers the detection
var method = new JavaMethodInfo
{
Name = "getUser",
Descriptor = "(Lcom/example/GetUserRequest;)Lcom/example/GetUserResponse;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 50,
Annotations = ["net.devh.boot.grpc.server.service.GrpcService"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.GrpcMethod, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_MainMethod_DetectedAsCliCommand()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.Application",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "Application.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "main",
Descriptor = "([Ljava/lang/String;)V",
AccessFlags = JavaAccessFlags.Public | JavaAccessFlags.Static,
LineNumber = 10,
Annotations = [],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.CliCommand, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_PrivateMethod_NotDetectedAsEntrypoint()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserController",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["org.springframework.web.bind.annotation.RestController"],
SourceFile = "UserController.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "validateUser",
Descriptor = "(Lcom/example/User;)Z",
AccessFlags = JavaAccessFlags.Private,
LineNumber = 100,
Annotations = [],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Null(result);
}
#endregion
#region JavaSinkMatcher Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_RuntimeExec_DetectedAsCmdExec()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_ProcessBuilderInit_DetectedAsCmdExec()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.lang.ProcessBuilder", "", "()V");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_StatementExecute_DetectedAsSqlRaw()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.sql.Statement", "executeQuery", "(Ljava/lang/String;)Ljava/sql/ResultSet;");
Assert.Equal(SinkCategory.SqlRaw, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_ObjectInputStream_DetectedAsUnsafeDeser()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.io.ObjectInputStream", "readObject", "()Ljava/lang/Object;");
Assert.Equal(SinkCategory.UnsafeDeser, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_HttpClientExecute_DetectedAsSsrf()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("org.apache.http.client.HttpClient", "execute", "(Lorg/apache/http/HttpHost;Lorg/apache/http/HttpRequest;)Lorg/apache/http/HttpResponse;");
Assert.Equal(SinkCategory.Ssrf, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_FileWriter_DetectedAsPathTraversal()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.io.FileWriter", "", "(Ljava/lang/String;)V");
Assert.Equal(SinkCategory.PathTraversal, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_UnknownMethod_ReturnsNull()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("com.example.MyService", "doSomething", "()V");
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_XxeVulnerableParsing_DetectedAsXxe()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("javax.xml.parsers.DocumentBuilderFactory", "newInstance", "()Ljavax/xml/parsers/DocumentBuilderFactory;");
Assert.Equal(SinkCategory.XxeInjection, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_ScriptEngineEval_DetectedAsCodeInjection()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("javax.script.ScriptEngine", "eval", "(Ljava/lang/String;)Ljava/lang/Object;");
Assert.Equal(SinkCategory.CodeInjection, result);
}
#endregion
#region JavaBytecodeAnalyzer Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaBytecodeAnalyzer_ValidClassHeader_Parsed()
{
var analyzer = new JavaBytecodeAnalyzer(NullLogger.Instance);
// Minimal valid Java class file header
// Magic: 0xCAFEBABE
// Minor: 0, Major: 52 (Java 8)
// Constant pool count: 1 (minimal)
var classData = new byte[]
{
0xCA, 0xFE, 0xBA, 0xBE, // magic
0x00, 0x00, // minor version
0x00, 0x34, // major version (52 = Java 8)
// This is incomplete but tests the magic number check
};
// The parser should handle incomplete class files gracefully
var result = analyzer.ParseClass(classData);
// May return null or partial result for incomplete class
// The important thing is it doesn't throw
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaBytecodeAnalyzer_InvalidMagic_ReturnsNull()
{
var analyzer = new JavaBytecodeAnalyzer(NullLogger.Instance);
var invalidData = new byte[] { 0x00, 0x00, 0x00, 0x00 };
var result = analyzer.ParseClass(invalidData);
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaBytecodeAnalyzer_EmptyArray_ReturnsNull()
{
var analyzer = new JavaBytecodeAnalyzer(NullLogger.Instance);
var result = analyzer.ParseClass([]);
Assert.Null(result);
}
#endregion
#region Integration Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractAsync_InvalidPath_ThrowsFileNotFound()
{
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "java",
TargetPath: "/nonexistent/path/to/jar");
await Assert.ThrowsAsync(
() => _extractor.ExtractAsync(request));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractAsync_WrongLanguage_ThrowsArgumentException()
{
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "python", // Wrong language
TargetPath: temp.Path);
await Assert.ThrowsAsync(
() => _extractor.ExtractAsync(request));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractAsync_EmptyDirectory_ProducesEmptySnapshot()
{
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "java",
TargetPath: temp.Path);
var snapshot = await _extractor.ExtractAsync(request);
Assert.Equal("scan-001", snapshot.ScanId);
Assert.Equal("java", snapshot.Language);
Assert.NotNull(snapshot.GraphDigest);
Assert.Empty(snapshot.Nodes);
Assert.Empty(snapshot.Edges);
Assert.Empty(snapshot.EntrypointIds);
Assert.Empty(snapshot.SinkIds);
Assert.Equal(_fixedTime, snapshot.ExtractedAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Extractor_Language_IsJava()
{
Assert.Equal("java", _extractor.Language);
}
#endregion
#region Determinism Verification Tests (JCG-020)
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractAsync_SamePath_ProducesSameDigest()
{
// Arrange: Create a temp directory
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-determinism-1",
Language: "java",
TargetPath: temp.Path);
// Act: Extract twice with same input
var snapshot1 = await _extractor.ExtractAsync(request);
var snapshot2 = await _extractor.ExtractAsync(request);
// Assert: Same digest
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExtractAsync_DifferentScanId_SameNodesAndEdges()
{
// Arrange: Create a temp directory
await using var temp = await TempDirectory.CreateAsync();
using StellaOps.TestKit;
var request1 = new CallGraphExtractionRequest(
ScanId: "scan-a",
Language: "java",
TargetPath: temp.Path);
var request2 = new CallGraphExtractionRequest(
ScanId: "scan-b",
Language: "java",
TargetPath: temp.Path);
// Act: Extract with different scan IDs
var snapshot1 = await _extractor.ExtractAsync(request1);
var snapshot2 = await _extractor.ExtractAsync(request2);
// Assert: Same graph content (nodes, edges, digests match)
Assert.Equal(snapshot1.Nodes.Length, snapshot2.Nodes.Length);
Assert.Equal(snapshot1.Edges.Length, snapshot2.Edges.Length);
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildNodeId_SameInputs_ProducesIdenticalIds()
{
// Act: Build node IDs multiple times with same inputs
var id1 = BuildTestNodeId("com.example.Service", "doWork", "(Ljava/lang/String;)V");
var id2 = BuildTestNodeId("com.example.Service", "doWork", "(Ljava/lang/String;)V");
// Assert: Identical
Assert.Equal(id1, id2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildNodeId_DifferentDescriptors_ProducesDifferentIds()
{
// Act: Build node IDs with different descriptors (overloaded methods)
var id1 = BuildTestNodeId("com.example.Service", "process", "(Ljava/lang/String;)V");
var id2 = BuildTestNodeId("com.example.Service", "process", "(I)V");
// Assert: Different (handles overloading)
Assert.NotEqual(id1, id2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaEntrypointClassifier_SameInput_AlwaysSameResult()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.Controller",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["org.springframework.web.bind.annotation.RestController"],
SourceFile = "Controller.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "handleRequest",
Descriptor = "()V",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 10,
Annotations = [],
Calls = []
};
// Act: Classify multiple times
var result1 = classifier.Classify(classInfo, method);
var result2 = classifier.Classify(classInfo, method);
var result3 = classifier.Classify(classInfo, method);
// Assert: All results identical
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void JavaSinkMatcher_SameInput_AlwaysSameResult()
{
var matcher = new JavaSinkMatcher();
// Act: Match multiple times
var result1 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
var result2 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
var result3 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
// Assert: All results identical
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
Assert.Equal(SinkCategory.CmdExec, result1);
}
///
/// Helper to match the internal BuildNodeId logic for testing.
///
private static string BuildTestNodeId(string className, string methodName, string descriptor)
{
return $"java:{className}.{methodName}{descriptor}";
}
#endregion
#region Helpers
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _instant;
public FixedTimeProvider(DateTimeOffset instant)
{
_instant = instant;
}
public override DateTimeOffset GetUtcNow() => _instant;
}
private sealed class TempDirectory : IAsyncDisposable
{
public string Path { get; }
private TempDirectory(string path)
{
Path = path;
}
public static Task CreateAsync()
{
var root = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"stella_java_callgraph_{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
return Task.FromResult(new TempDirectory(root));
}
public ValueTask DisposeAsync()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
return ValueTask.CompletedTask;
}
}
#endregion
}