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