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:
2026-03-08 23:44:55 +01:00
parent d3a8270d8a
commit bbb6adb7e5
36 changed files with 6037 additions and 283 deletions

View 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;
}
}

View File

@@ -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;
}
}
}

View 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());
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}
}

View 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());
}
}

View 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);
}
}

View File

@@ -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);
});
}
}

View 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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}