feat: add AI-based security suggestion engine and CLI integration
Introduce new package org.egothor.methodatlas.ai providing AI-assisted classification of JUnit tests for security relevance. Key changes: - add AI suggestion engine, provider abstraction, and provider clients (OpenAI-compatible, Ollama, Anthropic) - implement strict JSON prompt/response contract and taxonomy handling - integrate AI enrichment into MethodAtlas CLI output (CSV and plain modes) - add configuration via AiOptions and CLI flags - add comprehensive JUnit + Mockito test coverage for AI components and CLI integration scenarios - add realistic test fixtures for security-related test classes - update Gradle configuration for Mockito agent support on JDK 21+ - provide complete Javadoc for the AI module The AI layer is optional and degrades gracefully when providers are unavailable or responses fail.
This commit is contained in:
396
src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java
Normal file
396
src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java
Normal file
@@ -0,0 +1,396 @@
|
||||
package org.egothor.methodatlas;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.egothor.methodatlas.ai.AiClassSuggestion;
|
||||
import org.egothor.methodatlas.ai.AiMethodSuggestion;
|
||||
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
|
||||
import org.egothor.methodatlas.ai.AiSuggestionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
class MethodAtlasAppAiTest {
|
||||
|
||||
@Test
|
||||
void csvMode_aiEnabled_withRealisticFixtures_emitsMergedAiSuggestions(@TempDir Path tempDir) throws Exception {
|
||||
copyAllFixtures(tempDir);
|
||||
|
||||
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class,
|
||||
(mock, context) -> {
|
||||
when(mock.suggestForClass(eq("com.acme.tests.SampleOneTest"), anyString()))
|
||||
.thenReturn(sampleOneSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString()))
|
||||
.thenReturn(anotherSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString()))
|
||||
.thenReturn(accessControlSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString()))
|
||||
.thenReturn(pathTraversalSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString()))
|
||||
.thenReturn(auditLoggingSuggestion());
|
||||
})) {
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { "-ai", tempDir.toString() });
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
|
||||
assertEquals(18, lines.size(), "Expected header + 17 method rows across 5 fixtures");
|
||||
assertEquals("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason", lines.get(0));
|
||||
|
||||
Map<String, List<String>> rows = parseCsvAiRows(lines);
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.security.AccessControlServiceTest", "shouldRejectUnauthenticatedRequest",
|
||||
"security;authn", "true", "SECURITY: authentication - reject unauthenticated request",
|
||||
"security;auth;access-control",
|
||||
"The test verifies that anonymous access to a protected operation is rejected.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.storage.PathTraversalValidationTest",
|
||||
"shouldRejectRelativePathTraversalSequence", "security;validation", "true",
|
||||
"SECURITY: input validation - reject path traversal sequence", "security;input-validation;owasp",
|
||||
"The test rejects a classic parent-directory traversal payload.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.audit.AuditLoggingTest", "shouldNotLogRawBearerToken", "security;logging",
|
||||
"true", "SECURITY: logging - redact bearer token", "security;logging",
|
||||
"The test ensures that sensitive bearer tokens are redacted before logging.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.audit.AuditLoggingTest", "shouldFormatHumanReadableSupportMessage", "",
|
||||
"false", "", "", "The test is functional formatting coverage and is not security-specific.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.tests.SampleOneTest", "alpha", "fast;crypto", "true",
|
||||
"SECURITY: crypto - validates encrypted happy path", "security;crypto",
|
||||
"The test exercises a crypto-related security property.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.tests.SampleOneTest", "beta", "param", "", "", "", "");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.other.AnotherTest", "delta", "", "false", "", "",
|
||||
"The repeated test is not security-specific.");
|
||||
|
||||
assertFalse(rows.containsKey("com.acme.tests.SampleOneTest#ghostMethod"),
|
||||
"AI-only invented methods must not appear in CLI output");
|
||||
|
||||
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainMode_aiFailureForOneClass_continuesScanningAndFallsBackForThatClass(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
copyAllFixtures(tempDir);
|
||||
|
||||
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class,
|
||||
(mock, context) -> {
|
||||
when(mock.suggestForClass(eq("com.acme.tests.SampleOneTest"), anyString()))
|
||||
.thenReturn(sampleOneSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString()))
|
||||
.thenReturn(anotherSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString()))
|
||||
.thenThrow(new AiSuggestionException("Simulated provider failure"));
|
||||
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString()))
|
||||
.thenReturn(pathTraversalSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString()))
|
||||
.thenReturn(auditLoggingSuggestion());
|
||||
})) {
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { "-plain", "-ai", tempDir.toString() });
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
|
||||
assertEquals(17, lines.size(), "Expected one plain output line per discovered test method");
|
||||
|
||||
String failedClassLine = findLineContaining(lines,
|
||||
"com.acme.security.AccessControlServiceTest, shouldRejectUnauthenticatedRequest,");
|
||||
assertTrue(failedClassLine.contains("AI_SECURITY=-"));
|
||||
assertTrue(failedClassLine.contains("AI_DISPLAY=-"));
|
||||
assertTrue(failedClassLine.contains("AI_TAGS=-"));
|
||||
assertTrue(failedClassLine.contains("AI_REASON=-"));
|
||||
|
||||
String unaffectedLine = findLineContaining(lines,
|
||||
"com.acme.storage.PathTraversalValidationTest, shouldRejectRelativePathTraversalSequence,");
|
||||
assertTrue(unaffectedLine.contains("AI_SECURITY=true"));
|
||||
assertTrue(
|
||||
unaffectedLine.contains("AI_DISPLAY=SECURITY: input validation - reject path traversal sequence"));
|
||||
assertTrue(unaffectedLine.contains("AI_TAGS=security;input-validation;owasp"));
|
||||
|
||||
String nonSecurityLine = findLineContaining(lines,
|
||||
"com.acme.audit.AuditLoggingTest, shouldFormatHumanReadableSupportMessage,");
|
||||
assertTrue(nonSecurityLine.contains("AI_SECURITY=false"));
|
||||
assertTrue(nonSecurityLine.contains("AI_DISPLAY=-"));
|
||||
assertTrue(nonSecurityLine.contains("AI_TAGS=-"));
|
||||
assertTrue(nonSecurityLine
|
||||
.contains("AI_REASON=The test is functional formatting coverage and is not security-specific."));
|
||||
|
||||
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void csvMode_oversizedClass_skipsAiLookup_andLeavesAiColumnsEmpty(@TempDir Path tempDir) throws Exception {
|
||||
writeOversizedFixture(tempDir);
|
||||
|
||||
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class)) {
|
||||
String output = runAppCapturingStdout(
|
||||
new String[] { "-ai", "-ai-max-class-chars", "10", tempDir.toString() });
|
||||
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
|
||||
assertEquals(2, lines.size(), "Expected header + 1 emitted method row");
|
||||
assertEquals("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason", lines.get(0));
|
||||
|
||||
Map<String, List<String>> rows = parseCsvAiRows(lines);
|
||||
List<String> row = rows.get("com.acme.big.HugeAiSkipTest#hugeSecurityTest");
|
||||
|
||||
assertNotNull(row, "Missing oversized-class row");
|
||||
assertEquals(8, row.size());
|
||||
assertEquals("security", row.get(3));
|
||||
assertEquals("", row.get(4));
|
||||
assertEquals("", row.get(5));
|
||||
assertEquals("", row.get(6));
|
||||
assertEquals("", row.get(7));
|
||||
|
||||
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
|
||||
verify(mocked.constructed().get(0), never()).suggestForClass(anyString(), anyString());
|
||||
}
|
||||
}
|
||||
|
||||
private static AiClassSuggestion sampleOneSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.tests.SampleOneTest", true, List.of("security", "crypto"),
|
||||
"Class contains crypto-related security coverage.",
|
||||
List.of(new AiMethodSuggestion("alpha", true, "SECURITY: crypto - validates encrypted happy path",
|
||||
List.of("security", "crypto"), "The test exercises a crypto-related security property."),
|
||||
new AiMethodSuggestion("ghostMethod", true, "SECURITY: invented - should never appear",
|
||||
List.of("security"), "This invented method must not be emitted by the CLI.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion anotherSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.other.AnotherTest", false, List.of(), "Class is not security-relevant.",
|
||||
List.of(new AiMethodSuggestion("delta", false, null, List.of(),
|
||||
"The repeated test is not security-specific.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion accessControlSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.security.AccessControlServiceTest", true,
|
||||
List.of("security", "access-control"), "Class verifies authorization and authentication controls.",
|
||||
List.of(new AiMethodSuggestion("shouldAllowOwnerToReadOwnStatement", true,
|
||||
"SECURITY: access control - allow owner access", List.of("security", "access-control"),
|
||||
"The test verifies that the resource owner is granted access."),
|
||||
new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
|
||||
"SECURITY: access control - allow administrator access",
|
||||
List.of("security", "access-control"),
|
||||
"The test verifies privileged administrative access."),
|
||||
new AiMethodSuggestion("shouldDenyForeignUserFromReadingAnotherUsersStatement", true,
|
||||
"SECURITY: access control - deny foreign user access",
|
||||
List.of("security", "access-control"),
|
||||
"The test verifies that one user cannot access another user's statement."),
|
||||
new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
|
||||
"SECURITY: authentication - reject unauthenticated request",
|
||||
List.of("security", "auth", "access-control"),
|
||||
"The test verifies that anonymous access to a protected operation is rejected."),
|
||||
new AiMethodSuggestion("shouldRenderFriendlyAccountLabel", false, null, List.of(),
|
||||
"The test is purely presentational and not security-specific.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion pathTraversalSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest", true,
|
||||
List.of("security", "input-validation"), "Class validates filesystem input handling.",
|
||||
List.of(new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
|
||||
"SECURITY: input validation - reject path traversal sequence",
|
||||
List.of("security", "input-validation", "owasp"),
|
||||
"The test rejects a classic parent-directory traversal payload."),
|
||||
new AiMethodSuggestion("shouldRejectNestedTraversalAfterNormalization", true,
|
||||
"SECURITY: input validation - block normalized root escape",
|
||||
List.of("security", "input-validation", "owasp"),
|
||||
"The test verifies that normalized traversal cannot escape the upload root."),
|
||||
new AiMethodSuggestion("shouldAllowSafePathInsideUploadRoot", true,
|
||||
"SECURITY: input validation - allow safe normalized path",
|
||||
List.of("security", "input-validation"),
|
||||
"The test verifies that a normalized in-root path is accepted."),
|
||||
new AiMethodSuggestion("shouldBuildDownloadFileName", false, null, List.of(),
|
||||
"The test only formats a filename and is not security-specific.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion auditLoggingSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.audit.AuditLoggingTest", true, List.of("security", "logging"),
|
||||
"Class verifies security-relevant logging and audit behavior.",
|
||||
List.of(new AiMethodSuggestion("shouldWriteAuditEventForPrivilegeChange", true,
|
||||
"SECURITY: logging - audit privilege change", List.of("security", "logging"),
|
||||
"The test verifies audit logging of a privileged security action."),
|
||||
new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
|
||||
"SECURITY: logging - redact bearer token", List.of("security", "logging"),
|
||||
"The test ensures that sensitive bearer tokens are redacted before logging."),
|
||||
new AiMethodSuggestion("shouldNotLogPlaintextPasswordOnAuthenticationFailure", true,
|
||||
"SECURITY: logging - avoid plaintext password disclosure",
|
||||
List.of("security", "logging"),
|
||||
"The test verifies that plaintext passwords are not written to logs."),
|
||||
new AiMethodSuggestion("shouldFormatHumanReadableSupportMessage", false, null, List.of(),
|
||||
"The test is functional formatting coverage and is not security-specific.")));
|
||||
}
|
||||
|
||||
private static List<String> parseCsvFields(String line) {
|
||||
List<String> out = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
|
||||
boolean inQuotes = false;
|
||||
int i = 0;
|
||||
while (i < line.length()) {
|
||||
char ch = line.charAt(i);
|
||||
|
||||
if (inQuotes) {
|
||||
if (ch == '\"') {
|
||||
if (i + 1 < line.length() && line.charAt(i + 1) == '\"') {
|
||||
current.append('\"');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current.append(ch);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == ',') {
|
||||
out.add(current.toString());
|
||||
current.setLength(0);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current.append(ch);
|
||||
i++;
|
||||
}
|
||||
|
||||
out.add(current.toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void copyAllFixtures(Path tempDir) throws IOException {
|
||||
copyFixtures(tempDir, "SampleOneTest.java", "AnotherTest.java", "AccessControlServiceTest.java",
|
||||
"PathTraversalValidationTest.java", "AuditLoggingTest.java");
|
||||
}
|
||||
|
||||
private static void copyFixtures(Path tempDir, String... fixtureFileNames) throws IOException {
|
||||
for (String fixtureFileName : fixtureFileNames) {
|
||||
copyFixture(tempDir, fixtureFileName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyFixture(Path destDir, String fixtureFileName) throws IOException {
|
||||
String resourcePath = "/fixtures/" + fixtureFileName + ".txt";
|
||||
try (InputStream in = MethodAtlasAppAiTest.class.getResourceAsStream(resourcePath)) {
|
||||
assertNotNull(in, "Missing test resource: " + resourcePath);
|
||||
Files.copy(in, destDir.resolve(fixtureFileName));
|
||||
}
|
||||
}
|
||||
|
||||
private static String runAppCapturingStdout(String[] args) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PrintStream previous = System.out;
|
||||
|
||||
try (PrintStream ps = new PrintStream(baos, true, StandardCharsets.UTF_8)) {
|
||||
System.setOut(ps);
|
||||
MethodAtlasApp.main(args);
|
||||
} finally {
|
||||
System.setOut(previous);
|
||||
}
|
||||
|
||||
return baos.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static List<String> nonEmptyLines(String text) {
|
||||
String[] parts = text.split("\\R");
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (String part : parts) {
|
||||
String trimmed = part.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
lines.add(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String findLineContaining(List<String> lines, String fragment) {
|
||||
for (String line : lines) {
|
||||
if (line.contains(fragment)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Missing line containing: " + fragment);
|
||||
}
|
||||
|
||||
private static void writeOversizedFixture(Path tempDir) throws IOException {
|
||||
StringBuilder repeated = new StringBuilder();
|
||||
for (int i = 0; i < 100; i++) {
|
||||
repeated.append(" String s").append(i).append(" = \"padding").append(i).append("\";\n");
|
||||
}
|
||||
|
||||
String source = """
|
||||
package com.acme.big;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HugeAiSkipTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
void hugeSecurityTest() {
|
||||
""" + repeated + """
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
Files.writeString(tempDir.resolve("HugeAiSkipTest.java"), source, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void assertAiCsvRow(Map<String, List<String>> rows, String fqcn, String method,
|
||||
String expectedTagsText, String expectedAiSecurityRelevant, String expectedAiDisplayName,
|
||||
String expectedAiTagsText, String expectedAiReason) {
|
||||
|
||||
List<String> fields = rows.get(fqcn + "#" + method);
|
||||
assertNotNull(fields, "Missing row for " + fqcn + "#" + method);
|
||||
|
||||
assertEquals(8, fields.size(), "Expected 8 CSV fields for " + fqcn + "#" + method);
|
||||
assertEquals(expectedTagsText, fields.get(3), "Source tags mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiSecurityRelevant, fields.get(4), "AI security flag mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiDisplayName, fields.get(5), "AI display name mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiTagsText, fields.get(6), "AI tags mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiReason, fields.get(7), "AI reason mismatch for " + fqcn + "#" + method);
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> parseCsvAiRows(List<String> lines) {
|
||||
Map<String, List<String>> rows = new HashMap<>();
|
||||
for (int i = 1; i < lines.size(); i++) {
|
||||
List<String> fields = parseCsvFields(lines.get(i));
|
||||
assertEquals(8, fields.size(), "Expected 8 CSV fields, got " + fields.size() + " from: " + lines.get(i));
|
||||
rows.put(fields.get(0) + "#" + fields.get(1), fields);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -22,25 +22,38 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* End-to-end tests for {@link MethodAtlasApp} output formats (CSV default,
|
||||
* -plain).
|
||||
* Baseline end-to-end regression tests for {@link MethodAtlasApp} output
|
||||
* formats with AI enrichment disabled.
|
||||
*
|
||||
* <p>
|
||||
* These tests copy predefined Java fixture files from
|
||||
* src/test/resources/fixtures into a temporary directory and run
|
||||
* MethodAtlasApp.main(...) against that directory, asserting the detected
|
||||
* methods, LOC, and extracted @Tag values.
|
||||
* {@code src/test/resources/fixtures} into a temporary directory and execute
|
||||
* {@link MethodAtlasApp#main(String[])} against that directory. The assertions
|
||||
* verify the stable non-AI contract of the application:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>detected test methods</li>
|
||||
* <li>inclusive method LOC values</li>
|
||||
* <li>extracted JUnit {@code @Tag} values, including nested {@code @Tags}</li>
|
||||
* <li>CSV and plain-text rendering behavior</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* AI-specific behavior should be tested separately once a dedicated injection
|
||||
* seam exists for supplying a mocked
|
||||
* {@code org.egothor.methodatlas.ai.AiSuggestionEngine}.
|
||||
* </p>
|
||||
*/
|
||||
public class MethodAtlasAppTest {
|
||||
|
||||
@Test
|
||||
public void csvMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
|
||||
copyFixture(tempDir, "SampleOneTest.java");
|
||||
copyFixture(tempDir, "AnotherTest.java");
|
||||
copyStandardFixtures(tempDir);
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { tempDir.toString() });
|
||||
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
assertTrue(lines.size() >= 3, "Expected header + at least 2 records, got: " + lines.size());
|
||||
assertEquals(18, lines.size(), "Expected header + 17 records");
|
||||
|
||||
assertEquals("fqcn,method,loc,tags", lines.get(0));
|
||||
|
||||
@@ -58,13 +71,12 @@ public class MethodAtlasAppTest {
|
||||
|
||||
@Test
|
||||
public void plainMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
|
||||
copyFixture(tempDir, "SampleOneTest.java");
|
||||
copyFixture(tempDir, "AnotherTest.java");
|
||||
copyStandardFixtures(tempDir);
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { "-plain", tempDir.toString() });
|
||||
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
assertTrue(lines.size() >= 4, "Expected at least 4 method lines, got: " + lines.size());
|
||||
assertEquals(17, lines.size(), "Expected 17 method lines");
|
||||
|
||||
Map<String, PlainRow> rows = new HashMap<>();
|
||||
for (String line : lines) {
|
||||
@@ -78,6 +90,14 @@ public class MethodAtlasAppTest {
|
||||
assertPlainRow(rows, "com.acme.other.AnotherTest", "delta", 3, "-");
|
||||
}
|
||||
|
||||
private static void copyStandardFixtures(Path tempDir) throws IOException {
|
||||
copyFixture(tempDir, "SampleOneTest.java");
|
||||
copyFixture(tempDir, "AnotherTest.java");
|
||||
copyFixture(tempDir, "AccessControlServiceTest.java");
|
||||
copyFixture(tempDir, "PathTraversalValidationTest.java");
|
||||
copyFixture(tempDir, "AuditLoggingTest.java");
|
||||
}
|
||||
|
||||
private static void assertCsvRow(Map<String, CsvRow> rows, String fqcn, String method, int expectedLoc,
|
||||
List<String> expectedTags) {
|
||||
|
||||
@@ -239,4 +259,4 @@ public class MethodAtlasAppTest {
|
||||
private int loc;
|
||||
private String tagsText;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java
Normal file
187
src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java
Normal file
@@ -0,0 +1,187 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AiOptionsTest {
|
||||
|
||||
@Test
|
||||
void builder_defaults_areStableAndValid() {
|
||||
AiOptions options = AiOptions.builder().build();
|
||||
|
||||
assertEquals(false, options.enabled());
|
||||
assertEquals(AiProvider.AUTO, options.provider());
|
||||
assertEquals("qwen2.5-coder:7b", options.modelName());
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
assertNull(options.apiKey());
|
||||
assertNull(options.apiKeyEnv());
|
||||
assertNull(options.taxonomyFile());
|
||||
assertEquals(AiOptions.TaxonomyMode.DEFAULT, options.taxonomyMode());
|
||||
assertEquals(40_000, options.maxClassChars());
|
||||
assertEquals(Duration.ofSeconds(90), options.timeout());
|
||||
assertEquals(1, options.maxRetries());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesOllamaDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OLLAMA).build();
|
||||
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesAutoDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.AUTO).build();
|
||||
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesOpenAiDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).build();
|
||||
|
||||
assertEquals("https://api.openai.com", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesOpenRouterDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENROUTER).build();
|
||||
|
||||
assertEquals("https://openrouter.ai/api", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesAnthropicDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.ANTHROPIC).build();
|
||||
|
||||
assertEquals("https://api.anthropic.com", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_preservesExplicitBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI)
|
||||
.baseUrl("https://internal-gateway.example.test/openai").build();
|
||||
|
||||
assertEquals("https://internal-gateway.example.test/openai", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_treatsNullProviderAsAuto() {
|
||||
AiOptions options = AiOptions.builder().provider(null).build();
|
||||
|
||||
assertEquals(AiProvider.AUTO, options.provider());
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedApiKey_prefersDirectApiKey() {
|
||||
AiOptions options = AiOptions.builder().apiKey("sk-direct-value").apiKeyEnv("SHOULD_NOT_BE_USED").build();
|
||||
|
||||
assertEquals("sk-direct-value", options.resolvedApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedApiKey_returnsNullWhenDirectKeyIsBlankAndEnvIsMissing() {
|
||||
AiOptions options = AiOptions.builder().apiKey(" ").apiKeyEnv("METHODATLAS_TEST_ENV_NOT_PRESENT").build();
|
||||
|
||||
assertNull(options.resolvedApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedApiKey_returnsNullWhenNeitherDirectNorEnvAreConfigured() {
|
||||
AiOptions options = AiOptions.builder().build();
|
||||
|
||||
assertNull(options.resolvedApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullProvider() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class,
|
||||
() -> new AiOptions(true, null, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("provider", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullModelName() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, null, "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("modelName", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullTimeout() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, null, 1));
|
||||
|
||||
assertEquals("timeout", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullTaxonomyMode() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class, () -> new AiOptions(true, AiProvider.OPENAI,
|
||||
"gpt-4o-mini", "https://api.openai.com", null, null, null, null, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("taxonomyMode", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsBlankBaseUrl() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", " ", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("baseUrl must not be blank", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNonPositiveMaxClassChars() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 0, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("maxClassChars must be > 0", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNegativeMaxRetries() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), -1));
|
||||
|
||||
assertEquals("maxRetries must be >= 0", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_allowsFullCustomization() {
|
||||
Path taxonomyFile = Path.of("src/test/resources/security-taxonomy.yaml");
|
||||
Duration timeout = Duration.ofSeconds(15);
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
|
||||
.modelName("claude-3-5-sonnet").baseUrl("https://proxy.example.test/anthropic").apiKey("test-api-key")
|
||||
.apiKeyEnv("IGNORED_ENV").taxonomyFile(taxonomyFile).taxonomyMode(AiOptions.TaxonomyMode.OPTIMIZED)
|
||||
.maxClassChars(12_345).timeout(timeout).maxRetries(4).build();
|
||||
|
||||
assertEquals(true, options.enabled());
|
||||
assertEquals(AiProvider.ANTHROPIC, options.provider());
|
||||
assertEquals("claude-3-5-sonnet", options.modelName());
|
||||
assertEquals("https://proxy.example.test/anthropic", options.baseUrl());
|
||||
assertEquals("test-api-key", options.apiKey());
|
||||
assertEquals("IGNORED_ENV", options.apiKeyEnv());
|
||||
assertEquals(taxonomyFile, options.taxonomyFile());
|
||||
assertEquals(AiOptions.TaxonomyMode.OPTIMIZED, options.taxonomyMode());
|
||||
assertEquals(12_345, options.maxClassChars());
|
||||
assertEquals(timeout, options.timeout());
|
||||
assertEquals(4, options.maxRetries());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
class AiProviderFactoryTest {
|
||||
|
||||
@Test
|
||||
void create_withOllamaProvider_returnsOllamaClientWithoutAvailabilityCheck() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA).build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> mocked = mockConstruction(OllamaClient.class)) {
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OllamaClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenAiProvider_returnsOpenAiCompatibleClientWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
|
||||
.build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OpenAiCompatibleClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenAiProvider_throwsWhenUnavailable() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("OpenAI API key missing", ex.getMessage());
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenRouterProvider_returnsOpenAiCompatibleClientWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).apiKey("or-test-key")
|
||||
.build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OpenAiCompatibleClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenRouterProvider_throwsWhenUnavailable() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("OpenRouter API key missing", ex.getMessage());
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAnthropicProvider_returnsAnthropicClientWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
|
||||
.apiKey("anthropic-test-key").build();
|
||||
|
||||
try (MockedConstruction<AnthropicClient> mocked = mockConstruction(AnthropicClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(AnthropicClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAnthropicProvider_throwsWhenUnavailable() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC).build();
|
||||
|
||||
try (MockedConstruction<AnthropicClient> mocked = mockConstruction(AnthropicClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("Anthropic API key missing", ex.getMessage());
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAutoProvider_returnsOllamaWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).modelName("qwen2.5-coder:7b")
|
||||
.baseUrl("http://localhost:11434").build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true));
|
||||
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
|
||||
OpenAiCompatibleClient.class)) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OllamaClient.class, client);
|
||||
assertEquals(1, ollamaMocked.constructed().size());
|
||||
assertSame(ollamaMocked.constructed().get(0), client);
|
||||
assertEquals(0, openAiMocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAutoProvider_fallsBackToOpenAiCompatibleWhenOllamaUnavailableAndApiKeyPresent() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).modelName("gpt-4o-mini")
|
||||
.baseUrl("https://api.openai.com").apiKey("sk-test-value").build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false));
|
||||
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
|
||||
OpenAiCompatibleClient.class)) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OpenAiCompatibleClient.class, client);
|
||||
assertEquals(1, ollamaMocked.constructed().size());
|
||||
assertEquals(1, openAiMocked.constructed().size());
|
||||
assertSame(openAiMocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAutoProvider_throwsWhenOllamaUnavailableAndNoApiKeyConfigured() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false));
|
||||
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
|
||||
OpenAiCompatibleClient.class)) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("No AI provider available. Ollama is not reachable and no API key is configured.",
|
||||
ex.getMessage());
|
||||
assertEquals(1, ollamaMocked.constructed().size());
|
||||
assertEquals(0, openAiMocked.constructed().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
class AiSuggestionEngineImplTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void suggestForClass_delegatesToProviderClient_usingDefaultTaxonomy() throws Exception {
|
||||
AiProviderClient client = mock(AiProviderClient.class);
|
||||
AiClassSuggestion expected = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", true,
|
||||
List.of("security", "access-control"), "Class validates access-control behavior.",
|
||||
List.of(new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
|
||||
"SECURITY: authentication - reject unauthenticated request", List.of("security", "auth"),
|
||||
"The test verifies that anonymous access is rejected.")));
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
when(client.suggestForClass(eq("com.acme.security.AccessControlServiceTest"),
|
||||
eq("class AccessControlServiceTest {}"), eq(DefaultSecurityTaxonomy.text()))).thenReturn(expected);
|
||||
|
||||
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
|
||||
AiClassSuggestion actual = engine.suggestForClass("com.acme.security.AccessControlServiceTest",
|
||||
"class AccessControlServiceTest {}");
|
||||
|
||||
assertSame(expected, actual);
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verify(client).suggestForClass("com.acme.security.AccessControlServiceTest",
|
||||
"class AccessControlServiceTest {}", DefaultSecurityTaxonomy.text());
|
||||
verifyNoMoreInteractions(client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_delegatesToProviderClient_usingOptimizedTaxonomy() throws Exception {
|
||||
AiProviderClient client = mock(AiProviderClient.class);
|
||||
AiClassSuggestion expected = new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest", true,
|
||||
List.of("security", "input-validation"), "Class validates protection against unsafe path input.",
|
||||
List.of(new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
|
||||
"SECURITY: input validation - reject path traversal sequence",
|
||||
List.of("security", "input-validation", "owasp"),
|
||||
"The test rejects a classic path traversal payload.")));
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.taxonomyMode(AiOptions.TaxonomyMode.OPTIMIZED).build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
when(client.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"),
|
||||
eq("class PathTraversalValidationTest {}"), eq(OptimizedSecurityTaxonomy.text())))
|
||||
.thenReturn(expected);
|
||||
|
||||
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
|
||||
AiClassSuggestion actual = engine.suggestForClass("com.acme.storage.PathTraversalValidationTest",
|
||||
"class PathTraversalValidationTest {}");
|
||||
|
||||
assertSame(expected, actual);
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verify(client).suggestForClass("com.acme.storage.PathTraversalValidationTest",
|
||||
"class PathTraversalValidationTest {}", OptimizedSecurityTaxonomy.text());
|
||||
verifyNoMoreInteractions(client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_usesExternalTaxonomyFile_whenConfigured() throws Exception {
|
||||
Path taxonomyFile = tempDir.resolve("security-taxonomy.txt");
|
||||
String taxonomyText = """
|
||||
CUSTOM SECURITY TAXONOMY
|
||||
security
|
||||
access-control
|
||||
logging
|
||||
""";
|
||||
Files.writeString(taxonomyFile, taxonomyText);
|
||||
|
||||
AiProviderClient client = mock(AiProviderClient.class);
|
||||
AiClassSuggestion expected = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", true,
|
||||
List.of("security", "logging"), "Class verifies security-relevant audit logging behavior.",
|
||||
List.of(new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
|
||||
"SECURITY: logging - redact bearer token", List.of("security", "logging"),
|
||||
"The test ensures credentials are not written to logs.")));
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).taxonomyFile(taxonomyFile)
|
||||
.build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
when(client.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), eq("class AuditLoggingTest {}"),
|
||||
eq(taxonomyText))).thenReturn(expected);
|
||||
|
||||
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
|
||||
AiClassSuggestion actual = engine.suggestForClass("com.acme.audit.AuditLoggingTest",
|
||||
"class AuditLoggingTest {}");
|
||||
|
||||
assertSame(expected, actual);
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verify(client).suggestForClass("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
|
||||
taxonomyText);
|
||||
verifyNoMoreInteractions(client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_throwsWhenTaxonomyFileCannotBeRead() {
|
||||
Path missingTaxonomyFile = tempDir.resolve("missing-taxonomy.txt");
|
||||
|
||||
AiProviderClient client = mock(AiProviderClient.class);
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
|
||||
.taxonomyFile(missingTaxonomyFile).build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> new AiSuggestionEngineImpl(options));
|
||||
|
||||
assertEquals("Failed to read taxonomy file: " + missingTaxonomyFile, ex.getMessage());
|
||||
assertInstanceOf(IOException.class, ex.getCause());
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verifyNoMoreInteractions(client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_propagatesProviderFactoryFailure() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
|
||||
|
||||
AiSuggestionException expected = new AiSuggestionException("Provider initialization failed");
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenThrow(expected);
|
||||
|
||||
AiSuggestionException actual = assertThrows(AiSuggestionException.class,
|
||||
() -> new AiSuggestionEngineImpl(options));
|
||||
|
||||
assertSame(expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java
Normal file
126
src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class JsonTextTest {
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_returnsJsonWhenInputIsExactlyJson() throws Exception {
|
||||
String json = "{\"className\":\"AccessControlServiceTest\",\"methods\":[]}";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(json);
|
||||
|
||||
assertEquals(json, extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_extractsJsonWrappedByPlainText() throws Exception {
|
||||
String text = """
|
||||
Here is the analysis result:
|
||||
{"className":"AccessControlServiceTest","methods":[{"methodName":"shouldRejectUnauthenticatedRequest"}]}
|
||||
Thank you.
|
||||
""";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(text);
|
||||
|
||||
assertEquals(
|
||||
"{\"className\":\"AccessControlServiceTest\",\"methods\":[{\"methodName\":\"shouldRejectUnauthenticatedRequest\"}]}",
|
||||
extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_extractsJsonWrappedByMarkdownFence() throws Exception {
|
||||
String text = """
|
||||
```json
|
||||
{"className":"AuditLoggingTest","methods":[{"methodName":"shouldNotLogRawBearerToken"}]}
|
||||
```
|
||||
""";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(text);
|
||||
|
||||
assertEquals(
|
||||
"{\"className\":\"AuditLoggingTest\",\"methods\":[{\"methodName\":\"shouldNotLogRawBearerToken\"}]}",
|
||||
extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_preservesNestedObjectsAndArrays() throws Exception {
|
||||
String text = """
|
||||
Model output:
|
||||
{
|
||||
"className":"PathTraversalValidationTest",
|
||||
"methods":[
|
||||
{
|
||||
"methodName":"shouldRejectRelativePathTraversalSequence",
|
||||
"securityRelevant":true,
|
||||
"tags":["security","input-validation","path-traversal"]
|
||||
}
|
||||
]
|
||||
}
|
||||
End.
|
||||
""";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(text);
|
||||
|
||||
assertEquals("""
|
||||
{
|
||||
"className":"PathTraversalValidationTest",
|
||||
"methods":[
|
||||
{
|
||||
"methodName":"shouldRejectRelativePathTraversalSequence",
|
||||
"securityRelevant":true,
|
||||
"tags":["security","input-validation","path-traversal"]
|
||||
}
|
||||
]
|
||||
}""", extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_nullInput_throwsAiSuggestionException() {
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(null));
|
||||
|
||||
assertEquals("Model returned an empty response", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_blankInput_throwsAiSuggestionException() {
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(" \n\t "));
|
||||
|
||||
assertEquals("Model returned an empty response", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_missingOpeningBrace_throwsAiSuggestionException() {
|
||||
String text = "No JSON object here at all";
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(text));
|
||||
|
||||
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_missingClosingBrace_throwsAiSuggestionException() {
|
||||
String text = "{\"className\":\"AccessControlServiceTest\"";
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(text));
|
||||
|
||||
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_closingBraceBeforeOpeningBrace_throwsAiSuggestionException() {
|
||||
String text = "} not json {";
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(text));
|
||||
|
||||
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
|
||||
}
|
||||
}
|
||||
191
src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java
Normal file
191
src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java
Normal file
@@ -0,0 +1,191 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
class OllamaClientTest {
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsTrueWhenTagsEndpointResponds() throws Exception {
|
||||
HttpClient httpClient = mock(HttpClient.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
HttpResponse<Void> response = mock(HttpResponse.class);
|
||||
|
||||
when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler())).thenReturn(response);
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.httpClient()).thenReturn(httpClient);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.baseUrl("http://localhost:11434").build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
|
||||
assertTrue(client.isAvailable());
|
||||
|
||||
verify(httpClient)
|
||||
.send(argThat(request -> request.uri().toString().equals("http://localhost:11434/api/tags")
|
||||
&& "GET".equals(request.method())), anyVoidBodyHandler());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsFalseWhenTagsEndpointFails() throws Exception {
|
||||
HttpClient httpClient = mock(HttpClient.class);
|
||||
|
||||
when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler()))
|
||||
.thenThrow(new java.io.IOException("Connection refused"));
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.httpClient()).thenReturn(httpClient);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.baseUrl("http://localhost:11434").build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
|
||||
assertFalse(client.isAvailable());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String fqcn = "com.acme.storage.PathTraversalValidationTest";
|
||||
String classSource = """
|
||||
class PathTraversalValidationTest {
|
||||
void shouldRejectRelativePathTraversalSequence() {}
|
||||
}
|
||||
""";
|
||||
String taxonomyText = "security, input-validation, owasp";
|
||||
|
||||
String responseBody = """
|
||||
{
|
||||
"message": {
|
||||
"content": "Analysis complete:\\n{\\n \\"className\\": \\"com.acme.storage.PathTraversalValidationTest\\",\\n \\"classSecurityRelevant\\": true,\\n \\"classTags\\": null,\\n \\"classReason\\": \\"Class validates filesystem input handling.\\",\\n \\"methods\\": [\\n null,\\n {\\n \\"methodName\\": \\"shouldRejectRelativePathTraversalSequence\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: input validation - reject path traversal sequence\\",\\n \\"tags\\": null,\\n \\"reason\\": \\"The test rejects a classic parent-directory traversal payload.\\"\\n },\\n {\\n \\"methodName\\": \\" \\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: invalid - blank method\\",\\n \\"tags\\": [\\"security\\"],\\n \\"reason\\": \\"This malformed method must be filtered.\\"\\n }\\n ]\\n}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
AtomicReference<String> capturedBody = new AtomicReference<>();
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.objectMapper()).thenReturn(mapper);
|
||||
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
|
||||
URI uri = invocation.getArgument(0);
|
||||
String body = invocation.getArgument(1);
|
||||
Duration timeout = invocation.getArgument(2);
|
||||
|
||||
capturedBody.set(body);
|
||||
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
});
|
||||
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.modelName("qwen2.5-coder:7b").baseUrl("http://localhost:11434").build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
AiClassSuggestion suggestion = client.suggestForClass(fqcn, classSource, taxonomyText);
|
||||
|
||||
assertEquals(fqcn, suggestion.className());
|
||||
assertEquals(Boolean.TRUE, suggestion.classSecurityRelevant());
|
||||
assertEquals(List.of(), suggestion.classTags());
|
||||
assertEquals("Class validates filesystem input handling.", suggestion.classReason());
|
||||
assertNotNull(suggestion.methods());
|
||||
assertEquals(1, suggestion.methods().size());
|
||||
|
||||
AiMethodSuggestion method = suggestion.methods().get(0);
|
||||
assertEquals("shouldRejectRelativePathTraversalSequence", method.methodName());
|
||||
assertTrue(method.securityRelevant());
|
||||
assertEquals("SECURITY: input validation - reject path traversal sequence", method.displayName());
|
||||
assertEquals(List.of(), method.tags());
|
||||
assertEquals("The test rejects a classic parent-directory traversal payload.", method.reason());
|
||||
|
||||
HttpSupport httpSupport = mocked.constructed().get(0);
|
||||
verify(httpSupport)
|
||||
.postJson(argThat(request -> request.uri().toString().equals("http://localhost:11434/api/chat")
|
||||
&& "application/json".equals(request.headers().firstValue("Content-Type").orElse(null))
|
||||
&& "POST".equals(request.method())));
|
||||
|
||||
String requestBody = capturedBody.get();
|
||||
assertNotNull(requestBody);
|
||||
assertTrue(requestBody.contains("\"model\":\"qwen2.5-coder:7b\""));
|
||||
assertTrue(requestBody.contains("\"stream\":false"));
|
||||
assertTrue(requestBody.contains("You are a precise software security classification engine."));
|
||||
assertTrue(requestBody.contains("You classify JUnit 5 tests and return strict JSON only."));
|
||||
assertTrue(requestBody.contains("\"temperature\":0.0"));
|
||||
assertTrue(requestBody.contains("FQCN: " + fqcn));
|
||||
assertTrue(requestBody.contains("PathTraversalValidationTest"));
|
||||
assertTrue(requestBody.contains("shouldRejectRelativePathTraversalSequence"));
|
||||
assertTrue(requestBody.contains(taxonomyText));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_throwsWhenModelReturnsTextWithoutJsonObject() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String fqcn = "com.acme.audit.AuditLoggingTest";
|
||||
String responseBody = """
|
||||
{
|
||||
"message": {
|
||||
"content": "This looks security related, but I am not returning JSON."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.objectMapper()).thenReturn(mapper);
|
||||
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
|
||||
URI uri = invocation.getArgument(0);
|
||||
String body = invocation.getArgument(1);
|
||||
Duration timeout = invocation.getArgument(2);
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
});
|
||||
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA).build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
|
||||
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
|
||||
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging"));
|
||||
|
||||
assertEquals("Ollama suggestion failed for " + fqcn, ex.getMessage());
|
||||
assertInstanceOf(AiSuggestionException.class, ex.getCause());
|
||||
assertTrue(ex.getCause().getMessage().contains("Model response does not contain a JSON object"));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static HttpResponse.BodyHandler<Void> anyVoidBodyHandler() {
|
||||
return any(HttpResponse.BodyHandler.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
class OpenAiCompatibleClientTest {
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsTrueWhenApiKeyIsConfigured() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).apiKey("sk-test-value").build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
|
||||
assertTrue(client.isAvailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsFalseWhenApiKeyIsMissing() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
|
||||
assertFalse(client.isAvailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String fqcn = "com.acme.security.AccessControlServiceTest";
|
||||
String classSource = """
|
||||
class AccessControlServiceTest {
|
||||
void shouldRejectUnauthenticatedRequest() {}
|
||||
}
|
||||
""";
|
||||
String taxonomyText = "security, auth, access-control";
|
||||
|
||||
String responseBody = """
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "Here is the result:\\n{\\n \\"className\\": \\"com.acme.security.AccessControlServiceTest\\",\\n \\"classSecurityRelevant\\": true,\\n \\"classTags\\": null,\\n \\"classReason\\": \\"Class validates authentication and authorization controls.\\",\\n \\"methods\\": [\\n null,\\n {\\n \\"methodName\\": \\"shouldRejectUnauthenticatedRequest\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: authentication - reject unauthenticated request\\",\\n \\"tags\\": null,\\n \\"reason\\": \\"The test rejects anonymous access to a protected operation.\\"\\n },\\n {\\n \\"methodName\\": \\"\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: invalid - blank method\\",\\n \\"tags\\": [\\"security\\"],\\n \\"reason\\": \\"This malformed method must be filtered.\\"\\n }\\n ]\\n}\\nThanks."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
AtomicReference<String> capturedBody = new AtomicReference<>();
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, capturedBody)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).modelName("gpt-4o-mini")
|
||||
.baseUrl("https://api.openai.com").apiKey("sk-test-value").build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
AiClassSuggestion suggestion = client.suggestForClass(fqcn, classSource, taxonomyText);
|
||||
|
||||
assertEquals(fqcn, suggestion.className());
|
||||
assertEquals(Boolean.TRUE, suggestion.classSecurityRelevant());
|
||||
assertEquals(List.of(), suggestion.classTags());
|
||||
assertEquals("Class validates authentication and authorization controls.", suggestion.classReason());
|
||||
assertNotNull(suggestion.methods());
|
||||
assertEquals(1, suggestion.methods().size());
|
||||
|
||||
AiMethodSuggestion method = suggestion.methods().get(0);
|
||||
assertEquals("shouldRejectUnauthenticatedRequest", method.methodName());
|
||||
assertTrue(method.securityRelevant());
|
||||
assertEquals("SECURITY: authentication - reject unauthenticated request", method.displayName());
|
||||
assertEquals(List.of(), method.tags());
|
||||
assertEquals("The test rejects anonymous access to a protected operation.", method.reason());
|
||||
|
||||
HttpSupport httpSupport = mocked.constructed().get(0);
|
||||
verify(httpSupport).postJson(
|
||||
argThat(request -> request.uri().toString().equals("https://api.openai.com/v1/chat/completions")
|
||||
&& "Bearer sk-test-value".equals(request.headers().firstValue("Authorization").orElse(null))
|
||||
&& "application/json".equals(request.headers().firstValue("Content-Type").orElse(null))
|
||||
&& "POST".equals(request.method())));
|
||||
|
||||
String requestBody = capturedBody.get();
|
||||
assertNotNull(requestBody);
|
||||
assertTrue(requestBody.contains("\"model\":\"gpt-4o-mini\""));
|
||||
assertTrue(requestBody.contains("You are a precise software security classification engine."));
|
||||
assertTrue(requestBody.contains("You classify JUnit 5 tests and return strict JSON only."));
|
||||
assertTrue(requestBody.contains("FQCN: " + fqcn));
|
||||
assertTrue(requestBody.contains("AccessControlServiceTest"));
|
||||
assertTrue(requestBody.contains("shouldRejectUnauthenticatedRequest"));
|
||||
assertTrue(requestBody.contains(taxonomyText));
|
||||
assertTrue(requestBody.contains("\"temperature\":0.0"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_addsOpenRouterHeaders() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String responseBody = """
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "{\\"className\\":\\"com.acme.audit.AuditLoggingTest\\",\\"classSecurityRelevant\\":false,\\"classTags\\":[],\\"classReason\\":\\"Class is not security-relevant as a whole.\\",\\"methods\\":[]}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER)
|
||||
.modelName("openai/gpt-4o-mini").baseUrl("https://openrouter.ai/api").apiKey("or-test-key").build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
AiClassSuggestion suggestion = client.suggestForClass("com.acme.audit.AuditLoggingTest",
|
||||
"class AuditLoggingTest {}", "security, logging");
|
||||
|
||||
assertEquals("com.acme.audit.AuditLoggingTest", suggestion.className());
|
||||
|
||||
HttpSupport httpSupport = mocked.constructed().get(0);
|
||||
verify(httpSupport).postJson(
|
||||
argThat(request -> request.uri().toString().equals("https://openrouter.ai/api/v1/chat/completions")
|
||||
&& "Bearer or-test-key".equals(request.headers().firstValue("Authorization").orElse(null))
|
||||
&& "https://methodatlas.local"
|
||||
.equals(request.headers().firstValue("HTTP-Referer").orElse(null))
|
||||
&& "MethodAtlas".equals(request.headers().firstValue("X-Title").orElse(null))));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_throwsWhenNoChoicesAreReturned() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String fqcn = "com.acme.audit.AuditLoggingTest";
|
||||
String responseBody = """
|
||||
{
|
||||
"choices": []
|
||||
}
|
||||
""";
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
|
||||
.build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
|
||||
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
|
||||
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging"));
|
||||
|
||||
assertEquals("OpenAI-compatible suggestion failed for " + fqcn, ex.getMessage());
|
||||
assertInstanceOf(AiSuggestionException.class, ex.getCause());
|
||||
assertEquals("No choices returned by model", ex.getCause().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_throwsWhenModelReturnsTextWithoutJsonObject() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String fqcn = "com.acme.audit.AuditLoggingTest";
|
||||
String responseBody = """
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "I think this class is probably security relevant, but I will not provide JSON."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
|
||||
.build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
|
||||
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
|
||||
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging"));
|
||||
|
||||
assertEquals("OpenAI-compatible suggestion failed for " + fqcn, ex.getMessage());
|
||||
assertInstanceOf(AiSuggestionException.class, ex.getCause());
|
||||
assertTrue(ex.getCause().getMessage().contains("Model response does not contain a JSON object"));
|
||||
}
|
||||
}
|
||||
|
||||
private static MockedConstruction<HttpSupport> mockHttpSupport(ObjectMapper mapper, String responseBody,
|
||||
AtomicReference<String> capturedBody) {
|
||||
|
||||
return mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.objectMapper()).thenReturn(mapper);
|
||||
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
|
||||
URI uri = invocation.getArgument(0);
|
||||
String body = invocation.getArgument(1);
|
||||
Duration timeout = invocation.getArgument(2);
|
||||
|
||||
if (capturedBody != null) {
|
||||
capturedBody.set(body);
|
||||
}
|
||||
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
});
|
||||
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
|
||||
});
|
||||
}
|
||||
}
|
||||
125
src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java
Normal file
125
src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java
Normal file
@@ -0,0 +1,125 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PromptBuilderTest {
|
||||
|
||||
@Test
|
||||
void build_containsFqcnSourceAndTaxonomy() {
|
||||
String fqcn = "com.acme.security.AccessControlServiceTest";
|
||||
String classSource = """
|
||||
package com.acme.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AccessControlServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldRejectUnauthenticatedRequest() {}
|
||||
|
||||
@Test
|
||||
void shouldAllowOwnerToReadOwnStatement() {}
|
||||
}
|
||||
""";
|
||||
String taxonomyText = """
|
||||
SECURITY TAXONOMY
|
||||
- security
|
||||
- auth
|
||||
- access-control
|
||||
- input-validation
|
||||
- logging
|
||||
""";
|
||||
|
||||
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText);
|
||||
|
||||
assertTrue(prompt.contains("FQCN: " + fqcn));
|
||||
assertTrue(prompt.contains(classSource));
|
||||
assertTrue(prompt.contains(taxonomyText));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_containsExpectedTaskInstructions() {
|
||||
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
|
||||
"security, logging");
|
||||
|
||||
assertTrue(prompt.contains("You are analyzing a single JUnit 5 test class and suggesting security tags."));
|
||||
assertTrue(prompt.contains("- Analyze the WHOLE class for context."));
|
||||
assertTrue(prompt.contains("- Return per-method suggestions for JUnit test methods only."));
|
||||
assertTrue(prompt.contains("- Do not invent methods that do not exist."));
|
||||
assertTrue(prompt.contains("- Be conservative."));
|
||||
assertTrue(prompt.contains("- If uncertain, classify the method as securityRelevant=false."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_containsClosedTaxonomyRules() {
|
||||
String prompt = PromptBuilder.build("com.acme.storage.PathTraversalValidationTest",
|
||||
"class PathTraversalValidationTest {}", "security, input-validation, injection");
|
||||
|
||||
assertTrue(prompt.contains("Tags must come only from this closed set:"));
|
||||
assertTrue(prompt.contains(
|
||||
"security, auth, access-control, crypto, input-validation, injection, data-protection, logging, error-handling, owasp"));
|
||||
assertTrue(prompt.contains("If securityRelevant=true, tags MUST include \"security\"."));
|
||||
assertTrue(prompt.contains("Add 1-3 tags total per method."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_containsDisplayNameRules() {
|
||||
String prompt = PromptBuilder.build("com.acme.security.AccessControlServiceTest",
|
||||
"class AccessControlServiceTest {}", "security, auth, access-control");
|
||||
|
||||
assertTrue(prompt.contains("displayName must be null when securityRelevant=false."));
|
||||
assertTrue(prompt.contains("If securityRelevant=true, displayName must match:"));
|
||||
assertTrue(prompt.contains("SECURITY: <control/property> - <scenario>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_containsJsonShapeContract() {
|
||||
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
|
||||
"security, logging");
|
||||
|
||||
assertTrue(prompt.contains("JSON SHAPE"));
|
||||
assertTrue(prompt.contains("\"className\": \"string\""));
|
||||
assertTrue(prompt.contains("\"classSecurityRelevant\": true"));
|
||||
assertTrue(prompt.contains("\"classTags\": [\"security\", \"crypto\"]"));
|
||||
assertTrue(prompt.contains("\"classReason\": \"string\""));
|
||||
assertTrue(prompt.contains("\"methods\": ["));
|
||||
assertTrue(prompt.contains("\"methodName\": \"string\""));
|
||||
assertTrue(prompt.contains("\"securityRelevant\": true"));
|
||||
assertTrue(prompt.contains("\"displayName\": \"SECURITY: ...\""));
|
||||
assertTrue(prompt.contains("\"tags\": [\"security\", \"crypto\"]"));
|
||||
assertTrue(prompt.contains("\"reason\": \"string\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_includesCompleteClassSourceVerbatim() {
|
||||
String classSource = """
|
||||
class PathTraversalValidationTest {
|
||||
|
||||
void shouldRejectRelativePathTraversalSequence() {
|
||||
String userInput = "../etc/passwd";
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
String prompt = PromptBuilder.build("com.acme.storage.PathTraversalValidationTest", classSource,
|
||||
"security, input-validation, injection");
|
||||
|
||||
assertTrue(prompt.contains("String userInput = \"../etc/passwd\";"));
|
||||
assertTrue(prompt.contains("void shouldRejectRelativePathTraversalSequence()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_isDeterministicForSameInput() {
|
||||
String fqcn = "com.example.X";
|
||||
String source = "class X {}";
|
||||
String taxonomy = "security, logging";
|
||||
|
||||
String prompt1 = PromptBuilder.build(fqcn, source, taxonomy);
|
||||
String prompt2 = PromptBuilder.build(fqcn, source, taxonomy);
|
||||
|
||||
assertEquals(prompt1, prompt2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SuggestionLookupTest {
|
||||
|
||||
@Test
|
||||
void from_nullSuggestion_returnsEmptyLookup() {
|
||||
SuggestionLookup lookup = SuggestionLookup.from(null);
|
||||
|
||||
assertNotNull(lookup);
|
||||
assertFalse(lookup.find("shouldAuthenticateUser").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_nullMethods_returnsEmptyLookup() {
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "access-control"), "Class contains access-control related tests.", null);
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertNotNull(lookup);
|
||||
assertFalse(lookup.find("shouldAllowOwnerToReadOwnStatement").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_emptyMethods_returnsEmptyLookup() {
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "access-control"), "Class contains access-control related tests.", List.of());
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertNotNull(lookup);
|
||||
assertFalse(lookup.find("shouldAllowOwnerToReadOwnStatement").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_filtersNullBlankAndMissingMethodNames() {
|
||||
AiMethodSuggestion valid = new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
|
||||
"Reject unauthenticated access", List.of("security", "authentication", "access-control"),
|
||||
"The test verifies anonymous access is rejected.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "authentication", "access-control"), "Class tests protected-access scenarios.",
|
||||
Arrays.asList(null,
|
||||
new AiMethodSuggestion(null, true, "Invalid", List.of("security"), "missing method name"),
|
||||
new AiMethodSuggestion("", true, "Invalid", List.of("security"), "blank method name"),
|
||||
new AiMethodSuggestion(" ", true, "Invalid", List.of("security"), "blank method name"),
|
||||
valid));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertFalse(lookup.find("missing").isPresent());
|
||||
|
||||
Optional<AiMethodSuggestion> found = lookup.find("shouldRejectUnauthenticatedRequest");
|
||||
assertTrue(found.isPresent());
|
||||
assertSame(valid, found.get());
|
||||
assertTrue(found.get().tags().contains("security"));
|
||||
assertTrue(found.get().tags().contains("authentication"));
|
||||
assertTrue(found.get().tags().contains("access-control"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_duplicateMethodNames_keepsFirstOccurrence() {
|
||||
AiMethodSuggestion first = new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
|
||||
"Allow administrative access", List.of("security", "access-control", "authorization"),
|
||||
"The test verifies that an administrator is allowed access.");
|
||||
|
||||
AiMethodSuggestion duplicate = new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
|
||||
"Ignore duplicate", List.of("security", "logging"), "A later duplicate entry that must be ignored.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "access-control"), "Class covers authorization scenarios.",
|
||||
List.of(first, duplicate));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
Optional<AiMethodSuggestion> found = lookup.find("shouldAllowAdministratorToReadAnyStatement");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertSame(first, found.get());
|
||||
assertTrue(found.get().tags().contains("authorization"));
|
||||
assertFalse(found.get().tags().contains("logging"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_existingMethod_returnsSuggestion() {
|
||||
AiMethodSuggestion method = new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
|
||||
"Reject path traversal payload", List.of("security", "input-validation", "path-traversal"),
|
||||
"The test rejects a parent-directory traversal sequence.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest",
|
||||
Boolean.TRUE, List.of("security", "input-validation"), "Class validates filesystem input handling.",
|
||||
List.of(method));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
Optional<AiMethodSuggestion> found = lookup.find("shouldRejectRelativePathTraversalSequence");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertSame(method, found.get());
|
||||
assertTrue(found.get().tags().contains("security"));
|
||||
assertTrue(found.get().tags().contains("input-validation"));
|
||||
assertTrue(found.get().tags().contains("path-traversal"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_missingMethod_returnsEmptyOptional() {
|
||||
AiMethodSuggestion method = new AiMethodSuggestion("shouldWriteAuditEventForPrivilegeChange", true,
|
||||
"Audit privilege changes", List.of("security", "audit", "logging"),
|
||||
"The test verifies audit logging for a security-sensitive action.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", Boolean.TRUE,
|
||||
List.of("security", "audit", "logging"), "Class contains audit and secure logging tests.",
|
||||
List.of(method));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertFalse(lookup.find("shouldFormatHumanReadableSupportMessage").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_nullMethodName_throwsNullPointerException() {
|
||||
AiMethodSuggestion method = new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
|
||||
"Redact bearer token in logs", List.of("security", "logging", "secrets-handling"),
|
||||
"The test ensures sensitive credentials are not written to logs.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", Boolean.TRUE,
|
||||
List.of("security", "logging"), "Class checks secure logging behavior.", List.of(method));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertThrows(NullPointerException.class, () -> lookup.find(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.acme.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class AccessControlServiceTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authz")
|
||||
void shouldAllowOwnerToReadOwnStatement() {
|
||||
String userId = "user-100";
|
||||
String ownerId = "user-100";
|
||||
|
||||
boolean allowed = userId.equals(ownerId);
|
||||
|
||||
assertEquals(true, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authz")
|
||||
void shouldAllowAdministratorToReadAnyStatement() {
|
||||
String role = "ADMIN";
|
||||
|
||||
boolean allowed = "ADMIN".equals(role);
|
||||
|
||||
assertEquals(true, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authz")
|
||||
void shouldDenyForeignUserFromReadingAnotherUsersStatement() {
|
||||
String requesterId = "user-200";
|
||||
String ownerId = "user-100";
|
||||
|
||||
boolean allowed = requesterId.equals(ownerId);
|
||||
|
||||
assertEquals(false, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authn")
|
||||
void shouldRejectUnauthenticatedRequest() {
|
||||
String principal = null;
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
|
||||
if (principal == null) {
|
||||
throw new IllegalStateException("Unauthenticated request");
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("Unauthenticated request", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRenderFriendlyAccountLabel() {
|
||||
String firstName = "Ada";
|
||||
String lastName = "Lovelace";
|
||||
|
||||
String label = firstName + " " + lastName;
|
||||
|
||||
assertEquals("Ada Lovelace", label);
|
||||
}
|
||||
}
|
||||
53
src/test/resources/fixtures/AuditLoggingTest.java.txt
Normal file
53
src/test/resources/fixtures/AuditLoggingTest.java.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.acme.audit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class AuditLoggingTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("audit")
|
||||
void shouldWriteAuditEventForPrivilegeChange() {
|
||||
String actor = "admin-7";
|
||||
String action = "GRANT_ROLE";
|
||||
String target = "user-17";
|
||||
|
||||
String auditLine = "actor=" + actor + ";action=" + action + ";target=" + target;
|
||||
|
||||
assertEquals("actor=admin-7;action=GRANT_ROLE;target=user-17", auditLine);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("logging")
|
||||
void shouldNotLogRawBearerToken() {
|
||||
String token = "Bearer eyJhbGciOiJIUzI1NiJ9.secret.signature";
|
||||
String logLine = "Authorization header redacted";
|
||||
|
||||
assertFalse(logLine.contains(token));
|
||||
assertEquals("Authorization header redacted", logLine);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("logging")
|
||||
void shouldNotLogPlaintextPasswordOnAuthenticationFailure() {
|
||||
String password = "Sup3rSecret!";
|
||||
String logLine = "Authentication failed for user alice";
|
||||
|
||||
assertFalse(logLine.contains(password));
|
||||
assertEquals("Authentication failed for user alice", logLine);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFormatHumanReadableSupportMessage() {
|
||||
String user = "alice";
|
||||
String message = "Support ticket opened for " + user;
|
||||
|
||||
assertEquals("Support ticket opened for alice", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.acme.storage;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class PathTraversalValidationTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("validation")
|
||||
void shouldRejectRelativePathTraversalSequence() {
|
||||
String userInput = "../secrets.txt";
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
|
||||
if (userInput.contains("..")) {
|
||||
throw new IllegalArgumentException("Path traversal attempt detected");
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("Path traversal attempt detected", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("validation")
|
||||
void shouldRejectNestedTraversalAfterNormalization() {
|
||||
String userInput = "reports/../../admin/keys.txt";
|
||||
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
|
||||
if (!normalized.startsWith(Path.of("/srv/app/uploads"))) {
|
||||
throw new IllegalArgumentException("Escaped upload root");
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("Escaped upload root", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("validation")
|
||||
void shouldAllowSafePathInsideUploadRoot() {
|
||||
String userInput = "reports/2026/statement.pdf";
|
||||
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
|
||||
|
||||
boolean allowed = normalized.startsWith(Path.of("/srv/app/uploads"));
|
||||
|
||||
assertEquals(true, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildDownloadFileName() {
|
||||
String accountId = "ACC-42";
|
||||
String fileName = accountId + "-statement.pdf";
|
||||
|
||||
assertEquals("ACC-42-statement.pdf", fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user