693 lines
22 KiB
C#
693 lines
22 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="JavaCallGraphExtractor"/>.
|
|
/// Tests entrypoint detection, sink matching, and call graph extraction from Java bytecode.
|
|
/// </summary>
|
|
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<JavaCallGraphExtractor>.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", "<init>", "()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", "<init>", "(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<JavaCallGraphExtractor>.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<JavaCallGraphExtractor>.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<JavaCallGraphExtractor>.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<FileNotFoundException>(
|
|
() => _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<ArgumentException>(
|
|
() => _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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper to match the internal BuildNodeId logic for testing.
|
|
/// </summary>
|
|
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<TempDirectory> 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
|
|
}
|