diff --git a/MethodAtlasApp.java b/MethodAtlasApp.java new file mode 100644 index 0000000..937e3e8 --- /dev/null +++ b/MethodAtlasApp.java @@ -0,0 +1,396 @@ +package org.egothor.methodatlas; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ParserConfiguration.LanguageLevel; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.ArrayInitializerExpr; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.MemberValuePair; + +import org.egothor.methodatlas.ai.AiClassSuggestion; +import org.egothor.methodatlas.ai.AiMethodSuggestion; +import org.egothor.methodatlas.ai.AiOptions; +import org.egothor.methodatlas.ai.AiProvider; +import org.egothor.methodatlas.ai.AiSuggestionEngine; +import org.egothor.methodatlas.ai.AiSuggestionEngineImpl; +import org.egothor.methodatlas.ai.AiSuggestionException; +import org.egothor.methodatlas.ai.SuggestionLookup; + +public class MethodAtlasApp { + + private static final Logger LOG = Logger.getLogger(MethodAtlasApp.class.getName()); + + private enum OutputMode { + CSV, + PLAIN + } + + private record CliConfig( + OutputMode outputMode, + AiOptions aiOptions, + List paths + ) { + } + + public static void main(String[] args) throws IOException { + ParserConfiguration parserConfiguration = new ParserConfiguration(); + parserConfiguration.setLanguageLevel(LanguageLevel.JAVA_21); + StaticJavaParser.setConfiguration(parserConfiguration); + + CliConfig cliConfig = parseArgs(args); + AiSuggestionEngine aiEngine = buildAiEngine(cliConfig.aiOptions()); + + if (cliConfig.outputMode() == OutputMode.CSV) { + if (cliConfig.aiOptions().enabled()) { + System.out.println("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason"); + } else { + System.out.println("fqcn,method,loc,tags"); + } + } + + if (cliConfig.paths().isEmpty()) { + scanRoot(Paths.get("."), cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine); + return; + } + + for (Path path : cliConfig.paths()) { + scanRoot(path, cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine); + } + } + + private static void scanRoot( + Path root, + OutputMode mode, + AiOptions aiOptions, + AiSuggestionEngine aiEngine + ) throws IOException { + LOG.log(Level.INFO, "Scanning {0} for JUnit files", root); + + try (Stream stream = Files.walk(root)) { + stream.filter(path -> path.toString().endsWith("Test.java")) + .forEach(path -> processFile(path, mode, aiOptions, aiEngine)); + } + } + + private static void processFile( + Path path, + OutputMode mode, + AiOptions aiOptions, + AiSuggestionEngine aiEngine + ) { + try { + CompilationUnit compilationUnit = StaticJavaParser.parse(path); + String packageName = compilationUnit.getPackageDeclaration() + .map(packageDeclaration -> packageDeclaration.getNameAsString()) + .orElse(""); + + compilationUnit.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> { + String className = clazz.getNameAsString(); + String fqcn = packageName.isEmpty() ? className : packageName + "." + className; + SuggestionLookup suggestionLookup = resolveSuggestionLookup(clazz, fqcn, aiOptions, aiEngine); + + clazz.findAll(MethodDeclaration.class).forEach(method -> { + if (!isJUnitTest(method)) { + return; + } + + int loc = countLOC(method); + List tags = getTagValues(method); + AiMethodSuggestion suggestion = suggestionLookup.find(method.getNameAsString()).orElse(null); + + emit( + mode, + aiOptions.enabled(), + fqcn, + method.getNameAsString(), + loc, + tags, + suggestion + ); + }); + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to parse: {0}", path); + e.printStackTrace(); + } + } + + private static SuggestionLookup resolveSuggestionLookup( + ClassOrInterfaceDeclaration clazz, + String fqcn, + AiOptions aiOptions, + AiSuggestionEngine aiEngine + ) { + if (!aiOptions.enabled() || aiEngine == null) { + return SuggestionLookup.from(null); + } + + String classSource = clazz.toString(); + if (classSource.length() > aiOptions.maxClassChars()) { + LOG.log( + Level.INFO, + "Skipping AI for {0}: class source too large ({1} chars)", + new Object[] { fqcn, classSource.length() } + ); + return SuggestionLookup.from(null); + } + + try { + AiClassSuggestion aiClassSuggestion = aiEngine.suggestForClass(fqcn, classSource); + return SuggestionLookup.from(aiClassSuggestion); + } catch (AiSuggestionException e) { + LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn, e); + return SuggestionLookup.from(null); + } + } + + private static AiSuggestionEngine buildAiEngine(AiOptions aiOptions) { + if (!aiOptions.enabled()) { + return null; + } + + try { + return new AiSuggestionEngineImpl(aiOptions); + } catch (AiSuggestionException e) { + throw new IllegalStateException("Failed to initialize AI engine", e); + } + } + + private static void emit( + OutputMode mode, + boolean aiEnabled, + String fqcn, + String method, + int loc, + List tags, + AiMethodSuggestion suggestion + ) { + if (mode == OutputMode.PLAIN) { + emitPlain(aiEnabled, fqcn, method, loc, tags, suggestion); + return; + } + + emitCsv(aiEnabled, fqcn, method, loc, tags, suggestion); + } + + private static void emitPlain( + boolean aiEnabled, + String fqcn, + String method, + int loc, + List tags, + AiMethodSuggestion suggestion + ) { + String existingTags = tags.isEmpty() ? "-" : String.join(";", tags); + + if (!aiEnabled) { + System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + existingTags); + return; + } + + String aiSecurity = suggestion == null ? "-" : Boolean.toString(suggestion.securityRelevant()); + String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "-" : suggestion.displayName(); + String aiTags = suggestion == null || suggestion.tags() == null || suggestion.tags().isEmpty() + ? "-" + : String.join(";", suggestion.tags()); + String aiReason = suggestion == null || suggestion.reason() == null || suggestion.reason().isBlank() + ? "-" + : suggestion.reason(); + + System.out.println( + fqcn + ", " + method + + ", LOC=" + loc + + ", TAGS=" + existingTags + + ", AI_SECURITY=" + aiSecurity + + ", AI_DISPLAY=" + aiDisplayName + + ", AI_TAGS=" + aiTags + + ", AI_REASON=" + aiReason + ); + } + + private static void emitCsv( + boolean aiEnabled, + String fqcn, + String method, + int loc, + List tags, + AiMethodSuggestion suggestion + ) { + String existingTags = tags.isEmpty() ? "" : String.join(";", tags); + + if (!aiEnabled) { + System.out.println( + csvEscape(fqcn) + "," + + csvEscape(method) + "," + + loc + "," + + csvEscape(existingTags) + ); + return; + } + + String aiSecurity = suggestion == null ? "" : Boolean.toString(suggestion.securityRelevant()); + String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "" : suggestion.displayName(); + String aiTags = suggestion == null || suggestion.tags() == null ? "" : String.join(";", suggestion.tags()); + String aiReason = suggestion == null || suggestion.reason() == null ? "" : suggestion.reason(); + + System.out.println( + csvEscape(fqcn) + "," + + csvEscape(method) + "," + + loc + "," + + csvEscape(existingTags) + "," + + csvEscape(aiSecurity) + "," + + csvEscape(aiDisplayName) + "," + + csvEscape(aiTags) + "," + + csvEscape(aiReason) + ); + } + + private static String csvEscape(String value) { + if (value == null) { + return ""; + } + + boolean mustQuote = + value.indexOf(',') >= 0 + || value.indexOf('"') >= 0 + || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0; + + if (!mustQuote) { + return value; + } + + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private static CliConfig parseArgs(String[] args) { + OutputMode outputMode = OutputMode.CSV; + List paths = new ArrayList<>(); + AiOptions.Builder aiBuilder = AiOptions.builder(); + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + + switch (arg) { + case "-plain" -> outputMode = OutputMode.PLAIN; + case "-ai" -> aiBuilder.enabled(true); + case "-ai-provider" -> aiBuilder.provider( + AiProvider.valueOf(nextArg(args, ++i, arg).toUpperCase()) + ); + case "-ai-model" -> aiBuilder.modelName(nextArg(args, ++i, arg)); + case "-ai-base-url" -> aiBuilder.baseUrl(nextArg(args, ++i, arg)); + case "-ai-api-key" -> aiBuilder.apiKey(nextArg(args, ++i, arg)); + case "-ai-api-key-env" -> aiBuilder.apiKeyEnv(nextArg(args, ++i, arg)); + case "-ai-taxonomy" -> aiBuilder.taxonomyFile(Paths.get(nextArg(args, ++i, arg))); + case "-ai-taxonomy-mode" -> aiBuilder.taxonomyMode( + AiOptions.TaxonomyMode.valueOf(nextArg(args, ++i, arg).toUpperCase()) + ); + case "-ai-max-class-chars" -> aiBuilder.maxClassChars( + Integer.parseInt(nextArg(args, ++i, arg)) + ); + case "-ai-timeout-sec" -> aiBuilder.timeout( + Duration.ofSeconds(Long.parseLong(nextArg(args, ++i, arg))) + ); + case "-ai-max-retries" -> aiBuilder.maxRetries( + Integer.parseInt(nextArg(args, ++i, arg)) + ); + default -> { + if (arg.startsWith("-")) { + throw new IllegalArgumentException("Unknown argument: " + arg); + } + paths.add(Paths.get(arg)); + } + } + } + + return new CliConfig(outputMode, aiBuilder.build(), paths); + } + + private static String nextArg(String[] args, int index, String option) { + if (index >= args.length) { + throw new IllegalArgumentException("Missing value for " + option); + } + return args[index]; + } + + private static boolean isJUnitTest(MethodDeclaration method) { + for (AnnotationExpr annotation : method.getAnnotations()) { + String name = annotation.getNameAsString(); + if ("Test".equals(name) || "ParameterizedTest".equals(name) || "RepeatedTest".equals(name)) { + return true; + } + } + + return false; + } + + private static int countLOC(MethodDeclaration method) { + return method.getRange() + .map(range -> range.end.line - range.begin.line + 1) + .orElse(0); + } + + private static List getTagValues(MethodDeclaration method) { + List tagValues = new ArrayList<>(); + + for (AnnotationExpr annotation : method.getAnnotations()) { + String name = annotation.getNameAsString(); + + if ("Tag".equals(name)) { + extractTagValue(annotation).ifPresent(tagValues::add); + } else if ("Tags".equals(name) && annotation.isNormalAnnotationExpr()) { + for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) { + if ("value".equals(pair.getNameAsString()) && pair.getValue().isArrayInitializerExpr()) { + ArrayInitializerExpr array = pair.getValue().asArrayInitializerExpr(); + for (Expression expression : array.getValues()) { + if (expression.isAnnotationExpr()) { + extractTagValue(expression.asAnnotationExpr()).ifPresent(tagValues::add); + } + } + } + } + } + } + + return tagValues; + } + + private static Optional extractTagValue(AnnotationExpr annotation) { + if (!"Tag".equals(annotation.getNameAsString())) { + return Optional.empty(); + } + + if (annotation.isSingleMemberAnnotationExpr()) { + Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue(); + if (memberValue.isStringLiteralExpr()) { + return Optional.of(memberValue.asStringLiteralExpr().asString()); + } + } + + if (annotation.isNormalAnnotationExpr()) { + for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) { + if ("value".equals(pair.getNameAsString()) && pair.getValue().isStringLiteralExpr()) { + return Optional.of(pair.getValue().asStringLiteralExpr().asString()); + } + } + } + + return Optional.empty(); + } +} diff --git a/build.gradle b/build.gradle index 338cb84..aaeb29d 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,10 @@ plugins { group = 'org.egothor.methodatlas' version = gitVersion(prefix:'release@') +configurations { + mockitoAgent +} + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -20,10 +24,27 @@ repositories { dependencies { implementation 'com.github.javaparser:javaparser-core:3.28.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.21.1' testImplementation(platform("org.junit:junit-bom:5.14.2")) testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core:5.22.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.22.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + mockitoAgent('org.mockito:mockito-core:5.22.0') { + transitive = false + } +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}" + + doFirst { + println "Mockito agent: ${configurations.mockitoAgent.singleFile}" + println "JVM args: ${jvmArgs}" + } } application { diff --git a/src/main/java/org/egothor/methodatlas/MethodAtlasApp.java b/src/main/java/org/egothor/methodatlas/MethodAtlasApp.java index 25b6bf6..8e88f33 100644 --- a/src/main/java/org/egothor/methodatlas/MethodAtlasApp.java +++ b/src/main/java/org/egothor/methodatlas/MethodAtlasApp.java @@ -4,11 +4,22 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Stream; + +import org.egothor.methodatlas.ai.AiClassSuggestion; +import org.egothor.methodatlas.ai.AiMethodSuggestion; +import org.egothor.methodatlas.ai.AiOptions; +import org.egothor.methodatlas.ai.AiProvider; +import org.egothor.methodatlas.ai.AiSuggestionEngine; +import org.egothor.methodatlas.ai.AiSuggestionEngineImpl; +import org.egothor.methodatlas.ai.AiSuggestionException; +import org.egothor.methodatlas.ai.SuggestionLookup; import com.github.javaparser.ParserConfiguration; import com.github.javaparser.ParserConfiguration.LanguageLevel; @@ -22,170 +33,289 @@ import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.MemberValuePair; /** - * Command-line utility that scans Java source trees for JUnit test methods and - * reports per-method statistics. + * Command-line application for scanning Java test sources, extracting JUnit + * test metadata, and optionally enriching the emitted results with AI-generated + * security tagging suggestions. * *

- * The tool walks one or more root directories, parses {@code *Test.java} files - * using JavaParser, and emits a record for each method annotated with one of - * the supported JUnit Jupiter test annotations. + * The application traverses one or more directory roots, parses matching source + * files using JavaParser, identifies supported JUnit Jupiter test methods, and + * emits one output record per discovered test method. The current file + * selection strategy includes only source files whose names end with + * {@code Test.java}. *

* - *

Detection

+ *

Source-Derived Metadata

+ * + *

+ * For each discovered test method, the application reports source-derived + * metadata including: + *

*
    - *
  • Test methods are detected by annotations {@code @Test}, - * {@code @ParameterizedTest}, and {@code @RepeatedTest} (simple name - * match).
  • - *
  • {@code @Tag} values are collected from repeated {@code @Tag("...")} - * annotations and from {@code @Tags({ @Tag("..."), ... })} containers.
  • - *
  • Lines of code (LOC) is computed from the AST source range: - * {@code endLine - beginLine + 1}. If the range is unavailable, LOC is - * {@code 0}.
  • + *
  • fully qualified class name
  • + *
  • method name
  • + *
  • inclusive line count of the method declaration
  • + *
  • JUnit {@code @Tag} values declared on the method
  • *
* - *

Output

- *

- * By default, the tool prints CSV with a header line: - * {@code fqcn,method,loc,tags}. The {@code tags} field is a semicolon-separated - * list. - *

+ *

AI Enrichment

* *

- * If {@code -plain} is provided as the first argument, the tool prints a plain - * text format: + * When AI support is enabled, the application submits each discovered test + * class to an {@link org.egothor.methodatlas.ai.AiSuggestionEngine} and merges + * the returned method-level suggestions into the emitted output. Depending on + * the configured provider and taxonomy, these suggestions may include: *

- *
- * fqcn, method, LOC=<n>, TAGS=<tag1;tag2>
- * 
+ *
    + *
  • whether a test method is considered security-relevant
  • + *
  • a suggested security-oriented display name
  • + *
  • taxonomy-based security tags
  • + *
  • an explanatory rationale
  • + *
+ * + *

Supported Command-Line Options

+ * *

- * If no tags are present, {@code TAGS=-} is printed. + * The application recognizes the following principal command-line options: + *

+ *
    + *
  • {@code -plain} — emits plain text output instead of CSV
  • + *
  • {@code -ai} — enables AI-based enrichment of emitted method records
  • + *
  • {@code -ai-provider } — selects the AI provider
  • + *
  • {@code -ai-model } — selects the provider-specific model
  • + *
  • {@code -ai-base-url } — overrides the provider base URL
  • + *
  • {@code -ai-api-key } — supplies the AI API key directly
  • + *
  • {@code -ai-api-key-env } — resolves the AI API key from an + * environment variable
  • + *
  • {@code -ai-taxonomy } — loads taxonomy text from an external + * file
  • + *
  • {@code -ai-taxonomy-mode } — selects the built-in taxonomy + * variant
  • + *
  • {@code -ai-max-class-chars } — limits class source size submitted + * to AI
  • + *
  • {@code -ai-timeout-sec } — sets the AI request timeout
  • + *
  • {@code -ai-max-retries } — sets the retry limit for AI + * operations
  • + *
+ * + *

+ * Any remaining non-option arguments are interpreted as root paths to scan. If + * no scan path is supplied, the current working directory is scanned. *

* - *

Examples

- * java -jar methodatlas.jar /path/to/repo
- * java -jar methodatlas.jar -plain /path/to/repo /another/repo
- * 
+ *

Output Modes

+ * + *

+ * The application supports two output modes: + *

+ *
    + *
  • CSV (default)
  • + *
  • Plain text, enabled by {@code -plain}
  • + *
+ * + *

+ * In CSV mode, the emitted header is: + *

+ *
{@code
+ * fqcn,method,loc,tags
+ * }
+ * + *

+ * When AI support is enabled, the emitted CSV header becomes: + *

+ *
{@code
+ * fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason
+ * }
+ * + *

Typical Usage

+ * + *
{@code
+ * java -jar methodatlas.jar /path/to/project
+ * }
+ * + *
{@code
+ * java -jar methodatlas.jar -plain /path/to/project
+ * }
+ * + *
{@code
+ * java -jar methodatlas.jar -ai -ai-provider ollama -ai-model qwen2.5-coder:7b /path/to/project
+ * }
+ * + * @see com.github.javaparser.StaticJavaParser + * @see org.egothor.methodatlas.ai.AiSuggestionEngine + * @see #main(String[]) */ public class MethodAtlasApp { - /** - * Logging facility for scan progress and parse failures. - */ + private static final Logger LOG = Logger.getLogger(MethodAtlasApp.class.getName()); /** * Output formats supported by the application. + * + *

+ * The selected mode determines both the emitted header and the per-method + * output representation. + *

*/ private enum OutputMode { /** - * Comma-separated values with a header line and raw numeric LOC. + * Emits output in comma-separated value format. + * + *

+ * Fields are escaped according to the rules implemented by + * {@link #csvEscape(String)}. + *

*/ CSV, /** - * Plain text lines including {@code LOC=} and {@code TAGS=} labels. + * Emits output in a human-readable plain text format. */ PLAIN } + /** + * Parsed command-line configuration used to drive a single application run. + * + * @param outputMode selected output mode + * @param aiOptions AI configuration controlling provider selection, taxonomy, + * limits, and timeouts + * @param paths root paths to scan; when empty, the current working + * directory is scanned + */ + private record CliConfig(OutputMode outputMode, AiOptions aiOptions, List paths) { + } + /** * Program entry point. * *

- * Usage: + * This method performs the complete startup sequence of the application: *

- *
-     * java -jar methodatlas.jar [ -plain ] <path1> [ <path2> ... ]
-     * 
- * - *
    - *
  • If {@code -plain} is provided as the first argument, the tool uses the - * plain-text output mode; otherwise CSV output is used.
  • - *
  • If no paths are provided, the current directory {@code "."} is - * scanned.
  • - *
+ *
    + *
  1. configures JavaParser for Java 21 source syntax
  2. + *
  3. parses command-line arguments into a structured runtime + * configuration
  4. + *
  5. initializes the AI suggestion engine when AI support is requested
  6. + *
  7. emits the CSV header when CSV output mode is selected
  8. + *
  9. scans the requested root paths, or the current directory when no path is + * supplied
  10. + *
* *

- * The JavaParser language level is configured before parsing to support modern - * Java syntax (for example, {@code record} declarations). + * Command-line arguments control both output rendering and optional AI + * enrichment. Non-option arguments are interpreted as root paths to scan. *

* - * @param args command-line arguments; see usage above - * @throws IOException if directory traversal fails while scanning input paths + *

+ * If no scan path is provided, the method scans the current working directory. + * If AI support is enabled and engine initialization fails, the method aborts + * by propagating an {@link IllegalStateException}. + *

+ * + * @param args command-line arguments controlling output mode, AI configuration, + * and scan roots + * @throws IOException if traversal of a configured file tree fails + * @throws IllegalArgumentException if an option is unknown, if a required + * option value is missing, or if an option + * value cannot be parsed into the required + * type + * @throws IllegalStateException if AI support is enabled but the AI engine + * cannot be created successfully + * @see #parseArgs(String[]) + * @see #buildAiEngine(AiOptions) + * @see #scanRoot(Path, OutputMode, AiOptions, AiSuggestionEngine) */ public static void main(String[] args) throws IOException { - ParserConfiguration pc = new ParserConfiguration(); - pc.setLanguageLevel(LanguageLevel.JAVA_21); // or JAVA_17, etc. - StaticJavaParser.setConfiguration(pc); + ParserConfiguration parserConfiguration = new ParserConfiguration(); + parserConfiguration.setLanguageLevel(LanguageLevel.JAVA_21); + StaticJavaParser.setConfiguration(parserConfiguration); - OutputMode mode = OutputMode.CSV; - int firstPathIndex = 0; + CliConfig cliConfig = parseArgs(args); + AiSuggestionEngine aiEngine = buildAiEngine(cliConfig.aiOptions()); - if (args.length > 0 && "-plain".equals(args[0])) { - mode = OutputMode.PLAIN; - firstPathIndex = 1; + if (cliConfig.outputMode() == OutputMode.CSV) { + if (cliConfig.aiOptions().enabled()) { + System.out.println("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason"); + } else { + System.out.println("fqcn,method,loc,tags"); + } } - if (mode == OutputMode.CSV) { - System.out.println("fqcn,method,loc,tags"); - } - - if (args.length <= firstPathIndex) { - scanRoot(Paths.get("."), mode); + if (cliConfig.paths().isEmpty()) { + scanRoot(Paths.get("."), cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine); return; } - for (int i = firstPathIndex; i < args.length; i++) { - scanRoot(Paths.get(args[i]), mode); + for (Path path : cliConfig.paths()) { + scanRoot(path, cliConfig.outputMode(), cliConfig.aiOptions(), aiEngine); } } /** - * Recursively scans the supplied root directory for Java test files and - * processes them. + * Recursively scans a directory tree for Java test source files and processes + * each matching file. * *

- * The current implementation matches files by suffix {@code "Test.java"}. + * The current implementation selects files whose names end with + * {@code Test.java}. *

* - * @param root root directory to scan - * @param mode output mode to use for emitted records - * @throws IOException if the file tree walk fails + * @param root root directory to scan + * @param mode output mode used for emitted records + * @param aiOptions AI configuration for the current run + * @param aiEngine AI engine used to enrich results, or {@code null} when AI + * support is disabled + * @throws IOException if traversing the file tree fails + * @see Files#walk(Path, java.nio.file.FileVisitOption...) */ - private static void scanRoot(Path root, OutputMode mode) throws IOException { + private static void scanRoot(Path root, OutputMode mode, AiOptions aiOptions, AiSuggestionEngine aiEngine) + throws IOException { LOG.log(Level.INFO, "Scanning {0} for JUnit files", root); - Files.walk(root).filter(p -> p.toString().endsWith("Test.java")).forEach(p -> processFile(p, mode)); + try (Stream stream = Files.walk(root)) { + stream.filter(path -> path.toString().endsWith("Test.java")) + .forEach(path -> processFile(path, mode, aiOptions, aiEngine)); + } } /** - * Parses a single Java source file and emits records for all detected JUnit - * test methods. + * Parses a single Java source file, discovers JUnit test methods, optionally + * resolves AI suggestions for their enclosing classes, and emits output records + * for each discovered test method. * *

- * Parse errors are logged. Files that cannot be parsed are skipped. + * Parsing and processing failures are logged and do not abort the overall scan. *

* - * @param path Java source file to parse - * @param mode output mode to use for emitted records + * @param path source file to parse + * @param mode output mode used for emitted records + * @param aiOptions AI configuration for the current run + * @param aiEngine AI engine used to enrich results, or {@code null} when AI + * support is disabled */ - private static void processFile(Path path, OutputMode mode) { + private static void processFile(Path path, OutputMode mode, AiOptions aiOptions, AiSuggestionEngine aiEngine) { try { - CompilationUnit cu = StaticJavaParser.parse(path); + CompilationUnit compilationUnit = StaticJavaParser.parse(path); + String packageName = compilationUnit.getPackageDeclaration() + .map(packageDeclaration -> packageDeclaration.getNameAsString()).orElse(""); - String pkg = cu.getPackageDeclaration().map(p -> p.getNameAsString()).orElse(""); - - cu.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> { + compilationUnit.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> { String className = clazz.getNameAsString(); - String fqcn = pkg.isEmpty() ? className : pkg + "." + className; + String fqcn = packageName.isEmpty() ? className : packageName + "." + className; + SuggestionLookup suggestionLookup = resolveSuggestionLookup(clazz, fqcn, aiOptions, aiEngine); clazz.findAll(MethodDeclaration.class).forEach(method -> { - if (isJUnitTest(method)) { - int loc = countLOC(method); - List tags = getTagValues(method); - emit(mode, fqcn, method.getNameAsString(), loc, tags); + if (!isJUnitTest(method)) { + return; } + + int loc = countLOC(method); + List tags = getTagValues(method); + AiMethodSuggestion suggestion = suggestionLookup.find(method.getNameAsString()).orElse(null); + + emit(mode, aiOptions.enabled(), fqcn, method.getNameAsString(), loc, tags, suggestion); }); }); - } catch (Exception e) { LOG.log(Level.WARNING, "Failed to parse: {0}", path); e.printStackTrace(); @@ -193,251 +323,413 @@ public class MethodAtlasApp { } /** - * Emits a single output record representing one test method. + * Resolves method-level AI suggestions for a parsed class. * *

- * For CSV output, values are printed as {@code fqcn,method,loc,tags} with CSV - * escaping applied to text fields. The {@code tags} field is a - * semicolon-separated list, or an empty field if no tags exist. + * If AI support is disabled, no engine is available, or the serialized class + * source exceeds the configured maximum size, the method returns an empty + * lookup. Failures produced by the AI engine are logged and also result in an + * empty lookup. *

* - *

- * For plain output, labels {@code LOC=} and {@code TAGS=} are included. If no - * tags exist, {@code TAGS=-} is printed. - *

- * - * @param mode output mode to use - * @param fqcn fully-qualified class name - * @param method method name - * @param loc lines of code for the method declaration (inclusive range) - * @param tags list of tag values; may be empty + * @param clazz parsed class declaration to analyze + * @param fqcn fully qualified class name of {@code clazz} + * @param aiOptions AI configuration for the current run + * @param aiEngine AI engine used to produce suggestions + * @return lookup of AI suggestions keyed by method name; never {@code null} */ - private static void emit(OutputMode mode, String fqcn, String method, int loc, List tags) { - if (mode == OutputMode.PLAIN) { - String tagText = tags.isEmpty() ? "-" : String.join(";", tags); - System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + tagText); - return; + private static SuggestionLookup resolveSuggestionLookup(ClassOrInterfaceDeclaration clazz, String fqcn, + AiOptions aiOptions, AiSuggestionEngine aiEngine) { + if (!aiOptions.enabled() || aiEngine == null) { + return SuggestionLookup.from(null); } - String tagText = tags.isEmpty() ? "" : String.join(";", tags); - System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(tagText)); + String classSource = clazz.toString(); + if (classSource.length() > aiOptions.maxClassChars()) { + LOG.log(Level.INFO, "Skipping AI for {0}: class source too large ({1} chars)", + new Object[] { fqcn, classSource.length() }); + return SuggestionLookup.from(null); + } + + try { + AiClassSuggestion aiClassSuggestion = aiEngine.suggestForClass(fqcn, classSource); + return SuggestionLookup.from(aiClassSuggestion); + } catch (AiSuggestionException e) { + LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn + ": " + e.getMessage()); + return SuggestionLookup.from(null); + } } /** - * Escapes a value for safe inclusion in a CSV field. + * Creates the AI suggestion engine for the current run. * *

- * If the value contains a comma, quote, or line break, the value is quoted and - * internal quotes are doubled, per common CSV conventions. + * If AI support is disabled, the method returns {@code null}. Initialization + * failures are wrapped in an {@link IllegalStateException} because they prevent + * execution of the requested AI-enabled mode. *

* - * @param value input value; may be {@code null} - * @return escaped CSV field value (never {@code null}) + * @param aiOptions AI configuration for the current run + * @return initialized AI suggestion engine, or {@code null} when AI is disabled + * @throws IllegalStateException if engine initialization fails + */ + private static AiSuggestionEngine buildAiEngine(AiOptions aiOptions) { + if (!aiOptions.enabled()) { + return null; + } + + try { + return new AiSuggestionEngineImpl(aiOptions); + } catch (AiSuggestionException e) { + throw new IllegalStateException("Failed to initialize AI engine", e); + } + } + + /** + * Dispatches emission of a single method record to the configured output + * renderer. + * + * @param mode selected output mode + * @param aiEnabled whether AI enrichment is enabled for the current run + * @param fqcn fully qualified class name containing the method + * @param method test method name + * @param loc inclusive line count of the method declaration + * @param tags source-level JUnit tags extracted from the method + * @param suggestion AI suggestion associated with the method, or {@code null} + * if none is available + */ + private static void emit(OutputMode mode, boolean aiEnabled, String fqcn, String method, int loc, List tags, + AiMethodSuggestion suggestion) { + if (mode == OutputMode.PLAIN) { + emitPlain(aiEnabled, fqcn, method, loc, tags, suggestion); + return; + } + + emitCsv(aiEnabled, fqcn, method, loc, tags, suggestion); + } + + /** + * Emits a single method record in plain text format. + * + *

+ * When AI support is disabled, only source-derived metadata is emitted. When AI + * support is enabled, the method appends AI-derived fields to the same line. + * Missing AI values are rendered as {@code -}. + *

+ * + * @param aiEnabled whether AI enrichment is enabled for the current run + * @param fqcn fully qualified class name containing the method + * @param method test method name + * @param loc inclusive line count of the method declaration + * @param tags source-level JUnit tags extracted from the method + * @param suggestion AI suggestion associated with the method, or {@code null} + * if none is available + */ + private static void emitPlain(boolean aiEnabled, String fqcn, String method, int loc, List tags, + AiMethodSuggestion suggestion) { + String existingTags = tags.isEmpty() ? "-" : String.join(";", tags); + + if (!aiEnabled) { + System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + existingTags); + return; + } + + String aiSecurity = suggestion == null ? "-" : Boolean.toString(suggestion.securityRelevant()); + String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "-" : suggestion.displayName(); + String aiTags = suggestion == null || suggestion.tags() == null || suggestion.tags().isEmpty() ? "-" + : String.join(";", suggestion.tags()); + String aiReason = suggestion == null || suggestion.reason() == null || suggestion.reason().isBlank() ? "-" + : suggestion.reason(); + + System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + existingTags + ", AI_SECURITY=" + + aiSecurity + ", AI_DISPLAY=" + aiDisplayName + ", AI_TAGS=" + aiTags + ", AI_REASON=" + aiReason); + } + + /** + * Emits a single method record in CSV format. + * + *

+ * All textual fields are escaped using {@link #csvEscape(String)}. When AI + * support is disabled, only the base columns are emitted. When AI support is + * enabled, the AI-related columns are appended in the header order established + * by {@link #main(String[])}. + *

+ * + * @param aiEnabled whether AI enrichment is enabled for the current run + * @param fqcn fully qualified class name containing the method + * @param method test method name + * @param loc inclusive line count of the method declaration + * @param tags source-level JUnit tags extracted from the method + * @param suggestion AI suggestion associated with the method, or {@code null} + * if none is available + */ + private static void emitCsv(boolean aiEnabled, String fqcn, String method, int loc, List tags, + AiMethodSuggestion suggestion) { + String existingTags = tags.isEmpty() ? "" : String.join(";", tags); + + if (!aiEnabled) { + System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(existingTags)); + return; + } + + String aiSecurity = suggestion == null ? "" : Boolean.toString(suggestion.securityRelevant()); + String aiDisplayName = suggestion == null || suggestion.displayName() == null ? "" : suggestion.displayName(); + String aiTags = suggestion == null || suggestion.tags() == null ? "" : String.join(";", suggestion.tags()); + String aiReason = suggestion == null || suggestion.reason() == null ? "" : suggestion.reason(); + + System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(existingTags) + "," + + csvEscape(aiSecurity) + "," + csvEscape(aiDisplayName) + "," + csvEscape(aiTags) + "," + + csvEscape(aiReason)); + } + + /** + * Escapes a value for CSV output. + * + *

+ * If the value contains a comma, double quote, carriage return, or line feed, + * it is wrapped in double quotes and embedded quotes are doubled. A + * {@code null} input is converted to an empty field. + *

+ * + * @param value value to escape; may be {@code null} + * @return CSV-safe representation of {@code value} */ private static String csvEscape(String value) { if (value == null) { return ""; } + boolean mustQuote = value.indexOf(',') >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0; if (!mustQuote) { return value; } + return "\"" + value.replace("\"", "\"\"") + "\""; } /** - * Determines whether the given method declaration represents a JUnit Jupiter + * Parses command-line arguments into a structured configuration object. + * + *

+ * The method recognizes output mode switches, AI-related options, and one or + * more scan paths. Unrecognized options beginning with {@code -} cause an + * {@link IllegalArgumentException}. + *

+ * + * @param args raw command-line arguments + * @return parsed command-line configuration + * @throws IllegalArgumentException if an option value is missing, malformed, or + * unsupported + */ + private static CliConfig parseArgs(String[] args) { + OutputMode outputMode = OutputMode.CSV; + List paths = new ArrayList<>(); + AiOptions.Builder aiBuilder = AiOptions.builder(); + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + + switch (arg) { + case "-plain" -> outputMode = OutputMode.PLAIN; + case "-ai" -> aiBuilder.enabled(true); + case "-ai-provider" -> aiBuilder.provider(AiProvider.valueOf(nextArg(args, ++i, arg).toUpperCase())); + case "-ai-model" -> aiBuilder.modelName(nextArg(args, ++i, arg)); + case "-ai-base-url" -> aiBuilder.baseUrl(nextArg(args, ++i, arg)); + case "-ai-api-key" -> aiBuilder.apiKey(nextArg(args, ++i, arg)); + case "-ai-api-key-env" -> aiBuilder.apiKeyEnv(nextArg(args, ++i, arg)); + case "-ai-taxonomy" -> aiBuilder.taxonomyFile(Paths.get(nextArg(args, ++i, arg))); + case "-ai-taxonomy-mode" -> + aiBuilder.taxonomyMode(AiOptions.TaxonomyMode.valueOf(nextArg(args, ++i, arg).toUpperCase())); + case "-ai-max-class-chars" -> aiBuilder.maxClassChars(Integer.parseInt(nextArg(args, ++i, arg))); + case "-ai-timeout-sec" -> + aiBuilder.timeout(Duration.ofSeconds(Long.parseLong(nextArg(args, ++i, arg)))); + case "-ai-max-retries" -> aiBuilder.maxRetries(Integer.parseInt(nextArg(args, ++i, arg))); + default -> { + if (arg.startsWith("-")) { + throw new IllegalArgumentException("Unknown argument: " + arg); + } + paths.add(Paths.get(arg)); + } + } + } + + return new CliConfig(outputMode, aiBuilder.build(), paths); + } + + /** + * Returns the argument value following an option token. + * + * @param args full command-line argument array + * @param index index of the expected option value + * @param option option whose value is being retrieved + * @return argument value at {@code index} + * @throws IllegalArgumentException if {@code index} is outside the bounds of + * {@code args} + */ + private static String nextArg(String[] args, int index, String option) { + if (index >= args.length) { + throw new IllegalArgumentException("Missing value for " + option); + } + return args[index]; + } + + /** + * Determines whether a method declaration represents a supported JUnit Jupiter * test method. * *

- * The method is considered a test if it is annotated with one of the supported - * test annotations by simple name: {@code Test}, {@code ParameterizedTest}, or - * {@code RepeatedTest}. + * The current implementation recognizes methods annotated with {@code @Test}, + * {@code @ParameterizedTest}, or {@code @RepeatedTest} by simple annotation + * name. *

* * @param method method declaration to inspect - * @return {@code true} if the method is considered a test method; {@code false} + * @return {@code true} if the method is treated as a test method; {@code false} * otherwise */ private static boolean isJUnitTest(MethodDeclaration method) { - for (AnnotationExpr ann : method.getAnnotations()) { - String name = ann.getNameAsString(); + for (AnnotationExpr annotation : method.getAnnotations()) { + String name = annotation.getNameAsString(); if ("Test".equals(name) || "ParameterizedTest".equals(name) || "RepeatedTest".equals(name)) { return true; } } + return false; } /** - * Collects all {@code @Tag} values declared directly on a method. + * Computes the inclusive line count of a method declaration from its source + * range. * *

- * Supported forms: + * If the parser did not retain source position information for the method, the + * method returns {@code 0}. *

- *
    - *
  • Repeated {@code @Tag("...")} annotations
  • - *
  • {@code @Tags({ @Tag("..."), @Tag("...") })} container annotation
  • - *
* - * @param method method declaration to inspect - * @return list of tag values in encounter order; never {@code null} + * @param method method declaration whose size should be measured + * @return inclusive line count, or {@code 0} if no range information is + * available + */ + private static int countLOC(MethodDeclaration method) { + return method.getRange().map(range -> range.end.line - range.begin.line + 1).orElse(0); + } + + /** + * Extracts all JUnit tag values declared on a method. + * + *

+ * The method supports both direct {@code @Tag} annotations and the + * container-style {@code @Tags} annotation. Tags are returned in declaration + * order. + *

+ * + * @param method method declaration whose annotations should be inspected + * @return list of extracted tag values; possibly empty but never {@code null} */ private static List getTagValues(MethodDeclaration method) { - List tags = new ArrayList<>(); + List tagValues = new ArrayList<>(); - for (AnnotationExpr ann : method.getAnnotations()) { - String name = ann.getNameAsString(); - if (isTagAnnotationName(name)) { - extractTagValue(ann).ifPresent(tags::add); - } else if (isTagsContainerAnnotationName(name)) { - tags.addAll(extractTagValuesFromContainer(ann)); + for (AnnotationExpr annotation : method.getAnnotations()) { + String name = annotation.getNameAsString(); + + if ("Tag".equals(name)) { + extractTagValue(annotation).ifPresent(tagValues::add); + } else if ("Tags".equals(name)) { + extractTagsContainerValues(annotation, tagValues); } } - return tags; + return tagValues; } /** - * Checks whether an annotation name represents {@code @Tag}. - * - * @param name annotation name (simple or qualified) - * @return {@code true} if it matches {@code Tag}; {@code false} otherwise - */ - private static boolean isTagAnnotationName(String name) { - return "Tag".equals(name) || name.endsWith(".Tag"); - } - - /** - * Checks whether an annotation name represents {@code @Tags} (the container for - * {@code @Tag}). - * - * @param name annotation name (simple or qualified) - * @return {@code true} if it matches {@code Tags}; {@code false} otherwise - */ - private static boolean isTagsContainerAnnotationName(String name) { - return "Tags".equals(name) || name.endsWith(".Tags"); - } - - /** - * Extracts the tag value from a {@code @Tag} annotation expression. + * Extracts tag values from a JUnit {@code @Tags} container annotation. * *

- * Both single-member and normal annotation syntaxes are supported: + * Both the single-member form {@code @Tags({@Tag("a"), @Tag("b")})} and the + * normal form {@code @Tags(value = {...})} are supported. *

- *
    - *
  • {@code @Tag("fast")}
  • - *
  • {@code @Tag(value = "fast")}
  • - *
* - * @param ann annotation expression representing {@code @Tag} - * @return extracted tag value, or empty if it cannot be determined + * @param annotation annotation expected to represent {@code @Tags} + * @param tagValues destination list to which extracted tag values are appended */ - private static Optional extractTagValue(AnnotationExpr ann) { - if (ann.isSingleMemberAnnotationExpr()) { - return Optional.of(expressionToTagText(ann.asSingleMemberAnnotationExpr().getMemberValue())); + private static void extractTagsContainerValues(AnnotationExpr annotation, List tagValues) { + if (annotation.isSingleMemberAnnotationExpr()) { + Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue(); + extractTagsFromContainerValue(memberValue, tagValues); + return; } - if (ann.isNormalAnnotationExpr()) { - for (MemberValuePair pair : ann.asNormalAnnotationExpr().getPairs()) { + if (annotation.isNormalAnnotationExpr()) { + for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) { if ("value".equals(pair.getNameAsString())) { - return Optional.of(expressionToTagText(pair.getValue())); + extractTagsFromContainerValue(pair.getValue(), tagValues); + } + } + } + } + + /** + * Extracts individual {@code @Tag} values from the value expression of a + * {@code @Tags} container annotation. + * + *

+ * If the supplied expression is not an array initializer, the method does + * nothing. + *

+ * + * @param value expression holding the container contents + * @param tagValues destination list to which extracted tag values are appended + */ + private static void extractTagsFromContainerValue(Expression value, List tagValues) { + if (!value.isArrayInitializerExpr()) { + return; + } + + ArrayInitializerExpr array = value.asArrayInitializerExpr(); + for (Expression expression : array.getValues()) { + if (expression.isAnnotationExpr()) { + extractTagValue(expression.asAnnotationExpr()).ifPresent(tagValues::add); + } + } + } + + /** + * Extracts the value from a single JUnit {@code @Tag} annotation. + * + *

+ * Both the single-member form {@code @Tag("x")} and the normal form + * {@code @Tag(value = "x")} are supported. + *

+ * + * @param annotation annotation expected to represent {@code @Tag} + * @return extracted tag value, or {@link Optional#empty()} if the annotation is + * not a supported {@code @Tag} form + */ + private static Optional extractTagValue(AnnotationExpr annotation) { + if (!"Tag".equals(annotation.getNameAsString())) { + return Optional.empty(); + } + + if (annotation.isSingleMemberAnnotationExpr()) { + Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue(); + if (memberValue.isStringLiteralExpr()) { + return Optional.of(memberValue.asStringLiteralExpr().asString()); + } + return Optional.empty(); + } + + if (annotation.isNormalAnnotationExpr()) { + for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) { + if ("value".equals(pair.getNameAsString()) && pair.getValue().isStringLiteralExpr()) { + return Optional.of(pair.getValue().asStringLiteralExpr().asString()); } } } return Optional.empty(); } - - /** - * Extracts all contained {@code @Tag} values from a {@code @Tags} container - * annotation. - * - *

- * Handles array initializers such as: - *

- *
-     * @Tags({ @Tag("a"), @Tag("b") })
-     * 
- * - * @param ann annotation expression representing {@code @Tags} - * @return list of extracted tag values; never {@code null} - */ - private static List extractTagValuesFromContainer(AnnotationExpr ann) { - List tags = new ArrayList<>(); - Optional maybeValue = Optional.empty(); - - if (ann.isSingleMemberAnnotationExpr()) { - maybeValue = Optional.of(ann.asSingleMemberAnnotationExpr().getMemberValue()); - } else if (ann.isNormalAnnotationExpr()) { - for (MemberValuePair pair : ann.asNormalAnnotationExpr().getPairs()) { - if ("value".equals(pair.getNameAsString())) { - maybeValue = Optional.of(pair.getValue()); - break; - } - } - } - - if (maybeValue.isEmpty()) { - return tags; - } - - Expression value = maybeValue.get(); - if (value.isArrayInitializerExpr()) { - ArrayInitializerExpr array = value.asArrayInitializerExpr(); - for (Expression element : array.getValues()) { - if (element.isAnnotationExpr()) { - AnnotationExpr inner = element.asAnnotationExpr(); - if (isTagAnnotationName(inner.getNameAsString())) { - extractTagValue(inner).ifPresent(tags::add); - } - } - } - } else if (value.isAnnotationExpr()) { - AnnotationExpr inner = value.asAnnotationExpr(); - if (isTagAnnotationName(inner.getNameAsString())) { - extractTagValue(inner).ifPresent(tags::add); - } - } - - return tags; - } - - /** - * Converts an annotation value expression to tag text. - * - *

- * String literals are returned as their unescaped string value. Other - * expressions are returned using {@link Expression#toString()}. - *

- * - * @param expr expression to convert; may be {@code null} - * @return converted tag text (never {@code null}) - */ - private static String expressionToTagText(Expression expr) { - if (expr == null) { - return ""; - } - if (expr.isStringLiteralExpr()) { - return expr.asStringLiteralExpr().asString(); - } - return expr.toString(); - } - - /** - * Computes the lines of code (LOC) for a method declaration using its source - * range. - * - * @param method method declaration - * @return inclusive LOC computed from the source range; {@code 0} if range is - * not available - */ - private static int countLOC(MethodDeclaration method) { - if (method.getRange().isPresent()) { - return method.getRange().get().end.line - method.getRange().get().begin.line + 1; - } - return 0; - } } diff --git a/src/main/java/org/egothor/methodatlas/ai/AiClassSuggestion.java b/src/main/java/org/egothor/methodatlas/ai/AiClassSuggestion.java new file mode 100644 index 0000000..504e7ef --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiClassSuggestion.java @@ -0,0 +1,47 @@ +package org.egothor.methodatlas.ai; + +import java.util.List; + +/** + * Immutable AI-generated classification result for a single parsed test class. + * + *

+ * This record represents the structured output returned by an AI suggestion + * engine after analyzing the source of one JUnit test class. It contains both + * optional class-level security classification data and method-level + * suggestions for individual test methods declared within the class. + *

+ * + *

+ * Class-level fields describe whether the class as a whole appears to be + * security-relevant and, if so, which aggregate tags or rationale apply. + * Method-level results are provided separately through {@link #methods()} and + * are typically used by the calling code as the primary source for per-method + * enrichment of emitted scan results. + *

+ * + *

+ * Instances of this record are commonly deserialized from provider-specific AI + * responses after normalization into the application's internal result model. + *

+ * + * @param className simple or fully qualified class name reported by + * the AI; may be {@code null} if omitted by the + * provider response + * @param classSecurityRelevant whether the class as a whole is considered + * security-relevant; may be {@code null} when the + * AI does not provide a class-level decision + * @param classTags class-level security tags suggested by the AI; + * may be empty or {@code null} depending on + * response normalization + * @param classReason explanatory rationale for the class-level + * classification; may be {@code null} + * @param methods method-level suggestions produced for individual + * test methods; may be empty or {@code null} + * depending on response normalization + * @see AiMethodSuggestion + * @see org.egothor.methodatlas.ai.AiSuggestionEngine + */ +public record AiClassSuggestion(String className, Boolean classSecurityRelevant, List classTags, + String classReason, List methods) { +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/AiMethodSuggestion.java b/src/main/java/org/egothor/methodatlas/ai/AiMethodSuggestion.java new file mode 100644 index 0000000..68408b6 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiMethodSuggestion.java @@ -0,0 +1,60 @@ +package org.egothor.methodatlas.ai; + +import java.util.List; + +/** + * Immutable AI-generated security classification result for a single test + * method. + * + *

+ * This record represents the normalized method-level output returned by an + * {@link org.egothor.methodatlas.ai.AiSuggestionEngine} after analyzing the + * source of a JUnit test class. Each instance describes the AI's interpretation + * of the security relevance and taxonomy classification of one test method. + *

+ * + *

+ * The classification data contained in this record is typically produced by an + * external AI provider and normalized by the application's AI integration layer + * before being returned to the scanning logic. The resulting values are then + * merged with source-derived metadata during output generation. + *

+ * + *

+ * The fields of this record correspond to the security analysis dimensions used + * by the {@code MethodAtlasApp} enrichment pipeline: + *

+ * + *
    + *
  • whether the test method validates a security property
  • + *
  • a suggested {@code @DisplayName} describing the security intent
  • + *
  • taxonomy-based security tags associated with the test
  • + *
  • a short explanatory rationale describing the classification
  • + *
+ * + *

+ * Instances of this record are typically stored in a + * {@link org.egothor.methodatlas.ai.SuggestionLookup} and retrieved using the + * method name as the lookup key when emitting enriched scan results. + *

+ * + * @param methodName name of the analyzed test method as reported by the + * AI + * @param securityRelevant {@code true} if the AI classified the test as + * validating a security property + * @param displayName suggested {@code @DisplayName} value describing the + * security intent of the test; may be {@code null} + * @param tags taxonomy-based security tags suggested for the test + * method; may be empty or {@code null} depending on + * provider response + * @param reason explanatory rationale describing why the method was + * classified as security-relevant or why specific tags + * were assigned; may be {@code null} + * + * @see org.egothor.methodatlas.MethodAtlasApp + * @see org.egothor.methodatlas.ai.AiSuggestionEngine + * @see org.egothor.methodatlas.ai.SuggestionLookup + */ +public record AiMethodSuggestion(String methodName, boolean securityRelevant, String displayName, List tags, + String reason) { +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/AiOptions.java b/src/main/java/org/egothor/methodatlas/ai/AiOptions.java new file mode 100644 index 0000000..ce514a5 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiOptions.java @@ -0,0 +1,338 @@ +package org.egothor.methodatlas.ai; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Objects; + +/** + * Immutable configuration describing how AI-based enrichment should be + * performed during a {@link org.egothor.methodatlas.MethodAtlasApp} execution. + * + *

+ * This record aggregates all runtime parameters required by the AI integration + * layer, including provider selection, model identification, authentication + * configuration, taxonomy selection, request limits, and retry behavior. + *

+ * + *

+ * Instances of this record are typically constructed using the associated + * {@link Builder} and passed to the AI subsystem when initializing an + * {@link AiSuggestionEngine}. The configuration is immutable once constructed + * and therefore safe to share between concurrent components. + *

+ * + *

Configuration Responsibilities

+ * + *
    + *
  • AI provider selection and endpoint configuration
  • + *
  • model name resolution
  • + *
  • API key discovery
  • + *
  • taxonomy configuration for security classification
  • + *
  • input size limits for class source submission
  • + *
  • network timeout configuration
  • + *
  • retry policy for transient AI failures
  • + *
+ * + *

+ * Default values are supplied by the {@link Builder} when parameters are not + * explicitly provided. + *

+ * + * @param enabled whether AI enrichment is enabled + * @param provider AI provider used to perform analysis + * @param modelName provider-specific model identifier + * @param baseUrl base API endpoint used by the selected provider + * @param apiKey API key used for authentication, if provided directly + * @param apiKeyEnv environment variable name containing the API key + * @param taxonomyFile optional path to an external taxonomy definition + * @param taxonomyMode built-in taxonomy mode to use when no file is provided + * @param maxClassChars maximum number of characters allowed for class source + * submitted to the AI provider + * @param timeout request timeout applied to AI calls + * @param maxRetries number of retry attempts for failed AI operations + * + * @see AiSuggestionEngine + * @see Builder + */ +public record AiOptions(boolean enabled, AiProvider provider, String modelName, String baseUrl, String apiKey, + String apiKeyEnv, Path taxonomyFile, TaxonomyMode taxonomyMode, int maxClassChars, Duration timeout, + int maxRetries) { + /** + * Built-in taxonomy modes used for security classification. + * + *

+ * These modes determine which internal taxonomy definition is supplied to the + * AI provider when an external taxonomy file is not configured. + *

+ * + *
    + *
  • {@link #DEFAULT} – general-purpose taxonomy suitable for human + * readability
  • + *
  • {@link #OPTIMIZED} – compact taxonomy optimized for AI classification + * accuracy
  • + *
+ */ + public enum TaxonomyMode { + /** + * Standard taxonomy definition emphasizing clarity and documentation. + */ + DEFAULT, + /** + * Reduced taxonomy optimized for improved AI classification reliability. + */ + OPTIMIZED + } + + /** + * Canonical constructor performing validation of configuration parameters. + * + *

+ * The constructor enforces basic invariants required for correct operation of + * the AI integration layer. Invalid values result in an + * {@link IllegalArgumentException}. + *

+ * + * @throws NullPointerException if required parameters such as + * {@code provider}, {@code modelName}, + * {@code timeout}, or {@code taxonomyMode} are + * {@code null} + * @throws IllegalArgumentException if configuration values violate required + * constraints + */ + public AiOptions { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(modelName, "modelName"); + Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(taxonomyMode, "taxonomyMode"); + + if (baseUrl == null || baseUrl.isBlank()) { + throw new IllegalArgumentException("baseUrl must not be blank"); + } + if (maxClassChars <= 0) { + throw new IllegalArgumentException("maxClassChars must be > 0"); + } + if (maxRetries < 0) { + throw new IllegalArgumentException("maxRetries must be >= 0"); + } + } + + /** + * Creates a new {@link Builder} used to construct {@link AiOptions} instances. + * + *

+ * The builder supplies sensible defaults for most configuration values and + * allows incremental customization before producing the final immutable + * configuration record. + *

+ * + * @return new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Resolves the effective API key used for authenticating AI provider requests. + * + *

+ * The resolution strategy is: + *

+ * + *
    + *
  1. If {@link #apiKey()} is defined and non-empty, it is returned.
  2. + *
  3. If {@link #apiKeyEnv()} is defined, the corresponding environment + * variable is resolved using {@link System#getenv(String)}.
  4. + *
  5. If neither source yields a value, {@code null} is returned.
  6. + *
+ * + * @return resolved API key or {@code null} if none is available + */ + public String resolvedApiKey() { + if (apiKey != null && !apiKey.isBlank()) { + return apiKey; + } + if (apiKeyEnv != null && !apiKeyEnv.isBlank()) { + String value = System.getenv(apiKeyEnv); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + /** + * Mutable builder used to construct validated {@link AiOptions} instances. + * + *

+ * The builder follows the conventional staged construction pattern, allowing + * optional parameters to be supplied before producing the final immutable + * configuration record via {@link #build()}. + *

+ * + *

+ * Reasonable defaults are provided for most parameters so that only + * provider-specific details typically need to be configured explicitly. + *

+ */ + public static final class Builder { + private boolean enabled; + private AiProvider provider = AiProvider.AUTO; + private String modelName = "qwen2.5-coder:7b"; + private String baseUrl; + private String apiKey; + private String apiKeyEnv; + private Path taxonomyFile; + private TaxonomyMode taxonomyMode = TaxonomyMode.DEFAULT; + private int maxClassChars = 40_000; + private Duration timeout = Duration.ofSeconds(90); + private int maxRetries = 1; + + /** + * Enables or disables AI enrichment. + * + * @param enabled {@code true} to enable AI integration + * @return this builder + */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Selects the AI provider. + * + * @param provider provider implementation to use + * @return this builder + */ + public Builder provider(AiProvider provider) { + this.provider = provider; + return this; + } + + /** + * Specifies the provider-specific model identifier. + * + * @param modelName name of the model to use + * @return this builder + */ + public Builder modelName(String modelName) { + this.modelName = modelName; + return this; + } + + /** + * Sets the base API endpoint used by the provider. + * + * @param baseUrl base URL of the provider API + * @return this builder + */ + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + /** + * Sets the API key used for authentication. + * + * @param apiKey API key value + * @return this builder + */ + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + /** + * Specifies the environment variable that stores the API key. + * + * @param apiKeyEnv environment variable name + * @return this builder + */ + public Builder apiKeyEnv(String apiKeyEnv) { + this.apiKeyEnv = apiKeyEnv; + return this; + } + + /** + * Specifies an external taxonomy definition file. + * + * @param taxonomyFile path to taxonomy definition + * @return this builder + */ + public Builder taxonomyFile(Path taxonomyFile) { + this.taxonomyFile = taxonomyFile; + return this; + } + + /** + * Selects the built-in taxonomy mode. + * + * @param taxonomyMode taxonomy variant + * @return this builder + */ + public Builder taxonomyMode(TaxonomyMode taxonomyMode) { + this.taxonomyMode = taxonomyMode; + return this; + } + + /** + * Sets the maximum size of class source submitted to the AI provider. + * + * @param maxClassChars maximum allowed character count + * @return this builder + */ + public Builder maxClassChars(int maxClassChars) { + this.maxClassChars = maxClassChars; + return this; + } + + /** + * Sets the timeout applied to AI requests. + * + * @param timeout request timeout + * @return this builder + */ + public Builder timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** + * Sets the retry limit for AI requests. + * + * @param maxRetries retry count + * @return this builder + */ + public Builder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + /** + * Builds the final immutable {@link AiOptions} configuration. + * + *

+ * If no base URL is explicitly supplied, a provider-specific default endpoint + * is selected automatically. + *

+ * + * @return validated AI configuration + */ + public AiOptions build() { + AiProvider effectiveProvider = provider == null ? AiProvider.AUTO : provider; + String effectiveBaseUrl = baseUrl; + + if (effectiveBaseUrl == null || effectiveBaseUrl.isBlank()) { + effectiveBaseUrl = switch (effectiveProvider) { + case AUTO, OLLAMA -> "http://localhost:11434"; + case OPENAI -> "https://api.openai.com"; + case OPENROUTER -> "https://openrouter.ai/api"; + case ANTHROPIC -> "https://api.anthropic.com"; + }; + } + + return new AiOptions(enabled, effectiveProvider, modelName, effectiveBaseUrl, apiKey, apiKeyEnv, + taxonomyFile, taxonomyMode, maxClassChars, timeout, maxRetries); + } + } +} diff --git a/src/main/java/org/egothor/methodatlas/ai/AiProvider.java b/src/main/java/org/egothor/methodatlas/ai/AiProvider.java new file mode 100644 index 0000000..7bd3007 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiProvider.java @@ -0,0 +1,88 @@ +package org.egothor.methodatlas.ai; + +/** + * Enumeration of supported AI provider implementations used by the + * {@link org.egothor.methodatlas.ai.AiSuggestionEngine}. + * + *

+ * Each constant represents a distinct AI platform capable of performing + * security classification of test sources. The provider selected through + * {@link AiOptions} determines which concrete client implementation is used for + * communicating with the external AI service. + *

+ * + *

+ * Provider integrations typically differ in authentication model, request + * format, endpoint structure, and supported model identifiers. The AI + * integration layer normalizes these differences so that the rest of the + * application can interact with a consistent abstraction. + *

+ * + *

Provider Selection

+ * + *

+ * The selected provider influences: + *

+ *
    + *
  • the HTTP endpoint used for inference requests
  • + *
  • authentication behavior
  • + *
  • the model identifier format
  • + *
  • response normalization logic
  • + *
+ * + *

+ * When {@link #AUTO} is selected, the system attempts to determine the most + * suitable provider automatically based on the configured endpoint or local + * runtime environment. + *

+ * + * @see AiOptions + * @see AiSuggestionEngine + */ +public enum AiProvider { + /** + * Automatically selects the most appropriate AI provider based on configuration + * and runtime availability. + * + *

+ * This mode allows the application to operate with minimal configuration, + * preferring locally available providers when possible. + *

+ */ + AUTO, + /** + * Uses a locally running Ollama instance as + * the AI inference backend. + * + *

+ * This provider typically communicates with an HTTP endpoint hosted on the + * local machine and allows the use of locally installed large language models + * without external API calls. + *

+ */ + OLLAMA, + /** + * Uses the OpenAI API for AI inference. + * + *

+ * Requests are sent to the OpenAI platform using API key authentication and + * provider-specific model identifiers such as {@code gpt-4} or {@code gpt-4o}. + *

+ */ + OPENAI, + /** + * Uses the OpenRouter aggregation service + * to access multiple AI models through a unified API. + * + *

+ * OpenRouter acts as a routing layer that forwards requests to different + * underlying model providers while maintaining a consistent API surface. + *

+ */ + OPENROUTER, + /** + * Uses the Anthropic API for AI + * inference, typically through models in the Claude family. + */ + ANTHROPIC +} diff --git a/src/main/java/org/egothor/methodatlas/ai/AiProviderClient.java b/src/main/java/org/egothor/methodatlas/ai/AiProviderClient.java new file mode 100644 index 0000000..81a8e27 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiProviderClient.java @@ -0,0 +1,93 @@ +package org.egothor.methodatlas.ai; + +/** + * Provider-specific client abstraction used to communicate with external AI + * inference services. + * + *

+ * Implementations of this interface encapsulate the protocol and request + * formatting required to interact with a particular AI provider such as OpenAI, + * Ollama, Anthropic, or OpenRouter. The interface isolates the rest of the + * application from provider-specific details including authentication, endpoint + * layout, and response normalization. + *

+ * + *

+ * Instances are typically created by the AI integration layer during + * initialization of the {@link AiSuggestionEngine}. Each client is responsible + * for transforming a class-level analysis request into the provider’s native + * API format and mapping the response back into the internal + * {@link AiClassSuggestion} representation used by the application. + *

+ * + *

Provider Responsibilities

+ * + *
    + *
  • constructing provider-specific HTTP requests
  • + *
  • handling authentication and API keys
  • + *
  • sending inference requests
  • + *
  • parsing and validating AI responses
  • + *
  • normalizing results into {@link AiClassSuggestion}
  • + *
+ * + *

+ * Implementations are expected to be stateless and thread-safe unless + * explicitly documented otherwise. + *

+ * + * @see AiSuggestionEngine + * @see AiClassSuggestion + * @see AiProvider + */ +public interface AiProviderClient { + /** + * Determines whether the provider is reachable and usable in the current + * runtime environment. + * + *

+ * Implementations typically perform a lightweight availability check such as + * probing the provider's base endpoint or verifying that required configuration + * (for example, API keys or local services) is present. + *

+ * + *

+ * This method is primarily used when {@link AiProvider#AUTO} selection is + * enabled so the system can choose the first available provider. + *

+ * + * @return {@code true} if the provider appears available and ready to accept + * inference requests; {@code false} otherwise + */ + boolean isAvailable(); + + /** + * Requests AI-based security classification for a parsed test class. + * + *

+ * The implementation submits the provided class source code together with the + * taxonomy specification to the underlying AI provider. The provider analyzes + * the class and produces structured classification results for the class itself + * and for each test method contained within the class. + *

+ * + *

+ * The response is normalized into an {@link AiClassSuggestion} instance + * containing both class-level metadata and a list of {@link AiMethodSuggestion} + * objects describing individual test methods. + *

+ * + * @param fqcn fully qualified name of the analyzed class + * @param classSource complete source code of the class being analyzed + * @param taxonomyText security taxonomy definition guiding the AI + * classification + * @return normalized AI classification result + * + * @throws AiSuggestionException if the request fails due to provider errors, + * malformed responses, or communication failures + * + * @see AiClassSuggestion + * @see AiMethodSuggestion + */ + AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText) + throws AiSuggestionException; +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/AiProviderFactory.java b/src/main/java/org/egothor/methodatlas/ai/AiProviderFactory.java new file mode 100644 index 0000000..3fd8ede --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiProviderFactory.java @@ -0,0 +1,149 @@ +package org.egothor.methodatlas.ai; + +/** + * Factory responsible for creating provider-specific AI client implementations. + * + *

+ * This class centralizes the logic for selecting and constructing concrete + * {@link AiProviderClient} implementations based on the configuration provided + * through {@link AiOptions}. It abstracts provider instantiation from the rest + * of the application so that higher-level components interact only with the + * {@link AiProviderClient} interface. + *

+ * + *

Provider Resolution

+ * + *

+ * When an explicit provider is configured in {@link AiOptions#provider()}, the + * factory constructs the corresponding client implementation. When + * {@link AiProvider#AUTO} is selected, the factory attempts to determine a + * suitable provider automatically using the following strategy: + *

+ * + *
    + *
  1. Attempt to use a locally running {@link OllamaClient}.
  2. + *
  3. If Ollama is not reachable and an API key is configured, fall back to an + * OpenAI-compatible provider.
  4. + *
  5. If no provider can be resolved, an {@link AiSuggestionException} is + * thrown.
  6. + *
+ * + *

+ * The factory ensures that returned clients are usable by verifying provider + * availability when required. + *

+ * + *

+ * This class is intentionally non-instantiable and exposes only static factory + * methods. + *

+ * + * @see AiProviderClient + * @see AiProvider + * @see AiOptions + */ +public final class AiProviderFactory { + /** + * Prevents instantiation of this utility class. + */ + private AiProviderFactory() { + } + + /** + * Creates a provider-specific {@link AiProviderClient} based on the supplied + * configuration. + * + *

+ * The selected provider determines which concrete implementation is + * instantiated and how availability checks are performed. When + * {@link AiProvider#AUTO} is configured, the method delegates provider + * selection to {@link #auto(AiOptions)}. + *

+ * + * @param options AI configuration describing provider, model, endpoint, + * authentication, and runtime limits + * @return initialized provider client ready to perform inference requests + * + * @throws AiSuggestionException if the provider cannot be initialized, required + * authentication is missing, or no suitable + * provider can be resolved + */ + public static AiProviderClient create(AiOptions options) throws AiSuggestionException { + return switch (options.provider()) { + case OLLAMA -> new OllamaClient(options); + case OPENAI -> requireAvailable(new OpenAiCompatibleClient(options), "OpenAI API key missing"); + case OPENROUTER -> requireAvailable(new OpenAiCompatibleClient(options), "OpenRouter API key missing"); + case ANTHROPIC -> requireAvailable(new AnthropicClient(options), "Anthropic API key missing"); + case AUTO -> auto(options); + }; + } + + /** + * Performs automatic provider discovery when {@link AiProvider#AUTO} is + * selected. + * + *

+ * The discovery process prioritizes locally available inference services to + * enable operation without external dependencies whenever possible. + *

+ * + *

+ * The current discovery strategy is: + *

+ *
    + *
  1. Attempt to connect to a local {@link OllamaClient}.
  2. + *
  3. If Ollama is not available but an API key is configured, create an + * {@link OpenAiCompatibleClient}.
  4. + *
  5. If neither provider can be used, throw an exception.
  6. + *
+ * + * @param options AI configuration used to construct candidate providers + * @return resolved provider client + * + * @throws AiSuggestionException if no suitable provider can be discovered + */ + private static AiProviderClient auto(AiOptions options) throws AiSuggestionException { + AiOptions ollamaOptions = AiOptions.builder().enabled(options.enabled()).provider(AiProvider.OLLAMA) + .modelName(options.modelName()).baseUrl(options.baseUrl()).taxonomyFile(options.taxonomyFile()) + .maxClassChars(options.maxClassChars()).timeout(options.timeout()).maxRetries(options.maxRetries()) + .build(); + + OllamaClient ollamaClient = new OllamaClient(ollamaOptions); + if (ollamaClient.isAvailable()) { + return ollamaClient; + } + + String apiKey = options.resolvedApiKey(); + if (apiKey != null && !apiKey.isBlank()) { + return new OpenAiCompatibleClient(options); + } + + throw new AiSuggestionException( + "No AI provider available. Ollama is not reachable and no API key is configured."); + } + + /** + * Ensures that a provider client is available before returning it. + * + *

+ * This helper method invokes {@link AiProviderClient#isAvailable()} and throws + * an {@link AiSuggestionException} if the provider cannot be used. It is + * primarily used when constructing clients that require external services or + * authentication to function correctly. + *

+ * + * @param client provider client to verify + * @param message error message used if the provider is unavailable + * @return the supplied client if it is available + * + * @throws AiSuggestionException if the provider reports that it is not + * available + */ + private static AiProviderClient requireAvailable(AiProviderClient client, String message) + throws AiSuggestionException { + if (!client.isAvailable()) { + throw new AiSuggestionException(message); + } + return client; + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/AiSuggestionEngine.java b/src/main/java/org/egothor/methodatlas/ai/AiSuggestionEngine.java new file mode 100644 index 0000000..8e82f35 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiSuggestionEngine.java @@ -0,0 +1,66 @@ +package org.egothor.methodatlas.ai; + +/** + * High-level AI orchestration contract for security classification of parsed + * test classes. + * + *

+ * This interface defines the provider-agnostic entry point used by + * {@link org.egothor.methodatlas.MethodAtlasApp} to request AI-generated + * security tagging suggestions for a single parsed JUnit test class. + * Implementations coordinate taxonomy selection, provider resolution, request + * submission, response normalization, and conversion into the application's + * internal result model. + *

+ * + *

Responsibilities

+ * + *
    + *
  • accepting a fully qualified class name and corresponding class + * source
  • + *
  • submitting the class for AI-based security analysis
  • + *
  • normalizing provider-specific responses into + * {@link AiClassSuggestion}
  • + *
  • surfacing failures through {@link AiSuggestionException}
  • + *
+ * + *

+ * The interface intentionally hides provider-specific protocol details so that + * the rest of the application can depend on a stable abstraction independent of + * the selected AI backend. + *

+ * + * @see AiClassSuggestion + * @see AiProviderClient + * @see org.egothor.methodatlas.MethodAtlasApp + */ +public interface AiSuggestionEngine { + /** + * Requests AI-generated security classification for a single parsed test class. + * + *

+ * The supplied source code is analyzed in the context of the configured + * taxonomy and AI provider. The returned result may contain both class-level + * and method-level suggestions, including security relevance, display name + * proposals, taxonomy tags, and optional explanatory rationale. + *

+ * + *

+ * The method expects the complete source of the class being analyzed, rather + * than a single method fragment, so that the AI engine can evaluate test intent + * using full class context. + *

+ * + * @param fqcn fully qualified class name of the parsed test class + * @param classSource complete source code of the class to analyze + * @return normalized AI classification result for the class and its methods + * + * @throws AiSuggestionException if analysis fails due to provider communication + * errors, invalid responses, provider + * unavailability, or normalization failures + * + * @see AiClassSuggestion + * @see AiMethodSuggestion + */ + AiClassSuggestion suggestForClass(String fqcn, String classSource) throws AiSuggestionException; +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/AiSuggestionEngineImpl.java b/src/main/java/org/egothor/methodatlas/ai/AiSuggestionEngineImpl.java new file mode 100644 index 0000000..8da4da9 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiSuggestionEngineImpl.java @@ -0,0 +1,126 @@ +package org.egothor.methodatlas.ai; + +import java.io.IOException; +import java.nio.file.Files; + +/** + * Default implementation of {@link AiSuggestionEngine} that coordinates + * provider selection and taxonomy loading for AI-based security classification. + * + *

+ * This implementation acts as the primary orchestration layer between the + * command-line application and the provider-specific AI client subsystem. It + * resolves the effective {@link AiProviderClient} through + * {@link AiProviderFactory}, loads the taxonomy text used to guide + * classification, and delegates class-level analysis requests to the selected + * provider client. + *

+ * + *

Responsibilities

+ * + *
    + *
  • creating the effective provider client from {@link AiOptions}
  • + *
  • loading taxonomy text from a configured file or from the selected + * built-in taxonomy mode
  • + *
  • delegating class analysis requests to the provider client
  • + *
  • presenting a provider-independent {@link AiSuggestionEngine} contract to + * higher-level callers
  • + *
+ * + *

+ * Instances of this class are immutable after construction and are intended to + * be created once per application run. + *

+ * + * @see AiSuggestionEngine + * @see AiProviderFactory + * @see AiProviderClient + * @see AiOptions.TaxonomyMode + */ +public final class AiSuggestionEngineImpl implements AiSuggestionEngine { + + private final AiProviderClient client; + private final String taxonomyText; + + /** + * Creates a new AI suggestion engine using the supplied runtime options. + * + *

+ * During construction, the implementation resolves the effective provider + * client and loads the taxonomy text that will be supplied to the AI provider + * for subsequent classification requests. The taxonomy is taken from an + * external file when configured; otherwise, the built-in taxonomy selected by + * {@link AiOptions#taxonomyMode()} is used. + *

+ * + * @param options AI runtime configuration controlling provider selection, + * taxonomy loading, and request behavior + * + * @throws AiSuggestionException if provider initialization fails or if the + * configured taxonomy cannot be loaded + */ + public AiSuggestionEngineImpl(AiOptions options) throws AiSuggestionException { + this.client = AiProviderFactory.create(options); + this.taxonomyText = loadTaxonomy(options); + } + + /** + * Requests AI-generated security classification for a single parsed test class. + * + *

+ * The method delegates directly to the configured {@link AiProviderClient}, + * supplying the fully qualified class name, the complete class source, and the + * taxonomy text loaded at engine initialization time. + *

+ * + * @param fqcn fully qualified class name of the analyzed test class + * @param classSource complete source code of the class to analyze + * @return normalized AI classification result for the class and its methods + * + * @throws AiSuggestionException if the provider fails to analyze the class or + * returns an invalid response + * + * @see AiClassSuggestion + * @see AiProviderClient#suggestForClass(String, String, String) + */ + @Override + public AiClassSuggestion suggestForClass(String fqcn, String classSource) throws AiSuggestionException { + return client.suggestForClass(fqcn, classSource, taxonomyText); + } + + /** + * Loads the taxonomy text used to guide AI classification. + * + *

+ * Resolution order: + *

+ *
    + *
  1. If an external taxonomy file is configured, its contents are used.
  2. + *
  3. Otherwise, the built-in taxonomy selected by + * {@link AiOptions#taxonomyMode()} is used.
  4. + *
+ * + * @param options AI runtime configuration + * @return taxonomy text to be supplied to the AI provider + * + * @throws AiSuggestionException if an external taxonomy file is configured but + * cannot be read successfully + * + * @see DefaultSecurityTaxonomy#text() + * @see OptimizedSecurityTaxonomy#text() + */ + private static String loadTaxonomy(AiOptions options) throws AiSuggestionException { + if (options.taxonomyFile() != null) { + try { + return Files.readString(options.taxonomyFile()); + } catch (IOException e) { + throw new AiSuggestionException("Failed to read taxonomy file: " + options.taxonomyFile(), e); + } + } + + return switch (options.taxonomyMode()) { + case DEFAULT -> DefaultSecurityTaxonomy.text(); + case OPTIMIZED -> OptimizedSecurityTaxonomy.text(); + }; + } +} diff --git a/src/main/java/org/egothor/methodatlas/ai/AiSuggestionException.java b/src/main/java/org/egothor/methodatlas/ai/AiSuggestionException.java new file mode 100644 index 0000000..5130085 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AiSuggestionException.java @@ -0,0 +1,47 @@ +package org.egothor.methodatlas.ai; + +/** + * Checked exception indicating failure during AI-based suggestion generation or + * related AI subsystem operations. + * + *

+ * This exception is used throughout the AI integration layer to report provider + * initialization failures, taxonomy loading errors, connectivity problems, + * malformed provider responses, and other conditions that prevent successful + * generation of AI-based classification results. + *

+ * + *

+ * The exception is declared as a checked exception because such failures are + * part of the normal operational contract of the AI subsystem and callers are + * expected to either handle them explicitly or convert them into higher-level + * application failures when AI support is mandatory. + *

+ * + * @see AiSuggestionEngine + * @see AiProviderClient + * @see AiSuggestionEngineImpl + */ +public final class AiSuggestionException extends Exception { + + private static final long serialVersionUID = 6365662915183382629L; + + /** + * Creates a new exception with the specified detail message. + * + * @param message detail message describing the failure + */ + public AiSuggestionException(String message) { + super(message); + } + + /** + * Creates a new exception with the specified detail message and cause. + * + * @param message detail message describing the failure + * @param cause underlying cause of the failure + */ + public AiSuggestionException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/AnthropicClient.java b/src/main/java/org/egothor/methodatlas/ai/AnthropicClient.java new file mode 100644 index 0000000..45b6eb0 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/AnthropicClient.java @@ -0,0 +1,259 @@ +package org.egothor.methodatlas.ai; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * {@link AiProviderClient} implementation for the Anthropic API. + * + *

+ * This client submits classification requests to the Anthropic + * Claude API and converts the + * returned response into the internal {@link AiClassSuggestion} model used by + * the MethodAtlas AI subsystem. + *

+ * + *

Operational Responsibilities

+ * + *
    + *
  • constructing Anthropic message API requests
  • + *
  • injecting the taxonomy-driven classification prompt
  • + *
  • performing authenticated HTTP calls to the Anthropic service
  • + *
  • extracting the JSON result embedded in the model response
  • + *
  • normalizing the result into {@link AiClassSuggestion}
  • + *
+ * + *

+ * The client uses the {@code /v1/messages} endpoint and relies on the Claude + * message format, where a system prompt defines classification rules and the + * user message contains the class source together with the taxonomy + * specification. + *

+ * + *

+ * Instances of this class are typically created by + * {@link AiProviderFactory#create(AiOptions)}. + *

+ * + *

+ * This implementation is stateless apart from immutable configuration and is + * therefore safe for reuse across multiple requests. + *

+ * + * @see AiProviderClient + * @see AiSuggestionEngine + * @see AiProviderFactory + */ +public final class AnthropicClient implements AiProviderClient { + /** + * System prompt used to instruct the model to return strictly formatted JSON + * responses suitable for automated parsing. + * + *

+ * The prompt enforces deterministic output behavior and prevents the model from + * returning explanations, markdown formatting, or conversational responses that + * would break the JSON extraction pipeline. + *

+ */ + private static final String SYSTEM_PROMPT = """ + You are a precise software security classification engine. + You classify JUnit 5 tests and return strict JSON only. + Never include markdown fences, explanations, or extra text. + """; + + private final AiOptions options; + private final HttpSupport httpSupport; + + /** + * Creates a new Anthropic client using the supplied runtime configuration. + * + *

+ * The configuration defines the model identifier, API endpoint, request + * timeout, and authentication settings used when communicating with the + * Anthropic service. + *

+ * + * @param options AI runtime configuration + */ + public AnthropicClient(AiOptions options) { + this.options = options; + this.httpSupport = new HttpSupport(options.timeout()); + } + + /** + * Determines whether the Anthropic provider can be used in the current runtime + * environment. + * + *

+ * The provider is considered available when a non-empty API key can be resolved + * from {@link AiOptions#resolvedApiKey()}. + *

+ * + * @return {@code true} if a usable API key is configured + */ + @Override + public boolean isAvailable() { + return options.resolvedApiKey() != null && !options.resolvedApiKey().isBlank(); + } + + /** + * Submits a classification request to the Anthropic API for the specified test + * class. + * + *

+ * The method constructs a message-based request containing: + *

+ * + *
    + *
  • a system prompt enforcing deterministic JSON output
  • + *
  • a user prompt containing the class source and taxonomy definition
  • + *
+ * + *

+ * The response is parsed to extract the first JSON object returned by the + * model, which is then deserialized into an {@link AiClassSuggestion}. + *

+ * + * @param fqcn fully qualified class name being analyzed + * @param classSource complete source code of the class + * @param taxonomyText taxonomy definition guiding classification + * + * @return normalized AI classification result + * + * @throws AiSuggestionException if the provider request fails, the response + * cannot be parsed, or the provider returns + * invalid content + */ + @Override + public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText) + throws AiSuggestionException { + try { + String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText); + + MessageRequest payload = new MessageRequest(options.modelName(), SYSTEM_PROMPT, + List.of(new ContentMessage("user", List.of(new ContentBlock("text", prompt)))), 0.0, 2_000); + + String requestBody = httpSupport.objectMapper().writeValueAsString(payload); + URI uri = URI.create(options.baseUrl() + "/v1/messages"); + + HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout()) + .header("x-api-key", options.resolvedApiKey()).header("anthropic-version", "2023-06-01").build(); + + String responseBody = httpSupport.postJson(request); + MessageResponse response = httpSupport.objectMapper().readValue(responseBody, MessageResponse.class); + + if (response.content == null || response.content.isEmpty()) { + throw new AiSuggestionException("No content returned by Anthropic"); + } + + String text = response.content.stream().filter(block -> "text".equals(block.type)).map(block -> block.text) + .filter(value -> value != null && !value.isBlank()).findFirst() + .orElseThrow(() -> new AiSuggestionException("Anthropic returned no text block")); + + String json = JsonText.extractFirstJsonObject(text); + AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class); + return normalize(suggestion); + + } catch (Exception e) { + throw new AiSuggestionException("Anthropic suggestion failed for " + fqcn, e); + } + } + + /** + * Normalizes AI results returned by the provider. + * + *

+ * This method ensures that collection fields are never {@code null} and removes + * malformed method entries that do not contain a valid method name. + *

+ * + *

+ * The normalization step protects the rest of the application from + * provider-side inconsistencies and guarantees that the resulting + * {@link AiClassSuggestion} object satisfies the expected invariants. + *

+ * + * @param input raw suggestion returned by the provider + * @return normalized suggestion instance + */ + private static AiClassSuggestion normalize(AiClassSuggestion input) { + List methods = input.methods() == null ? List.of() : input.methods(); + List classTags = input.classTags() == null ? List.of() : input.classTags(); + + List normalizedMethods = methods.stream() + .filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank()) + .map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(), + method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason())) + .toList(); + + return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(), + normalizedMethods); + } + + /** + * Request payload sent to the Anthropic message API. + * + *

+ * This record models the JSON structure expected by the {@code /v1/messages} + * endpoint and is serialized using Jackson before transmission. + *

+ * + * @param model model identifier + * @param system system prompt controlling model behavior + * @param messages list of message objects forming the conversation + * @param temperature sampling temperature + * @param maxTokens maximum token count for the response + */ + private record MessageRequest(String model, String system, List messages, Double temperature, + @JsonProperty("max_tokens") Integer maxTokens) { + } + + /** + * Message container used by the Anthropic message API. + * + * @param role role of the message sender (for example {@code user}) + * @param content message content blocks + */ + private record ContentMessage(String role, List content) { + } + + /** + * Individual content block within a message payload. + * + * @param type block type (for example {@code text}) + * @param text textual content of the block + */ + private record ContentBlock(String type, String text) { + } + + /** + * Partial response model returned by the Anthropic API. + * + *

+ * Only the fields required by this client are mapped. Additional fields are + * ignored to maintain forward compatibility with API changes. + *

+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class MessageResponse { + public List content; + } + + /** + * Content block returned within a provider response. + * + *

+ * The client scans these blocks to locate the first text segment containing the + * JSON classification result. + *

+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ResponseBlock { + public String type; + public String text; + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/DefaultSecurityTaxonomy.java b/src/main/java/org/egothor/methodatlas/ai/DefaultSecurityTaxonomy.java new file mode 100644 index 0000000..fa2e436 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/DefaultSecurityTaxonomy.java @@ -0,0 +1,416 @@ +package org.egothor.methodatlas.ai; + +/** + * Provides the default built-in taxonomy used to guide AI-based security + * classification of JUnit test methods. + * + *

+ * This class exposes a human-readable taxonomy definition that is supplied to + * the AI suggestion engine when no external taxonomy file is configured and + * {@link org.egothor.methodatlas.ai.AiOptions.TaxonomyMode#DEFAULT} is + * selected. The taxonomy defines the controlled vocabulary, decision rules, and + * naming conventions used when classifying security-relevant tests. + *

+ * + *

Purpose

+ * + *

+ * The taxonomy is designed to improve classification consistency by providing + * the AI provider with a stable and explicit specification of: + *

+ *
    + *
  • what constitutes a security-relevant test
  • + *
  • which security category tags are allowed
  • + *
  • how tags should be selected
  • + *
  • how security-oriented display names should be formed
  • + *
+ * + *

+ * The default taxonomy favors readability and professional descriptive clarity. + * For a more compact taxonomy tuned specifically for model reliability, see + * {@link OptimizedSecurityTaxonomy}. + *

+ * + *

+ * This class is a non-instantiable utility holder. + *

+ * + * @see OptimizedSecurityTaxonomy + * @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl + * @see org.egothor.methodatlas.ai.AiOptions.TaxonomyMode + */ +public final class DefaultSecurityTaxonomy { + /** + * Prevents instantiation of this utility class. + */ + private DefaultSecurityTaxonomy() { + } + + /** + * Returns the default built-in taxonomy text used for AI classification. + * + *

+ * The returned text is intended to be embedded directly into provider prompts + * and therefore contains both conceptual guidance and operational + * classification rules. It defines: + *

+ *
    + *
  • scope of security-relevant tests
  • + *
  • mandatory and optional tagging rules
  • + *
  • allowed taxonomy categories
  • + *
  • guidance for class-level versus method-level tagging
  • + *
  • display name conventions
  • + *
  • AI-oriented decision instructions
  • + *
+ * + *

+ * The taxonomy includes the following category tags: {@code auth}, + * {@code access-control}, {@code crypto}, {@code input-validation}, + * {@code injection}, {@code data-protection}, {@code logging}, + * {@code error-handling}, and {@code owasp}. + *

+ * + *

+ * The returned value is immutable text and may safely be reused across multiple + * AI requests. + *

+ * + * @return default taxonomy text used to instruct AI classification + * + * @see OptimizedSecurityTaxonomy#text() + * @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl + */ + public static String text() { + return """ + SECURITY TEST TAGGING TAXONOMY + ============================== + + Purpose + ------- + This taxonomy defines a controlled vocabulary for labeling security-relevant JUnit tests. + The goal is to enable automated classification of test methods that validate security + properties, controls, mitigations, or invariants. + + The taxonomy is intentionally small and stable to avoid uncontrolled tag proliferation. + + Classification Scope + -------------------- + + Applies to: + - JUnit 5 test classes and methods + - primarily unit tests + - integration tests may follow the same model when applicable + + A test should be considered *security-relevant* if its failure could plausibly lead to: + + - loss of confidentiality + - loss of integrity + - loss of availability + - unauthorized actions + - exposure of sensitive data + - bypass of security controls + + Examples of security-relevant verification: + + - access control decisions + - authentication or identity validation + - cryptographic correctness or misuse resistance + - input validation or canonicalization + - injection prevention + - safe handling of sensitive data + - correct security event logging + - secure error handling + + + Non-Security Tests (Out of Scope) + --------------------------------- + + Do NOT classify tests as security tests when they only verify: + + - functional correctness unrelated to security + - performance characteristics + - UI behavior + - formatting or presentation logic + - internal implementation details with no security implications + + If a test contains security logic but its intent is purely functional, + prefer NOT classifying it as a security test. + + + Tagging Model + ------------- + + Every security-relevant test MUST include: + + @Tag("security") + + and SHOULD include a descriptive display name: + + @DisplayName("SECURITY: - ") + + + Example: + + @Test + @Tag("security") + @Tag("access-control") + @DisplayName("SECURITY: access control - deny non-owner account access") + + Category tags provide additional classification. + + + Allowed Category Tags + --------------------- + + Only the following category tags may be used. + + Use lowercase and hyphenated names exactly as defined. + + + 1. auth + ------- + + Authentication and identity validation. + + Use when the test validates: + + - login or credential verification + - authentication workflows + - MFA enforcement + - token validation + - session binding + - subject or identity claims + + Typical signals: + + - login handlers + - token parsing + - identity providers + - credential verification + + + 2. access-control + ----------------- + + Authorization and permission enforcement. + + Use when the test validates: + + - role-based or attribute-based access control + - ACL evaluation + - policy decision logic + - object ownership checks + - deny-by-default behavior + + Typical signals: + + - permission checks + - policy evaluation + - role validation + - ownership checks + + + 3. crypto + --------- + + Cryptographic correctness or misuse resistance. + + Use when the test validates: + + - encryption and decryption + - signature verification + - key handling + - nonce or IV requirements + - secure randomness + - hashing or key derivation + + Typical signals: + + - cryptographic libraries + - key material + - ciphersuites + - signature APIs + + + 4. input-validation + ------------------- + + Validation or normalization of untrusted inputs. + + Use when the test validates: + + - schema validation + - format validation + - canonicalization rules + - path normalization + - rejection of malformed inputs + + Typical signals: + + - parsing logic + - validation layers + - normalization routines + + + 5. injection + ------------ + + Prevention of injection vulnerabilities. + + Use when the test validates protection against: + + - SQL injection + - NoSQL injection + - command injection + - template injection + - XPath/LDAP injection + - deserialization attacks + + Typical signals: + + - query construction + - escaping + - parameterization + - command execution + + + 6. data-protection + ------------------ + + Protection of sensitive or regulated data. + + Use when the test validates: + + - encryption of stored data + - encryption in transit at unit level + - masking or redaction + - secret handling + - secure storage of credentials + + Typical signals: + + - PII handling + - encryption enforcement + - secrets management + + + 7. logging + ---------- + + Security event logging and auditability. + + Use when the test validates: + + - absence of secrets in logs + - presence of required audit events + - correct security event messages + - traceability identifiers + + Typical signals: + + - log assertions + - audit event emission + + + 8. error-handling + ----------------- + + Security-safe error behavior. + + Use when the test validates: + + - absence of information leakage + - sanitized error messages + - safe fallback behavior + - secure default behavior on failure + + Typical signals: + + - negative path tests + - exception handling checks + + + 9. owasp + -------- + + Optional mapping tag linking the test to a widely recognized OWASP risk category. + + Use when the test explicitly addresses a vulnerability class defined by the + OWASP Top 10 or related OWASP guidance. + + Examples include tests targeting: + + - injection vulnerabilities + - broken authentication + - broken access control + - sensitive data exposure + - security misconfiguration + - insecure deserialization + - cross-site scripting + + Important: + + The `owasp` tag should only be used when the test clearly maps to a well-known + OWASP vulnerability category. + + When possible, the `owasp` tag should be combined with a more precise category + from this taxonomy (for example `injection` or `access-control`). + + + Tagging Rules + ------------- + + Mandatory rules: + + - Every security-relevant test MUST include the tag: + + security + + - Security tests SHOULD include 1 to 3 category tags. + + - Category tags MUST be selected only from the allowed taxonomy. + + - Do NOT invent new tags. + + + Class-Level vs Method-Level Tags + -------------------------------- + + Class-level tags may be used when: + + - all tests in the class validate the same security concern. + + Method-level tags should be used when: + + - only some tests are security-relevant + - tests cover different security categories + + + Display Name Convention + ----------------------- + + Security test names should follow this format: + + SECURITY: - + + Examples: + + SECURITY: access control - deny non-owner account access + SECURITY: crypto - reject reused nonce in AEAD + SECURITY: input validation - reject path traversal sequences + + + AI Classification Guidance + -------------------------- + + When classifying tests: + + 1. Identify the security property validated. + 2. Determine whether the test enforces or validates a security control. + 3. Assign the umbrella tag `security` when applicable. + 4. Select 1–3 category tags that best describe the security concern. + 5. Prefer specific categories over broad ones. + 6. Avoid assigning tags when the security intent is unclear. + """; + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/HttpSupport.java b/src/main/java/org/egothor/methodatlas/ai/HttpSupport.java new file mode 100644 index 0000000..34cf60e --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/HttpSupport.java @@ -0,0 +1,160 @@ +package org.egothor.methodatlas.ai; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Small HTTP utility component used by AI provider clients for outbound network + * communication and JSON processing support. + * + *

+ * This class centralizes common HTTP-related functionality required by the AI + * provider integrations, including: + *

+ *
    + *
  • creation of a configured {@link HttpClient}
  • + *
  • provision of a shared Jackson {@link ObjectMapper}
  • + *
  • execution of JSON-oriented HTTP requests
  • + *
  • construction of JSON {@code POST} requests
  • + *
+ * + *

+ * The helper is intentionally lightweight and provider-agnostic. It does not + * implement provider-specific authentication, endpoint selection, or response + * normalization logic; those responsibilities remain in the concrete provider + * clients. + *

+ * + *

+ * The internally managed {@link ObjectMapper} is configured to ignore unknown + * JSON properties so that provider response deserialization remains resilient + * to non-breaking API changes. + *

+ * + *

+ * Instances of this class are immutable after construction. + *

+ * + * @see HttpClient + * @see ObjectMapper + * @see AiProviderClient + */ +public final class HttpSupport { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + /** + * Creates a new HTTP support helper with the specified connection timeout. + * + *

+ * The supplied timeout is used as the connection timeout of the underlying + * {@link HttpClient}. Request-specific timeouts may still be configured + * independently on individual {@link HttpRequest} instances. + *

+ * + *

+ * The constructor also initializes a Jackson {@link ObjectMapper} configured + * with {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} disabled. + *

+ * + * @param timeout connection timeout used for the underlying HTTP client + */ + public HttpSupport(Duration timeout) { + this.httpClient = HttpClient.newBuilder().connectTimeout(timeout).build(); + + this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Returns the configured HTTP client used by this helper. + * + * @return configured HTTP client instance + */ + public HttpClient httpClient() { + return httpClient; + } + + /** + * Returns the configured Jackson object mapper used for JSON serialization and + * deserialization. + * + * @return configured object mapper instance + */ + public ObjectMapper objectMapper() { + return objectMapper; + } + + /** + * Executes an HTTP request expected to return a JSON response body and returns + * the response content as text. + * + *

+ * The method sends the supplied request using the internally configured + * {@link HttpClient}. Responses with HTTP status codes outside the successful + * {@code 2xx} range are treated as failures and cause an {@link IOException} to + * be thrown containing both the status code and response body. + *

+ * + *

+ * Despite the method name, the request itself is not required to be a + * {@code POST} request; the method simply executes the provided request and + * validates that the response indicates success. + *

+ * + * @param request HTTP request to execute + * @return response body as text + * + * @throws IOException if request execution fails or if the HTTP + * response status code is outside the successful + * {@code 2xx} range + * @throws InterruptedException if the calling thread is interrupted while + * waiting for the response + */ + public String postJson(HttpRequest request) throws IOException, InterruptedException { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + int statusCode = response.statusCode(); + + if (statusCode < 200 || statusCode >= 300) { + throw new IOException("HTTP " + statusCode + ": " + response.body()); + } + + return response.body(); + } + + /** + * Creates a JSON-oriented HTTP {@code POST} request builder. + * + *

+ * The returned builder is preconfigured with: + *

+ *
    + *
  • the supplied target {@link URI}
  • + *
  • the supplied request timeout
  • + *
  • {@code Content-Type: application/json}
  • + *
  • a {@code POST} request body containing the supplied JSON text
  • + *
+ * + *

+ * Callers may further customize the returned builder, for example by adding + * authentication or provider-specific headers, before invoking + * {@link HttpRequest.Builder#build()}. + *

+ * + * @param uri target URI of the request + * @param body serialized JSON request body + * @param timeout request timeout + * @return preconfigured HTTP request builder for a JSON {@code POST} request + */ + public HttpRequest.Builder jsonPost(URI uri, String body, Duration timeout) { + return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/JsonText.java b/src/main/java/org/egothor/methodatlas/ai/JsonText.java new file mode 100644 index 0000000..6e14be2 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/JsonText.java @@ -0,0 +1,77 @@ +package org.egothor.methodatlas.ai; + +/** + * Utility methods for extracting JSON fragments from free-form text produced by + * AI model responses. + * + *

+ * Some AI providers may return textual responses that contain additional + * commentary or formatting around the actual JSON payload requested by the + * application. This helper provides a minimal extraction mechanism that + * isolates the first JSON object found within such responses so that it can be + * deserialized safely. + *

+ * + *

+ * The current implementation performs a simple structural search for the first + * opening brace ({) and the last closing brace (}), + * and returns the substring spanning those positions. + *

+ * + *

+ * The method is intentionally tolerant of provider-specific output formats and + * is primarily used as a defensive measure to recover valid JSON payloads from + * otherwise well-formed responses. + *

+ * + *

+ * This class is a non-instantiable utility holder. + *

+ * + * @see AiSuggestionException + * @see AiClassSuggestion + */ +public final class JsonText { + /** + * Prevents instantiation of this utility class. + */ + private JsonText() { + } + + /** + * Extracts the first JSON object found within a text response. + * + *

+ * The method scans the supplied text for the first occurrence of an opening + * brace ({) and the last occurrence of a closing brace + * (}). The substring between these positions (inclusive) is + * returned as the extracted JSON object. + *

+ * + *

+ * This approach allows the application to recover structured data even when the + * model returns additional natural-language content or formatting around the + * JSON payload. + *

+ * + * @param text text returned by the AI model + * @return extracted JSON object as text + * + * @throws AiSuggestionException if the input text is empty or if no valid JSON + * object boundaries can be located + */ + public static String extractFirstJsonObject(String text) throws AiSuggestionException { + if (text == null || text.isBlank()) { + throw new AiSuggestionException("Model returned an empty response"); + } + + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + + if (start < 0 || end < 0 || end < start) { + throw new AiSuggestionException("Model response does not contain a JSON object: " + text); + } + + return text.substring(start, end + 1); + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/OllamaClient.java b/src/main/java/org/egothor/methodatlas/ai/OllamaClient.java new file mode 100644 index 0000000..db14343 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/OllamaClient.java @@ -0,0 +1,277 @@ +package org.egothor.methodatlas.ai; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * {@link AiProviderClient} implementation for a locally running + * Ollama inference service. + * + *

+ * This client submits taxonomy-guided classification prompts to the Ollama HTTP + * API and converts the returned model response into the internal + * {@link AiClassSuggestion} representation used by the MethodAtlas AI + * subsystem. + *

+ * + *

Operational Responsibilities

+ * + *
    + *
  • verifying local Ollama availability
  • + *
  • constructing chat-style inference requests
  • + *
  • injecting the system prompt and taxonomy-guided user prompt
  • + *
  • executing HTTP requests against the Ollama API
  • + *
  • extracting and normalizing JSON classification results
  • + *
+ * + *

+ * The client uses the Ollama {@code /api/chat} endpoint for inference and the + * {@code /api/tags} endpoint as a lightweight availability probe. + *

+ * + *

+ * This implementation is intended primarily for local, offline, or + * privacy-preserving inference scenarios where source code should not be sent + * to an external provider. + *

+ * + * @see AiProviderClient + * @see AiProviderFactory + * @see AiSuggestionEngine + */ +public final class OllamaClient implements AiProviderClient { + /** + * System prompt used to enforce deterministic, machine-readable model output. + * + *

+ * The prompt instructs the model to behave as a strict classification engine + * and to return JSON only, without markdown fences or explanatory prose, so + * that the response can be parsed automatically. + *

+ */ + private static final String SYSTEM_PROMPT = """ + You are a precise software security classification engine. + You classify JUnit 5 tests and return strict JSON only. + Never include markdown fences, explanations, or extra text. + """; + + private final AiOptions options; + private final HttpSupport httpSupport; + + /** + * Creates a new Ollama client using the supplied runtime configuration. + * + *

+ * The configuration determines the base URL of the Ollama service, the model + * identifier, and request timeout values used by this client. + *

+ * + * @param options AI runtime configuration + */ + public OllamaClient(AiOptions options) { + this.options = options; + this.httpSupport = new HttpSupport(options.timeout()); + } + + /** + * Determines whether the configured Ollama service is reachable. + * + *

+ * The method performs a lightweight availability probe against the + * {@code /api/tags} endpoint. If the endpoint responds successfully, the + * provider is considered available. + *

+ * + *

+ * Any exception raised during the probe is treated as an indication that the + * provider is unavailable. + *

+ * + * @return {@code true} if the Ollama service is reachable; {@code false} + * otherwise + */ + @Override + public boolean isAvailable() { + try { + URI uri = URI.create(options.baseUrl() + "/api/tags"); + HttpRequest request = HttpRequest.newBuilder(uri).GET().timeout(options.timeout()).build(); + + httpSupport.httpClient().send(request, HttpResponse.BodyHandlers.discarding()); + + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Submits a classification request to the Ollama chat API for the specified + * test class. + * + *

+ * The request consists of: + *

+ *
    + *
  • a system prompt enforcing strict JSON output
  • + *
  • a user prompt containing the test class source and taxonomy text
  • + *
  • provider options such as deterministic temperature settings
  • + *
+ * + *

+ * The returned response is expected to contain a JSON object in the message + * content field. That JSON text is extracted, deserialized into an + * {@link AiClassSuggestion}, and then normalized before being returned. + *

+ * + * @param fqcn fully qualified class name being analyzed + * @param classSource complete source code of the class being analyzed + * @param taxonomyText taxonomy definition guiding classification + * @return normalized AI classification result + * + * @throws AiSuggestionException if the request fails, if the provider returns + * invalid content, or if response deserialization + * fails + */ + @Override + public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText) + throws AiSuggestionException { + try { + String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText); + + ChatRequest payload = new ChatRequest(options.modelName(), + List.of(new Message("system", SYSTEM_PROMPT), new Message("user", prompt)), false, + new Options(0.0)); + + String requestBody = httpSupport.objectMapper().writeValueAsString(payload); + URI uri = URI.create(options.baseUrl() + "/api/chat"); + + HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout()).build(); + String responseBody = httpSupport.postJson(request); + ChatResponse response = httpSupport.objectMapper().readValue(responseBody, ChatResponse.class); + + if (response.message == null || response.message.content == null || response.message.content.isBlank()) { + throw new AiSuggestionException("Ollama returned no message content"); + } + + String json = JsonText.extractFirstJsonObject(response.message.content); + AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class); + return normalize(suggestion); + + } catch (Exception e) { + throw new AiSuggestionException("Ollama suggestion failed for " + fqcn, e); + } + } + + /** + * Normalizes a provider response into the application's internal result + * invariants. + * + *

+ * The method ensures that collection-valued fields are never {@code null} and + * removes malformed method entries that do not define a usable method name. + *

+ * + * @param input raw suggestion returned by the provider + * @return normalized suggestion + */ + private static AiClassSuggestion normalize(AiClassSuggestion input) { + List methods = input.methods() == null ? List.of() : input.methods(); + List classTags = input.classTags() == null ? List.of() : input.classTags(); + + List normalizedMethods = methods.stream() + .filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank()) + .map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(), + method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason())) + .toList(); + + return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(), + normalizedMethods); + } + + /** + * Request payload sent to the Ollama chat API. + * + *

+ * This record models the JSON structure expected by the {@code /api/chat} + * endpoint. + *

+ * + * @param model model identifier used for inference + * @param messages ordered chat messages sent to the model + * @param stream whether streaming responses are requested + * @param options provider-specific inference options + */ + private record ChatRequest(String model, List messages, boolean stream, Options options) { + } + + /** + * Chat message sent to the Ollama API. + * + * @param role logical role of the message sender, such as {@code system} or + * {@code user} + * @param content textual message content + */ + private record Message(String role, String content) { + } + + /** + * Provider-specific inference options supplied to the Ollama API. + * + *

+ * Currently only the {@code temperature} sampling parameter is configured. + * Temperature controls the randomness of model output: + *

+ * + *
    + *
  • {@code 0.0} produces deterministic output
  • + *
  • higher values increase variation and creativity
  • + *
+ * + *

+ * The MethodAtlas AI integration explicitly sets {@code temperature} to + * {@code 0.0} in order to obtain stable, repeatable classification results and + * strictly formatted JSON output suitable for automated parsing. + *

+ * + *

+ * Allowing stochastic sampling would significantly increase the probability + * that the model produces explanatory text, formatting variations, or malformed + * JSON responses, which would break the downstream deserialization pipeline. + *

+ * + * @param temperature sampling temperature controlling response randomness + */ + private record Options(@JsonProperty("temperature") Double temperature) { + } + + /** + * Partial response model returned by the Ollama chat API. + * + *

+ * Only the fields required by this client are modeled. Unknown properties are + * ignored to maintain compatibility with future API extensions. + *

+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ChatResponse { + public ResponseMessage message; + } + + /** + * Message payload returned within an Ollama chat response. + * + *

+ * The client reads the {@link #content} field and expects it to contain the + * JSON classification result generated by the model. + *

+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ResponseMessage { + public String content; + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/OpenAiCompatibleClient.java b/src/main/java/org/egothor/methodatlas/ai/OpenAiCompatibleClient.java new file mode 100644 index 0000000..080b554 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/OpenAiCompatibleClient.java @@ -0,0 +1,257 @@ +package org.egothor.methodatlas.ai; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * {@link AiProviderClient} implementation for AI providers that expose an + * OpenAI-compatible chat completion API. + * + *

+ * This client supports providers that implement the OpenAI-style + * {@code /v1/chat/completions} endpoint. The same implementation is used for: + *

+ * + *
    + *
  • {@link AiProvider#OPENAI}
  • + *
  • {@link AiProvider#OPENROUTER}
  • + *
+ * + *

+ * The client constructs a chat-style prompt consisting of a system message + * defining the classification rules and a user message containing the test + * class source together with the taxonomy definition. The model response is + * expected to contain a JSON object describing the security classification. + *

+ * + *

Operational Responsibilities

+ * + *
    + *
  • constructing OpenAI-compatible chat completion requests
  • + *
  • injecting the taxonomy-driven classification prompt
  • + *
  • performing authenticated HTTP requests
  • + *
  • extracting JSON content from the model response
  • + *
  • normalizing the result into {@link AiClassSuggestion}
  • + *
+ * + *

+ * The implementation is provider-neutral for APIs that follow the OpenAI + * protocol, which allows reuse across multiple compatible services such as + * OpenRouter. + *

+ * + *

+ * Instances are typically created through + * {@link AiProviderFactory#create(AiOptions)}. + *

+ * + * @see AiProvider + * @see AiProviderClient + * @see AiProviderFactory + */ +public final class OpenAiCompatibleClient implements AiProviderClient { + /** + * System prompt instructing the model to operate strictly as a classification + * engine and to return machine-readable JSON output. + * + *

+ * The prompt intentionally forbids explanatory text and markdown formatting to + * ensure that the returned content can be parsed reliably by the application. + *

+ */ + private static final String SYSTEM_PROMPT = """ + You are a precise software security classification engine. + You classify JUnit 5 tests and return strict JSON only. + Never include markdown fences, explanations, or extra text. + """; + + private final AiOptions options; + private final HttpSupport httpSupport; + + /** + * Creates a new client for an OpenAI-compatible provider. + * + *

+ * The supplied configuration determines the provider endpoint, model name, + * authentication method, request timeout, and other runtime parameters. + *

+ * + * @param options AI runtime configuration + */ + public OpenAiCompatibleClient(AiOptions options) { + this.options = options; + this.httpSupport = new HttpSupport(options.timeout()); + } + + /** + * Determines whether the configured provider can be used in the current runtime + * environment. + * + *

+ * For OpenAI-compatible providers, availability is determined by the presence + * of a usable API key resolved through {@link AiOptions#resolvedApiKey()}. + *

+ * + * @return {@code true} if a usable API key is available + */ + @Override + public boolean isAvailable() { + return options.resolvedApiKey() != null && !options.resolvedApiKey().isBlank(); + } + + /** + * Submits a classification request to an OpenAI-compatible chat completion API. + * + *

+ * The request payload includes: + *

+ * + *
    + *
  • the configured model identifier
  • + *
  • a system prompt defining classification rules
  • + *
  • a user prompt containing the test class source and taxonomy
  • + *
  • a deterministic temperature setting
  • + *
+ * + *

+ * When the selected provider is {@link AiProvider#OPENROUTER}, additional HTTP + * headers are included to identify the calling application. + *

+ * + *

+ * The response is expected to contain a JSON object in the message content + * field. The JSON text is extracted and deserialized into an + * {@link AiClassSuggestion}. + *

+ * + * @param fqcn fully qualified class name being analyzed + * @param classSource complete source code of the class + * @param taxonomyText taxonomy definition guiding classification + * + * @return normalized classification result + * + * @throws AiSuggestionException if the provider request fails, the model + * response is invalid, or JSON deserialization + * fails + */ + @Override + public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText) + throws AiSuggestionException { + try { + String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText); + + ChatRequest payload = new ChatRequest(options.modelName(), + List.of(new Message("system", SYSTEM_PROMPT), new Message("user", prompt)), 0.0); + + String requestBody = httpSupport.objectMapper().writeValueAsString(payload); + + URI uri = URI.create(options.baseUrl() + "/v1/chat/completions"); + HttpRequest.Builder requestBuilder = httpSupport.jsonPost(uri, requestBody, options.timeout()) + .header("Authorization", "Bearer " + options.resolvedApiKey()); + + if (options.provider() == AiProvider.OPENROUTER) { + requestBuilder.header("HTTP-Referer", "https://methodatlas.local"); + requestBuilder.header("X-Title", "MethodAtlas"); + } + + String responseBody = httpSupport.postJson(requestBuilder.build()); + ChatResponse response = httpSupport.objectMapper().readValue(responseBody, ChatResponse.class); + + if (response.choices == null || response.choices.isEmpty()) { + throw new AiSuggestionException("No choices returned by model"); + } + + String content = response.choices.get(0).message.content; + String json = JsonText.extractFirstJsonObject(content); + AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class); + return normalize(suggestion); + + } catch (Exception e) { + throw new AiSuggestionException("OpenAI-compatible suggestion failed for " + fqcn, e); + } + } + + /** + * Normalizes provider results to ensure structural invariants expected by the + * application. + * + *

+ * The method replaces {@code null} collections with empty lists and removes + * malformed method entries that do not contain a valid method name. + *

+ * + * @param input raw suggestion returned by the provider + * @return normalized suggestion instance + */ + private static AiClassSuggestion normalize(AiClassSuggestion input) { + List methods = input.methods() == null ? List.of() : input.methods(); + List classTags = input.classTags() == null ? List.of() : input.classTags(); + + List normalizedMethods = methods.stream() + .filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank()) + .map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(), + method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason())) + .toList(); + + return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(), + normalizedMethods); + } + + /** + * Request payload for an OpenAI-compatible chat completion request. + * + * @param model model identifier used for inference + * @param messages ordered chat messages sent to the model + * @param temperature sampling temperature controlling response variability + */ + private record ChatRequest(String model, List messages, @JsonProperty("temperature") Double temperature) { + } + + /** + * Chat message included in the request payload. + * + * @param role logical role of the message sender, such as {@code system} or + * {@code user} + * @param content textual message content + */ + private record Message(String role, String content) { + } + + /** + * Partial response model returned by the chat completion API. + * + *

+ * Only fields required for extracting the model response are mapped. Unknown + * properties are ignored to preserve compatibility with provider API changes. + *

+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ChatResponse { + public List choices; + } + + /** + * Individual completion choice returned by the provider. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class Choice { + public ResponseMessage message; + } + + /** + * Message payload returned inside a completion choice. + * + *

+ * The {@code content} field is expected to contain the JSON classification + * result generated by the model. + *

+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class ResponseMessage { + public String content; + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/OptimizedSecurityTaxonomy.java b/src/main/java/org/egothor/methodatlas/ai/OptimizedSecurityTaxonomy.java new file mode 100644 index 0000000..ce2ae7b --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/OptimizedSecurityTaxonomy.java @@ -0,0 +1,233 @@ +package org.egothor.methodatlas.ai; + +/** + * Provides the optimized built-in taxonomy used to guide AI-based security + * classification when prompt compactness and model reliability are prioritized. + * + *

+ * This class supplies a condensed taxonomy definition intended for use with + * {@link org.egothor.methodatlas.ai.AiOptions.TaxonomyMode#OPTIMIZED}. In + * contrast to {@link DefaultSecurityTaxonomy}, this variant is structured to + * improve AI classification consistency by reducing prompt verbosity while + * preserving the same controlled category set and classification intent. + *

+ * + *

Design Goals

+ * + *
    + *
  • minimize prompt length without changing the supported taxonomy
  • + *
  • increase deterministic model behavior
  • + *
  • reduce ambiguity in category selection
  • + *
  • preserve professional terminology and decision rules
  • + *
+ * + *

+ * The taxonomy text returned by this class is intended to be embedded directly + * into AI prompts and therefore favors concise, machine-oriented instruction + * structure over explanatory prose. + *

+ * + *

+ * This class is a non-instantiable utility holder. + *

+ * + * @see DefaultSecurityTaxonomy + * @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl + * @see org.egothor.methodatlas.ai.AiOptions.TaxonomyMode + */ +public final class OptimizedSecurityTaxonomy { + /** + * Prevents instantiation of this utility class. + */ + private OptimizedSecurityTaxonomy() { + } + + /** + * Returns the optimized built-in taxonomy text used for AI classification. + * + *

+ * The returned taxonomy is a compact instruction set designed for large + * language models performing security classification of JUnit test methods. It + * preserves the same controlled tag set as the default taxonomy while + * presenting the rules in a shorter, more model-oriented structure. + *

+ * + *

+ * The taxonomy defines: + *

+ *
    + *
  • the meaning of a security-relevant test
  • + *
  • the mandatory {@code security} umbrella tag
  • + *
  • the allowed category tags
  • + *
  • selection rules for assigning taxonomy tags
  • + *
  • guidance for use of the optional {@code owasp} tag
  • + *
  • the required {@code SECURITY: - } display name + * format
  • + *
+ * + *

+ * This optimized variant is suitable when improved model consistency or shorter + * prompt size is more important than human-oriented explanatory wording. + *

+ * + * @return optimized taxonomy text used to instruct AI classification + * + * @see DefaultSecurityTaxonomy#text() + * @see org.egothor.methodatlas.ai.AiSuggestionEngineImpl + */ + public static String text() { + return """ + SECURITY TEST CLASSIFICATION SPECIFICATION + ========================================== + + Goal + ---- + + Classify JUnit 5 test methods that validate security properties. + + The output MUST follow the allowed tag taxonomy and MUST NOT introduce new tags. + + + Security-Relevant Test Definition + --------------------------------- + + A test is security-relevant when it verifies any of the following: + + • authentication behavior + • authorization decisions + • cryptographic correctness + • validation of untrusted input + • protection against injection attacks + • protection of sensitive data + • security event logging + • secure error handling + + If failure of the test could allow: + + • unauthorized access + • data exposure + • privilege escalation + • security control bypass + + then the test is security-relevant. + + + Mandatory Tag + ------------- + + Every security-relevant test MUST contain: + + security + + + Allowed Category Tags + --------------------- + + Only the following tags are permitted: + + auth + access-control + crypto + input-validation + injection + data-protection + logging + error-handling + owasp + + + Category Semantics + ------------------ + + auth + authentication validation + identity verification + credential checks + token/session validation + + access-control + authorization enforcement + permission checks + role evaluation + ownership validation + + crypto + encryption/decryption + signature verification + key usage + nonce/IV rules + hashing or key derivation + + input-validation + validation of untrusted inputs + canonicalization + malformed input rejection + path normalization + + injection + protection against injection attacks + SQL/NoSQL injection + command injection + template injection + deserialization vulnerabilities + + data-protection + encryption of sensitive data + secret handling + PII protection + secure storage + + logging + security event logging + audit events + absence of secrets in logs + + error-handling + safe error messages + no information leakage + safe fallback behavior + + + OWASP Tag + --------- + + The `owasp` tag indicates that the test validates protection against a vulnerability + category commonly described in OWASP guidance such as: + + • injection + • broken authentication + • broken access control + • security misconfiguration + • sensitive data exposure + • insecure deserialization + • cross-site scripting + + The `owasp` tag should only be used when the test clearly targets a known + OWASP vulnerability category. + + Prefer combining `owasp` with a more precise taxonomy tag. + + + Tag Selection Rules + ------------------- + + 1. If a test validates a security property → include `security`. + 2. Add 1–3 additional category tags when applicable. + 3. Prefer the most specific tag. + 4. Do not assign tags when security relevance is unclear. + 5. Never invent new tags. + + + Display Name Format + ------------------- + + SECURITY: - + + Examples: + + SECURITY: access control - deny non-owner account access + SECURITY: crypto - reject reused nonce in AEAD + SECURITY: input validation - reject path traversal sequences + """; + } +} diff --git a/src/main/java/org/egothor/methodatlas/ai/PromptBuilder.java b/src/main/java/org/egothor/methodatlas/ai/PromptBuilder.java new file mode 100644 index 0000000..15e62f9 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/PromptBuilder.java @@ -0,0 +1,136 @@ +package org.egothor.methodatlas.ai; + +/** + * Utility responsible for constructing the prompt supplied to AI providers for + * security classification of JUnit test classes. + * + *

+ * The prompt produced by this class combines several components into a single + * instruction payload: + *

+ * + *
    + *
  • classification instructions for the AI model
  • + *
  • a controlled security taxonomy definition
  • + *
  • strict output formatting rules
  • + *
  • the fully qualified class name
  • + *
  • the complete source code of the analyzed test class
  • + *
+ * + *

+ * The resulting prompt is passed to the configured AI provider and instructs + * the model to produce a deterministic JSON classification result describing + * security relevance and taxonomy tags for individual test methods. + *

+ * + *

+ * The prompt enforces a closed taxonomy and strict JSON output rules to ensure + * that the returned content can be parsed reliably by the application. + *

+ * + *

+ * This class is a non-instantiable utility holder. + *

+ * + * @see AiSuggestionEngine + * @see AiProviderClient + * @see DefaultSecurityTaxonomy + * @see OptimizedSecurityTaxonomy + */ +public final class PromptBuilder { + /** + * Prevents instantiation of this utility class. + */ + private PromptBuilder() { + } + + /** + * Builds the complete prompt supplied to an AI provider for security + * classification of a JUnit test class. + * + *

+ * The generated prompt contains: + *

+ * + *
    + *
  • task instructions describing the classification objective
  • + *
  • the security taxonomy definition controlling allowed tags
  • + *
  • strict output rules enforcing JSON-only responses
  • + *
  • a formal JSON schema describing the expected result structure
  • + *
  • the fully qualified class name of the analyzed test class
  • + *
  • the complete class source used as analysis input
  • + *
+ * + *

+ * The taxonomy text supplied to this method is typically obtained from either + * {@link DefaultSecurityTaxonomy#text()} or + * {@link OptimizedSecurityTaxonomy#text()}, depending on the selected + * {@link AiOptions.TaxonomyMode}. + *

+ * + *

+ * The returned prompt is intended to be used as the content of a user message + * in chat-based inference APIs. + *

+ * + * @param fqcn fully qualified class name of the test class being + * analyzed + * @param classSource complete source code of the test class + * @param taxonomyText taxonomy definition guiding classification + * @return formatted prompt supplied to the AI provider + * + * @see AiSuggestionEngine#suggestForClass(String, String) + */ + public static String build(String fqcn, String classSource, String taxonomyText) { + return """ + You are analyzing a single JUnit 5 test class and suggesting security tags. + + TASK + - Analyze the WHOLE class for context. + - Return per-method suggestions for JUnit test methods only. + - Do not invent methods that do not exist. + - Be conservative. + - If uncertain, classify the method as securityRelevant=false. + - Ignore pure functional / performance / UX tests unless they explicitly validate a security property. + + CONTROLLED TAXONOMY + %s + + OUTPUT RULES + - Return JSON only. + - No markdown. + - No prose outside JSON. + - Tags must come only from this closed set: + security, auth, access-control, crypto, input-validation, injection, data-protection, logging, error-handling, owasp + - If securityRelevant=true, tags MUST include "security". + - Add 1-3 tags total per method. + - displayName must be null when securityRelevant=false. + - If securityRelevant=true, displayName must match: + SECURITY: - + + JSON SHAPE + { + "className": "string", + "classSecurityRelevant": true, + "classTags": ["security", "crypto"], + "classReason": "string", + "methods": [ + { + "methodName": "string", + "securityRelevant": true, + "displayName": "SECURITY: ...", + "tags": ["security", "crypto"], + "reason": "string" + } + ] + } + + CLASS + FQCN: %s + + SOURCE + %s + """ + .formatted(taxonomyText, fqcn, classSource); + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/SuggestionLookup.java b/src/main/java/org/egothor/methodatlas/ai/SuggestionLookup.java new file mode 100644 index 0000000..43d58f8 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/SuggestionLookup.java @@ -0,0 +1,109 @@ +package org.egothor.methodatlas.ai; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Immutable lookup structure providing efficient access to AI-generated method + * suggestions by method name. + * + *

+ * This class acts as an adapter between the class-level suggestion model + * returned by the AI subsystem ({@link AiClassSuggestion}) and the per-method + * processing logic used by {@code MethodAtlasApp}. It converts the list of + * {@link AiMethodSuggestion} objects into a name-indexed lookup map so that + * suggestions can be retrieved in constant time during traversal of parsed test + * methods. + *

+ * + *

Design Characteristics

+ * + *
    + *
  • immutable after construction
  • + *
  • null-safe for missing or malformed AI responses
  • + *
  • optimized for repeated method-level lookups
  • + *
+ * + *

+ * If the AI response contains duplicate suggestions for the same method, only + * the first occurrence is retained. + *

+ * + * @see AiClassSuggestion + * @see AiMethodSuggestion + */ +public final class SuggestionLookup { + + private final Map byMethodName; + + /** + * Creates a new immutable lookup instance backed by the supplied map. + * + *

+ * The internal map is defensively copied to guarantee immutability of the + * lookup structure. + *

+ * + * @param byMethodName mapping from method names to AI suggestions + */ + private SuggestionLookup(Map byMethodName) { + this.byMethodName = Map.copyOf(byMethodName); + } + + /** + * Creates a lookup instance from a class-level AI suggestion result. + * + *

+ * The method extracts all method suggestions contained in the supplied + * {@link AiClassSuggestion} and indexes them by method name. Entries with + * {@code null} suggestions, missing method names, or blank method names are + * ignored. + *

+ * + *

+ * If the suggestion contains no method entries, an empty lookup instance is + * returned. + *

+ * + * @param suggestion AI classification result for a test class + * @return lookup structure providing fast access to method suggestions + */ + public static SuggestionLookup from(AiClassSuggestion suggestion) { + if (suggestion == null || suggestion.methods() == null || suggestion.methods().isEmpty()) { + return new SuggestionLookup(Map.of()); + } + + Map map = new HashMap<>(); + for (AiMethodSuggestion methodSuggestion : suggestion.methods()) { + if (methodSuggestion == null) { + continue; + } + if (methodSuggestion.methodName() == null || methodSuggestion.methodName().isBlank()) { + continue; + } + map.putIfAbsent(methodSuggestion.methodName(), methodSuggestion); + } + + return new SuggestionLookup(map); + } + + /** + * Retrieves the AI suggestion for the specified method name. + * + *

+ * If no suggestion exists for the method, an empty {@link Optional} is + * returned. + *

+ * + * @param methodName name of the method being queried + * @return optional containing the suggestion if present + * + * @throws NullPointerException if {@code methodName} is {@code null} + */ + public Optional find(String methodName) { + Objects.requireNonNull(methodName, "methodName"); + return Optional.ofNullable(byMethodName.get(methodName)); + } +} \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/ai/package-info.java b/src/main/java/org/egothor/methodatlas/ai/package-info.java new file mode 100644 index 0000000..8499ae5 --- /dev/null +++ b/src/main/java/org/egothor/methodatlas/ai/package-info.java @@ -0,0 +1,69 @@ +/** + * AI integration layer for MethodAtlas providing automated security + * classification of JUnit test methods. + * + *

+ * This package contains the infrastructure required to obtain AI-assisted + * suggestions for security tagging of JUnit 5 tests. The subsystem analyzes + * complete test classes, submits classification prompts to an AI provider, and + * converts the returned results into structured suggestions that can be + * consumed by the main application. + *

+ * + *

Architecture Overview

+ * + *

+ * The AI subsystem follows a layered design: + *

+ * + *
    + *
  • Engine layer – + * {@link org.egothor.methodatlas.ai.AiSuggestionEngine} orchestrates provider + * communication and taxonomy handling.
  • + * + *
  • Provider layer – implementations of + * {@link org.egothor.methodatlas.ai.AiProviderClient} integrate with specific + * AI services such as Ollama, OpenAI-compatible APIs, or Anthropic.
  • + * + *
  • Prompt construction – + * {@link org.egothor.methodatlas.ai.PromptBuilder} builds the prompt that + * instructs the model how to perform security classification.
  • + * + *
  • Taxonomy definition – + * {@link org.egothor.methodatlas.ai.DefaultSecurityTaxonomy} and + * {@link org.egothor.methodatlas.ai.OptimizedSecurityTaxonomy} define the + * controlled vocabulary used for tagging.
  • + * + *
  • Result normalization – AI responses are converted into the + * structured domain model ({@link org.egothor.methodatlas.ai.AiClassSuggestion} + * and {@link org.egothor.methodatlas.ai.AiMethodSuggestion}).
  • + *
+ * + *

Security Considerations

+ * + *

+ * Source code analyzed by the AI subsystem may contain sensitive information. + * For environments where external transmission of code is undesirable, the + * subsystem supports local inference through + * {@link org.egothor.methodatlas.ai.OllamaClient}. + *

+ * + *

Deterministic Output

+ * + *

+ * The subsystem is designed to obtain deterministic, machine-readable output + * from AI models. Prompts enforce strict JSON responses and classification + * decisions are constrained by a controlled taxonomy. + *

+ * + *

Extensibility

+ * + *

+ * Additional AI providers can be integrated by implementing + * {@link org.egothor.methodatlas.ai.AiProviderClient} and registering the + * implementation in {@link org.egothor.methodatlas.ai.AiProviderFactory}. + *

+ * + * @since 1.0.1 + */ +package org.egothor.methodatlas.ai; \ No newline at end of file diff --git a/src/main/java/org/egothor/methodatlas/package-info.java b/src/main/java/org/egothor/methodatlas/package-info.java index 01fe1fb..c41bd27 100644 --- a/src/main/java/org/egothor/methodatlas/package-info.java +++ b/src/main/java/org/egothor/methodatlas/package-info.java @@ -1,18 +1,111 @@ /** - * Provides the {@code MethodAtlasApp} command-line utility for scanning Java - * source trees for JUnit test methods and emitting per-method statistics. + * Provides the core command-line utility for analyzing Java test sources and + * producing structured metadata about JUnit test methods. * *

- * The primary entry point is {@link org.egothor.methodatlas.MethodAtlasApp}. + * The central component of this package is + * {@link org.egothor.methodatlas.MethodAtlasApp}, a command-line application + * that scans Java source trees, identifies JUnit Jupiter test methods, and + * emits per-method metadata describing the discovered tests. + *

+ * + *

Overview

+ * + *

+ * The application traverses one or more directory roots, parses Java source + * files using the JavaParser library, and + * extracts information about test methods declared in classes whose file names + * follow the conventional {@code *Test.java} pattern. *

* *

- * Output modes: + * For each detected test method the application reports: *

+ * *
    - *
  • CSV (default): {@code fqcn,method,loc,tags}
  • - *
  • Plain text: enabled by {@code -plain} as the first command-line - * argument
  • + *
  • fully-qualified class name (FQCN)
  • + *
  • test method name
  • + *
  • method size measured in lines of code (LOC)
  • + *
  • JUnit {@code @Tag} annotations declared on the method
  • *
+ * + *

+ * The resulting dataset can be used for test inventory generation, quality + * metrics, governance reporting, or security analysis of test coverage. + *

+ * + *

AI-Based Security Tagging

+ * + *

+ * When enabled via command-line options, the application can augment the + * extracted test metadata with security classification suggestions produced by + * an AI provider. The AI integration is implemented through the + * {@link org.egothor.methodatlas.ai.AiSuggestionEngine} abstraction located in + * the {@code org.egothor.methodatlas.ai} package. + *

+ * + *

+ * In this mode the application sends each discovered test class to the + * configured AI provider and receives suggested security annotations, such as: + *

+ * + *
    + *
  • whether the test validates a security property
  • + *
  • suggested {@code @DisplayName} describing the security intent
  • + *
  • taxonomy-based security tags
  • + *
  • optional explanatory reasoning
  • + *
+ * + *

+ * These suggestions are merged with the source-derived metadata and emitted + * alongside the standard output fields. + *

+ * + *

Output Formats

+ * + *

+ * The application supports two output modes: + *

+ * + *
    + *
  • CSV (default)
    {@code fqcn,method,loc,tags}
    or, when AI + * enrichment is enabled: + *
    {@code fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason}
    + *
  • + *
  • Plain text, enabled using the {@code -plain} command-line option + *
  • + *
+ * + *

Typical Usage

+ * + *
{@code
+ * java -jar methodatlas.jar /path/to/project
+ * }
+ * 
+ * + *
{@code
+ * java -jar methodatlas.jar -plain /path/to/project
+ * }
+ * 
+ * + *

+ * The command scans the specified source directory recursively and emits one + * output record per detected test method. + *

+ * + *

Implementation Notes

+ * + *
    + *
  • Parsing is performed using + * {@link com.github.javaparser.StaticJavaParser}.
  • + *
  • Test detection is based on JUnit Jupiter annotations such as + * {@code @Test}, {@code @ParameterizedTest}, and {@code @RepeatedTest}.
  • + *
  • Tag extraction supports both {@code @Tag} annotations and the container + * form {@code @Tags}.
  • + *
+ * + * @see org.egothor.methodatlas.MethodAtlasApp + * @see org.egothor.methodatlas.ai.AiSuggestionEngine + * @see com.github.javaparser.StaticJavaParser */ package org.egothor.methodatlas; \ No newline at end of file diff --git a/src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java b/src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java new file mode 100644 index 0000000..7e6a067 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java @@ -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 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 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> 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 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 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 mocked = mockConstruction(AiSuggestionEngineImpl.class)) { + String output = runAppCapturingStdout( + new String[] { "-ai", "-ai-max-class-chars", "10", tempDir.toString() }); + + List 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> rows = parseCsvAiRows(lines); + List 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 parseCsvFields(String line) { + List 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 nonEmptyLines(String text) { + String[] parts = text.split("\\R"); + List lines = new ArrayList<>(); + for (String part : parts) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + lines.add(trimmed); + } + } + return lines; + } + + private static String findLineContaining(List 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> rows, String fqcn, String method, + String expectedTagsText, String expectedAiSecurityRelevant, String expectedAiDisplayName, + String expectedAiTagsText, String expectedAiReason) { + + List 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> parseCsvAiRows(List lines) { + Map> rows = new HashMap<>(); + for (int i = 1; i < lines.size(); i++) { + List 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; + } +} \ No newline at end of file diff --git a/src/test/java/org/egothor/methodatlas/MethodAtlasAppTest.java b/src/test/java/org/egothor/methodatlas/MethodAtlasAppTest.java index 5c4bda8..9f5460b 100644 --- a/src/test/java/org/egothor/methodatlas/MethodAtlasAppTest.java +++ b/src/test/java/org/egothor/methodatlas/MethodAtlasAppTest.java @@ -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. * + *

* 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: + *

+ *
    + *
  • detected test methods
  • + *
  • inclusive method LOC values
  • + *
  • extracted JUnit {@code @Tag} values, including nested {@code @Tags}
  • + *
  • CSV and plain-text rendering behavior
  • + *
+ * + *

+ * AI-specific behavior should be tested separately once a dedicated injection + * seam exists for supplying a mocked + * {@code org.egothor.methodatlas.ai.AiSuggestionEngine}. + *

*/ 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 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 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 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 rows, String fqcn, String method, int expectedLoc, List expectedTags) { @@ -239,4 +259,4 @@ public class MethodAtlasAppTest { private int loc; private String tagsText; } -} +} \ No newline at end of file diff --git a/src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java b/src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java new file mode 100644 index 0000000..cfeb871 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java @@ -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()); + } +} diff --git a/src/test/java/org/egothor/methodatlas/ai/AiProviderFactoryTest.java b/src/test/java/org/egothor/methodatlas/ai/AiProviderFactoryTest.java new file mode 100644 index 0000000..d0abbb9 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/AiProviderFactoryTest.java @@ -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 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 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 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 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 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 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 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 ollamaMocked = mockConstruction(OllamaClient.class, + (mock, context) -> when(mock.isAvailable()).thenReturn(true)); + MockedConstruction 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 ollamaMocked = mockConstruction(OllamaClient.class, + (mock, context) -> when(mock.isAvailable()).thenReturn(false)); + MockedConstruction 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 ollamaMocked = mockConstruction(OllamaClient.class, + (mock, context) -> when(mock.isAvailable()).thenReturn(false)); + MockedConstruction 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()); + } + } +} diff --git a/src/test/java/org/egothor/methodatlas/ai/AiSuggestionEngineImplTest.java b/src/test/java/org/egothor/methodatlas/ai/AiSuggestionEngineImplTest.java new file mode 100644 index 0000000..e4d4cdd --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/AiSuggestionEngineImplTest.java @@ -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 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 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 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 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 factory = mockStatic(AiProviderFactory.class)) { + factory.when(() -> AiProviderFactory.create(options)).thenThrow(expected); + + AiSuggestionException actual = assertThrows(AiSuggestionException.class, + () -> new AiSuggestionEngineImpl(options)); + + assertSame(expected, actual); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java b/src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java new file mode 100644 index 0000000..c4c1345 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java @@ -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()); + } +} diff --git a/src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java b/src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java new file mode 100644 index 0000000..892e669 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java @@ -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 response = mock(HttpResponse.class); + + when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler())).thenReturn(response); + + try (MockedConstruction 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 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 capturedBody = new AtomicReference<>(); + + try (MockedConstruction 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 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 anyVoidBodyHandler() { + return any(HttpResponse.BodyHandler.class); + } +} diff --git a/src/test/java/org/egothor/methodatlas/ai/OpenAiCompatibleClientTest.java b/src/test/java/org/egothor/methodatlas/ai/OpenAiCompatibleClientTest.java new file mode 100644 index 0000000..303894b --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/OpenAiCompatibleClientTest.java @@ -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 capturedBody = new AtomicReference<>(); + + try (MockedConstruction 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 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 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 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 mockHttpSupport(ObjectMapper mapper, String responseBody, + AtomicReference 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); + }); + } +} \ No newline at end of file diff --git a/src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java b/src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java new file mode 100644 index 0000000..404db43 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java @@ -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: - ")); + } + + @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); + } +} \ No newline at end of file diff --git a/src/test/java/org/egothor/methodatlas/ai/SuggestionLookupTest.java b/src/test/java/org/egothor/methodatlas/ai/SuggestionLookupTest.java new file mode 100644 index 0000000..6ea0604 --- /dev/null +++ b/src/test/java/org/egothor/methodatlas/ai/SuggestionLookupTest.java @@ -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 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 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 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)); + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/AccessControlServiceTest.java.txt b/src/test/resources/fixtures/AccessControlServiceTest.java.txt new file mode 100644 index 0000000..63235f5 --- /dev/null +++ b/src/test/resources/fixtures/AccessControlServiceTest.java.txt @@ -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); + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/AuditLoggingTest.java.txt b/src/test/resources/fixtures/AuditLoggingTest.java.txt new file mode 100644 index 0000000..00dcb99 --- /dev/null +++ b/src/test/resources/fixtures/AuditLoggingTest.java.txt @@ -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); + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/PathTraversalValidationTest.java.txt b/src/test/resources/fixtures/PathTraversalValidationTest.java.txt new file mode 100644 index 0000000..fc263f5 --- /dev/null +++ b/src/test/resources/fixtures/PathTraversalValidationTest.java.txt @@ -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); + } +} \ No newline at end of file