Compare commits
8 Commits
release@0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
bb4dc2f402
|
|||
|
e3f494924e
|
|||
|
a592ce1330
|
|||
|
32ddfa988b
|
|||
|
2dd3a687a5
|
|||
|
344d24dec9
|
|||
|
bbb6adb7e5
|
|||
|
d3a8270d8a
|
381
README.md
381
README.md
@@ -1,44 +1,117 @@
|
||||
# MethodAtlasApp
|
||||
|
||||
<img src=MethodAtlas.png width=20% align="right" />
|
||||
<img src="MethodAtlas.png" width="20%" align="right" alt="MethodAtlas logo" />
|
||||
|
||||
`MethodAtlasApp` is a small standalone CLI that scans Java source trees for JUnit test methods and prints per-method
|
||||
statistics:
|
||||
MethodAtlas is a small standalone CLI that scans Java source trees for JUnit 5 test methods and emits one record per discovered method.
|
||||
|
||||
- **FQCN** (fully-qualified class name)
|
||||
- **method name**
|
||||
- **LOC** (lines of code, based on the AST source range)
|
||||
- **@Tag values** attached to the method (supports repeated `@Tag` and `@Tags({...})`)
|
||||
The tool combines **deterministic source analysis** with optional **AI-assisted classification** so that developers can quickly understand what a test suite contains and which tests appear security-relevant.
|
||||
|
||||
It supports two output modes:
|
||||
Unlike tools that rely entirely on large language models or agent pipelines, MethodAtlas separates the problem into two parts:
|
||||
|
||||
- **CSV** (default)
|
||||
- **Plain text** (`-plain` as the first CLI argument)
|
||||
- **Deterministic discovery** — a Java AST parser determines exactly which test methods exist
|
||||
- **AI interpretation** — an optional model classifies those methods and suggests security-related annotations
|
||||
|
||||
## Build & run
|
||||
This approach keeps the analysis **predictable, reproducible, and reviewable**, while still benefiting from AI where it adds value.
|
||||
|
||||
Assuming you have a runnable JAR (e.g. `methodatlas.jar`):
|
||||
The parser determines *what exists* in the code.
|
||||
The AI suggests *what it means*.
|
||||
|
||||
## What MethodAtlas reports
|
||||
|
||||
For each discovered JUnit test method, MethodAtlas emits a single record containing:
|
||||
|
||||
- `fqcn` – fully qualified class name
|
||||
- `method` – test method name
|
||||
- `loc` – inclusive lines of code for the method declaration
|
||||
- `tags` – existing JUnit `@Tag` values declared on the method
|
||||
|
||||
When AI enrichment is enabled, additional fields are included:
|
||||
|
||||
- `ai_security_relevant` – whether the model classified the test as security-relevant
|
||||
- `ai_display_name` – suggested security-oriented `@DisplayName`
|
||||
- `ai_tags` – suggested security taxonomy tags
|
||||
- `ai_reason` – short rationale for the classification
|
||||
|
||||
These suggestions help identify tests that verify authentication, access control, cryptography, input validation, or other security-relevant behavior.
|
||||
|
||||
## Deterministic method discovery
|
||||
|
||||
Test discovery is performed using **JavaParser** and the Java AST rather than regex scanning or LLM inference.
|
||||
|
||||
The CLI:
|
||||
|
||||
- scans files matching `*Test.java`
|
||||
- detects JUnit Jupiter methods annotated with
|
||||
`@Test`, `@ParameterizedTest`, or `@RepeatedTest`
|
||||
- extracts existing tags from both repeated `@Tag` usage and `@Tags({...})`
|
||||
|
||||
Because the list of test methods is obtained from the AST, the analysis is **deterministic and reproducible** regardless of the AI provider used for classification.
|
||||
|
||||
## AI-assisted security classification
|
||||
|
||||
If AI mode is enabled, MethodAtlas sends the **full class source for context** together with the **exact list of parser-discovered test methods**.
|
||||
|
||||
The model is asked to classify only those methods and suggest:
|
||||
|
||||
- whether the test appears security-relevant
|
||||
- consistent security taxonomy tags
|
||||
- a meaningful security-oriented display name
|
||||
|
||||
This design avoids relying on AI to infer program structure and instead uses it only for semantic interpretation.
|
||||
|
||||
MethodAtlas supports multiple providers and can also run against **locally hosted models via Ollama**, allowing teams to use AI without exposing proprietary source code.
|
||||
|
||||
MethodAtlas is designed to be lightweight, deterministic, and easy to integrate into developer workflows or CI pipelines.
|
||||
|
||||
## Distribution layout
|
||||
|
||||
After building and packaging, the distribution archive has this structure:
|
||||
|
||||
```text
|
||||
methodatlas-<version>/
|
||||
├── bin/
|
||||
│ ├── methodatlas
|
||||
│ └── methodatlas.bat
|
||||
└── lib/
|
||||
└── methodatlas-<version>.jar
|
||||
```
|
||||
|
||||
Run the CLI from the `bin` directory, for example:
|
||||
|
||||
```bash
|
||||
java -jar methodatlas.jar [ -plain ] <path1> [<path2> ...]
|
||||
````
|
||||
cd methodatlas-<version>/bin
|
||||
./methodatlas /path/to/project
|
||||
```
|
||||
|
||||
* If **no paths** are provided, the current directory (i.e., `.`) is scanned.
|
||||
* Multiple root paths are supported.
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./methodatlas [options] [path1] [path2] ...
|
||||
```
|
||||
|
||||
If no scan path is provided, the current directory is scanned. Multiple root paths are supported.
|
||||
|
||||
## Output modes
|
||||
|
||||
### CSV (default)
|
||||
### CSV mode (default)
|
||||
|
||||
* Prints a **header line**
|
||||
* Each record contains **values only**
|
||||
* Tags are **semicolon-separated** in the `tags` column (empty if no tags)
|
||||
CSV mode prints a header followed by one record per discovered test method.
|
||||
|
||||
Without AI:
|
||||
|
||||
```text
|
||||
fqcn,method,loc,tags
|
||||
```
|
||||
|
||||
With AI:
|
||||
|
||||
```text
|
||||
fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Feb 11, 2026 1:33:35 AM org.egothor.methodatlas.MethodAtlasApp scanRoot
|
||||
INFO: Scanning /tmp/junit-15560885133010516491 for JUnit files
|
||||
fqcn,method,loc,tags
|
||||
com.acme.tests.SampleOneTest,alpha,8,fast;crypto
|
||||
com.acme.tests.SampleOneTest,beta,6,param
|
||||
@@ -46,30 +119,264 @@ com.acme.tests.SampleOneTest,gamma,4,nested1;nested2
|
||||
com.acme.other.AnotherTest,delta,3,
|
||||
```
|
||||
|
||||
### Plain text (`-plain`)
|
||||
### Plain mode
|
||||
|
||||
* Prints one line per detected method:
|
||||
* `FQCN, method, LOC=<n>, TAGS=<tag1;tag2;...>`
|
||||
* If a method has **no tags**, it prints `TAGS=-`
|
||||
Enable plain mode with `-plain`:
|
||||
|
||||
Example:
|
||||
```bash
|
||||
./methodatlas -plain /path/to/project
|
||||
```
|
||||
|
||||
Plain mode renders one line per method:
|
||||
|
||||
```text
|
||||
Feb 11, 2026 1:33:35 AM org.egothor.methodatlas.MethodAtlasApp scanRoot
|
||||
INFO: Scanning /tmp/junit-12139245189413750595 for JUnit files
|
||||
com.acme.tests.SampleOneTest, alpha, LOC=8, TAGS=fast;crypto
|
||||
com.acme.tests.SampleOneTest, beta, LOC=6, TAGS=param
|
||||
com.acme.tests.SampleOneTest, gamma, LOC=4, TAGS=nested1;nested2
|
||||
com.acme.other.AnotherTest, delta, LOC=3, TAGS=-
|
||||
```
|
||||
|
||||
If a method has no source-level JUnit tags, plain mode prints `TAGS=-`.
|
||||
|
||||
## AI enrichment
|
||||
|
||||
When AI support is enabled, MethodAtlas submits each parsed test class to a provider-agnostic suggestion engine and merges returned method-level suggestions into the emitted output.
|
||||
|
||||
The AI subsystem can:
|
||||
|
||||
- classify whether a test is security-relevant
|
||||
- propose a `SECURITY: ...` display name
|
||||
- assign controlled taxonomy tags
|
||||
- provide a short rationale
|
||||
|
||||
Supported providers:
|
||||
|
||||
- `auto`
|
||||
- `ollama`
|
||||
- `openai`
|
||||
- `openrouter`
|
||||
- `anthropic`
|
||||
|
||||
In `auto` mode, MethodAtlas prefers a reachable local Ollama instance and otherwise falls back to an OpenAI-compatible provider when an API key is configured.
|
||||
|
||||
## Complete command-line arguments
|
||||
|
||||
### General options
|
||||
|
||||
| Argument | Meaning | Default |
|
||||
| --- | --- | --- |
|
||||
| `-plain` | Emit plain text instead of CSV | CSV mode |
|
||||
| `[path ...]` | One or more root paths to scan | Current directory |
|
||||
|
||||
### AI options
|
||||
|
||||
| Argument | Meaning | Notes / default |
|
||||
| --- | --- | --- |
|
||||
| `-ai` | Enable AI enrichment | Disabled by default |
|
||||
| `-ai-provider <provider>` | Select provider | `auto`, `ollama`, `openai`, `openrouter`, `anthropic` |
|
||||
| `-ai-model <model>` | Provider-specific model identifier | Default is `qwen2.5-coder:7b` |
|
||||
| `-ai-base-url <url>` | Override provider base URL | Provider-specific default URL is used otherwise |
|
||||
| `-ai-api-key <key>` | Supply API key directly on the command line | Useful for quick experiments; env vars are often preferable |
|
||||
| `-ai-api-key-env <name>` | Read API key from an environment variable | Used if `-ai-api-key` is not supplied |
|
||||
| `-ai-taxonomy <path>` | Load taxonomy text from an external file | Overrides built-in taxonomy text |
|
||||
| `-ai-taxonomy-mode <mode>` | Select built-in taxonomy mode | `default` or `optimized`; default is `default` |
|
||||
| `-ai-max-class-chars <count>` | Skip AI analysis for larger classes | Default is `40000` |
|
||||
| `-ai-timeout-sec <seconds>` | Set request timeout for provider calls | Default is `90` seconds |
|
||||
| `-ai-max-retries <count>` | Set retry limit for AI operations | Default is `1` |
|
||||
|
||||
Unknown options cause an error. Missing option values also fail fast.
|
||||
|
||||
### Argument details
|
||||
|
||||
#### `-plain`
|
||||
|
||||
Switches output rendering from CSV to a human-readable line-oriented format. This affects rendering only; method discovery and AI classification behavior remain the same.
|
||||
|
||||
#### `-ai`
|
||||
|
||||
Turns on AI enrichment. Without this flag, MethodAtlas behaves as a pure static scanner and emits only source-derived metadata. When this flag is present, the application initializes an AI suggestion engine before scanning.
|
||||
|
||||
#### `-ai-provider <provider>`
|
||||
|
||||
Selects the provider implementation.
|
||||
|
||||
Accepted values are case-insensitive because the CLI normalizes them internally before mapping them to the provider enum. Available providers are:
|
||||
|
||||
- `auto`
|
||||
- `ollama`
|
||||
- `openai`
|
||||
- `openrouter`
|
||||
- `anthropic`
|
||||
|
||||
`auto` is the default.
|
||||
|
||||
#### `-ai-model <model>`
|
||||
|
||||
Specifies the provider-specific model name. Examples include local Ollama model names or hosted model identifiers accepted by OpenAI-compatible providers. The default is `qwen2.5-coder:7b`.
|
||||
|
||||
#### `-ai-base-url <url>`
|
||||
|
||||
Overrides the provider base URL.
|
||||
|
||||
If omitted, MethodAtlas uses these defaults:
|
||||
|
||||
| Provider | Default base URL |
|
||||
| --- | --- |
|
||||
| `auto` | `http://localhost:11434` |
|
||||
| `ollama` | `http://localhost:11434` |
|
||||
| `openai` | `https://api.openai.com` |
|
||||
| `openrouter` | `https://openrouter.ai/api` |
|
||||
| `anthropic` | `https://api.anthropic.com` |
|
||||
|
||||
This is useful for self-hosted gateways, proxies, compatible endpoints, or non-default local deployments.
|
||||
|
||||
#### `-ai-api-key <key>`
|
||||
|
||||
Provides the API key directly. This takes precedence over `-ai-api-key-env` because the resolved API key logic first checks the explicit key and only then consults the environment variable.
|
||||
|
||||
#### `-ai-api-key-env <name>`
|
||||
|
||||
Reads the API key from an environment variable such as:
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY=...
|
||||
./methodatlas -ai -ai-provider openrouter -ai-api-key-env OPENROUTER_API_KEY /path/to/tests
|
||||
```
|
||||
|
||||
If both `-ai-api-key` and `-ai-api-key-env` are omitted, providers that require hosted authentication will be unavailable.
|
||||
|
||||
#### `-ai-taxonomy <path>`
|
||||
|
||||
Loads taxonomy text from an external file instead of using the built-in taxonomy. This lets you tailor classification categories or rules to your own security testing conventions.
|
||||
|
||||
#### `-ai-taxonomy-mode <mode>`
|
||||
|
||||
Selects one of the built-in taxonomy variants:
|
||||
|
||||
- `default` — more descriptive, human-readable taxonomy
|
||||
- `optimized` — more compact taxonomy intended to improve model reliability and reduce prompt size
|
||||
|
||||
When `-ai-taxonomy` is also supplied, the external taxonomy file takes precedence.
|
||||
|
||||
#### `-ai-max-class-chars <count>`
|
||||
|
||||
Sets the maximum serialized class size eligible for AI analysis. If a class source exceeds this number of characters, MethodAtlas skips AI classification for that class and continues scanning normally.
|
||||
|
||||
#### `-ai-timeout-sec <seconds>`
|
||||
|
||||
Configures the timeout applied to AI provider requests. The default is 90 seconds.
|
||||
|
||||
#### `-ai-max-retries <count>`
|
||||
|
||||
Configures the retry count retained in AI runtime options. The current default is `1`.
|
||||
|
||||
## Example commands
|
||||
|
||||
Basic scan:
|
||||
|
||||
```bash
|
||||
./methodatlas /path/to/project
|
||||
```
|
||||
|
||||
Plain output:
|
||||
|
||||
```bash
|
||||
./methodatlas -plain /path/to/project
|
||||
```
|
||||
|
||||
AI with OpenRouter and direct API key:
|
||||
|
||||
```bash
|
||||
./methodatlas -ai -ai-provider openrouter -ai-api-key YOUR_API_KEY -ai-model stepfun/step-3.5-flash:free /path/to/junit/tests
|
||||
```
|
||||
|
||||
AI with OpenRouter and environment variable:
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY=YOUR_API_KEY
|
||||
./methodatlas -ai -ai-provider openrouter -ai-api-key-env OPENROUTER_API_KEY -ai-model stepfun/step-3.5-flash:free /path/to/junit/tests
|
||||
```
|
||||
|
||||
AI with local Ollama:
|
||||
|
||||
```bash
|
||||
./methodatlas -ai -ai-provider ollama -ai-model qwen2.5-coder:7b /path/to/junit/tests
|
||||
```
|
||||
|
||||
Automatic provider selection:
|
||||
|
||||
```bash
|
||||
./methodatlas -ai /path/to/junit/tests
|
||||
```
|
||||
|
||||
## Highlighted example: AI extension in action
|
||||
|
||||
In a real packaged setup, running MethodAtlas from the unzipped distribution against a subset of MethodAtlas and ZeroEcho test sources with:
|
||||
|
||||
```bash
|
||||
./methodatlas -ai -ai-provider openrouter -ai-api-key OBTAIN_YOUR_API_KEY -ai-model stepfun/step-3.5-flash:free some/dir/with/junit/tests/
|
||||
```
|
||||
|
||||
produced output such as:
|
||||
|
||||
```csv
|
||||
fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason
|
||||
org.egothor.methodatlas.MethodAtlasAppTest,csvMode_detectsMethodsLocAndTags,22,,false,,,"Test verifies functional output format and data extraction of MethodAtlasApp, not security properties."
|
||||
org.egothor.methodatlas.MethodAtlasAppTest,plainMode_detectsMethodsLocAndTags,20,,false,,,"Test verifies functional output format and data extraction of MethodAtlasApp, not security properties."
|
||||
zeroecho.core.alg.aes.AesGcmCrossCheckTest,aesGcm_stream_vs_jca_ctxOnly_crosscheck,52,,true,SECURITY: crypto - cross-check AES-GCM stream encryption with JCA reference,security;crypto,"The test verifies that the custom AES-GCM stream implementation produces identical ciphertexts and plaintexts as the JCA reference, ensuring cryptographic correctness and preventing failures that could lead to loss of confidentiality or integrity."
|
||||
zeroecho.core.alg.aes.AesLargeDataTest,aesGcmLargeData_ctxOnly,27,,true,SECURITY: crypto - AES-GCM round-trip with context-only parameters,security;crypto,"Tests encryption and decryption correctness for large data using AES-GCM, ensuring the authenticated encryption mechanism functions properly for confidentiality and integrity."
|
||||
zeroecho.core.alg.aes.AesLargeDataTest,aesGcmLargeData_headerCodec,29,,true,SECURITY: crypto - AES-GCM round-trip with header codec,security;crypto,"Validates AES-GCM with an in-band header codec, confirming correct handling of additional authenticated data in the encryption process."
|
||||
zeroecho.core.alg.aes.AesLargeDataTest,aesCbcPkcs5LargeData_ctxOnly,27,,true,SECURITY: crypto - AES-CBC/PKCS7Padding round-trip with context-only IV,security;crypto,"Ensures AES-CBC encryption and decryption with PKCS7 padding works correctly for large data, testing confidentiality without integrity protection."
|
||||
zeroecho.core.alg.mldsa.MldsaLargeDataTest,mldsa_complete_suite_streaming_sign_verify_large_data,24,,true,SECURITY: crypto - ML-DSA streaming signature and verification for large data with integrity check,security;crypto;owasp,"Validates cryptographic correctness of ML-DSA signature creation and verification, including handling large data streams, signature length checks, and rejection of tampered signatures via bit-flip, ensuring data integrity and resistance to forgery."
|
||||
```
|
||||
|
||||
What this shows in practice:
|
||||
|
||||
- Functional tests remain untouched.
|
||||
- Security-relevant cryptographic tests are detected correctly.
|
||||
- The tool suggests consistent taxonomy tags such as `security`, `crypto`, and, where appropriate, `owasp`.
|
||||
- The generated display names are already suitable as candidate `@DisplayName` values.
|
||||
- The rationale column explains why a method was classified as security-relevant.
|
||||
|
||||
For a programmer, this turns a raw test tree into a searchable, structured inventory of security tests without requiring manual tagging of every method.
|
||||
|
||||
## Built-in security taxonomy
|
||||
|
||||
The prompt builder enforces a closed tag set so that providers do not invent categories. The built-in taxonomy covers these security areas:
|
||||
|
||||
- `auth`
|
||||
- `access-control`
|
||||
- `crypto`
|
||||
- `input-validation`
|
||||
- `injection`
|
||||
- `data-protection`
|
||||
- `logging`
|
||||
- `error-handling`
|
||||
- `owasp`
|
||||
|
||||
Every security-relevant method must include the umbrella tag `security`, and suggested display names should follow:
|
||||
|
||||
```text
|
||||
SECURITY: <security property> - <scenario>
|
||||
```
|
||||
|
||||
MethodAtlas ships both a default taxonomy and a more compact optimized taxonomy.
|
||||
|
||||
## Why this is useful
|
||||
|
||||
MethodAtlas is useful when you need to:
|
||||
|
||||
- inventory a large JUnit suite quickly
|
||||
- find tests that already validate security properties
|
||||
- identify where security tagging is inconsistent or missing
|
||||
- export structured metadata for reporting, dashboards, or CI jobs
|
||||
- review security test coverage before an audit or release
|
||||
|
||||
Because the application emits one row per test method, the output is easy to pipe into shell scripts, spreadsheets, data pipelines, or further static analysis.
|
||||
|
||||
## Notes
|
||||
|
||||
* The scanner looks for files ending with `*Test.java`.
|
||||
* JUnit methods are detected by annotations such as:
|
||||
* `@Test`
|
||||
* `@ParameterizedTest`
|
||||
* `@RepeatedTest`
|
||||
* Tag extraction supports:
|
||||
* `@Tag("x")` (including repeated `@Tag`)
|
||||
* `@Tags({ @Tag("x"), @Tag("y") })`
|
||||
- The scanner currently considers files ending with `*Test.java`.
|
||||
- AI classification is class-contextual: the full class source is submitted so the model can classify methods with more context.
|
||||
- If AI support is enabled but engine initialization fails, the application aborts.
|
||||
- If AI classification of a particular class fails, the scan continues and MethodAtlas emits base metadata without AI suggestions for that class.
|
||||
|
||||
73
build.gradle
73
build.gradle
@@ -8,6 +8,21 @@ plugins {
|
||||
group = 'org.egothor.methodatlas'
|
||||
version = gitVersion(prefix:'release@')
|
||||
|
||||
configurations {
|
||||
mockitoAgent
|
||||
}
|
||||
|
||||
pmd {
|
||||
consoleOutput = true
|
||||
toolVersion = '7.20.0'
|
||||
sourceSets = [sourceSets.main]
|
||||
ruleSetFiles = files(rootProject.file(".ruleset"))
|
||||
}
|
||||
|
||||
tasks.withType(Pmd) {
|
||||
maxHeapSize = "16g"
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
@@ -20,10 +35,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 {
|
||||
@@ -60,17 +92,21 @@ javadoc {
|
||||
source = sourceSets.main.allJava
|
||||
}
|
||||
|
||||
jar {
|
||||
tasks.named('jar') {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.register('fatJar', Jar) {
|
||||
archiveClassifier = ''
|
||||
manifest {
|
||||
attributes(
|
||||
'Main-Class': application.mainClass,
|
||||
'Main-Class': application.mainClass.get(),
|
||||
'Implementation-Title': rootProject.name,
|
||||
'Implementation-Version': "${version}"
|
||||
'Implementation-Version': "${version}"
|
||||
)
|
||||
}
|
||||
|
||||
from sourceSets.main.output
|
||||
|
||||
from sourceSets.main.output
|
||||
dependsOn configurations.runtimeClasspath
|
||||
|
||||
// Include each JAR dependency
|
||||
@@ -95,6 +131,33 @@ jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
distributions {
|
||||
create('fat') {
|
||||
distributionBaseName = project.name
|
||||
contents {
|
||||
into('bin') {
|
||||
from(tasks.named('startScripts'))
|
||||
filePermissions {
|
||||
unix('rwxr-xr-x')
|
||||
}
|
||||
}
|
||||
into('lib') {
|
||||
from(tasks.named('fatJar'))
|
||||
}
|
||||
from('src/dist')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('assemble') {
|
||||
dependsOn tasks.named('fatDistZip'), tasks.named('fatDistTar')
|
||||
}
|
||||
|
||||
tasks.named('startScripts') {
|
||||
dependsOn tasks.named('fatJar')
|
||||
classpath = files(tasks.named('fatJar').flatMap { it.archiveFile })
|
||||
}
|
||||
|
||||
gradle.taskGraph.whenReady { taskGraph ->
|
||||
def banner = """
|
||||
\u001B[34m
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable AI-generated classification result for a single parsed test class.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Instances of this record are commonly deserialized from provider-specific AI
|
||||
* responses after normalization into the application's internal result model.
|
||||
* </p>
|
||||
*
|
||||
* @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<String> classTags,
|
||||
String classReason, List<AiMethodSuggestion> methods) {
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable AI-generated security classification result for a single test
|
||||
* method.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The fields of this record correspond to the security analysis dimensions used
|
||||
* by the {@code MethodAtlasApp} enrichment pipeline:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>whether the test method validates a security property</li>
|
||||
* <li>a suggested {@code @DisplayName} describing the security intent</li>
|
||||
* <li>taxonomy-based security tags associated with the test</li>
|
||||
* <li>a short explanatory rationale describing the classification</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<String> tags,
|
||||
String reason) {
|
||||
}
|
||||
338
src/main/java/org/egothor/methodatlas/ai/AiOptions.java
Normal file
338
src/main/java/org/egothor/methodatlas/ai/AiOptions.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Configuration Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>AI provider selection and endpoint configuration</li>
|
||||
* <li>model name resolution</li>
|
||||
* <li>API key discovery</li>
|
||||
* <li>taxonomy configuration for security classification</li>
|
||||
* <li>input size limits for class source submission</li>
|
||||
* <li>network timeout configuration</li>
|
||||
* <li>retry policy for transient AI failures</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Default values are supplied by the {@link Builder} when parameters are not
|
||||
* explicitly provided.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* These modes determine which internal taxonomy definition is supplied to the
|
||||
* AI provider when an external taxonomy file is not configured.
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #DEFAULT} – general-purpose taxonomy suitable for human
|
||||
* readability</li>
|
||||
* <li>{@link #OPTIMIZED} – compact taxonomy optimized for AI classification
|
||||
* accuracy</li>
|
||||
* </ul>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* The constructor enforces basic invariants required for correct operation of
|
||||
* the AI integration layer. Invalid values result in an
|
||||
* {@link IllegalArgumentException}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The builder supplies sensible defaults for most configuration values and
|
||||
* allows incremental customization before producing the final immutable
|
||||
* configuration record.
|
||||
* </p>
|
||||
*
|
||||
* @return new builder instance
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective API key used for authenticating AI provider requests.
|
||||
*
|
||||
* <p>
|
||||
* The resolution strategy is:
|
||||
* </p>
|
||||
*
|
||||
* <ol>
|
||||
* <li>If {@link #apiKey()} is defined and non-empty, it is returned.</li>
|
||||
* <li>If {@link #apiKeyEnv()} is defined, the corresponding environment
|
||||
* variable is resolved using {@link System#getenv(String)}.</li>
|
||||
* <li>If neither source yields a value, {@code null} is returned.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The builder follows the conventional staged construction pattern, allowing
|
||||
* optional parameters to be supplied before producing the final immutable
|
||||
* configuration record via {@link #build()}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Reasonable defaults are provided for most parameters so that only
|
||||
* provider-specific details typically need to be configured explicitly.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* If no base URL is explicitly supplied, a provider-specific default endpoint
|
||||
* is selected automatically.
|
||||
* </p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/main/java/org/egothor/methodatlas/ai/AiProvider.java
Normal file
88
src/main/java/org/egothor/methodatlas/ai/AiProvider.java
Normal file
@@ -0,0 +1,88 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
/**
|
||||
* Enumeration of supported AI provider implementations used by the
|
||||
* {@link org.egothor.methodatlas.ai.AiSuggestionEngine}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Provider Selection</h2>
|
||||
*
|
||||
* <p>
|
||||
* The selected provider influences:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>the HTTP endpoint used for inference requests</li>
|
||||
* <li>authentication behavior</li>
|
||||
* <li>the model identifier format</li>
|
||||
* <li>response normalization logic</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* When {@link #AUTO} is selected, the system attempts to determine the most
|
||||
* suitable provider automatically based on the configured endpoint or local
|
||||
* runtime environment.
|
||||
* </p>
|
||||
*
|
||||
* @see AiOptions
|
||||
* @see AiSuggestionEngine
|
||||
*/
|
||||
public enum AiProvider {
|
||||
/**
|
||||
* Automatically selects the most appropriate AI provider based on configuration
|
||||
* and runtime availability.
|
||||
*
|
||||
* <p>
|
||||
* This mode allows the application to operate with minimal configuration,
|
||||
* preferring locally available providers when possible.
|
||||
* </p>
|
||||
*/
|
||||
AUTO,
|
||||
/**
|
||||
* Uses a locally running <a href="https://ollama.ai/">Ollama</a> instance as
|
||||
* the AI inference backend.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
OLLAMA,
|
||||
/**
|
||||
* Uses the OpenAI API for AI inference.
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*/
|
||||
OPENAI,
|
||||
/**
|
||||
* Uses the <a href="https://openrouter.ai/">OpenRouter</a> aggregation service
|
||||
* to access multiple AI models through a unified API.
|
||||
*
|
||||
* <p>
|
||||
* OpenRouter acts as a routing layer that forwards requests to different
|
||||
* underlying model providers while maintaining a consistent API surface.
|
||||
* </p>
|
||||
*/
|
||||
OPENROUTER,
|
||||
/**
|
||||
* Uses the <a href="https://www.anthropic.com/">Anthropic</a> API for AI
|
||||
* inference, typically through models in the Claude family.
|
||||
*/
|
||||
ANTHROPIC
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Provider-specific client abstraction used to communicate with external AI
|
||||
* inference services.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Provider Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>constructing provider-specific HTTP requests</li>
|
||||
* <li>handling authentication and API keys</li>
|
||||
* <li>sending inference requests</li>
|
||||
* <li>parsing and validating AI responses</li>
|
||||
* <li>normalizing results into {@link AiClassSuggestion}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Implementations are expected to be stateless and thread-safe unless
|
||||
* explicitly documented otherwise.
|
||||
* </p>
|
||||
*
|
||||
* @see AiSuggestionEngine
|
||||
* @see AiClassSuggestion
|
||||
* @see AiProvider
|
||||
*/
|
||||
public interface AiProviderClient {
|
||||
/**
|
||||
* Determines whether the provider is reachable and usable in the current
|
||||
* runtime environment.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This method is primarily used when {@link AiProvider#AUTO} selection is
|
||||
* enabled so the system can choose the first available provider.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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
|
||||
* @param targetMethods deterministically extracted JUnit test methods that must
|
||||
* be classified
|
||||
* @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,
|
||||
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException;
|
||||
}
|
||||
149
src/main/java/org/egothor/methodatlas/ai/AiProviderFactory.java
Normal file
149
src/main/java/org/egothor/methodatlas/ai/AiProviderFactory.java
Normal file
@@ -0,0 +1,149 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
/**
|
||||
* Factory responsible for creating provider-specific AI client implementations.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Provider Resolution</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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:
|
||||
* </p>
|
||||
*
|
||||
* <ol>
|
||||
* <li>Attempt to use a locally running {@link OllamaClient}.</li>
|
||||
* <li>If Ollama is not reachable and an API key is configured, fall back to an
|
||||
* OpenAI-compatible provider.</li>
|
||||
* <li>If no provider can be resolved, an {@link AiSuggestionException} is
|
||||
* thrown.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The factory ensures that returned clients are usable by verifying provider
|
||||
* availability when required.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is intentionally non-instantiable and exposes only static factory
|
||||
* methods.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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)}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The discovery process prioritizes locally available inference services to
|
||||
* enable operation without external dependencies whenever possible.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The current discovery strategy is:
|
||||
* </p>
|
||||
* <ol>
|
||||
* <li>Attempt to connect to a local {@link OllamaClient}.</li>
|
||||
* <li>If Ollama is not available but an API key is configured, create an
|
||||
* {@link OpenAiCompatibleClient}.</li>
|
||||
* <li>If neither provider can be used, throw an exception.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* High-level AI orchestration contract for security classification of parsed
|
||||
* test classes.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>accepting a fully qualified class name and corresponding class
|
||||
* source</li>
|
||||
* <li>submitting the class for AI-based security analysis</li>
|
||||
* <li>normalizing provider-specific responses into
|
||||
* {@link AiClassSuggestion}</li>
|
||||
* <li>surfacing failures through {@link AiSuggestionException}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @see AiClassSuggestion
|
||||
* @see AiProviderClient
|
||||
* @see org.egothor.methodatlas.MethodAtlasApp
|
||||
*/
|
||||
@SuppressWarnings("PMD.ImplicitFunctionalInterface")
|
||||
public interface AiSuggestionEngine {
|
||||
/**
|
||||
* Requests AI-generated security classification for a single parsed test class.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param fqcn fully qualified class name of the parsed test class
|
||||
* @param classSource complete source code of the class to analyze
|
||||
* @param targetMethods deterministically extracted JUnit test methods that must
|
||||
* be classified
|
||||
* @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, List<PromptBuilder.TargetMethod> targetMethods)
|
||||
throws AiSuggestionException;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link AiSuggestionEngine} that coordinates
|
||||
* provider selection and taxonomy loading for AI-based security classification.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>creating the effective provider client from {@link AiOptions}</li>
|
||||
* <li>loading taxonomy text from a configured file or from the selected
|
||||
* built-in taxonomy mode</li>
|
||||
* <li>delegating class analysis requests to the provider client</li>
|
||||
* <li>presenting a provider-independent {@link AiSuggestionEngine} contract to
|
||||
* higher-level callers</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Instances of this class are immutable after construction and are intended to
|
||||
* be created once per application run.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param fqcn fully qualified class name of the analyzed test class
|
||||
* @param classSource complete source code of the class to analyze
|
||||
* @param targetMethods deterministically extracted JUnit test methods that must
|
||||
* be classified
|
||||
* @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,
|
||||
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
|
||||
return client.suggestForClass(fqcn, classSource, taxonomyText, targetMethods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the taxonomy text used to guide AI classification.
|
||||
*
|
||||
* <p>
|
||||
* Resolution order:
|
||||
* </p>
|
||||
* <ol>
|
||||
* <li>If an external taxonomy file is configured, its contents are used.</li>
|
||||
* <li>Otherwise, the built-in taxonomy selected by
|
||||
* {@link AiOptions#taxonomyMode()} is used.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
/**
|
||||
* Checked exception indicating failure during AI-based suggestion generation or
|
||||
* related AI subsystem operations.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
261
src/main/java/org/egothor/methodatlas/ai/AnthropicClient.java
Normal file
261
src/main/java/org/egothor/methodatlas/ai/AnthropicClient.java
Normal file
@@ -0,0 +1,261 @@
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* This client submits classification requests to the Anthropic
|
||||
* <a href="https://docs.anthropic.com/">Claude API</a> and converts the
|
||||
* returned response into the internal {@link AiClassSuggestion} model used by
|
||||
* the MethodAtlas AI subsystem.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Operational Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>constructing Anthropic message API requests</li>
|
||||
* <li>injecting the taxonomy-driven classification prompt</li>
|
||||
* <li>performing authenticated HTTP calls to the Anthropic service</li>
|
||||
* <li>extracting the JSON result embedded in the model response</li>
|
||||
* <li>normalizing the result into {@link AiClassSuggestion}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Instances of this class are typically created by
|
||||
* {@link AiProviderFactory#create(AiOptions)}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This implementation is stateless apart from immutable configuration and is
|
||||
* therefore safe for reuse across multiple requests.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* The configuration defines the model identifier, API endpoint, request
|
||||
* timeout, and authentication settings used when communicating with the
|
||||
* Anthropic service.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The provider is considered available when a non-empty API key can be resolved
|
||||
* from {@link AiOptions#resolvedApiKey()}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The method constructs a message-based request containing:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>a system prompt enforcing deterministic JSON output</li>
|
||||
* <li>a user prompt containing the class source and taxonomy definition</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The response is parsed to extract the first JSON object returned by the
|
||||
* model, which is then deserialized into an {@link AiClassSuggestion}.
|
||||
* </p>
|
||||
*
|
||||
* @param fqcn fully qualified class name being analyzed
|
||||
* @param classSource complete source code of the class
|
||||
* @param taxonomyText taxonomy definition guiding classification
|
||||
* @param targetMethods deterministically extracted JUnit test methods that must
|
||||
* be classified
|
||||
*
|
||||
* @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,
|
||||
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
|
||||
try {
|
||||
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
|
||||
|
||||
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) { // NOPMD
|
||||
throw new AiSuggestionException("Anthropic suggestion failed for " + fqcn, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes AI results returned by the provider.
|
||||
*
|
||||
* <p>
|
||||
* This method ensures that collection fields are never {@code null} and removes
|
||||
* malformed method entries that do not contain a valid method name.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param input raw suggestion returned by the provider
|
||||
* @return normalized suggestion instance
|
||||
*/
|
||||
private static AiClassSuggestion normalize(AiClassSuggestion input) {
|
||||
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
|
||||
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
|
||||
|
||||
List<AiMethodSuggestion> 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.
|
||||
*
|
||||
* <p>
|
||||
* This record models the JSON structure expected by the {@code /v1/messages}
|
||||
* endpoint and is serialized using Jackson before transmission.
|
||||
* </p>
|
||||
*
|
||||
* @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<ContentMessage> 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<ContentBlock> 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.
|
||||
*
|
||||
* <p>
|
||||
* Only the fields required by this client are mapped. Additional fields are
|
||||
* ignored to maintain forward compatibility with API changes.
|
||||
* </p>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private static final class MessageResponse {
|
||||
public List<ResponseBlock> content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block returned within a provider response.
|
||||
*
|
||||
* <p>
|
||||
* The client scans these blocks to locate the first text segment containing the
|
||||
* JSON classification result.
|
||||
* </p>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private static final class ResponseBlock {
|
||||
public String type;
|
||||
public String text;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Purpose</h2>
|
||||
*
|
||||
* <p>
|
||||
* The taxonomy is designed to improve classification consistency by providing
|
||||
* the AI provider with a stable and explicit specification of:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>what constitutes a security-relevant test</li>
|
||||
* <li>which security category tags are allowed</li>
|
||||
* <li>how tags should be selected</li>
|
||||
* <li>how security-oriented display names should be formed</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The default taxonomy favors readability and professional descriptive clarity.
|
||||
* For a more compact taxonomy tuned specifically for model reliability, see
|
||||
* {@link OptimizedSecurityTaxonomy}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is a non-instantiable utility holder.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The returned text is intended to be embedded directly into provider prompts
|
||||
* and therefore contains both conceptual guidance and operational
|
||||
* classification rules. It defines:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>scope of security-relevant tests</li>
|
||||
* <li>mandatory and optional tagging rules</li>
|
||||
* <li>allowed taxonomy categories</li>
|
||||
* <li>guidance for class-level versus method-level tagging</li>
|
||||
* <li>display name conventions</li>
|
||||
* <li>AI-oriented decision instructions</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The returned value is immutable text and may safely be reused across multiple
|
||||
* AI requests.
|
||||
* </p>
|
||||
*
|
||||
* @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: <control/property> - <scenario>")
|
||||
|
||||
|
||||
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: <security property> - <test scenario>
|
||||
|
||||
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.
|
||||
""";
|
||||
}
|
||||
}
|
||||
160
src/main/java/org/egothor/methodatlas/ai/HttpSupport.java
Normal file
160
src/main/java/org/egothor/methodatlas/ai/HttpSupport.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This class centralizes common HTTP-related functionality required by the AI
|
||||
* provider integrations, including:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>creation of a configured {@link HttpClient}</li>
|
||||
* <li>provision of a shared Jackson {@link ObjectMapper}</li>
|
||||
* <li>execution of JSON-oriented HTTP requests</li>
|
||||
* <li>construction of JSON {@code POST} requests</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The internally managed {@link ObjectMapper} is configured to ignore unknown
|
||||
* JSON properties so that provider response deserialization remains resilient
|
||||
* to non-breaking API changes.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Instances of this class are immutable after construction.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The constructor also initializes a Jackson {@link ObjectMapper} configured
|
||||
* with {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} disabled.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<String> 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.
|
||||
*
|
||||
* <p>
|
||||
* The returned builder is preconfigured with:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>the supplied target {@link URI}</li>
|
||||
* <li>the supplied request timeout</li>
|
||||
* <li>{@code Content-Type: application/json}</li>
|
||||
* <li>a {@code POST} request body containing the supplied JSON text</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Callers may further customize the returned builder, for example by adding
|
||||
* authentication or provider-specific headers, before invoking
|
||||
* {@link HttpRequest.Builder#build()}.
|
||||
* </p>
|
||||
*
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
77
src/main/java/org/egothor/methodatlas/ai/JsonText.java
Normal file
77
src/main/java/org/egothor/methodatlas/ai/JsonText.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
/**
|
||||
* Utility methods for extracting JSON fragments from free-form text produced by
|
||||
* AI model responses.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The current implementation performs a simple structural search for the first
|
||||
* opening brace (<code>{</code>) and the last closing brace (<code>}</code>),
|
||||
* and returns the substring spanning those positions.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is a non-instantiable utility holder.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The method scans the supplied text for the first occurrence of an opening
|
||||
* brace (<code>{</code>) and the last occurrence of a closing brace
|
||||
* (<code>}</code>). The substring between these positions (inclusive) is
|
||||
* returned as the extracted JSON object.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This approach allows the application to recover structured data even when the
|
||||
* model returns additional natural-language content or formatting around the
|
||||
* JSON payload.
|
||||
* </p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
279
src/main/java/org/egothor/methodatlas/ai/OllamaClient.java
Normal file
279
src/main/java/org/egothor/methodatlas/ai/OllamaClient.java
Normal file
@@ -0,0 +1,279 @@
|
||||
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
|
||||
* <a href="https://ollama.ai/">Ollama</a> inference service.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Operational Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>verifying local Ollama availability</li>
|
||||
* <li>constructing chat-style inference requests</li>
|
||||
* <li>injecting the system prompt and taxonomy-guided user prompt</li>
|
||||
* <li>executing HTTP requests against the Ollama API</li>
|
||||
* <li>extracting and normalizing JSON classification results</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The client uses the Ollama {@code /api/chat} endpoint for inference and the
|
||||
* {@code /api/tags} endpoint as a lightweight availability probe.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This implementation is intended primarily for local, offline, or
|
||||
* privacy-preserving inference scenarios where source code should not be sent
|
||||
* to an external provider.
|
||||
* </p>
|
||||
*
|
||||
* @see AiProviderClient
|
||||
* @see AiProviderFactory
|
||||
* @see AiSuggestionEngine
|
||||
*/
|
||||
public final class OllamaClient implements AiProviderClient {
|
||||
/**
|
||||
* System prompt used to enforce deterministic, machine-readable model output.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* The configuration determines the base URL of the Ollama service, the model
|
||||
* identifier, and request timeout values used by this client.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The method performs a lightweight availability probe against the
|
||||
* {@code /api/tags} endpoint. If the endpoint responds successfully, the
|
||||
* provider is considered available.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Any exception raised during the probe is treated as an indication that the
|
||||
* provider is unavailable.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The request consists of:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>a system prompt enforcing strict JSON output</li>
|
||||
* <li>a user prompt containing the test class source and taxonomy text</li>
|
||||
* <li>provider options such as deterministic temperature settings</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param fqcn fully qualified class name being analyzed
|
||||
* @param classSource complete source code of the class being analyzed
|
||||
* @param taxonomyText taxonomy definition guiding classification
|
||||
* @param targetMethods deterministically extracted JUnit test methods that must
|
||||
* be classified
|
||||
* @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,
|
||||
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
|
||||
try {
|
||||
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
|
||||
|
||||
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) { // NOPMD
|
||||
throw new AiSuggestionException("Ollama suggestion failed for " + fqcn, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a provider response into the application's internal result
|
||||
* invariants.
|
||||
*
|
||||
* <p>
|
||||
* The method ensures that collection-valued fields are never {@code null} and
|
||||
* removes malformed method entries that do not define a usable method name.
|
||||
* </p>
|
||||
*
|
||||
* @param input raw suggestion returned by the provider
|
||||
* @return normalized suggestion
|
||||
*/
|
||||
private static AiClassSuggestion normalize(AiClassSuggestion input) {
|
||||
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
|
||||
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
|
||||
|
||||
List<AiMethodSuggestion> 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.
|
||||
*
|
||||
* <p>
|
||||
* This record models the JSON structure expected by the {@code /api/chat}
|
||||
* endpoint.
|
||||
* </p>
|
||||
*
|
||||
* @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<Message> 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.
|
||||
*
|
||||
* <p>
|
||||
* Currently only the {@code temperature} sampling parameter is configured.
|
||||
* Temperature controls the randomness of model output:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code 0.0} produces deterministic output</li>
|
||||
* <li>higher values increase variation and creativity</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param temperature sampling temperature controlling response randomness
|
||||
*/
|
||||
private record Options(@JsonProperty("temperature") Double temperature) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial response model returned by the Ollama chat API.
|
||||
*
|
||||
* <p>
|
||||
* Only the fields required by this client are modeled. Unknown properties are
|
||||
* ignored to maintain compatibility with future API extensions.
|
||||
* </p>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private static final class ChatResponse {
|
||||
public ResponseMessage message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message payload returned within an Ollama chat response.
|
||||
*
|
||||
* <p>
|
||||
* The client reads the {@link #content} field and expects it to contain the
|
||||
* JSON classification result generated by the model.
|
||||
* </p>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private static final class ResponseMessage {
|
||||
public String content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* This client supports providers that implement the OpenAI-style
|
||||
* {@code /v1/chat/completions} endpoint. The same implementation is used for:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link AiProvider#OPENAI}</li>
|
||||
* <li>{@link AiProvider#OPENROUTER}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Operational Responsibilities</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>constructing OpenAI-compatible chat completion requests</li>
|
||||
* <li>injecting the taxonomy-driven classification prompt</li>
|
||||
* <li>performing authenticated HTTP requests</li>
|
||||
* <li>extracting JSON content from the model response</li>
|
||||
* <li>normalizing the result into {@link AiClassSuggestion}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The implementation is provider-neutral for APIs that follow the OpenAI
|
||||
* protocol, which allows reuse across multiple compatible services such as
|
||||
* OpenRouter.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Instances are typically created through
|
||||
* {@link AiProviderFactory#create(AiOptions)}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The prompt intentionally forbids explanatory text and markdown formatting to
|
||||
* ensure that the returned content can be parsed reliably by the application.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* The supplied configuration determines the provider endpoint, model name,
|
||||
* authentication method, request timeout, and other runtime parameters.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* For OpenAI-compatible providers, availability is determined by the presence
|
||||
* of a usable API key resolved through {@link AiOptions#resolvedApiKey()}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The request payload includes:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>the configured model identifier</li>
|
||||
* <li>a system prompt defining classification rules</li>
|
||||
* <li>a user prompt containing the test class source and taxonomy</li>
|
||||
* <li>a deterministic temperature setting</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* When the selected provider is {@link AiProvider#OPENROUTER}, additional HTTP
|
||||
* headers are included to identify the calling application.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* @param fqcn fully qualified class name being analyzed
|
||||
* @param classSource complete source code of the class
|
||||
* @param taxonomyText taxonomy definition guiding classification
|
||||
* @param targetMethods deterministically extracted JUnit test methods that must
|
||||
* be classified
|
||||
* @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,
|
||||
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
|
||||
try {
|
||||
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
|
||||
|
||||
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) { // NOPMD
|
||||
throw new AiSuggestionException("OpenAI-compatible suggestion failed for " + fqcn, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes provider results to ensure structural invariants expected by the
|
||||
* application.
|
||||
*
|
||||
* <p>
|
||||
* The method replaces {@code null} collections with empty lists and removes
|
||||
* malformed method entries that do not contain a valid method name.
|
||||
* </p>
|
||||
*
|
||||
* @param input raw suggestion returned by the provider
|
||||
* @return normalized suggestion instance
|
||||
*/
|
||||
private static AiClassSuggestion normalize(AiClassSuggestion input) {
|
||||
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
|
||||
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
|
||||
|
||||
List<AiMethodSuggestion> 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<Message> 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.
|
||||
*
|
||||
* <p>
|
||||
* Only fields required for extracting the model response are mapped. Unknown
|
||||
* properties are ignored to preserve compatibility with provider API changes.
|
||||
* </p>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private static final class ChatResponse {
|
||||
public List<Choice> 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.
|
||||
*
|
||||
* <p>
|
||||
* The {@code content} field is expected to contain the JSON classification
|
||||
* result generated by the model.
|
||||
* </p>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private static final class ResponseMessage {
|
||||
public String content;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Design Goals</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>minimize prompt length without changing the supported taxonomy</li>
|
||||
* <li>increase deterministic model behavior</li>
|
||||
* <li>reduce ambiguity in category selection</li>
|
||||
* <li>preserve professional terminology and decision rules</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is a non-instantiable utility holder.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The taxonomy defines:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>the meaning of a security-relevant test</li>
|
||||
* <li>the mandatory {@code security} umbrella tag</li>
|
||||
* <li>the allowed category tags</li>
|
||||
* <li>selection rules for assigning taxonomy tags</li>
|
||||
* <li>guidance for use of the optional {@code owasp} tag</li>
|
||||
* <li>the required {@code SECURITY: <property> - <scenario>} display name
|
||||
* format</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This optimized variant is suitable when improved model consistency or shorter
|
||||
* prompt size is more important than human-oriented explanatory wording.
|
||||
* </p>
|
||||
*
|
||||
* @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: <security property> - <scenario>
|
||||
|
||||
Examples:
|
||||
|
||||
SECURITY: access control - deny non-owner account access
|
||||
SECURITY: crypto - reject reused nonce in AEAD
|
||||
SECURITY: input validation - reject path traversal sequences
|
||||
""";
|
||||
}
|
||||
}
|
||||
207
src/main/java/org/egothor/methodatlas/ai/PromptBuilder.java
Normal file
207
src/main/java/org/egothor/methodatlas/ai/PromptBuilder.java
Normal file
@@ -0,0 +1,207 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Utility responsible for constructing the prompt supplied to AI providers for
|
||||
* security classification of JUnit test classes.
|
||||
*
|
||||
* <p>
|
||||
* The prompt produced by this class combines several components into a single
|
||||
* instruction payload:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>classification instructions for the AI model</li>
|
||||
* <li>a controlled security taxonomy definition</li>
|
||||
* <li>strict output formatting rules</li>
|
||||
* <li>the fully qualified class name</li>
|
||||
* <li>the complete source code of the analyzed test class</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This revision keeps the full class source as semantic context but removes
|
||||
* method discovery from the AI model. The caller supplies the exact list of
|
||||
* JUnit test methods that must be classified, optionally with source line
|
||||
* anchors.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The prompt enforces a closed taxonomy and strict JSON output rules to ensure
|
||||
* that the returned content can be parsed reliably by the application.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is a non-instantiable utility holder.
|
||||
* </p>
|
||||
*
|
||||
* @see AiSuggestionEngine
|
||||
* @see AiProviderClient
|
||||
* @see DefaultSecurityTaxonomy
|
||||
* @see OptimizedSecurityTaxonomy
|
||||
*/
|
||||
public final class PromptBuilder {
|
||||
|
||||
/**
|
||||
* Deterministically extracted test method descriptor supplied to the prompt.
|
||||
*
|
||||
* @param methodName name of the JUnit test method
|
||||
* @param beginLine first source line of the method, or {@code null} if unknown
|
||||
* @param endLine last source line of the method, or {@code null} if unknown
|
||||
*/
|
||||
public record TargetMethod(String methodName, Integer beginLine, Integer endLine) {
|
||||
public TargetMethod {
|
||||
Objects.requireNonNull(methodName, "methodName");
|
||||
if (methodName.isBlank()) {
|
||||
throw new IllegalArgumentException("methodName must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* The generated prompt contains:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>task instructions describing the classification objective</li>
|
||||
* <li>the security taxonomy definition controlling allowed tags</li>
|
||||
* <li>the exact list of target test methods to classify</li>
|
||||
* <li>strict output rules enforcing JSON-only responses</li>
|
||||
* <li>a formal JSON schema describing the expected result structure</li>
|
||||
* <li>the fully qualified class name of the analyzed test class</li>
|
||||
* <li>the complete class source used as analysis input</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The returned prompt is intended to be used as the content of a user message
|
||||
* in chat-based inference APIs.
|
||||
* </p>
|
||||
*
|
||||
* @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
|
||||
* @param targetMethods exact list of deterministically discovered JUnit test
|
||||
* methods to classify
|
||||
* @return formatted prompt supplied to the AI provider
|
||||
*
|
||||
* @see AiSuggestionEngine#suggestForClass(String, String)
|
||||
*/
|
||||
public static String build(String fqcn, String classSource, String taxonomyText, List<TargetMethod> targetMethods) {
|
||||
Objects.requireNonNull(fqcn, "fqcn");
|
||||
Objects.requireNonNull(classSource, "classSource");
|
||||
Objects.requireNonNull(taxonomyText, "taxonomyText");
|
||||
Objects.requireNonNull(targetMethods, "targetMethods");
|
||||
|
||||
if (targetMethods.isEmpty()) {
|
||||
throw new IllegalArgumentException("targetMethods must not be empty");
|
||||
}
|
||||
|
||||
String targetMethodBlock = targetMethods.stream().map(PromptBuilder::formatTargetMethod)
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
String expectedMethodNames = targetMethods.stream().map(TargetMethod::methodName)
|
||||
.map(name -> "\"" + name + "\"").collect(Collectors.joining(", "));
|
||||
|
||||
return """
|
||||
You are analyzing a single JUnit 5 test class and suggesting security tags.
|
||||
|
||||
TASK
|
||||
- Analyze the WHOLE class for context.
|
||||
- Classify ONLY the methods explicitly listed in TARGET TEST METHODS.
|
||||
- Do not invent methods that do not exist.
|
||||
- Do not classify helper methods, lifecycle methods, nested classes, or any method not listed.
|
||||
- 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
|
||||
|
||||
TARGET TEST METHODS
|
||||
The following methods were extracted deterministically by the parser and are the ONLY methods
|
||||
you are allowed to classify. Use the full class source only as context for understanding them.
|
||||
|
||||
%s
|
||||
|
||||
OUTPUT RULES
|
||||
- Return JSON only.
|
||||
- No markdown.
|
||||
- No prose outside JSON.
|
||||
- Return exactly one result for each target method.
|
||||
- methodName values in the output must exactly match one of:
|
||||
[%s]
|
||||
- Do not omit any listed method.
|
||||
- Do not include any additional methods.
|
||||
- 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.
|
||||
- If securityRelevant=false, displayName must be null.
|
||||
- If securityRelevant=false, tags must be [].
|
||||
- If securityRelevant=true, displayName must match:
|
||||
SECURITY: <control/property> - <scenario>
|
||||
- reason should be short and specific.
|
||||
|
||||
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, targetMethodBlock, expectedMethodNames, fqcn, classSource);
|
||||
}
|
||||
|
||||
private static String formatTargetMethod(TargetMethod targetMethod) {
|
||||
StringBuilder builder = new StringBuilder("- ").append(targetMethod.methodName());
|
||||
|
||||
if (targetMethod.beginLine() != null || targetMethod.endLine() != null) {
|
||||
builder.append(" [lines ").append(targetMethod.beginLine() == null ? "?" : targetMethod.beginLine())
|
||||
.append('-').append(targetMethod.endLine() == null ? "?" : targetMethod.endLine()).append(']');
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
109
src/main/java/org/egothor/methodatlas/ai/SuggestionLookup.java
Normal file
109
src/main/java/org/egothor/methodatlas/ai/SuggestionLookup.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Design Characteristics</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>immutable after construction</li>
|
||||
* <li>null-safe for missing or malformed AI responses</li>
|
||||
* <li>optimized for repeated method-level lookups</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* If the AI response contains duplicate suggestions for the same method, only
|
||||
* the first occurrence is retained.
|
||||
* </p>
|
||||
*
|
||||
* @see AiClassSuggestion
|
||||
* @see AiMethodSuggestion
|
||||
*/
|
||||
public final class SuggestionLookup {
|
||||
|
||||
private final Map<String, AiMethodSuggestion> byMethodName;
|
||||
|
||||
/**
|
||||
* Creates a new immutable lookup instance backed by the supplied map.
|
||||
*
|
||||
* <p>
|
||||
* The internal map is defensively copied to guarantee immutability of the
|
||||
* lookup structure.
|
||||
* </p>
|
||||
*
|
||||
* @param byMethodName mapping from method names to AI suggestions
|
||||
*/
|
||||
private SuggestionLookup(Map<String, AiMethodSuggestion> byMethodName) {
|
||||
this.byMethodName = Map.copyOf(byMethodName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lookup instance from a class-level AI suggestion result.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* If the suggestion contains no method entries, an empty lookup instance is
|
||||
* returned.
|
||||
* </p>
|
||||
*
|
||||
* @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<String, AiMethodSuggestion> 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.
|
||||
*
|
||||
* <p>
|
||||
* If no suggestion exists for the method, an empty {@link Optional} is
|
||||
* returned.
|
||||
* </p>
|
||||
*
|
||||
* @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<AiMethodSuggestion> find(String methodName) {
|
||||
Objects.requireNonNull(methodName, "methodName");
|
||||
return Optional.ofNullable(byMethodName.get(methodName));
|
||||
}
|
||||
}
|
||||
69
src/main/java/org/egothor/methodatlas/ai/package-info.java
Normal file
69
src/main/java/org/egothor/methodatlas/ai/package-info.java
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI integration layer for MethodAtlas providing automated security
|
||||
* classification of JUnit test methods.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Architecture Overview</h2>
|
||||
*
|
||||
* <p>
|
||||
* The AI subsystem follows a layered design:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Engine layer</b> –
|
||||
* {@link org.egothor.methodatlas.ai.AiSuggestionEngine} orchestrates provider
|
||||
* communication and taxonomy handling.</li>
|
||||
*
|
||||
* <li><b>Provider layer</b> – implementations of
|
||||
* {@link org.egothor.methodatlas.ai.AiProviderClient} integrate with specific
|
||||
* AI services such as Ollama, OpenAI-compatible APIs, or Anthropic.</li>
|
||||
*
|
||||
* <li><b>Prompt construction</b> –
|
||||
* {@link org.egothor.methodatlas.ai.PromptBuilder} builds the prompt that
|
||||
* instructs the model how to perform security classification.</li>
|
||||
*
|
||||
* <li><b>Taxonomy definition</b> –
|
||||
* {@link org.egothor.methodatlas.ai.DefaultSecurityTaxonomy} and
|
||||
* {@link org.egothor.methodatlas.ai.OptimizedSecurityTaxonomy} define the
|
||||
* controlled vocabulary used for tagging.</li>
|
||||
*
|
||||
* <li><b>Result normalization</b> – AI responses are converted into the
|
||||
* structured domain model ({@link org.egothor.methodatlas.ai.AiClassSuggestion}
|
||||
* and {@link org.egothor.methodatlas.ai.AiMethodSuggestion}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security Considerations</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Deterministic Output</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Extensibility</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0.1
|
||||
*/
|
||||
package org.egothor.methodatlas.ai;
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Overview</h2>
|
||||
*
|
||||
* <p>
|
||||
* The application traverses one or more directory roots, parses Java source
|
||||
* files using the <a href="https://javaparser.org/">JavaParser</a> library, and
|
||||
* extracts information about test methods declared in classes whose file names
|
||||
* follow the conventional {@code *Test.java} pattern.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Output modes:
|
||||
* For each detected test method the application reports:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>CSV (default): {@code fqcn,method,loc,tags}</li>
|
||||
* <li>Plain text: enabled by {@code -plain} as the first command-line
|
||||
* argument</li>
|
||||
* <li>fully-qualified class name (FQCN)</li>
|
||||
* <li>test method name</li>
|
||||
* <li>method size measured in lines of code (LOC)</li>
|
||||
* <li>JUnit {@code @Tag} annotations declared on the method</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The resulting dataset can be used for test inventory generation, quality
|
||||
* metrics, governance reporting, or security analysis of test coverage.
|
||||
* </p>
|
||||
*
|
||||
* <h2>AI-Based Security Tagging</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* In this mode the application sends each discovered test class to the
|
||||
* configured AI provider and receives suggested security annotations, such as:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>whether the test validates a security property</li>
|
||||
* <li>suggested {@code @DisplayName} describing the security intent</li>
|
||||
* <li>taxonomy-based security tags</li>
|
||||
* <li>optional explanatory reasoning</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* These suggestions are merged with the source-derived metadata and emitted
|
||||
* alongside the standard output fields.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Output Formats</h2>
|
||||
*
|
||||
* <p>
|
||||
* The application supports two output modes:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>CSV (default)</b> <pre>{@code fqcn,method,loc,tags}</pre> or, when AI
|
||||
* enrichment is enabled:
|
||||
* <pre>{@code fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason}</pre>
|
||||
* </li>
|
||||
* <li><b>Plain text</b>, enabled using the {@code -plain} command-line option
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Typical Usage</h2>
|
||||
*
|
||||
* <pre>{@code
|
||||
* java -jar methodatlas.jar /path/to/project
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <pre>{@code
|
||||
* java -jar methodatlas.jar -plain /path/to/project
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* The command scans the specified source directory recursively and emits one
|
||||
* output record per detected test method.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Implementation Notes</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>Parsing is performed using
|
||||
* {@link com.github.javaparser.StaticJavaParser}.</li>
|
||||
* <li>Test detection is based on JUnit Jupiter annotations such as
|
||||
* {@code @Test}, {@code @ParameterizedTest}, and {@code @RepeatedTest}.</li>
|
||||
* <li>Tag extraction supports both {@code @Tag} annotations and the container
|
||||
* form {@code @Tags}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see org.egothor.methodatlas.MethodAtlasApp
|
||||
* @see org.egothor.methodatlas.ai.AiSuggestionEngine
|
||||
* @see com.github.javaparser.StaticJavaParser
|
||||
*/
|
||||
package org.egothor.methodatlas;
|
||||
397
src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java
Normal file
397
src/test/java/org/egothor/methodatlas/MethodAtlasAppAiTest.java
Normal file
@@ -0,0 +1,397 @@
|
||||
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.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.egothor.methodatlas.ai.AiClassSuggestion;
|
||||
import org.egothor.methodatlas.ai.AiMethodSuggestion;
|
||||
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
|
||||
import org.egothor.methodatlas.ai.AiSuggestionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
class MethodAtlasAppAiTest {
|
||||
|
||||
@Test
|
||||
void csvMode_aiEnabled_withRealisticFixtures_emitsMergedAiSuggestions(@TempDir Path tempDir) throws Exception {
|
||||
copyAllFixtures(tempDir);
|
||||
|
||||
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class,
|
||||
(mock, context) -> {
|
||||
when(mock.suggestForClass(eq("com.acme.tests.SampleOneTest"), anyString(), any()))
|
||||
.thenReturn(sampleOneSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString(), any()))
|
||||
.thenReturn(anotherSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString(), any()))
|
||||
.thenReturn(accessControlSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString(), any()))
|
||||
.thenReturn(pathTraversalSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString(), any()))
|
||||
.thenReturn(auditLoggingSuggestion());
|
||||
})) {
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { "-ai", tempDir.toString() });
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
|
||||
assertEquals(18, lines.size(), "Expected header + 17 method rows across 5 fixtures");
|
||||
assertEquals("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason", lines.get(0));
|
||||
|
||||
Map<String, List<String>> rows = parseCsvAiRows(lines);
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.security.AccessControlServiceTest", "shouldRejectUnauthenticatedRequest",
|
||||
"security;authn", "true", "SECURITY: authentication - reject unauthenticated request",
|
||||
"security;auth;access-control",
|
||||
"The test verifies that anonymous access to a protected operation is rejected.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.storage.PathTraversalValidationTest",
|
||||
"shouldRejectRelativePathTraversalSequence", "security;validation", "true",
|
||||
"SECURITY: input validation - reject path traversal sequence", "security;input-validation;owasp",
|
||||
"The test rejects a classic parent-directory traversal payload.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.audit.AuditLoggingTest", "shouldNotLogRawBearerToken", "security;logging",
|
||||
"true", "SECURITY: logging - redact bearer token", "security;logging",
|
||||
"The test ensures that sensitive bearer tokens are redacted before logging.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.audit.AuditLoggingTest", "shouldFormatHumanReadableSupportMessage", "",
|
||||
"false", "", "", "The test is functional formatting coverage and is not security-specific.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.tests.SampleOneTest", "alpha", "fast;crypto", "true",
|
||||
"SECURITY: crypto - validates encrypted happy path", "security;crypto",
|
||||
"The test exercises a crypto-related security property.");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.tests.SampleOneTest", "beta", "param", "", "", "", "");
|
||||
|
||||
assertAiCsvRow(rows, "com.acme.other.AnotherTest", "delta", "", "false", "", "",
|
||||
"The repeated test is not security-specific.");
|
||||
|
||||
assertFalse(rows.containsKey("com.acme.tests.SampleOneTest#ghostMethod"),
|
||||
"AI-only invented methods must not appear in CLI output");
|
||||
|
||||
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainMode_aiFailureForOneClass_continuesScanningAndFallsBackForThatClass(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
copyAllFixtures(tempDir);
|
||||
|
||||
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class,
|
||||
(mock, context) -> {
|
||||
when(mock.suggestForClass(eq("com.acme.tests.SampleOneTest"), anyString(), any()))
|
||||
.thenReturn(sampleOneSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.other.AnotherTest"), anyString(), any()))
|
||||
.thenReturn(anotherSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.security.AccessControlServiceTest"), anyString(), any()))
|
||||
.thenThrow(new AiSuggestionException("Simulated provider failure"));
|
||||
when(mock.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"), anyString(), any()))
|
||||
.thenReturn(pathTraversalSuggestion());
|
||||
when(mock.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), anyString(), any()))
|
||||
.thenReturn(auditLoggingSuggestion());
|
||||
})) {
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { "-plain", "-ai", tempDir.toString() });
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
|
||||
assertEquals(17, lines.size(), "Expected one plain output line per discovered test method");
|
||||
|
||||
String failedClassLine = findLineContaining(lines,
|
||||
"com.acme.security.AccessControlServiceTest, shouldRejectUnauthenticatedRequest,");
|
||||
assertTrue(failedClassLine.contains("AI_SECURITY=-"));
|
||||
assertTrue(failedClassLine.contains("AI_DISPLAY=-"));
|
||||
assertTrue(failedClassLine.contains("AI_TAGS=-"));
|
||||
assertTrue(failedClassLine.contains("AI_REASON=-"));
|
||||
|
||||
String unaffectedLine = findLineContaining(lines,
|
||||
"com.acme.storage.PathTraversalValidationTest, shouldRejectRelativePathTraversalSequence,");
|
||||
assertTrue(unaffectedLine.contains("AI_SECURITY=true"));
|
||||
assertTrue(
|
||||
unaffectedLine.contains("AI_DISPLAY=SECURITY: input validation - reject path traversal sequence"));
|
||||
assertTrue(unaffectedLine.contains("AI_TAGS=security;input-validation;owasp"));
|
||||
|
||||
String nonSecurityLine = findLineContaining(lines,
|
||||
"com.acme.audit.AuditLoggingTest, shouldFormatHumanReadableSupportMessage,");
|
||||
assertTrue(nonSecurityLine.contains("AI_SECURITY=false"));
|
||||
assertTrue(nonSecurityLine.contains("AI_DISPLAY=-"));
|
||||
assertTrue(nonSecurityLine.contains("AI_TAGS=-"));
|
||||
assertTrue(nonSecurityLine
|
||||
.contains("AI_REASON=The test is functional formatting coverage and is not security-specific."));
|
||||
|
||||
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void csvMode_oversizedClass_skipsAiLookup_andLeavesAiColumnsEmpty(@TempDir Path tempDir) throws Exception {
|
||||
writeOversizedFixture(tempDir);
|
||||
|
||||
try (MockedConstruction<AiSuggestionEngineImpl> mocked = mockConstruction(AiSuggestionEngineImpl.class)) {
|
||||
String output = runAppCapturingStdout(
|
||||
new String[] { "-ai", "-ai-max-class-chars", "10", tempDir.toString() });
|
||||
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
|
||||
assertEquals(2, lines.size(), "Expected header + 1 emitted method row");
|
||||
assertEquals("fqcn,method,loc,tags,ai_security_relevant,ai_display_name,ai_tags,ai_reason", lines.get(0));
|
||||
|
||||
Map<String, List<String>> rows = parseCsvAiRows(lines);
|
||||
List<String> row = rows.get("com.acme.big.HugeAiSkipTest#hugeSecurityTest");
|
||||
|
||||
assertNotNull(row, "Missing oversized-class row");
|
||||
assertEquals(8, row.size());
|
||||
assertEquals("security", row.get(3));
|
||||
assertEquals("", row.get(4));
|
||||
assertEquals("", row.get(5));
|
||||
assertEquals("", row.get(6));
|
||||
assertEquals("", row.get(7));
|
||||
|
||||
assertEquals(1, mocked.constructed().size(), "Expected one AI engine instance");
|
||||
verify(mocked.constructed().get(0), never()).suggestForClass(anyString(), anyString(), any());
|
||||
}
|
||||
}
|
||||
|
||||
private static AiClassSuggestion sampleOneSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.tests.SampleOneTest", true, List.of("security", "crypto"),
|
||||
"Class contains crypto-related security coverage.",
|
||||
List.of(new AiMethodSuggestion("alpha", true, "SECURITY: crypto - validates encrypted happy path",
|
||||
List.of("security", "crypto"), "The test exercises a crypto-related security property."),
|
||||
new AiMethodSuggestion("ghostMethod", true, "SECURITY: invented - should never appear",
|
||||
List.of("security"), "This invented method must not be emitted by the CLI.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion anotherSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.other.AnotherTest", false, List.of(), "Class is not security-relevant.",
|
||||
List.of(new AiMethodSuggestion("delta", false, null, List.of(),
|
||||
"The repeated test is not security-specific.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion accessControlSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.security.AccessControlServiceTest", true,
|
||||
List.of("security", "access-control"), "Class verifies authorization and authentication controls.",
|
||||
List.of(new AiMethodSuggestion("shouldAllowOwnerToReadOwnStatement", true,
|
||||
"SECURITY: access control - allow owner access", List.of("security", "access-control"),
|
||||
"The test verifies that the resource owner is granted access."),
|
||||
new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
|
||||
"SECURITY: access control - allow administrator access",
|
||||
List.of("security", "access-control"),
|
||||
"The test verifies privileged administrative access."),
|
||||
new AiMethodSuggestion("shouldDenyForeignUserFromReadingAnotherUsersStatement", true,
|
||||
"SECURITY: access control - deny foreign user access",
|
||||
List.of("security", "access-control"),
|
||||
"The test verifies that one user cannot access another user's statement."),
|
||||
new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
|
||||
"SECURITY: authentication - reject unauthenticated request",
|
||||
List.of("security", "auth", "access-control"),
|
||||
"The test verifies that anonymous access to a protected operation is rejected."),
|
||||
new AiMethodSuggestion("shouldRenderFriendlyAccountLabel", false, null, List.of(),
|
||||
"The test is purely presentational and not security-specific.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion pathTraversalSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest", true,
|
||||
List.of("security", "input-validation"), "Class validates filesystem input handling.",
|
||||
List.of(new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
|
||||
"SECURITY: input validation - reject path traversal sequence",
|
||||
List.of("security", "input-validation", "owasp"),
|
||||
"The test rejects a classic parent-directory traversal payload."),
|
||||
new AiMethodSuggestion("shouldRejectNestedTraversalAfterNormalization", true,
|
||||
"SECURITY: input validation - block normalized root escape",
|
||||
List.of("security", "input-validation", "owasp"),
|
||||
"The test verifies that normalized traversal cannot escape the upload root."),
|
||||
new AiMethodSuggestion("shouldAllowSafePathInsideUploadRoot", true,
|
||||
"SECURITY: input validation - allow safe normalized path",
|
||||
List.of("security", "input-validation"),
|
||||
"The test verifies that a normalized in-root path is accepted."),
|
||||
new AiMethodSuggestion("shouldBuildDownloadFileName", false, null, List.of(),
|
||||
"The test only formats a filename and is not security-specific.")));
|
||||
}
|
||||
|
||||
private static AiClassSuggestion auditLoggingSuggestion() {
|
||||
return new AiClassSuggestion("com.acme.audit.AuditLoggingTest", true, List.of("security", "logging"),
|
||||
"Class verifies security-relevant logging and audit behavior.",
|
||||
List.of(new AiMethodSuggestion("shouldWriteAuditEventForPrivilegeChange", true,
|
||||
"SECURITY: logging - audit privilege change", List.of("security", "logging"),
|
||||
"The test verifies audit logging of a privileged security action."),
|
||||
new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
|
||||
"SECURITY: logging - redact bearer token", List.of("security", "logging"),
|
||||
"The test ensures that sensitive bearer tokens are redacted before logging."),
|
||||
new AiMethodSuggestion("shouldNotLogPlaintextPasswordOnAuthenticationFailure", true,
|
||||
"SECURITY: logging - avoid plaintext password disclosure",
|
||||
List.of("security", "logging"),
|
||||
"The test verifies that plaintext passwords are not written to logs."),
|
||||
new AiMethodSuggestion("shouldFormatHumanReadableSupportMessage", false, null, List.of(),
|
||||
"The test is functional formatting coverage and is not security-specific.")));
|
||||
}
|
||||
|
||||
private static List<String> parseCsvFields(String line) {
|
||||
List<String> out = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
|
||||
boolean inQuotes = false;
|
||||
int i = 0;
|
||||
while (i < line.length()) {
|
||||
char ch = line.charAt(i);
|
||||
|
||||
if (inQuotes) {
|
||||
if (ch == '\"') {
|
||||
if (i + 1 < line.length() && line.charAt(i + 1) == '\"') {
|
||||
current.append('\"');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current.append(ch);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '\"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == ',') {
|
||||
out.add(current.toString());
|
||||
current.setLength(0);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current.append(ch);
|
||||
i++;
|
||||
}
|
||||
|
||||
out.add(current.toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void copyAllFixtures(Path tempDir) throws IOException {
|
||||
copyFixtures(tempDir, "SampleOneTest.java", "AnotherTest.java", "AccessControlServiceTest.java",
|
||||
"PathTraversalValidationTest.java", "AuditLoggingTest.java");
|
||||
}
|
||||
|
||||
private static void copyFixtures(Path tempDir, String... fixtureFileNames) throws IOException {
|
||||
for (String fixtureFileName : fixtureFileNames) {
|
||||
copyFixture(tempDir, fixtureFileName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyFixture(Path destDir, String fixtureFileName) throws IOException {
|
||||
String resourcePath = "/fixtures/" + fixtureFileName + ".txt";
|
||||
try (InputStream in = MethodAtlasAppAiTest.class.getResourceAsStream(resourcePath)) {
|
||||
assertNotNull(in, "Missing test resource: " + resourcePath);
|
||||
Files.copy(in, destDir.resolve(fixtureFileName));
|
||||
}
|
||||
}
|
||||
|
||||
private static String runAppCapturingStdout(String[] args) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PrintStream previous = System.out;
|
||||
|
||||
try (PrintStream ps = new PrintStream(baos, true, StandardCharsets.UTF_8)) {
|
||||
System.setOut(ps);
|
||||
MethodAtlasApp.main(args);
|
||||
} finally {
|
||||
System.setOut(previous);
|
||||
}
|
||||
|
||||
return baos.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static List<String> nonEmptyLines(String text) {
|
||||
String[] parts = text.split("\\R");
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (String part : parts) {
|
||||
String trimmed = part.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
lines.add(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String findLineContaining(List<String> lines, String fragment) {
|
||||
for (String line : lines) {
|
||||
if (line.contains(fragment)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Missing line containing: " + fragment);
|
||||
}
|
||||
|
||||
private static void writeOversizedFixture(Path tempDir) throws IOException {
|
||||
StringBuilder repeated = new StringBuilder();
|
||||
for (int i = 0; i < 100; i++) {
|
||||
repeated.append(" String s").append(i).append(" = \"padding").append(i).append("\";\n");
|
||||
}
|
||||
|
||||
String source = """
|
||||
package com.acme.big;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HugeAiSkipTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
void hugeSecurityTest() {
|
||||
""" + repeated + """
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
Files.writeString(tempDir.resolve("HugeAiSkipTest.java"), source, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void assertAiCsvRow(Map<String, List<String>> rows, String fqcn, String method,
|
||||
String expectedTagsText, String expectedAiSecurityRelevant, String expectedAiDisplayName,
|
||||
String expectedAiTagsText, String expectedAiReason) {
|
||||
|
||||
List<String> fields = rows.get(fqcn + "#" + method);
|
||||
assertNotNull(fields, "Missing row for " + fqcn + "#" + method);
|
||||
|
||||
assertEquals(8, fields.size(), "Expected 8 CSV fields for " + fqcn + "#" + method);
|
||||
assertEquals(expectedTagsText, fields.get(3), "Source tags mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiSecurityRelevant, fields.get(4), "AI security flag mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiDisplayName, fields.get(5), "AI display name mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiTagsText, fields.get(6), "AI tags mismatch for " + fqcn + "#" + method);
|
||||
assertEquals(expectedAiReason, fields.get(7), "AI reason mismatch for " + fqcn + "#" + method);
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> parseCsvAiRows(List<String> lines) {
|
||||
Map<String, List<String>> rows = new HashMap<>();
|
||||
for (int i = 1; i < lines.size(); i++) {
|
||||
List<String> fields = parseCsvFields(lines.get(i));
|
||||
assertEquals(8, fields.size(), "Expected 8 CSV fields, got " + fields.size() + " from: " + lines.get(i));
|
||||
rows.put(fields.get(0) + "#" + fields.get(1), fields);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -22,25 +22,38 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* End-to-end tests for {@link MethodAtlasApp} output formats (CSV default,
|
||||
* -plain).
|
||||
* Baseline end-to-end regression tests for {@link MethodAtlasApp} output
|
||||
* formats with AI enrichment disabled.
|
||||
*
|
||||
* <p>
|
||||
* These tests copy predefined Java fixture files from
|
||||
* src/test/resources/fixtures into a temporary directory and run
|
||||
* MethodAtlasApp.main(...) against that directory, asserting the detected
|
||||
* methods, LOC, and extracted @Tag values.
|
||||
* {@code src/test/resources/fixtures} into a temporary directory and execute
|
||||
* {@link MethodAtlasApp#main(String[])} against that directory. The assertions
|
||||
* verify the stable non-AI contract of the application:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>detected test methods</li>
|
||||
* <li>inclusive method LOC values</li>
|
||||
* <li>extracted JUnit {@code @Tag} values, including nested {@code @Tags}</li>
|
||||
* <li>CSV and plain-text rendering behavior</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* AI-specific behavior should be tested separately once a dedicated injection
|
||||
* seam exists for supplying a mocked
|
||||
* {@code org.egothor.methodatlas.ai.AiSuggestionEngine}.
|
||||
* </p>
|
||||
*/
|
||||
public class MethodAtlasAppTest {
|
||||
|
||||
@Test
|
||||
public void csvMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
|
||||
copyFixture(tempDir, "SampleOneTest.java");
|
||||
copyFixture(tempDir, "AnotherTest.java");
|
||||
copyStandardFixtures(tempDir);
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { tempDir.toString() });
|
||||
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
assertTrue(lines.size() >= 3, "Expected header + at least 2 records, got: " + lines.size());
|
||||
assertEquals(18, lines.size(), "Expected header + 17 records");
|
||||
|
||||
assertEquals("fqcn,method,loc,tags", lines.get(0));
|
||||
|
||||
@@ -58,13 +71,12 @@ public class MethodAtlasAppTest {
|
||||
|
||||
@Test
|
||||
public void plainMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
|
||||
copyFixture(tempDir, "SampleOneTest.java");
|
||||
copyFixture(tempDir, "AnotherTest.java");
|
||||
copyStandardFixtures(tempDir);
|
||||
|
||||
String output = runAppCapturingStdout(new String[] { "-plain", tempDir.toString() });
|
||||
|
||||
List<String> lines = nonEmptyLines(output);
|
||||
assertTrue(lines.size() >= 4, "Expected at least 4 method lines, got: " + lines.size());
|
||||
assertEquals(17, lines.size(), "Expected 17 method lines");
|
||||
|
||||
Map<String, PlainRow> rows = new HashMap<>();
|
||||
for (String line : lines) {
|
||||
@@ -78,6 +90,14 @@ public class MethodAtlasAppTest {
|
||||
assertPlainRow(rows, "com.acme.other.AnotherTest", "delta", 3, "-");
|
||||
}
|
||||
|
||||
private static void copyStandardFixtures(Path tempDir) throws IOException {
|
||||
copyFixture(tempDir, "SampleOneTest.java");
|
||||
copyFixture(tempDir, "AnotherTest.java");
|
||||
copyFixture(tempDir, "AccessControlServiceTest.java");
|
||||
copyFixture(tempDir, "PathTraversalValidationTest.java");
|
||||
copyFixture(tempDir, "AuditLoggingTest.java");
|
||||
}
|
||||
|
||||
private static void assertCsvRow(Map<String, CsvRow> rows, String fqcn, String method, int expectedLoc,
|
||||
List<String> expectedTags) {
|
||||
|
||||
@@ -239,4 +259,4 @@ public class MethodAtlasAppTest {
|
||||
private int loc;
|
||||
private String tagsText;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java
Normal file
187
src/test/java/org/egothor/methodatlas/ai/AiOptionsTest.java
Normal file
@@ -0,0 +1,187 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AiOptionsTest {
|
||||
|
||||
@Test
|
||||
void builder_defaults_areStableAndValid() {
|
||||
AiOptions options = AiOptions.builder().build();
|
||||
|
||||
assertEquals(false, options.enabled());
|
||||
assertEquals(AiProvider.AUTO, options.provider());
|
||||
assertEquals("qwen2.5-coder:7b", options.modelName());
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
assertNull(options.apiKey());
|
||||
assertNull(options.apiKeyEnv());
|
||||
assertNull(options.taxonomyFile());
|
||||
assertEquals(AiOptions.TaxonomyMode.DEFAULT, options.taxonomyMode());
|
||||
assertEquals(40_000, options.maxClassChars());
|
||||
assertEquals(Duration.ofSeconds(90), options.timeout());
|
||||
assertEquals(1, options.maxRetries());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesOllamaDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OLLAMA).build();
|
||||
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesAutoDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.AUTO).build();
|
||||
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesOpenAiDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI).build();
|
||||
|
||||
assertEquals("https://api.openai.com", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesOpenRouterDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENROUTER).build();
|
||||
|
||||
assertEquals("https://openrouter.ai/api", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_usesAnthropicDefaultBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.ANTHROPIC).build();
|
||||
|
||||
assertEquals("https://api.anthropic.com", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_preservesExplicitBaseUrl() {
|
||||
AiOptions options = AiOptions.builder().provider(AiProvider.OPENAI)
|
||||
.baseUrl("https://internal-gateway.example.test/openai").build();
|
||||
|
||||
assertEquals("https://internal-gateway.example.test/openai", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_treatsNullProviderAsAuto() {
|
||||
AiOptions options = AiOptions.builder().provider(null).build();
|
||||
|
||||
assertEquals(AiProvider.AUTO, options.provider());
|
||||
assertEquals("http://localhost:11434", options.baseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedApiKey_prefersDirectApiKey() {
|
||||
AiOptions options = AiOptions.builder().apiKey("sk-direct-value").apiKeyEnv("SHOULD_NOT_BE_USED").build();
|
||||
|
||||
assertEquals("sk-direct-value", options.resolvedApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedApiKey_returnsNullWhenDirectKeyIsBlankAndEnvIsMissing() {
|
||||
AiOptions options = AiOptions.builder().apiKey(" ").apiKeyEnv("METHODATLAS_TEST_ENV_NOT_PRESENT").build();
|
||||
|
||||
assertNull(options.resolvedApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedApiKey_returnsNullWhenNeitherDirectNorEnvAreConfigured() {
|
||||
AiOptions options = AiOptions.builder().build();
|
||||
|
||||
assertNull(options.resolvedApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullProvider() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class,
|
||||
() -> new AiOptions(true, null, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("provider", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullModelName() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, null, "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("modelName", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullTimeout() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, null, 1));
|
||||
|
||||
assertEquals("timeout", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNullTaxonomyMode() {
|
||||
NullPointerException ex = assertThrows(NullPointerException.class, () -> new AiOptions(true, AiProvider.OPENAI,
|
||||
"gpt-4o-mini", "https://api.openai.com", null, null, null, null, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("taxonomyMode", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsBlankBaseUrl() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", " ", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("baseUrl must not be blank", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNonPositiveMaxClassChars() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 0, Duration.ofSeconds(30), 1));
|
||||
|
||||
assertEquals("maxClassChars must be > 0", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalConstructor_rejectsNegativeMaxRetries() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> new AiOptions(true, AiProvider.OPENAI, "gpt-4o-mini", "https://api.openai.com", null, null, null,
|
||||
AiOptions.TaxonomyMode.DEFAULT, 40_000, Duration.ofSeconds(30), -1));
|
||||
|
||||
assertEquals("maxRetries must be >= 0", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_allowsFullCustomization() {
|
||||
Path taxonomyFile = Path.of("src/test/resources/security-taxonomy.yaml");
|
||||
Duration timeout = Duration.ofSeconds(15);
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
|
||||
.modelName("claude-3-5-sonnet").baseUrl("https://proxy.example.test/anthropic").apiKey("test-api-key")
|
||||
.apiKeyEnv("IGNORED_ENV").taxonomyFile(taxonomyFile).taxonomyMode(AiOptions.TaxonomyMode.OPTIMIZED)
|
||||
.maxClassChars(12_345).timeout(timeout).maxRetries(4).build();
|
||||
|
||||
assertEquals(true, options.enabled());
|
||||
assertEquals(AiProvider.ANTHROPIC, options.provider());
|
||||
assertEquals("claude-3-5-sonnet", options.modelName());
|
||||
assertEquals("https://proxy.example.test/anthropic", options.baseUrl());
|
||||
assertEquals("test-api-key", options.apiKey());
|
||||
assertEquals("IGNORED_ENV", options.apiKeyEnv());
|
||||
assertEquals(taxonomyFile, options.taxonomyFile());
|
||||
assertEquals(AiOptions.TaxonomyMode.OPTIMIZED, options.taxonomyMode());
|
||||
assertEquals(12_345, options.maxClassChars());
|
||||
assertEquals(timeout, options.timeout());
|
||||
assertEquals(4, options.maxRetries());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
class AiProviderFactoryTest {
|
||||
|
||||
@Test
|
||||
void create_withOllamaProvider_returnsOllamaClientWithoutAvailabilityCheck() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA).build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> mocked = mockConstruction(OllamaClient.class)) {
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OllamaClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenAiProvider_returnsOpenAiCompatibleClientWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
|
||||
.build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OpenAiCompatibleClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenAiProvider_throwsWhenUnavailable() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("OpenAI API key missing", ex.getMessage());
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenRouterProvider_returnsOpenAiCompatibleClientWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).apiKey("or-test-key")
|
||||
.build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OpenAiCompatibleClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withOpenRouterProvider_throwsWhenUnavailable() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).build();
|
||||
|
||||
try (MockedConstruction<OpenAiCompatibleClient> mocked = mockConstruction(OpenAiCompatibleClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("OpenRouter API key missing", ex.getMessage());
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAnthropicProvider_returnsAnthropicClientWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
|
||||
.apiKey("anthropic-test-key").build();
|
||||
|
||||
try (MockedConstruction<AnthropicClient> mocked = mockConstruction(AnthropicClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true))) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(AnthropicClient.class, client);
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
assertSame(mocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAnthropicProvider_throwsWhenUnavailable() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC).build();
|
||||
|
||||
try (MockedConstruction<AnthropicClient> mocked = mockConstruction(AnthropicClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false))) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("Anthropic API key missing", ex.getMessage());
|
||||
assertEquals(1, mocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAutoProvider_returnsOllamaWhenAvailable() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).modelName("qwen2.5-coder:7b")
|
||||
.baseUrl("http://localhost:11434").build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(true));
|
||||
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
|
||||
OpenAiCompatibleClient.class)) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OllamaClient.class, client);
|
||||
assertEquals(1, ollamaMocked.constructed().size());
|
||||
assertSame(ollamaMocked.constructed().get(0), client);
|
||||
assertEquals(0, openAiMocked.constructed().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAutoProvider_fallsBackToOpenAiCompatibleWhenOllamaUnavailableAndApiKeyPresent() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).modelName("gpt-4o-mini")
|
||||
.baseUrl("https://api.openai.com").apiKey("sk-test-value").build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false));
|
||||
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
|
||||
OpenAiCompatibleClient.class)) {
|
||||
|
||||
AiProviderClient client = AiProviderFactory.create(options);
|
||||
|
||||
assertInstanceOf(OpenAiCompatibleClient.class, client);
|
||||
assertEquals(1, ollamaMocked.constructed().size());
|
||||
assertEquals(1, openAiMocked.constructed().size());
|
||||
assertSame(openAiMocked.constructed().get(0), client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_withAutoProvider_throwsWhenOllamaUnavailableAndNoApiKeyConfigured() {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.AUTO).build();
|
||||
|
||||
try (MockedConstruction<OllamaClient> ollamaMocked = mockConstruction(OllamaClient.class,
|
||||
(mock, context) -> when(mock.isAvailable()).thenReturn(false));
|
||||
MockedConstruction<OpenAiCompatibleClient> openAiMocked = mockConstruction(
|
||||
OpenAiCompatibleClient.class)) {
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> AiProviderFactory.create(options));
|
||||
|
||||
assertEquals("No AI provider available. Ollama is not reachable and no API key is configured.",
|
||||
ex.getMessage());
|
||||
assertEquals(1, ollamaMocked.constructed().size());
|
||||
assertEquals(0, openAiMocked.constructed().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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.")));
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List
|
||||
.of(new PromptBuilder.TargetMethod("shouldAllowOwnerToReadOwnStatement", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldAllowAdministratorToReadAnyStatement", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldDenyForeignUserFromReadingAnotherUsersStatement", null,
|
||||
null),
|
||||
new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldRenderFriendlyAccountLabel", null, null));
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
when(client.suggestForClass(eq("com.acme.security.AccessControlServiceTest"),
|
||||
eq("class AccessControlServiceTest {}"), eq(DefaultSecurityTaxonomy.text()), eq(targetMethods)))
|
||||
.thenReturn(expected);
|
||||
|
||||
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
|
||||
AiClassSuggestion actual = engine.suggestForClass("com.acme.security.AccessControlServiceTest",
|
||||
"class AccessControlServiceTest {}", targetMethods);
|
||||
|
||||
assertSame(expected, actual);
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verify(client).suggestForClass("com.acme.security.AccessControlServiceTest",
|
||||
"class AccessControlServiceTest {}", DefaultSecurityTaxonomy.text(), targetMethods);
|
||||
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.")));
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldRejectNestedTraversalAfterNormalization", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldAllowSafePathInsideUploadRoot", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldBuildDownloadFileName", null, null));
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.taxonomyMode(AiOptions.TaxonomyMode.OPTIMIZED).build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
when(client.suggestForClass(eq("com.acme.storage.PathTraversalValidationTest"),
|
||||
eq("class PathTraversalValidationTest {}"), eq(OptimizedSecurityTaxonomy.text()),
|
||||
eq(targetMethods))).thenReturn(expected);
|
||||
|
||||
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
|
||||
AiClassSuggestion actual = engine.suggestForClass("com.acme.storage.PathTraversalValidationTest",
|
||||
"class PathTraversalValidationTest {}", targetMethods);
|
||||
|
||||
assertSame(expected, actual);
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verify(client).suggestForClass("com.acme.storage.PathTraversalValidationTest",
|
||||
"class PathTraversalValidationTest {}", OptimizedSecurityTaxonomy.text(), targetMethods);
|
||||
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.")));
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
|
||||
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER).taxonomyFile(taxonomyFile)
|
||||
.build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
when(client.suggestForClass(eq("com.acme.audit.AuditLoggingTest"), eq("class AuditLoggingTest {}"),
|
||||
eq(taxonomyText), eq(targetMethods))).thenReturn(expected);
|
||||
|
||||
AiSuggestionEngineImpl engine = new AiSuggestionEngineImpl(options);
|
||||
AiClassSuggestion actual = engine.suggestForClass("com.acme.audit.AuditLoggingTest",
|
||||
"class AuditLoggingTest {}", targetMethods);
|
||||
|
||||
assertSame(expected, actual);
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verify(client).suggestForClass("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}", taxonomyText,
|
||||
targetMethods);
|
||||
verifyNoMoreInteractions(client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_throwsWhenTaxonomyFileCannotBeRead() {
|
||||
Path missingTaxonomyFile = tempDir.resolve("missing-taxonomy.txt");
|
||||
|
||||
AiProviderClient client = mock(AiProviderClient.class);
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.ANTHROPIC)
|
||||
.taxonomyFile(missingTaxonomyFile).build();
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenReturn(client);
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> new AiSuggestionEngineImpl(options));
|
||||
|
||||
assertEquals("Failed to read taxonomy file: " + missingTaxonomyFile, ex.getMessage());
|
||||
assertInstanceOf(IOException.class, ex.getCause());
|
||||
|
||||
factory.verify(() -> AiProviderFactory.create(options));
|
||||
verifyNoMoreInteractions(client);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_propagatesProviderFactoryFailure() throws Exception {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).build();
|
||||
|
||||
AiSuggestionException expected = new AiSuggestionException("Provider initialization failed");
|
||||
|
||||
try (MockedStatic<AiProviderFactory> factory = mockStatic(AiProviderFactory.class)) {
|
||||
factory.when(() -> AiProviderFactory.create(options)).thenThrow(expected);
|
||||
|
||||
AiSuggestionException actual = assertThrows(AiSuggestionException.class,
|
||||
() -> new AiSuggestionEngineImpl(options));
|
||||
|
||||
assertSame(expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java
Normal file
126
src/test/java/org/egothor/methodatlas/ai/JsonTextTest.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class JsonTextTest {
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_returnsJsonWhenInputIsExactlyJson() throws Exception {
|
||||
String json = "{\"className\":\"AccessControlServiceTest\",\"methods\":[]}";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(json);
|
||||
|
||||
assertEquals(json, extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_extractsJsonWrappedByPlainText() throws Exception {
|
||||
String text = """
|
||||
Here is the analysis result:
|
||||
{"className":"AccessControlServiceTest","methods":[{"methodName":"shouldRejectUnauthenticatedRequest"}]}
|
||||
Thank you.
|
||||
""";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(text);
|
||||
|
||||
assertEquals(
|
||||
"{\"className\":\"AccessControlServiceTest\",\"methods\":[{\"methodName\":\"shouldRejectUnauthenticatedRequest\"}]}",
|
||||
extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_extractsJsonWrappedByMarkdownFence() throws Exception {
|
||||
String text = """
|
||||
```json
|
||||
{"className":"AuditLoggingTest","methods":[{"methodName":"shouldNotLogRawBearerToken"}]}
|
||||
```
|
||||
""";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(text);
|
||||
|
||||
assertEquals(
|
||||
"{\"className\":\"AuditLoggingTest\",\"methods\":[{\"methodName\":\"shouldNotLogRawBearerToken\"}]}",
|
||||
extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_preservesNestedObjectsAndArrays() throws Exception {
|
||||
String text = """
|
||||
Model output:
|
||||
{
|
||||
"className":"PathTraversalValidationTest",
|
||||
"methods":[
|
||||
{
|
||||
"methodName":"shouldRejectRelativePathTraversalSequence",
|
||||
"securityRelevant":true,
|
||||
"tags":["security","input-validation","path-traversal"]
|
||||
}
|
||||
]
|
||||
}
|
||||
End.
|
||||
""";
|
||||
|
||||
String extracted = JsonText.extractFirstJsonObject(text);
|
||||
|
||||
assertEquals("""
|
||||
{
|
||||
"className":"PathTraversalValidationTest",
|
||||
"methods":[
|
||||
{
|
||||
"methodName":"shouldRejectRelativePathTraversalSequence",
|
||||
"securityRelevant":true,
|
||||
"tags":["security","input-validation","path-traversal"]
|
||||
}
|
||||
]
|
||||
}""", extracted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_nullInput_throwsAiSuggestionException() {
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(null));
|
||||
|
||||
assertEquals("Model returned an empty response", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_blankInput_throwsAiSuggestionException() {
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(" \n\t "));
|
||||
|
||||
assertEquals("Model returned an empty response", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_missingOpeningBrace_throwsAiSuggestionException() {
|
||||
String text = "No JSON object here at all";
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(text));
|
||||
|
||||
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_missingClosingBrace_throwsAiSuggestionException() {
|
||||
String text = "{\"className\":\"AccessControlServiceTest\"";
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(text));
|
||||
|
||||
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFirstJsonObject_closingBraceBeforeOpeningBrace_throwsAiSuggestionException() {
|
||||
String text = "} not json {";
|
||||
|
||||
AiSuggestionException ex = assertThrows(AiSuggestionException.class,
|
||||
() -> JsonText.extractFirstJsonObject(text));
|
||||
|
||||
assertEquals("Model response does not contain a JSON object: " + text, ex.getMessage());
|
||||
}
|
||||
}
|
||||
203
src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java
Normal file
203
src/test/java/org/egothor/methodatlas/ai/OllamaClientTest.java
Normal file
@@ -0,0 +1,203 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
class OllamaClientTest {
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsTrueWhenTagsEndpointResponds() throws Exception {
|
||||
HttpClient httpClient = mock(HttpClient.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
HttpResponse<Void> response = mock(HttpResponse.class);
|
||||
|
||||
when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler())).thenReturn(response);
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.httpClient()).thenReturn(httpClient);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.baseUrl("http://localhost:11434").build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
|
||||
assertTrue(client.isAvailable());
|
||||
|
||||
verify(httpClient)
|
||||
.send(argThat(request -> request.uri().toString().equals("http://localhost:11434/api/tags")
|
||||
&& "GET".equals(request.method())), anyVoidBodyHandler());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void isAvailable_returnsFalseWhenTagsEndpointFails() throws Exception {
|
||||
HttpClient httpClient = mock(HttpClient.class);
|
||||
|
||||
when(httpClient.send(any(HttpRequest.class), anyVoidBodyHandler()))
|
||||
.thenThrow(new java.io.IOException("Connection refused"));
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.httpClient()).thenReturn(httpClient);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.baseUrl("http://localhost:11434").build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
|
||||
assertFalse(client.isAvailable());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
String fqcn = "com.acme.storage.PathTraversalValidationTest";
|
||||
String classSource = """
|
||||
class PathTraversalValidationTest {
|
||||
void shouldRejectRelativePathTraversalSequence() {}
|
||||
}
|
||||
""";
|
||||
String taxonomyText = "security, input-validation, owasp";
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldRejectNestedTraversalAfterNormalization", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldAllowSafePathInsideUploadRoot", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldBuildDownloadFileName", null, null));
|
||||
|
||||
String responseBody = """
|
||||
{
|
||||
"message": {
|
||||
"content": "Analysis complete:\\n{\\n \\"className\\": \\"com.acme.storage.PathTraversalValidationTest\\",\\n \\"classSecurityRelevant\\": true,\\n \\"classTags\\": null,\\n \\"classReason\\": \\"Class validates filesystem input handling.\\",\\n \\"methods\\": [\\n null,\\n {\\n \\"methodName\\": \\"shouldRejectRelativePathTraversalSequence\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: input validation - reject path traversal sequence\\",\\n \\"tags\\": null,\\n \\"reason\\": \\"The test rejects a classic parent-directory traversal payload.\\"\\n },\\n {\\n \\"methodName\\": \\" \\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: invalid - blank method\\",\\n \\"tags\\": [\\"security\\"],\\n \\"reason\\": \\"This malformed method must be filtered.\\"\\n }\\n ]\\n}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
AtomicReference<String> capturedBody = new AtomicReference<>();
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.objectMapper()).thenReturn(mapper);
|
||||
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
|
||||
URI uri = invocation.getArgument(0);
|
||||
String body = invocation.getArgument(1);
|
||||
Duration timeout = invocation.getArgument(2);
|
||||
|
||||
capturedBody.set(body);
|
||||
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
});
|
||||
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA)
|
||||
.modelName("qwen2.5-coder:7b").baseUrl("http://localhost:11434").build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
AiClassSuggestion suggestion = client.suggestForClass(fqcn, classSource, taxonomyText, targetMethods);
|
||||
|
||||
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("FQCN: " + fqcn));
|
||||
assertTrue(requestBody.contains("PathTraversalValidationTest"));
|
||||
assertTrue(requestBody.contains("shouldRejectRelativePathTraversalSequence"));
|
||||
assertTrue(requestBody.contains("shouldRejectNestedTraversalAfterNormalization"));
|
||||
assertTrue(requestBody.contains("shouldAllowSafePathInsideUploadRoot"));
|
||||
assertTrue(requestBody.contains("shouldBuildDownloadFileName"));
|
||||
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";
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
|
||||
|
||||
String responseBody = """
|
||||
{
|
||||
"message": {
|
||||
"content": "This looks security related, but I am not returning JSON."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.objectMapper()).thenReturn(mapper);
|
||||
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
|
||||
URI uri = invocation.getArgument(0);
|
||||
String body = invocation.getArgument(1);
|
||||
Duration timeout = invocation.getArgument(2);
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
});
|
||||
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
|
||||
})) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OLLAMA).build();
|
||||
|
||||
OllamaClient client = new OllamaClient(options);
|
||||
|
||||
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
|
||||
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging",
|
||||
targetMethods));
|
||||
|
||||
assertEquals("Ollama suggestion failed for " + fqcn, ex.getMessage());
|
||||
assertInstanceOf(AiSuggestionException.class, ex.getCause());
|
||||
assertTrue(ex.getCause().getMessage().contains("Model response does not contain a JSON object"));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static HttpResponse.BodyHandler<Void> anyVoidBodyHandler() {
|
||||
return any(HttpResponse.BodyHandler.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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";
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List
|
||||
.of(new PromptBuilder.TargetMethod("shouldAllowOwnerToReadOwnStatement", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldAllowAdministratorToReadAnyStatement", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldDenyForeignUserFromReadingAnotherUsersStatement", null,
|
||||
null),
|
||||
new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldRenderFriendlyAccountLabel", null, null));
|
||||
|
||||
String responseBody = """
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "Here is the result:\\n{\\n \\"className\\": \\"com.acme.security.AccessControlServiceTest\\",\\n \\"classSecurityRelevant\\": true,\\n \\"classTags\\": null,\\n \\"classReason\\": \\"Class validates authentication and authorization controls.\\",\\n \\"methods\\": [\\n null,\\n {\\n \\"methodName\\": \\"shouldRejectUnauthenticatedRequest\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: authentication - reject unauthenticated request\\",\\n \\"tags\\": null,\\n \\"reason\\": \\"The test rejects anonymous access to a protected operation.\\"\\n },\\n {\\n \\"methodName\\": \\"\\",\\n \\"securityRelevant\\": true,\\n \\"displayName\\": \\"SECURITY: invalid - blank method\\",\\n \\"tags\\": [\\"security\\"],\\n \\"reason\\": \\"This malformed method must be filtered.\\"\\n }\\n ]\\n}\\nThanks."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
AtomicReference<String> capturedBody = new AtomicReference<>();
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, capturedBody)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).modelName("gpt-4o-mini")
|
||||
.baseUrl("https://api.openai.com").apiKey("sk-test-value").build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
AiClassSuggestion suggestion = client.suggestForClass(fqcn, classSource, taxonomyText, targetMethods);
|
||||
|
||||
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("FQCN: " + fqcn));
|
||||
assertTrue(requestBody.contains("AccessControlServiceTest"));
|
||||
assertTrue(requestBody.contains("shouldAllowOwnerToReadOwnStatement"));
|
||||
assertTrue(requestBody.contains("shouldAllowAdministratorToReadAnyStatement"));
|
||||
assertTrue(requestBody.contains("shouldDenyForeignUserFromReadingAnotherUsersStatement"));
|
||||
assertTrue(requestBody.contains("shouldRejectUnauthenticatedRequest"));
|
||||
assertTrue(requestBody.contains("shouldRenderFriendlyAccountLabel"));
|
||||
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\\":[]}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENROUTER)
|
||||
.modelName("openai/gpt-4o-mini").baseUrl("https://openrouter.ai/api").apiKey("or-test-key").build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
AiClassSuggestion suggestion = client.suggestForClass("com.acme.audit.AuditLoggingTest",
|
||||
"class AuditLoggingTest {}", "security, logging", targetMethods);
|
||||
|
||||
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": []
|
||||
}
|
||||
""";
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
|
||||
.build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
|
||||
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
|
||||
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging",
|
||||
targetMethods));
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogRawBearerToken", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldNotLogPlaintextPasswordOnAuthenticationFailure", null, null),
|
||||
new PromptBuilder.TargetMethod("shouldFormatHumanReadableSupportMessage", null, null));
|
||||
|
||||
try (MockedConstruction<HttpSupport> mocked = mockHttpSupport(mapper, responseBody, null)) {
|
||||
AiOptions options = AiOptions.builder().enabled(true).provider(AiProvider.OPENAI).apiKey("sk-test-value")
|
||||
.build();
|
||||
|
||||
OpenAiCompatibleClient client = new OpenAiCompatibleClient(options);
|
||||
|
||||
AiSuggestionException ex = org.junit.jupiter.api.Assertions.assertThrows(AiSuggestionException.class,
|
||||
() -> client.suggestForClass(fqcn, "class AuditLoggingTest {}", "security, logging",
|
||||
targetMethods));
|
||||
|
||||
assertEquals("OpenAI-compatible suggestion failed for " + fqcn, ex.getMessage());
|
||||
assertInstanceOf(AiSuggestionException.class, ex.getCause());
|
||||
assertTrue(ex.getCause().getMessage().contains("Model response does not contain a JSON object"));
|
||||
}
|
||||
}
|
||||
|
||||
private static MockedConstruction<HttpSupport> mockHttpSupport(ObjectMapper mapper, String responseBody,
|
||||
AtomicReference<String> capturedBody) {
|
||||
|
||||
return mockConstruction(HttpSupport.class, (mock, context) -> {
|
||||
when(mock.objectMapper()).thenReturn(mapper);
|
||||
when(mock.jsonPost(any(URI.class), any(String.class), any(Duration.class))).thenAnswer(invocation -> {
|
||||
URI uri = invocation.getArgument(0);
|
||||
String body = invocation.getArgument(1);
|
||||
Duration timeout = invocation.getArgument(2);
|
||||
|
||||
if (capturedBody != null) {
|
||||
capturedBody.set(body);
|
||||
}
|
||||
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
});
|
||||
when(mock.postJson(any(HttpRequest.class))).thenReturn(responseBody);
|
||||
});
|
||||
}
|
||||
}
|
||||
163
src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java
Normal file
163
src/test/java/org/egothor/methodatlas/ai/PromptBuilderTest.java
Normal file
@@ -0,0 +1,163 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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
|
||||
""";
|
||||
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(
|
||||
new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", 8, 8),
|
||||
new PromptBuilder.TargetMethod("shouldAllowOwnerToReadOwnStatement", 11, 11));
|
||||
|
||||
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods);
|
||||
|
||||
assertTrue(prompt.contains("FQCN: " + fqcn));
|
||||
assertTrue(prompt.contains(classSource));
|
||||
assertTrue(prompt.contains(taxonomyText));
|
||||
assertTrue(prompt.contains("- shouldRejectUnauthenticatedRequest [lines 8-8]"));
|
||||
assertTrue(prompt.contains("- shouldAllowOwnerToReadOwnStatement [lines 11-11]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_containsExpectedTaskInstructions() {
|
||||
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
|
||||
"security, logging",
|
||||
List.of(new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null)));
|
||||
|
||||
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("- Classify ONLY the methods explicitly listed in TARGET TEST METHODS."));
|
||||
assertTrue(prompt.contains("- Do not invent methods that do not exist."));
|
||||
assertTrue(prompt.contains(
|
||||
"- Do not classify helper methods, lifecycle methods, nested classes, or any method not listed."));
|
||||
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",
|
||||
List.of(new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", null, null)));
|
||||
|
||||
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",
|
||||
List.of(new PromptBuilder.TargetMethod("shouldRejectUnauthenticatedRequest", null, null)));
|
||||
|
||||
assertTrue(prompt.contains("If securityRelevant=false, displayName must be null."));
|
||||
assertTrue(prompt.contains("If securityRelevant=true, displayName must match:"));
|
||||
assertTrue(prompt.contains("SECURITY: <control/property> - <scenario>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_containsJsonShapeContract() {
|
||||
String prompt = PromptBuilder.build("com.acme.audit.AuditLoggingTest", "class AuditLoggingTest {}",
|
||||
"security, logging",
|
||||
List.of(new PromptBuilder.TargetMethod("shouldWriteAuditEventForPrivilegeChange", null, null)));
|
||||
|
||||
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",
|
||||
List.of(new PromptBuilder.TargetMethod("shouldRejectRelativePathTraversalSequence", 3, 5)));
|
||||
|
||||
assertTrue(prompt.contains("String userInput = \"../etc/passwd\";"));
|
||||
assertTrue(prompt.contains("void shouldRejectRelativePathTraversalSequence()"));
|
||||
assertTrue(prompt.contains("- shouldRejectRelativePathTraversalSequence [lines 3-5]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_includesExpectedMethodNamesConstraint() {
|
||||
String prompt = PromptBuilder.build("com.acme.tests.SampleOneTest", "class SampleOneTest {}",
|
||||
"security, crypto", List.of(new PromptBuilder.TargetMethod("alpha", 1, 1),
|
||||
new PromptBuilder.TargetMethod("beta", 2, 2), new PromptBuilder.TargetMethod("gamma", 3, 3)));
|
||||
|
||||
assertTrue(prompt.contains("- methodName values in the output must exactly match one of:"));
|
||||
assertTrue(prompt.contains("[\"alpha\", \"beta\", \"gamma\"]"));
|
||||
assertTrue(prompt.contains("- Do not omit any listed method."));
|
||||
assertTrue(prompt.contains("- Do not include any additional methods."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_isDeterministicForSameInput() {
|
||||
String fqcn = "com.example.X";
|
||||
String source = "class X {}";
|
||||
String taxonomy = "security, logging";
|
||||
List<PromptBuilder.TargetMethod> targetMethods = List.of(new PromptBuilder.TargetMethod("alpha", null, null));
|
||||
|
||||
String prompt1 = PromptBuilder.build(fqcn, source, taxonomy, targetMethods);
|
||||
String prompt2 = PromptBuilder.build(fqcn, source, taxonomy, targetMethods);
|
||||
|
||||
assertEquals(prompt1, prompt2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void build_rejectsEmptyTargetMethods() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> PromptBuilder.build("com.example.X", "class X {}", "security", List.of()));
|
||||
|
||||
assertEquals("targetMethods must not be empty", ex.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.egothor.methodatlas.ai;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SuggestionLookupTest {
|
||||
|
||||
@Test
|
||||
void from_nullSuggestion_returnsEmptyLookup() {
|
||||
SuggestionLookup lookup = SuggestionLookup.from(null);
|
||||
|
||||
assertNotNull(lookup);
|
||||
assertFalse(lookup.find("shouldAuthenticateUser").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_nullMethods_returnsEmptyLookup() {
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "access-control"), "Class contains access-control related tests.", null);
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertNotNull(lookup);
|
||||
assertFalse(lookup.find("shouldAllowOwnerToReadOwnStatement").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_emptyMethods_returnsEmptyLookup() {
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "access-control"), "Class contains access-control related tests.", List.of());
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertNotNull(lookup);
|
||||
assertFalse(lookup.find("shouldAllowOwnerToReadOwnStatement").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_filtersNullBlankAndMissingMethodNames() {
|
||||
AiMethodSuggestion valid = new AiMethodSuggestion("shouldRejectUnauthenticatedRequest", true,
|
||||
"Reject unauthenticated access", List.of("security", "authentication", "access-control"),
|
||||
"The test verifies anonymous access is rejected.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "authentication", "access-control"), "Class tests protected-access scenarios.",
|
||||
Arrays.asList(null,
|
||||
new AiMethodSuggestion(null, true, "Invalid", List.of("security"), "missing method name"),
|
||||
new AiMethodSuggestion("", true, "Invalid", List.of("security"), "blank method name"),
|
||||
new AiMethodSuggestion(" ", true, "Invalid", List.of("security"), "blank method name"),
|
||||
valid));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertFalse(lookup.find("missing").isPresent());
|
||||
|
||||
Optional<AiMethodSuggestion> found = lookup.find("shouldRejectUnauthenticatedRequest");
|
||||
assertTrue(found.isPresent());
|
||||
assertSame(valid, found.get());
|
||||
assertTrue(found.get().tags().contains("security"));
|
||||
assertTrue(found.get().tags().contains("authentication"));
|
||||
assertTrue(found.get().tags().contains("access-control"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void from_duplicateMethodNames_keepsFirstOccurrence() {
|
||||
AiMethodSuggestion first = new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
|
||||
"Allow administrative access", List.of("security", "access-control", "authorization"),
|
||||
"The test verifies that an administrator is allowed access.");
|
||||
|
||||
AiMethodSuggestion duplicate = new AiMethodSuggestion("shouldAllowAdministratorToReadAnyStatement", true,
|
||||
"Ignore duplicate", List.of("security", "logging"), "A later duplicate entry that must be ignored.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.security.AccessControlServiceTest", Boolean.TRUE,
|
||||
List.of("security", "access-control"), "Class covers authorization scenarios.",
|
||||
List.of(first, duplicate));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
Optional<AiMethodSuggestion> found = lookup.find("shouldAllowAdministratorToReadAnyStatement");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertSame(first, found.get());
|
||||
assertTrue(found.get().tags().contains("authorization"));
|
||||
assertFalse(found.get().tags().contains("logging"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_existingMethod_returnsSuggestion() {
|
||||
AiMethodSuggestion method = new AiMethodSuggestion("shouldRejectRelativePathTraversalSequence", true,
|
||||
"Reject path traversal payload", List.of("security", "input-validation", "path-traversal"),
|
||||
"The test rejects a parent-directory traversal sequence.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.storage.PathTraversalValidationTest",
|
||||
Boolean.TRUE, List.of("security", "input-validation"), "Class validates filesystem input handling.",
|
||||
List.of(method));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
Optional<AiMethodSuggestion> found = lookup.find("shouldRejectRelativePathTraversalSequence");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertSame(method, found.get());
|
||||
assertTrue(found.get().tags().contains("security"));
|
||||
assertTrue(found.get().tags().contains("input-validation"));
|
||||
assertTrue(found.get().tags().contains("path-traversal"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_missingMethod_returnsEmptyOptional() {
|
||||
AiMethodSuggestion method = new AiMethodSuggestion("shouldWriteAuditEventForPrivilegeChange", true,
|
||||
"Audit privilege changes", List.of("security", "audit", "logging"),
|
||||
"The test verifies audit logging for a security-sensitive action.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", Boolean.TRUE,
|
||||
List.of("security", "audit", "logging"), "Class contains audit and secure logging tests.",
|
||||
List.of(method));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertFalse(lookup.find("shouldFormatHumanReadableSupportMessage").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_nullMethodName_throwsNullPointerException() {
|
||||
AiMethodSuggestion method = new AiMethodSuggestion("shouldNotLogRawBearerToken", true,
|
||||
"Redact bearer token in logs", List.of("security", "logging", "secrets-handling"),
|
||||
"The test ensures sensitive credentials are not written to logs.");
|
||||
|
||||
AiClassSuggestion suggestion = new AiClassSuggestion("com.acme.audit.AuditLoggingTest", Boolean.TRUE,
|
||||
List.of("security", "logging"), "Class checks secure logging behavior.", List.of(method));
|
||||
|
||||
SuggestionLookup lookup = SuggestionLookup.from(suggestion);
|
||||
|
||||
assertThrows(NullPointerException.class, () -> lookup.find(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.acme.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class AccessControlServiceTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authz")
|
||||
void shouldAllowOwnerToReadOwnStatement() {
|
||||
String userId = "user-100";
|
||||
String ownerId = "user-100";
|
||||
|
||||
boolean allowed = userId.equals(ownerId);
|
||||
|
||||
assertEquals(true, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authz")
|
||||
void shouldAllowAdministratorToReadAnyStatement() {
|
||||
String role = "ADMIN";
|
||||
|
||||
boolean allowed = "ADMIN".equals(role);
|
||||
|
||||
assertEquals(true, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authz")
|
||||
void shouldDenyForeignUserFromReadingAnotherUsersStatement() {
|
||||
String requesterId = "user-200";
|
||||
String ownerId = "user-100";
|
||||
|
||||
boolean allowed = requesterId.equals(ownerId);
|
||||
|
||||
assertEquals(false, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("authn")
|
||||
void shouldRejectUnauthenticatedRequest() {
|
||||
String principal = null;
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
|
||||
if (principal == null) {
|
||||
throw new IllegalStateException("Unauthenticated request");
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("Unauthenticated request", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRenderFriendlyAccountLabel() {
|
||||
String firstName = "Ada";
|
||||
String lastName = "Lovelace";
|
||||
|
||||
String label = firstName + " " + lastName;
|
||||
|
||||
assertEquals("Ada Lovelace", label);
|
||||
}
|
||||
}
|
||||
53
src/test/resources/fixtures/AuditLoggingTest.java.txt
Normal file
53
src/test/resources/fixtures/AuditLoggingTest.java.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.acme.audit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class AuditLoggingTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("audit")
|
||||
void shouldWriteAuditEventForPrivilegeChange() {
|
||||
String actor = "admin-7";
|
||||
String action = "GRANT_ROLE";
|
||||
String target = "user-17";
|
||||
|
||||
String auditLine = "actor=" + actor + ";action=" + action + ";target=" + target;
|
||||
|
||||
assertEquals("actor=admin-7;action=GRANT_ROLE;target=user-17", auditLine);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("logging")
|
||||
void shouldNotLogRawBearerToken() {
|
||||
String token = "Bearer eyJhbGciOiJIUzI1NiJ9.secret.signature";
|
||||
String logLine = "Authorization header redacted";
|
||||
|
||||
assertFalse(logLine.contains(token));
|
||||
assertEquals("Authorization header redacted", logLine);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("logging")
|
||||
void shouldNotLogPlaintextPasswordOnAuthenticationFailure() {
|
||||
String password = "Sup3rSecret!";
|
||||
String logLine = "Authentication failed for user alice";
|
||||
|
||||
assertFalse(logLine.contains(password));
|
||||
assertEquals("Authentication failed for user alice", logLine);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFormatHumanReadableSupportMessage() {
|
||||
String user = "alice";
|
||||
String message = "Support ticket opened for " + user;
|
||||
|
||||
assertEquals("Support ticket opened for alice", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.acme.storage;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class PathTraversalValidationTest {
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("validation")
|
||||
void shouldRejectRelativePathTraversalSequence() {
|
||||
String userInput = "../secrets.txt";
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
|
||||
if (userInput.contains("..")) {
|
||||
throw new IllegalArgumentException("Path traversal attempt detected");
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("Path traversal attempt detected", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("validation")
|
||||
void shouldRejectNestedTraversalAfterNormalization() {
|
||||
String userInput = "reports/../../admin/keys.txt";
|
||||
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
|
||||
if (!normalized.startsWith(Path.of("/srv/app/uploads"))) {
|
||||
throw new IllegalArgumentException("Escaped upload root");
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("Escaped upload root", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("security")
|
||||
@Tag("validation")
|
||||
void shouldAllowSafePathInsideUploadRoot() {
|
||||
String userInput = "reports/2026/statement.pdf";
|
||||
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
|
||||
|
||||
boolean allowed = normalized.startsWith(Path.of("/srv/app/uploads"));
|
||||
|
||||
assertEquals(true, allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildDownloadFileName() {
|
||||
String accountId = "ACC-42";
|
||||
String fileName = accountId + "-statement.pdf";
|
||||
|
||||
assertEquals("ACC-42-statement.pdf", fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user