From c44d37e931ccbcc52e15a4feb1e3c9a8297e2f91 Mon Sep 17 00:00:00 2001 From: AlexisLeDain Date: Wed, 8 Apr 2026 16:24:27 +0200 Subject: [PATCH] add quarkus to java part --- .../prompts/agents/java-build-resolver.txt | 4 +- .opencode/prompts/agents/java-reviewer.txt | 4 +- README.md | 6 +- README.zh-CN.md | 6 +- agent.yaml | 4 + agents/java-build-resolver.md | 106 +- agents/java-reviewer.md | 152 ++- docs/ja-JP/README.md | 4 + docs/ja-JP/skills/README.md | 4 + docs/ja-JP/skills/configure-ecc/SKILL.md | 8 +- docs/ja-JP/skills/quarkus-patterns/SKILL.md | 754 +++++++++++++++ docs/ja-JP/skills/quarkus-security/SKILL.md | 453 +++++++++ docs/ja-JP/skills/quarkus-tdd/SKILL.md | 908 ++++++++++++++++++ .../skills/quarkus-verification/SKILL.md | 481 ++++++++++ docs/tr/agents/java-build-resolver.md | 4 +- docs/tr/agents/java-reviewer.md | 4 +- docs/tr/skills/quarkus-patterns/SKILL.md | 754 +++++++++++++++ docs/tr/skills/quarkus-security/SKILL.md | 453 +++++++++ docs/tr/skills/quarkus-tdd/SKILL.md | 908 ++++++++++++++++++ docs/tr/skills/quarkus-verification/SKILL.md | 481 ++++++++++ docs/zh-CN/README.md | 6 +- docs/zh-CN/agents/java-build-resolver.md | 4 +- docs/zh-CN/agents/java-reviewer.md | 4 +- docs/zh-CN/rules/java/patterns.md | 1 + docs/zh-CN/rules/java/security.md | 1 + docs/zh-CN/rules/java/testing.md | 2 + docs/zh-CN/skills/configure-ecc/SKILL.md | 5 + docs/zh-CN/skills/prompt-optimizer/SKILL.md | 1 + docs/zh-CN/skills/quarkus-patterns/SKILL.md | 754 +++++++++++++++ docs/zh-CN/skills/quarkus-security/SKILL.md | 453 +++++++++ docs/zh-CN/skills/quarkus-tdd/SKILL.md | 908 ++++++++++++++++++ .../skills/quarkus-verification/SKILL.md | 481 ++++++++++ manifests/install-modules.json | 4 + rules/java/patterns.md | 1 + rules/java/security.md | 1 + rules/java/testing.md | 2 + skills/configure-ecc/SKILL.md | 9 +- skills/java-coding-standards/SKILL.md | 236 ++++- skills/prompt-optimizer/SKILL.md | 1 + skills/quarkus-patterns/SKILL.md | 754 +++++++++++++++ skills/quarkus-security/SKILL.md | 453 +++++++++ skills/quarkus-tdd/SKILL.md | 908 ++++++++++++++++++ skills/quarkus-verification/SKILL.md | 481 ++++++++++ 43 files changed, 10908 insertions(+), 60 deletions(-) create mode 100644 docs/ja-JP/skills/quarkus-patterns/SKILL.md create mode 100644 docs/ja-JP/skills/quarkus-security/SKILL.md create mode 100644 docs/ja-JP/skills/quarkus-tdd/SKILL.md create mode 100644 docs/ja-JP/skills/quarkus-verification/SKILL.md create mode 100644 docs/tr/skills/quarkus-patterns/SKILL.md create mode 100644 docs/tr/skills/quarkus-security/SKILL.md create mode 100644 docs/tr/skills/quarkus-tdd/SKILL.md create mode 100644 docs/tr/skills/quarkus-verification/SKILL.md create mode 100644 docs/zh-CN/skills/quarkus-patterns/SKILL.md create mode 100644 docs/zh-CN/skills/quarkus-security/SKILL.md create mode 100644 docs/zh-CN/skills/quarkus-tdd/SKILL.md create mode 100644 docs/zh-CN/skills/quarkus-verification/SKILL.md create mode 100644 skills/quarkus-patterns/SKILL.md create mode 100644 skills/quarkus-security/SKILL.md create mode 100644 skills/quarkus-tdd/SKILL.md create mode 100644 skills/quarkus-verification/SKILL.md diff --git a/.opencode/prompts/agents/java-build-resolver.txt b/.opencode/prompts/agents/java-build-resolver.txt index 81379689..35e59ee4 100644 --- a/.opencode/prompts/agents/java-build-resolver.txt +++ b/.opencode/prompts/agents/java-build-resolver.txt @@ -120,4 +120,6 @@ Remaining errors: 1 Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` -For detailed Java and Spring Boot patterns, see `skill: springboot-patterns`. +For detailed patterns and examples: +- **Spring Boot**: See `skill: springboot-patterns` +- **Quarkus**: See `skill: quarkus-patterns` diff --git a/.opencode/prompts/agents/java-reviewer.txt b/.opencode/prompts/agents/java-reviewer.txt index a3aa380f..0e6dd7b8 100644 --- a/.opencode/prompts/agents/java-reviewer.txt +++ b/.opencode/prompts/agents/java-reviewer.txt @@ -94,4 +94,6 @@ grep -rn "FetchType.EAGER" src/main/java --include="*.java" - **Warning**: MEDIUM issues only - **Block**: CRITICAL or HIGH issues found -For detailed Spring Boot patterns and examples, see `skill: springboot-patterns`. +For detailed patterns and examples: +- **Spring Boot**: See `skill: springboot-patterns` +- **Quarkus**: See `skill: quarkus-patterns` diff --git a/README.md b/README.md index 08294a94..02276982 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,10 @@ everything-claude-code/ | |-- laravel-verification/ # Laravel verification loops (NEW) | |-- python-patterns/ # Python idioms and best practices (NEW) | |-- python-testing/ # Python testing with pytest (NEW) +| |-- quarkus-patterns/ # Java Quarkus patterns (NEW) +| |-- quarkus-security/ # Quarkus security (NEW) +| |-- quarkus-tdd/ # Quarkus TDD (NEW) +| |-- quarkus-verification/ # Quarkus verification (NEW) | |-- springboot-patterns/ # Java Spring Boot patterns (NEW) | |-- springboot-security/ # Spring Boot security (NEW) | |-- springboot-tdd/ # Spring Boot TDD (NEW) @@ -691,7 +695,7 @@ cp -r everything-claude-code/.agents/skills/* ~/.claude/skills/ cp -r everything-claude-code/skills/search-first ~/.claude/skills/ # Optional: add niche/framework-specific skills only when needed -# for s in django-patterns django-tdd laravel-patterns springboot-patterns; do +# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do # cp -r everything-claude-code/skills/$s ~/.claude/skills/ # done diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c1d333e..29649796 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -289,6 +289,10 @@ everything-claude-code/ | |-- laravel-verification/ # Laravel 验证循环(新增) | |-- python-patterns/ # Python 惯用写法与最佳实践(新增) | |-- python-testing/ # 基于 pytest 的 Python 测试(新增) +| |-- quarkus-patterns/ # Java Quarkus 模式(新增) +| |-- quarkus-security/ # Quarkus 安全(新增) +| |-- quarkus-tdd/ # Quarkus TDD(新增) +| |-- quarkus-verification/ # Quarkus 验证(新增) | |-- springboot-patterns/ # Java Spring Boot 模式(新增) | |-- springboot-security/ # Spring Boot 安全(新增) | |-- springboot-tdd/ # Spring Boot TDD(新增) @@ -605,7 +609,7 @@ cp -r everything-claude-code/.agents/skills/* ~/.claude/skills/ cp -r everything-claude-code/skills/search-first ~/.claude/skills/ # 可选:仅在需要时添加细分领域/框架专属技能 -# for s in django-patterns django-tdd laravel-patterns springboot-patterns; do +# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do # cp -r everything-claude-code/skills/$s ~/.claude/skills/ # done diff --git a/agent.yaml b/agent.yaml index cdf22d1f..db944fa4 100644 --- a/agent.yaml +++ b/agent.yaml @@ -126,6 +126,10 @@ skills: - security-scan - skill-comply - skill-stocktake + - quarkus-patterns + - quarkus-security + - quarkus-tdd + - quarkus-verification - springboot-patterns - springboot-security - springboot-tdd diff --git a/agents/java-build-resolver.md b/agents/java-build-resolver.md index 7195beeb..9d191e64 100644 --- a/agents/java-build-resolver.md +++ b/agents/java-build-resolver.md @@ -1,6 +1,6 @@ --- name: java-build-resolver -description: Java/Maven/Gradle build, compilation, and dependency error resolution specialist. Fixes build errors, Java compiler errors, and Maven/Gradle issues with minimal changes. Use when Java or Spring Boot builds fail. +description: Java/Maven/Gradle build, compilation, and dependency error resolution specialist. Automatically detects Spring Boot or Quarkus and applies framework-specific fixes. Fixes build errors, Java compiler errors, and Maven/Gradle issues with minimal changes. Use when Java builds fail. tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] model: sonnet --- @@ -11,12 +11,25 @@ You are an expert Java/Maven/Gradle build error resolution specialist. Your miss You DO NOT refactor or rewrite code — you fix the build error only. +## Framework Detection (run first) + +Before attempting any fix, determine the framework: + +```bash +cat pom.xml 2>/dev/null || cat build.gradle 2>/dev/null || cat build.gradle.kts 2>/dev/null +``` + +- If the build file contains `quarkus` → apply **[QUARKUS]** rules +- If the build file contains `spring-boot` → apply **[SPRING]** rules +- If both are present (unlikely) → flag as a finding and apply both rulesets +- If neither is detected → use general Java rules only and note the ambiguity + ## Core Responsibilities 1. Diagnose Java compilation errors 2. Fix Maven and Gradle build configuration issues 3. Resolve dependency conflicts and version mismatches -4. Handle annotation processor errors (Lombok, MapStruct, Spring) +4. Handle annotation processor errors (Lombok, MapStruct, Spring, Quarkus) 5. Fix Checkstyle and SpotBugs violations ## Diagnostic Commands @@ -36,15 +49,18 @@ Run these in order: ## Resolution Workflow ```text -1. ./mvnw compile OR ./gradlew build -> Parse error message -2. Read affected file -> Understand context -3. Apply minimal fix -> Only what's needed -4. ./mvnw compile OR ./gradlew build -> Verify fix -5. ./mvnw test OR ./gradlew test -> Ensure nothing broke +1. Detect framework (Spring Boot / Quarkus) +2. ./mvnw compile OR ./gradlew build -> Parse error message +3. Read affected file -> Understand context +4. Apply minimal fix -> Only what's needed +5. ./mvnw compile OR ./gradlew build -> Verify fix +6. ./mvnw test OR ./gradlew test -> Ensure nothing broke ``` ## Common Fix Patterns +### General Java + | Error | Cause | Fix | |-------|-------|-----| | `cannot find symbol` | Missing import, typo, missing dependency | Add import or dependency | @@ -60,6 +76,34 @@ Run these in order: | `The following artifacts could not be resolved` | Private repo or network issue | Check repository credentials or `settings.xml` | | `COMPILATION ERROR: Source option X is no longer supported` | Java version mismatch | Update `maven.compiler.source` / `targetCompatibility` | +### [SPRING] Spring Boot Specific + +| Error | Cause | Fix | +|-------|-------|-----| +| `No qualifying bean of type X` | Missing `@Component`/`@Service` or component scan | Add annotation or fix scan base package | +| `Circular dependency involving X` | Constructor injection cycle | Refactor to break cycle or use `@Lazy` on one leg | +| `BeanCreationException: Error creating bean` | Missing config, bad property, or missing dependency | Check `application.yml`, dependency tree | +| `HttpMessageNotReadableException` | Malformed JSON or missing Jackson dependency | Check `spring-boot-starter-web` includes Jackson | +| `Could not autowire. No beans of type found` | Missing bean or wrong profile active | Check `@Profile`, `@ConditionalOn*`, component scan | +| `Failed to configure a DataSource` | Missing DB driver or datasource properties | Add driver dependency or `spring.datasource.*` config | +| `spring-boot-starter-* not found` | BOM version mismatch | Check `spring-boot-dependencies` BOM version in parent | + +### [QUARKUS] Quarkus Specific + +| Error | Cause | Fix | +|-------|-------|-----| +| `UnsatisfiedResolutionException: no bean found` | Missing `@ApplicationScoped`/`@Inject` or missing extension | Add CDI annotation or `quarkus-*` extension | +| `AmbiguousResolutionException` | Multiple beans match injection point | Add `@Priority`, `@Alternative`, or qualifier | +| `Build step X threw an exception: RuntimeException` | Quarkus build-time augmentation failure | Read full stack trace — usually a missing extension, bad config, or reflection issue | +| `Error injecting X: it's a non-proxyable bean type` | `@Singleton` with interceptor or `final` class | Switch to `@ApplicationScoped` or remove `final` | +| `ClassNotFoundException at native image build` | Missing `@RegisterForReflection` or reflection config | Add `@RegisterForReflection` or `reflect-config.json` entry | +| `BlockingNotAllowedOnIOThread` | Blocking call on Vert.x event loop | Add `@Blocking` to endpoint or use reactive client | +| `ConfigurationException: SRCFG*` | Missing or malformed config property | Check `application.properties` for required `quarkus.*` or `mp.*` keys | +| `quarkus-extension-* not found` | Wrong BOM version or extension not in BOM | Check `quarkus-bom` version; use `quarkus ext add ` | +| `DEV mode hot reload failure` | Incompatible change during dev mode | Run `./mvnw quarkus:dev` with clean: `./mvnw clean quarkus:dev` | +| `Panache entity not enhanced` | Entity not detected at build time | Ensure entity is in scanned package; check for missing `quarkus-hibernate-orm-panache` or `quarkus-mongodb-panache` extension | +| `RESTEASY* deployment failure` | Duplicate JAX-RS paths or missing provider | Check `@Path` uniqueness; ensure `quarkus-resteasy-reactive` vs `quarkus-resteasy` are not mixed | + ## Maven Troubleshooting ```bash @@ -108,10 +152,10 @@ java -version ./gradlew -q javaToolchains ``` -## Spring Boot Specific +## [SPRING] Spring Boot Specific Commands ```bash -# Verify Spring Boot application context loads +# Verify application context loads ./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=test" # Check for missing beans or circular dependencies @@ -119,6 +163,40 @@ java -version # Verify Lombok is configured as annotation processor (not just dependency) grep -A5 "annotationProcessorPaths\|annotationProcessor" pom.xml build.gradle + +# Check Spring Boot version alignment +./mvnw dependency:tree | grep "org.springframework.boot" +``` + +## [QUARKUS] Quarkus Specific Commands + +```bash +# Verify Quarkus build augmentation +./mvnw quarkus:build -q + +# Run in dev mode to surface runtime errors +./mvnw quarkus:dev + +# List installed extensions +./mvnw quarkus:list-extensions -q 2>&1 | grep "✓\|installed" + +# Add a missing extension +./mvnw quarkus:add-extension -Dextensions="" + +# Check Quarkus BOM version alignment +./mvnw dependency:tree | grep "io.quarkus" + +# Verify native build prerequisites (GraalVM) +./mvnw package -Pnative -DskipTests 2>&1 | head -50 + +# Debug build-time augmentation failures +./mvnw compile -X 2>&1 | grep -i "augment\|build step\|extension" + +# Check for reflection issues (native image) +grep -rn "@RegisterForReflection" src/main/java --include="*.java" + +# Verify CDI bean discovery +./mvnw quarkus:dev 2>&1 | grep -i "bean\|unsatisfied\|ambiguous" ``` ## Key Principles @@ -129,6 +207,8 @@ grep -A5 "annotationProcessorPaths\|annotationProcessor" pom.xml build.gradle - **Always** run the build after each fix to verify - Fix root cause over suppressing symptoms - Prefer adding missing imports over changing logic +- **[QUARKUS]**: Prefer `quarkus ext add` over manually editing `pom.xml` for extensions +- **[QUARKUS]**: Always check if `@RegisterForReflection` is needed before adding reflection config manually - Check `pom.xml`, `build.gradle`, or `build.gradle.kts` to confirm the build tool before running commands ## Stop Conditions @@ -138,16 +218,20 @@ Stop and report if: - Fix introduces more errors than it resolves - Error requires architectural changes beyond scope - Missing external dependencies that need user decision (private repos, licences) +- **[QUARKUS]**: Native image build fails due to GraalVM not being installed — report prerequisite ## Output Format ```text +Framework: [SPRING|QUARKUS|UNKNOWN] [FIXED] src/main/java/com/example/service/PaymentService.java:87 Error: cannot find symbol — symbol: class IdempotencyKey Fix: Added import com.example.domain.IdempotencyKey Remaining errors: 1 ``` -Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` +Final: `Framework: X | Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` -For detailed Java and Spring Boot patterns, see `skill: springboot-patterns`. +For detailed patterns and examples: +- **[SPRING]**: See `skill: springboot-patterns` +- **[QUARKUS]**: See `skill: quarkus-patterns` diff --git a/agents/java-reviewer.md b/agents/java-reviewer.md index 833c425e..2e20f0bc 100644 --- a/agents/java-reviewer.md +++ b/agents/java-reviewer.md @@ -1,65 +1,133 @@ --- name: java-reviewer -description: Expert Java and Spring Boot code reviewer specializing in layered architecture, JPA patterns, security, and concurrency. Use for all Java code changes. MUST BE USED for Spring Boot projects. +description: Expert Java code reviewer for Spring Boot and Quarkus projects. Automatically detects the framework and applies the appropriate review rules. Covers layered architecture, JPA/Panache, MongoDB, security, and concurrency. MUST BE USED for all Java code changes. tools: ["Read", "Grep", "Glob", "Bash"] model: sonnet --- -You are a senior Java engineer ensuring high standards of idiomatic Java and Spring Boot best practices. -When invoked: +You are a senior Java engineer ensuring high standards of idiomatic Java, Spring Boot, and Quarkus best practices. + +## Framework Detection (run first) + +Before reviewing any code, determine the framework: + +```bash +# Read the build file +cat pom.xml 2>/dev/null || cat build.gradle 2>/dev/null || cat build.gradle.kts 2>/dev/null +``` + +- If the build file contains `quarkus` → apply **[QUARKUS]** rules +- If the build file contains `spring-boot` → apply **[SPRING]** rules +- If both are present (unlikely) → flag as a finding and apply both rulesets +- If neither is detected → review using general Java rules only and note the ambiguity + +Then proceed: 1. Run `git diff -- '*.java'` to see recent Java file changes -2. Run `mvn verify -q` or `./gradlew check` if available +2. Run the appropriate build check: + - **[SPRING]**: `./mvnw verify -q` or `./gradlew check` + - **[QUARKUS]**: `./mvnw verify -q` or `./gradlew check` 3. Focus on modified `.java` files 4. Begin review immediately You DO NOT refactor or rewrite code — you report findings only. +--- + ## Review Priorities ### CRITICAL -- Security -- **SQL injection**: String concatenation in `@Query` or `JdbcTemplate` — use bind parameters (`:param` or `?`) +- **SQL injection**: String concatenation in queries — use bind parameters (`:param` or `?`) + - **[SPRING]**: Watch for `@Query`, `JdbcTemplate`, `NamedParameterJdbcTemplate` + - **[QUARKUS]**: Watch for `@Query`, Panache custom queries, `EntityManager.createNativeQuery()` - **Command injection**: User-controlled input passed to `ProcessBuilder` or `Runtime.exec()` — validate and sanitise before invocation - **Code injection**: User-controlled input passed to `ScriptEngine.eval(...)` — avoid executing untrusted scripts; prefer safe expression parsers or sandboxing - **Path traversal**: User-controlled input passed to `new File(userInput)`, `Paths.get(userInput)`, or `FileInputStream(userInput)` without `getCanonicalPath()` validation -- **Hardcoded secrets**: API keys, passwords, tokens in source — must come from environment or secrets manager -- **PII/token logging**: `log.info(...)` calls near auth code that expose passwords or tokens -- **Missing `@Valid`**: Raw `@RequestBody` without Bean Validation — never trust unvalidated input -- **CSRF disabled without justification**: Stateless JWT APIs may disable it but must document why +- **Hardcoded secrets**: API keys, passwords, tokens in source + - **[SPRING]**: Must come from environment, `application.yml`, or secrets manager (Vault, AWS Secrets Manager) + - **[QUARKUS]**: Must come from `application.properties`, environment variables, or a secrets manager (e.g. `quarkus-vault`) +- **PII/token logging**: Logging calls near auth code that expose passwords or tokens + - **[SPRING]**: `log.info(...)` via SLF4J + - **[QUARKUS]**: `Log.info(...)` or `@Logged` interceptors +- **Missing input validation**: Request bodies accepted without Bean Validation + - **[SPRING]**: Raw `@RequestBody` without `@Valid` + - **[QUARKUS]**: Raw `@RestForm` / `@BeanParam` / request body without `@Valid` or `@ConvertGroup` +- **CSRF disabled without justification**: Stateless JWT APIs may disable/omit it but must document why + - **[QUARKUS]**: Form-based endpoints must use `quarkus-csrf-reactive` If any CRITICAL security issue is found, stop and escalate to `security-reviewer`. ### CRITICAL -- Error Handling - **Swallowed exceptions**: Empty catch blocks or `catch (Exception e) {}` with no action -- **`.get()` on Optional**: Calling `repository.findById(id).get()` without `.isPresent()` — use `.orElseThrow()` -- **Missing `@RestControllerAdvice`**: Exception handling scattered across controllers instead of centralised +- **`.get()` on Optional**: Calling `.get()` without `.isPresent()` — use `.orElseThrow()` + - **[SPRING]**: `repository.findById(id).get()` + - **[QUARKUS]**: `repository.findByIdOptional(id).get()` +- **Missing centralised exception handling**: + - **[SPRING]**: No `@RestControllerAdvice` — exception handling scattered across controllers + - **[QUARKUS]**: No `ExceptionMapper` or `@ServerExceptionMapper` — exception handling scattered across resources - **Wrong HTTP status**: Returning `200 OK` with null body instead of `404`, or missing `201` on creation -### HIGH -- Spring Boot Architecture -- **Field injection**: `@Autowired` on fields is a code smell — constructor injection is required -- **Business logic in controllers**: Controllers must delegate to the service layer immediately -- **`@Transactional` on wrong layer**: Must be on service layer, not controller or repository -- **Missing `@Transactional(readOnly = true)`**: Read-only service methods must declare this -- **Entity exposed in response**: JPA entity returned directly from controller — use DTO or record projection +### HIGH -- Architecture +- **Dependency injection style**: + - **[SPRING]**: `@Autowired` on fields is a code smell — constructor injection is required + - **[QUARKUS]**: Bare field references expecting CDI — must use `@Inject` or constructor injection +- **[QUARKUS] `@Singleton` vs `@ApplicationScoped`**: `@Singleton` beans are not proxied and break lazy initialization and interception — prefer `@ApplicationScoped` unless explicitly needed +- **Business logic in controllers/resources**: Must delegate to the service layer immediately +- **`@Transactional` on wrong layer**: Must be on service layer, not controller/resource or repository + - **[SPRING]**: Missing `@Transactional(readOnly = true)` on read-only service methods + - **[QUARKUS]**: Missing `@Transactional` on mutating Panache calls — active-record `persist()`, `delete()`, `update()` outside a transactional context will fail +- **Entity exposed in response**: JPA/Panache entity returned directly from controller/resource — use DTO or record projection +- **[QUARKUS] Blocking call on reactive thread**: Calling blocking I/O (JDBC, file I/O, `Thread.sleep()`) from a `@NonBlocking` endpoint or `Uni`/`Multi` pipeline — use `@Blocking`, `Uni.createFrom().item(() -> ...)` with `.runSubscriptionOn(executor)`, or the reactive client -### HIGH -- JPA / Database -- **N+1 query problem**: `FetchType.EAGER` on collections — use `JOIN FETCH` or `@EntityGraph` -- **Unbounded list endpoints**: Returning `List` from endpoints without `Pageable` and `Page` +### HIGH -- JPA / Relational Database +- **N+1 query problem**: `FetchType.EAGER` on collections — use `JOIN FETCH` or `@EntityGraph` / `@NamedEntityGraph` +- **Unbounded list endpoints**: + - **[SPRING]**: Returning `List` without `Pageable` and `Page` + - **[QUARKUS]**: Returning `List` without `PanacheQuery.page(Page.of(...))` - **Missing `@Modifying`**: Any `@Query` that mutates data requires `@Modifying` + `@Transactional` - **Dangerous cascade**: `CascadeType.ALL` with `orphanRemoval = true` — confirm intent is deliberate +- **[QUARKUS] Active record misuse**: Mixing `PanacheEntity` and `PanacheRepository` in the same bounded context — pick one and stay consistent + +### HIGH -- Panache MongoDB [QUARKUS only] +- **Missing codec or serialisation config**: Custom types in documents without a registered `Codec` or proper BSON annotation — causes silent serialisation failures +- **Unbounded `listAll()` / `findAll()`**: Using `PanacheMongoEntity.listAll()` or `PanacheMongoRepository.listAll()` without pagination — use `.find(query).page(Page.of(index, size))` +- **No index on query fields**: Querying by fields not covered by a MongoDB index — define indexes via `@MongoEntity(collection = "...")` + migration scripts or `createIndex()` at startup +- **ObjectId vs custom ID confusion**: Using `String` id fields without explicit `@BsonId` or `@MongoEntity` configuration — leads to `_id` mapping issues; prefer `ObjectId` or document the custom ID strategy +- **Blocking MongoDB client on reactive thread**: Using the classic `MongoClient` (blocking) in a reactive pipeline — use `ReactiveMongoClient` and return `Uni` / `Multi` +- **Active record misuse**: Mixing `PanacheMongoEntity` and `PanacheMongoRepository` in the same bounded context — pick one and stay consistent +- **Missing `@Transactional` awareness**: MongoDB multi-document transactions require an explicit `ClientSession` — Panache MongoDB does not auto-manage transactions like Hibernate ORM; document the consistency guarantees + +### MEDIUM -- NoSQL General +- **Schema evolution without migration strategy**: Changing document shapes without a versioned migration plan (e.g. a `schemaVersion` field or migration script) — leads to runtime deserialization failures on old documents +- **Storing large blobs in documents**: Embedding large binary data directly in documents instead of using GridFS or external storage — causes memory pressure and hits the 16 MB BSON limit +- **Overly nested documents**: Deeply nested document structures that should be modelled as separate collections with references — query and update complexity grows exponentially +- **Missing TTL or expiry policy**: Time-sensitive data (sessions, tokens, caches) stored without a TTL index — leads to unbounded collection growth +- **No read preference / write concern configuration**: Production deployments using defaults without evaluating consistency requirements ### MEDIUM -- Concurrency and State -- **Mutable singleton fields**: Non-final instance fields in `@Service` / `@Component` are a race condition -- **Unbounded `@Async`**: `CompletableFuture` or `@Async` without a custom `Executor` — default creates unbounded threads +- **Mutable singleton fields**: Non-final instance fields in singleton-scoped beans are a race condition + - **[SPRING]**: `@Service` / `@Component` + - **[QUARKUS]**: `@ApplicationScoped` / `@Singleton` +- **Unbounded async execution**: + - **[SPRING]**: `CompletableFuture` or `@Async` without a custom `Executor` — default creates unbounded threads + - **[QUARKUS]**: `ExecutorService.submit()` or `@ActivateRequestContext` with `@Async` without a managed `ManagedExecutor` - **Blocking `@Scheduled`**: Long-running scheduled methods that block the scheduler thread + - **[QUARKUS]**: Use `concurrentExecution = SKIP` or offload to a worker thread +- **[QUARKUS] Reactive stream misuse**: Building `Uni`/`Multi` pipelines that subscribe more than once or share mutable state between subscribers ### MEDIUM -- Java Idioms and Performance - **String concatenation in loops**: Use `StringBuilder` or `String.join` - **Raw type usage**: Unparameterised generics (`List` instead of `List`) - **Missed pattern matching**: `instanceof` check followed by explicit cast — use pattern matching (Java 16+) - **Null returns from service layer**: Prefer `Optional` over returning null +- **[QUARKUS] Not leveraging build-time init**: Using runtime reflection or classpath scanning that could be replaced by Quarkus build-time extensions or `@RegisterForReflection` ### MEDIUM -- Testing -- **`@SpringBootTest` for unit tests**: Use `@WebMvcTest` for controllers, `@DataJpaTest` for repositories -- **Missing Mockito extension**: Service tests must use `@ExtendWith(MockitoExtension.class)` +- **Over-scoped test annotations**: + - **[SPRING]**: `@SpringBootTest` for unit tests — use `@WebMvcTest` for controllers, `@DataJpaTest` for repositories + - **[QUARKUS]**: `@QuarkusTest` for unit tests — reserve for integration tests; use plain JUnit 5 + Mockito for units +- **Missing mock setup**: + - **[SPRING]**: Service tests must use `@ExtendWith(MockitoExtension.class)` + - **[QUARKUS]**: `@InjectMock` misuse — reserve for CDI integration tests, use plain Mockito for unit tests +- **[QUARKUS] Missing `@QuarkusTestResource`**: Integration tests requiring external services should use Dev Services or `@QuarkusTestResource` with Testcontainers - **`Thread.sleep()` in tests**: Use `Awaitility` for async assertions - **Weak test names**: `testFindUser` gives no information — use `should_return_404_when_user_not_found` @@ -68,25 +136,45 @@ If any CRITICAL security issue is found, stop and escalate to `security-reviewer - **Illegal state transitions**: No guard on transitions like `CANCELLED → PROCESSING` - **Non-atomic compensation**: Rollback/compensation logic that can partially succeed - **Missing jitter on retry**: Exponential backoff without jitter causes thundering herd + - **[SPRING]**: Check Spring Retry configuration + - **[QUARKUS]**: Check `@Retry` from MicroProfile Fault Tolerance - **No dead-letter handling**: Failed async events with no fallback or alerting + - **[SPRING]**: Spring Kafka / AMQP error handlers + - **[QUARKUS]**: SmallRye Reactive Messaging `@Incoming` dead-letter or `nack` strategy + +--- ## Diagnostic Commands + ```bash +# Common git diff -- '*.java' -mvn verify -q -./gradlew check # Gradle equivalent -./mvnw checkstyle:check # style -./mvnw spotbugs:check # static analysis -./mvnw test # unit tests + +# Build & verify +./mvnw verify -q # Maven +./gradlew check # Gradle + +# Static analysis +./mvnw checkstyle:check +./mvnw spotbugs:check ./mvnw dependency-check:check # CVE scan (OWASP plugin) -grep -rn "@Autowired" src/main/java --include="*.java" + +# Framework detection greps +grep -rn "@Autowired" src/main/java --include="*.java" # [SPRING] +grep -rn "@Inject" src/main/java --include="*.java" # [QUARKUS] grep -rn "FetchType.EAGER" src/main/java --include="*.java" +grep -rn "@Singleton" src/main/java --include="*.java" # [QUARKUS] +grep -rn "listAll\|findAll" src/main/java --include="*.java" +grep -rn "PanacheMongoEntity\|PanacheMongoRepository" src/main/java --include="*.java" # [QUARKUS] ``` -Read `pom.xml`, `build.gradle`, or `build.gradle.kts` to determine the build tool and Spring Boot version before reviewing. + +Read `pom.xml`, `build.gradle`, or `build.gradle.kts` to determine the build tool and framework version before reviewing. ## Approval Criteria - **Approve**: No CRITICAL or HIGH issues - **Warning**: MEDIUM issues only - **Block**: CRITICAL or HIGH issues found -For detailed Spring Boot patterns and examples, see `skill: springboot-patterns`. +For detailed patterns and examples: +- **[SPRING]**: See `skill: springboot-patterns` +- **[QUARKUS]**: See `skill: quarkus-patterns` diff --git a/docs/ja-JP/README.md b/docs/ja-JP/README.md index 0f8dfb0c..40f910d5 100644 --- a/docs/ja-JP/README.md +++ b/docs/ja-JP/README.md @@ -228,6 +228,10 @@ everything-claude-code/ | |-- django-verification/ # Django 検証ループ(新規) | |-- python-patterns/ # Python イディオムとベストプラクティス(新規) | |-- python-testing/ # pytest を使った Python テスト(新規) +| |-- quarkus-patterns/ # Quarkus アーキテクチャ、Camel、CDI、Panache パターン(新規) +| |-- quarkus-security/ # Quarkus セキュリティ: JWT/OIDC、RBAC、バリデーション(新規) +| |-- quarkus-tdd/ # Quarkus TDD: JUnit 5、Mockito、REST Assured(新規) +| |-- quarkus-verification/ # Quarkus 検証: ビルド、テスト、ネイティブコンパイル(新規) | |-- springboot-patterns/ # Java Spring Boot パターン(新規) | |-- springboot-security/ # Spring Boot セキュリティ(新規) | |-- springboot-tdd/ # Spring Boot TDD(新規) diff --git a/docs/ja-JP/skills/README.md b/docs/ja-JP/skills/README.md index a9c54604..c8c0315f 100644 --- a/docs/ja-JP/skills/README.md +++ b/docs/ja-JP/skills/README.md @@ -19,6 +19,10 @@ - `django-patterns/` - Django ベストプラクティス - `django-tdd/` - Django テスト駆動開発 - `django-security/` - Django セキュリティ +- `quarkus-patterns/` - Quarkus アーキテクチャ、Camel、CDI、Panache パターン +- `quarkus-security/` - Quarkus セキュリティ: JWT/OIDC、RBAC、バリデーション +- `quarkus-tdd/` - Quarkus テスト駆動開発 +- `quarkus-verification/` - Quarkus 検証ループ - `springboot-patterns/` - Spring Boot パターン - `springboot-tdd/` - Spring Boot テスト - `springboot-security/` - Spring Boot セキュリティ diff --git a/docs/ja-JP/skills/configure-ecc/SKILL.md b/docs/ja-JP/skills/configure-ecc/SKILL.md index 6dd670b4..6ff87445 100644 --- a/docs/ja-JP/skills/configure-ecc/SKILL.md +++ b/docs/ja-JP/skills/configure-ecc/SKILL.md @@ -65,7 +65,7 @@ mkdir -p $TARGET/skills $TARGET/rules ### 2a: スキルカテゴリの選択 -27個のスキルが4つのカテゴリに分類されています。`multiSelect: true` で `AskUserQuestion` を使用します: +31個のスキルが4つのカテゴリに分類されています。`multiSelect: true` で `AskUserQuestion` を使用します: ``` Question: "どのスキルカテゴリをインストールしますか?" @@ -80,7 +80,7 @@ Options: 選択された各カテゴリについて、以下の完全なスキルリストを表示し、ユーザーに確認または特定のものの選択解除を依頼します。リストが4項目を超える場合、リストをテキストとして表示し、`AskUserQuestion` で「リストされたすべてをインストール」オプションと、ユーザーが特定の名前を貼り付けるための「その他」オプションを使用します。 -**カテゴリ: Framework & Language(16スキル)** +**カテゴリ: Framework & Language(20スキル)** | スキル | 説明 | |-------|-------------| @@ -96,6 +96,10 @@ Options: | `java-coding-standards` | Spring Boot 用 Java コーディング標準: 命名、不変性、Optional、ストリーム | | `python-patterns` | Pythonic なイディオム、PEP 8、型ヒント、ベストプラクティス | | `python-testing` | pytest、TDD、フィクスチャ、モック、パラメータ化による Python テスト | +| `quarkus-patterns` | Quarkus アーキテクチャ、Camel メッセージング、CDI サービス、Panache データアクセス | +| `quarkus-security` | Quarkus セキュリティ: JWT/OIDC、RBAC、入力バリデーション、シークレット管理 | +| `quarkus-tdd` | JUnit 5、Mockito、REST Assured、Camel テストによる Quarkus TDD | +| `quarkus-verification` | Quarkus 検証: ビルド、静的解析、テスト、ネイティブコンパイル | | `springboot-patterns` | Spring Boot アーキテクチャ、REST API、レイヤードサービス、キャッシング、非同期 | | `springboot-security` | Spring Security: 認証/認可、検証、CSRF、シークレット、レート制限 | | `springboot-tdd` | JUnit 5、Mockito、MockMvc、Testcontainers による Spring Boot TDD | diff --git a/docs/ja-JP/skills/quarkus-patterns/SKILL.md b/docs/ja-JP/skills/quarkus-patterns/SKILL.md new file mode 100644 index 00000000..117f02dc --- /dev/null +++ b/docs/ja-JP/skills/quarkus-patterns/SKILL.md @@ -0,0 +1,754 @@ +--- +name: quarkus-patterns +description: Quarkus 3.x LTS architecture patterns with Camel for messaging, RESTful API design, CDI services, data access with Panache, and async processing. Use for Java Quarkus backend work with event-driven architectures. +origin: ECC +--- + +# Quarkus Development Patterns + +Quarkus 3.x architecture and API patterns for cloud-native, event-driven services with Apache Camel. + +## When to Activate + +- Building REST APIs with JAX-RS or RESTEasy Reactive +- Structuring resource → service → repository layers +- Implementing event-driven patterns with Apache Camel and RabbitMQ +- Configuring Hibernate Panache, caching, or reactive streams +- Adding validation, exception mapping, or pagination +- Setting up profiles for dev/staging/production environments (YAML config) +- Custom logging with LogContext and Logback/Logstash encoder +- Working with CompletableFuture for async operations +- Implementing conditional flow processing +- Working with GraalVM native compilation + +## Service Layer with Multiple Dependencies (Lombok) + +```java +@Slf4j +@ApplicationScoped +@RequiredArgsConstructor +public class As2ProcessingService { + + private final InvoiceFlowValidator invoiceFlowValidator; + private final EventService eventService; + private final DocumentJobService documentJobService; + private final BusinessRulesPublisher businessRulesPublisher; + private final FileStorageService fileStorageService; + + public void processFile(Path filePath) throws Exception { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + + String structureIdPartner = logContext.get(As2Constants.STRUCTURE_ID); + + // Conditional flow logic + boolean isChorusFlow = Boolean.parseBoolean(logContext.get(As2Constants.CHORUS_FLOW)); + log.info("Is CHORUS_FLOW message: {}", isChorusFlow); + + ValidationFlowConfig validationFlowConfig = isChorusFlow + ? ValidationFlowConfig.xsdOnly() + : ValidationFlowConfig.allValidations(); + + InvoiceValidationResult invoiceValidationResult = this.invoiceFlowValidator + .validateFlowWithConfig(filePath, validationFlowConfig, + EInvoiceSyntaxFormat.UBL, logContext); + + FlowProfile flowProfile = isChorusFlow ? + FlowProfile.EXTENDED_CTC_FR : + this.invoiceFlowValidator.computeFlowProfile(invoiceValidationResult, + invoiceValidationResult.getInvoiceDetails().invoiceFormat().getProfile()); + + log.info("Invoice validation completed. Message is valid"); + + // CompletableFuture async operation + try(InputStream inputStream = Files.newInputStream(filePath)) { + CompletableFuture documentInfoCompletableFuture = + fileStorageService.uploadOriginalFile(inputStream, + invoiceValidationResult.getSize(), logContext, + invoiceValidationResult.getInvoiceFormat()); + + StoredDocumentInfo documentInfo = documentInfoCompletableFuture.join(); + log.info("File uploaded successfully: {}", documentInfo.getPath()); + + if (StringUtils.isBlank(documentInfo.getPath())) { + String errorMsg = "File path is empty after upload"; + log.error(errorMsg); + this.eventService.createErrorEvent(documentInfo, "FILE_UPLOAD_FAILED", errorMsg); + throw new As2ServerProcessingException(errorMsg); + } + + this.eventService.createSuccessEvent(documentInfo, "PERSISTENCE_BLOB_EVENT_TYPE"); + + BusinessRulesPayload payload = this.documentJobService.createDocumentAndJobEntities( + documentInfo, originalFileName, structureIdPartner, + flowProfile, invoiceValidationResult.getDocumentHash()); + + // Async Camel publishing + businessRulesPublisher.publishAsync(payload); + this.eventService.createSuccessEvent(payload, "BUSINESS_RULES_MESSAGE_SENT"); + } + } + } +} +``` + +**Key Patterns:** +- `@RequiredArgsConstructor` for constructor injection via Lombok +- `@Slf4j` for Logback logging +- Scoped LogContext with try-with-resources +- Conditional flow logic based on runtime parameters +- CompletableFuture with `.join()` for async operations +- Event tracking for success/error scenarios +- Async Camel message publishing + +## Custom Logging Context Pattern (Logback) + +```java +@ApplicationScoped +public class ProcessingService { + + public void processDocument(Document doc) { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + // Add context to all log statements + logContext.put("documentId", doc.getId().toString()); + logContext.put("documentType", doc.getType()); + logContext.put("userId", SecurityContext.getUserId()); + + log.info("Starting document processing"); + + // All logs within this scope inherit the context + processInternal(doc); + + log.info("Document processing completed"); + } catch (Exception e) { + log.error("Document processing failed", e); + throw e; + } + } +} +``` + +**Logback Configuration (logback.xml):** + +```xml + + + + true + true + + + + + + + + +``` + +## Event Service Pattern + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class EventService { + private final EventRepository eventRepository; + + public void createSuccessEvent(Object payload, String eventType) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.SUCCESS); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.info("Success event created: {}", eventType); + } + + public void createErrorEvent(Object payload, String eventType, String errorMessage) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.ERROR); + event.setErrorMessage(errorMessage); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.error("Error event created: {} - {}", eventType, errorMessage); + } + + private String serializePayload(Object payload) { + // JSON serialization + return objectMapper.writeValueAsString(payload); + } +} +``` + +## Camel Message Publishing (RabbitMQ) + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class BusinessRulesPublisher { + private final ProducerTemplate producerTemplate; + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + public void publishAsync(BusinessRulesPayload payload) { + producerTemplate.asyncSendBody( + "direct:business-rules-publisher", + payload + ); + log.info("Message published to business rules queue: {}", payload.getDocumentId()); + } + + public void publishSync(BusinessRulesPayload payload) { + producerTemplate.sendBody( + "direct:business-rules-publisher", + payload + ); + } +} +``` + +**Camel Route Configuration:** + +```java +@ApplicationScoped +public class BusinessRulesRoute extends RouteBuilder { + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + @ConfigProperty(name = "rabbitmq.host") + String rabbitHost; + + @ConfigProperty(name = "rabbitmq.port") + Integer rabbitPort; + + @Override + public void configure() { + from("direct:business-rules-publisher") + .routeId("business-rules-publisher") + .log("Publishing message to RabbitMQ: ${body}") + .marshal().json(JsonLibrary.Jackson) + .toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d", + businessRulesQueue, rabbitHost, rabbitPort); + } +} +``` + +## Camel Direct Routes (In-Memory) + +```java +@ApplicationScoped +public class DocumentProcessingRoute extends RouteBuilder { + + @Override + public void configure() { + // Error handling + onException(ValidationException.class) + .handled(true) + .to("direct:validation-error-handler") + .log("Validation error: ${exception.message}"); + + // Main processing route + from("direct:process-document") + .routeId("document-processing") + .log("Processing document: ${header.documentId}") + .bean(DocumentValidator.class, "validate") + .bean(DocumentTransformer.class, "transform") + .choice() + .when(header("documentType").isEqualTo("INVOICE")) + .to("direct:process-invoice") + .when(header("documentType").isEqualTo("CREDIT_NOTE")) + .to("direct:process-credit-note") + .otherwise() + .to("direct:process-generic") + .end(); + + from("direct:validation-error-handler") + .bean(EventService.class, "createErrorEvent") + .log("Validation error handled"); + } +} +``` + +## Camel File Processing + +```java +@ApplicationScoped +public class FileMonitoringRoute extends RouteBuilder { + + @ConfigProperty(name = "file.input.directory") + String inputDirectory; + + @ConfigProperty(name = "file.processed.directory") + String processedDirectory; + + @ConfigProperty(name = "file.error.directory") + String errorDirectory; + + @Override + public void configure() { + from("file:" + inputDirectory + "?move=" + processedDirectory + + "&moveFailed=" + errorDirectory + "&delay=5000") + .routeId("file-monitor") + .log("Processing file: ${header.CamelFileName}") + .to("direct:process-file"); + + from("direct:process-file") + .bean(As2ProcessingService.class, "processFile") + .log("File processing completed"); + } +} +``` + +## Camel Bean Invocation + +```java +@ApplicationScoped +public class InvoiceRoute extends RouteBuilder { + + @Override + public void configure() { + from("direct:invoice-validation") + .bean(InvoiceFlowValidator.class, "validateFlowWithConfig") + .log("Validation result: ${body}"); + + from("direct:persist-and-publish") + .bean(DocumentJobService.class, "createDocumentAndJobEntities") + .bean(BusinessRulesPublisher.class, "publishAsync") + .bean(EventService.class, "createSuccessEvent(${body}, 'PUBLISHED')"); + } +} +``` + +## REST API Structure + +```java +@Path("/api/documents") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class DocumentResource { + private final DocumentService documentService; + + @GET + public Response list( + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("20") int size) { + PaginatedList documents = documentService.list(page, size); + return Response.ok(documents).build(); + } + + @POST + public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) { + Document document = documentService.create(request); + URI location = uriInfo.getAbsolutePathBuilder() + .path(String.valueOf(document.id)) + .build(); + return Response.created(location).entity(DocumentResponse.from(document)).build(); + } + + @GET + @Path("/{id}") + public Response getById(@PathParam("id") Long id) { + return documentService.findById(id) + .map(DocumentResponse::from) + .map(Response::ok) + .orElse(Response.status(Response.Status.NOT_FOUND)) + .build(); + } +} +``` + +## Repository Pattern (Panache Repository) + +```java +@ApplicationScoped +public class DocumentRepository implements PanacheRepository { + + public List findByStatus(DocumentStatus status, int page, int size) { + return find("status = ?1 order by createdAt desc", status) + .page(page, size) + .list(); + } + + public Optional findByReferenceNumber(String referenceNumber) { + return find("referenceNumber", referenceNumber).firstResultOptional(); + } + + public long countByStatusAndDate(DocumentStatus status, LocalDate date) { + return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay()); + } +} +``` + +## Service Layer with Transactions + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentService { + private final DocumentRepository repo; + private final EventService eventService; + + @Transactional + public Document create(CreateDocumentRequest request) { + Document document = new Document(); + document.setReferenceNumber(request.referenceNumber()); + document.setDescription(request.description()); + document.setStatus(DocumentStatus.PENDING); + document.setCreatedAt(Instant.now()); + + repo.persist(document); + + eventService.createSuccessEvent(document, "DOCUMENT_CREATED"); + + return document; + } + + public Optional findById(Long id) { + return repo.findByIdOptional(id); + } + + public PaginatedList list(int page, int size) { + return repo.findAll() + .page(page, size) + .list(); + } +} +``` + +## DTOs and Validation + +```java +public record CreateDocumentRequest( + @NotBlank @Size(max = 200) String referenceNumber, + @NotBlank @Size(max = 2000) String description, + @NotNull @FutureOrPresent Instant validUntil, + @NotEmpty List<@NotBlank String> categories) {} + +public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) { + public static DocumentResponse from(Document document) { + return new DocumentResponse(document.getId(), document.getReferenceNumber(), + document.getStatus()); + } +} +``` + +## Exception Mapping + +```java +@Provider +public class ValidationExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(ConstraintViolationException exception) { + String message = exception.getConstraintViolations().stream() + .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage()) + .collect(Collectors.joining(", ")); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "validation_error", "message", message)) + .build(); + } +} + +@Provider +@Slf4j +public class GenericExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(Exception exception) { + log.error("Unhandled exception", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "internal_error", "message", "An unexpected error occurred")) + .build(); + } +} +``` + +## CompletableFuture Async Operations + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class FileStorageService { + private final S3Client s3Client; + private final ExecutorService executorService; + + public CompletableFuture uploadOriginalFile( + InputStream inputStream, + long size, + LogContext logContext, + InvoiceFormat format) { + + return CompletableFuture.supplyAsync(() -> { + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + String path = generateStoragePath(format); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(path) + .contentLength(size) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, size)); + + log.info("File uploaded to S3: {}", path); + + return new StoredDocumentInfo(path, size, Instant.now()); + } catch (Exception e) { + log.error("Failed to upload file to S3", e); + throw new StorageException("Upload failed", e); + } + }, executorService); + } +} +``` + +## Caching + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentCacheService { + private final DocumentRepository repo; + + @CacheResult(cacheName = "document-cache") + public Optional getById(@CacheKey Long id) { + return repo.findByIdOptional(id); + } + + @CacheInvalidate(cacheName = "document-cache") + public void evict(@CacheKey Long id) {} + + @CacheInvalidateAll(cacheName = "document-cache") + public void evictAll() {} +} +``` + +## Configuration as YAML + +```yaml +# application.yml +"%dev": + quarkus: + datasource: + jdbc: + url: jdbc:postgresql://localhost:5432/dev_db + username: dev_user + password: dev_pass + hibernate-orm: + database: + generation: drop-and-create + + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + +"%test": + quarkus: + datasource: + jdbc: + url: jdbc:h2:mem:test + hibernate-orm: + database: + generation: drop-and-create + +"%prod": + quarkus: + datasource: + jdbc: + url: ${DATABASE_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + hibernate-orm: + database: + generation: validate + + rabbitmq: + host: ${RABBITMQ_HOST} + port: ${RABBITMQ_PORT} + username: ${RABBITMQ_USER} + password: ${RABBITMQ_PASSWORD} + +# Camel configuration +camel: + rabbitmq: + queue: + business-rules: business-rules-queue + invoice-processing: invoice-processing-queue +``` + +## Health Checks + +```java +@Readiness +@ApplicationScoped +@RequiredArgsConstructor +public class DatabaseHealthCheck implements HealthCheck { + private final AgroalDataSource dataSource; + + @Override + public HealthCheckResponse call() { + try (Connection conn = dataSource.getConnection()) { + boolean valid = conn.isValid(2); + return HealthCheckResponse.named("Database connection") + .status(valid) + .build(); + } catch (SQLException e) { + return HealthCheckResponse.down("Database connection"); + } + } +} + +@Liveness +@ApplicationScoped +public class CamelHealthCheck implements HealthCheck { + @Inject + CamelContext camelContext; + + @Override + public HealthCheckResponse call() { + boolean isStarted = camelContext.getStatus().isStarted(); + return HealthCheckResponse.named("Camel Context") + .status(isStarted) + .build(); + } +} +``` + +## Dependencies (Maven) + +```xml + + 3.27.0 + 1.18.42 + 3.24.2 + 0.8.13 + 17 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + org.apache.camel.quarkus + camel-quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-config-yaml + + + + + org.apache.camel.quarkus + camel-quarkus-spring-rabbitmq + + + org.apache.camel.quarkus + camel-quarkus-direct + + + org.apache.camel.quarkus + camel-quarkus-bean + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + io.quarkiverse.logging.logback + quarkus-logging-logback + + + net.logstash.logback + logstash-logback-encoder + + +``` + +## Best Practices + +### Architecture +- Use `@RequiredArgsConstructor` with Lombok for constructor injection +- Keep service layer thin; delegate complex logic to specialized classes +- Use Camel routes for message routing and integration patterns +- Prefer Panache Repository pattern for data access + +### Event-Driven +- Always track operations with EventService (success/error events) +- Use Camel `direct:` endpoints for in-memory routing +- Use `spring-rabbitmq` component for RabbitMQ integration +- Implement async publishing with `ProducerTemplate.asyncSendBody()` + +### Logging +- Use Logback with Logstash encoder for structured logging +- Propagate LogContext through service calls with `SafeAutoCloseable` +- Add contextual information to LogContext for request tracing +- Use `@Slf4j` instead of manual logger instantiation + +### Async Operations +- Use CompletableFuture for non-blocking I/O operations +- Call `.join()` when you need to wait for completion +- Handle exceptions from CompletableFuture properly +- Pass LogContext to async operations for tracing + +### Configuration +- Use YAML configuration (`quarkus-config-yaml`) +- Profile-aware configuration for dev/test/prod environments +- Externalize sensitive configuration to environment variables +- Use `@ConfigProperty` for type-safe config injection + +### Validation +- Validate at resource layer with `@Valid` +- Use Bean Validation annotations on DTOs +- Map exceptions to proper HTTP responses with `@Provider` + +### Transactions +- Use `@Transactional` on service methods that modify data +- Keep transactions short and focused +- Avoid calling async operations within transactions + +### Testing +- Use `camel-quarkus-junit5` for route testing +- Use AssertJ for assertions +- Mock all external dependencies +- Test conditional flow logic thoroughly + +### Quarkus-Specific +- Stay on latest LTS version (3.x) +- Use Quarkus dev mode for hot reload +- Add health checks for production readiness +- Test native compilation compatibility periodically diff --git a/docs/ja-JP/skills/quarkus-security/SKILL.md b/docs/ja-JP/skills/quarkus-security/SKILL.md new file mode 100644 index 00000000..b3fa9705 --- /dev/null +++ b/docs/ja-JP/skills/quarkus-security/SKILL.md @@ -0,0 +1,453 @@ +--- +name: quarkus-security +description: Quarkus Security best practices for authentication, authorization, JWT/OIDC, RBAC, input validation, CSRF, secrets management, and dependency security. +origin: ECC +--- + +# Quarkus Security Review + +Best practices for securing Quarkus applications with authentication, authorization, and input validation. + +## When to Activate + +- Adding authentication (JWT, OIDC, Basic Auth) +- Implementing authorization with @RolesAllowed or SecurityIdentity +- Validating user input (Bean Validation, custom validators) +- Configuring CORS or security headers +- Managing secrets (Vault, environment variables, config sources) +- Adding rate limiting or brute-force protection +- Scanning dependencies for CVEs +- Working with MicroProfile JWT or SmallRye JWT + +## Authentication + +### JWT Authentication + +```java +// Resource protected with JWT +@Path("/api/protected") +@Authenticated +public class ProtectedResource { + + @Inject + JsonWebToken jwt; + + @Inject + SecurityIdentity securityIdentity; + + @GET + public Response getData() { + String username = jwt.getName(); + Set roles = jwt.getGroups(); + return Response.ok(Map.of( + "username", username, + "roles", roles, + "principal", securityIdentity.getPrincipal().getName() + )).build(); + } +} +``` + +Configuration (application.properties): +```properties +mp.jwt.verify.publickey.location=publicKey.pem +mp.jwt.verify.issuer=https://auth.example.com + +# OIDC +quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm +quarkus.oidc.client-id=backend-service +quarkus.oidc.credentials.secret=${OIDC_SECRET} +``` + +### Custom Authentication Filter + +```java +@Provider +@Priority(Priorities.AUTHENTICATION) +public class CustomAuthFilter implements ContainerRequestFilter { + + @Inject + SecurityIdentity identity; + + @Override + public void filter(ContainerRequestContext requestContext) { + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + // Validate token and set SecurityIdentity + if (!validateToken(token)) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + } + + private boolean validateToken(String token) { + // Token validation logic + return true; + } +} +``` + +## Authorization + +### Role-Based Access Control + +```java +@Path("/api/admin") +@RolesAllowed("ADMIN") +public class AdminResource { + + @GET + @Path("/users") + public List listUsers() { + return userService.findAll(); + } + + @DELETE + @Path("/users/{id}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) + public Response deleteUser(@PathParam("id") Long id) { + userService.delete(id); + return Response.noContent().build(); + } +} + +@Path("/api/users") +public class UserResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("/{id}") + @RolesAllowed("USER") + public Response getUser(@PathParam("id") Long id) { + // Check ownership + if (!securityIdentity.hasRole("ADMIN") && + !isOwner(id, securityIdentity.getPrincipal().getName())) { + return Response.status(Response.Status.FORBIDDEN).build(); + } + return Response.ok(userService.findById(id)).build(); + } + + private boolean isOwner(Long userId, String username) { + return userService.isOwner(userId, username); + } +} +``` + +### Programmatic Security + +```java +@ApplicationScoped +public class SecurityService { + + @Inject + SecurityIdentity securityIdentity; + + public boolean canAccessResource(Long resourceId) { + if (securityIdentity.isAnonymous()) { + return false; + } + + if (securityIdentity.hasRole("ADMIN")) { + return true; + } + + String userId = securityIdentity.getPrincipal().getName(); + return resourceRepository.isOwner(resourceId, userId); + } +} +``` + +## Input Validation + +### Bean Validation + +```java +// BAD: No validation +@POST +public Response createUser(UserDto dto) { + return Response.ok(userService.create(dto)).build(); +} + +// GOOD: Validated DTO +public record CreateUserDto( + @NotBlank @Size(max = 100) String name, + @NotBlank @Email String email, + @NotNull @Min(18) @Max(150) Integer age, + @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone +) {} + +@POST +@Path("/users") +public Response createUser(@Valid CreateUserDto dto) { + User user = userService.create(dto); + return Response.status(Response.Status.CREATED).entity(user).build(); +} +``` + +### Custom Validators + +```java +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +public @interface ValidUsername { + String message() default "Invalid username format"; + Class[] groups() default {}; + Class[] payload() default {}; +} + +public class UsernameValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return value.matches("^[a-zA-Z0-9_-]{3,20}$"); + } +} + +// Usage +public record CreateUserDto( + @ValidUsername String username, + @NotBlank @Email String email +) {} +``` + +## SQL Injection Prevention + +### Panache Active Record (Safe by Default) + +```java +// GOOD: Parameterized queries with Panache +List users = User.list("email = ?1 and active = ?2", email, true); + +Optional user = User.find("username", username).firstResultOptional(); + +// GOOD: Named parameters +List users = User.list("email = :email and age > :minAge", + Parameters.with("email", email).and("minAge", 18)); +``` + +### Native Queries (Use Parameters) + +```java +// BAD: String concatenation +@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true) + +// GOOD: Parameterized native query +@Entity +public class User extends PanacheEntity { + public static List findByEmailNative(String email) { + return getEntityManager() + .createNativeQuery("SELECT * FROM users WHERE email = :email", User.class) + .setParameter("email", email) + .getResultList(); + } +} +``` + +## Password Hashing + +```java +@ApplicationScoped +public class PasswordService { + + public String hash(String plainPassword) { + return BcryptUtil.bcryptHash(plainPassword); + } + + public boolean verify(String plainPassword, String hashedPassword) { + return BcryptUtil.matches(plainPassword, hashedPassword); + } +} + +// In service +@ApplicationScoped +public class UserService { + @Inject + PasswordService passwordService; + + @Transactional + public User register(CreateUserDto dto) { + String hashedPassword = passwordService.hash(dto.password()); + User user = new User(); + user.email = dto.email(); + user.password = hashedPassword; + user.persist(); + return user; + } + + public boolean authenticate(String email, String password) { + return User.find("email", email) + .firstResultOptional() + .map(u -> passwordService.verify(password, u.password)) + .orElse(false); + } +} +``` + +## CORS Configuration + +```properties +# application.properties +quarkus.http.cors=true +quarkus.http.cors.origins=https://app.example.com,https://admin.example.com +quarkus.http.cors.methods=GET,POST,PUT,DELETE +quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with +quarkus.http.cors.exposed-headers=Content-Disposition +quarkus.http.cors.access-control-max-age=24H +quarkus.http.cors.access-control-allow-credentials=true +``` + +## Secrets Management + +```properties +# application.properties - NO SECRETS HERE + +# Use environment variables +quarkus.datasource.username=${DB_USER} +quarkus.datasource.password=${DB_PASSWORD} +quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET} + +# Or use Vault +quarkus.vault.url=https://vault.example.com +quarkus.vault.authentication.kubernetes.role=my-role +``` + +### HashiCorp Vault Integration + +```java +@ApplicationScoped +public class SecretService { + + @ConfigProperty(name = "api-key") + String apiKey; // Fetched from Vault + + public String getSecret(String key) { + return ConfigProvider.getConfig().getValue(key, String.class); + } +} +``` + +## Rate Limiting + +```java +@ApplicationScoped +public class RateLimitFilter implements ContainerRequestFilter { + private final Map limiters = new ConcurrentHashMap<>(); + + @Override + public void filter(ContainerRequestContext requestContext) { + String clientId = getClientIdentifier(requestContext); + RateLimiter limiter = limiters.computeIfAbsent(clientId, + k -> RateLimiter.create(100.0)); // 100 requests per second + + if (!limiter.tryAcquire()) { + requestContext.abortWith( + Response.status(429) + .entity(Map.of("error", "Too many requests")) + .build() + ); + } + } + + private String getClientIdentifier(ContainerRequestContext ctx) { + // Use IP, API key, or user ID + return ctx.getHeaderString("X-Forwarded-For"); + } +} +``` + +## Security Headers + +```java +@Provider +public class SecurityHeadersFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext request, ContainerResponseContext response) { + MultivaluedMap headers = response.getHeaders(); + + // Prevent clickjacking + headers.putSingle("X-Frame-Options", "DENY"); + + // XSS protection + headers.putSingle("X-Content-Type-Options", "nosniff"); + headers.putSingle("X-XSS-Protection", "1; mode=block"); + + // HSTS + headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + + // CSP + headers.putSingle("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"); + } +} +``` + +## Audit Logging + +```java +@ApplicationScoped +public class AuditService { + private static final Logger LOG = Logger.getLogger(AuditService.class); + + @Inject + SecurityIdentity securityIdentity; + + public void logAccess(String resource, String action) { + String user = securityIdentity.isAnonymous() + ? "anonymous" + : securityIdentity.getPrincipal().getName(); + + LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s", + user, action, resource, Instant.now()); + } +} + +// Usage in resource +@Path("/api/sensitive") +public class SensitiveResource { + @Inject + AuditService auditService; + + @GET + @RolesAllowed("ADMIN") + public Response getData() { + auditService.logAccess("sensitive-data", "READ"); + return Response.ok(data).build(); + } +} +``` + +## Dependency Security Scanning + +```bash +# Maven +mvn org.owasp:dependency-check-maven:check + +# Gradle +./gradlew dependencyCheckAnalyze + +# Check Quarkus extensions +quarkus extension list --installable +``` + +## Best Practices + +- Always use HTTPS in production +- Enable JWT or OIDC for stateless authentication +- Use `@RolesAllowed` for declarative authorization +- Validate all input with Bean Validation +- Hash passwords with BCrypt (never plaintext) +- Store secrets in Vault or environment variables +- Use parameterized queries to prevent SQL injection +- Add security headers to all responses +- Implement rate limiting for public endpoints +- Audit sensitive operations +- Keep dependencies updated and scan for CVEs +- Use SecurityIdentity for programmatic checks +- Set appropriate CORS policies +- Test authentication and authorization paths diff --git a/docs/ja-JP/skills/quarkus-tdd/SKILL.md b/docs/ja-JP/skills/quarkus-tdd/SKILL.md new file mode 100644 index 00000000..c049ceda --- /dev/null +++ b/docs/ja-JP/skills/quarkus-tdd/SKILL.md @@ -0,0 +1,908 @@ +--- +name: quarkus-tdd +description: Test-driven development for Quarkus 3.x LTS using JUnit 5, Mockito, REST Assured, Camel testing, and JaCoCo. Use when adding features, fixing bugs, or refactoring event-driven services. +origin: ECC +--- + +# Quarkus TDD Workflow + +TDD guidance for Quarkus 3.x services with 80%+ coverage (unit + integration). Optimized for event-driven architectures with Apache Camel. + +## When to Use + +- New features or REST endpoints +- Bug fixes or refactors +- Adding data access logic, security rules, or reactive streams +- Testing Apache Camel routes and event handlers +- Testing event-driven services with RabbitMQ +- Testing conditional flow logic +- Validating CompletableFuture async operations +- Testing LogContext propagation + +## Workflow + +1. Write tests first (they should fail) +2. Implement minimal code to pass +3. Refactor with tests green +4. Enforce coverage with JaCoCo (80%+ target) + +## Unit Tests with @Nested Organization + +Follow this structured approach for comprehensive, readable tests: + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("As2ProcessingService Unit Tests") +class As2ProcessingServiceTest { + + @Mock + private InvoiceFlowValidator invoiceFlowValidator; + + @Mock + private EventService eventService; + + @Mock + private DocumentJobService documentJobService; + + @Mock + private BusinessRulesPublisher businessRulesPublisher; + + @Mock + private FileStorageService fileStorageService; + + @InjectMocks + private As2ProcessingService as2ProcessingService; + + private Path testFilePath; + private LogContext testLogContext; + private InvoiceValidationResult validationResult; + private StoredDocumentInfo documentInfo; + + @BeforeEach + void setUp() { + // ARRANGE - Common test data + testFilePath = Path.of("/tmp/test-invoice.xml"); + + testLogContext = new LogContext(); + testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001"); + testLogContext.put(As2Constants.FILE_NAME, "invoice.xml"); + testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001"); + + validationResult = new InvoiceValidationResult(); + validationResult.setValid(true); + validationResult.setSize(1024L); + validationResult.setDocumentHash("abc123"); + + documentInfo = new StoredDocumentInfo(); + documentInfo.setPath("s3://bucket/path/invoice.xml"); + documentInfo.setSize(1024L); + } + + @Nested + @DisplayName("Tests for processFile") + class ProcessFile { + + @Test + @DisplayName("Should successfully process non-CHORUS file with all validations") + void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class), + eq("PERSISTENCE_BLOB_EVENT_TYPE")); + verify(eventService).createSuccessEvent(any(BusinessRulesPayload.class), + eq("BUSINESS_RULES_MESSAGE_SENT")); + verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class)); + } + + @Test + @DisplayName("Should bypass schematron validation for CHORUS_FLOW") + void givenChorusFlow_whenProcessFile_thenSchematronBypassed() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "true"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(documentJobService).createDocumentAndJobEntities( + any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), + any()); + } + + @Test + @DisplayName("Should create error event when file upload fails") + void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + documentInfo.setPath(""); // Blank path triggers error + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + // ACT & ASSERT + As2ServerProcessingException exception = assertThrows( + As2ServerProcessingException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + + assertThat(exception.getMessage()) + .contains("File path is empty after upload"); + + verify(eventService).createErrorEvent( + eq(documentInfo), + eq("FILE_UPLOAD_FAILED"), + contains("File path is empty")); + + verify(businessRulesPublisher, never()).publishAsync(any()); + } + + @Test + @DisplayName("Should handle CompletableFuture.join() failure") + void givenAsyncUploadFailure_whenProcessFile_thenExceptionThrown() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + CompletableFuture failedFuture = + CompletableFuture.failedFuture(new StorageException("S3 connection failed")); + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(failedFuture); + + // ACT & ASSERT + assertThrows( + CompletionException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + } + + @Test + @DisplayName("Should throw exception when file path is null") + void givenNullFilePath_whenProcessFile_thenThrowsException() { + // ARRANGE + Path nullPath = null; + + // ACT & ASSERT + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> as2ProcessingService.processFile(nullPath) + ); + + verify(invoiceFlowValidator, never()).validateFlowWithConfig(any(), any(), any(), any()); + } + } +} +``` + +### Key Testing Patterns + +1. **@Nested Classes**: Group tests by method being tested +2. **@DisplayName**: Provide readable test descriptions for test reports +3. **Naming Convention**: `givenX_whenY_thenZ` for clarity +4. **AAA Pattern**: Explicit `// ARRANGE`, `// ACT`, `// ASSERT` comments +5. **@BeforeEach**: Setup common test data to reduce duplication +6. **assertDoesNotThrow**: Test success scenarios without catching exceptions +7. **assertThrows**: Test exception scenarios with message validation using AssertJ +8. **Comprehensive Coverage**: Test happy paths, null inputs, edge cases, exceptions +9. **Verify Interactions**: Use Mockito `verify()` to ensure methods are called correctly +10. **Never Verify**: Use `never()` to ensure methods are NOT called in error scenarios + +## Testing Camel Routes + +```java +@QuarkusTest +@DisplayName("Business Rules Camel Route Tests") +class BusinessRulesRouteTest { + + @Inject + CamelContext camelContext; + + @Inject + ProducerTemplate producerTemplate; + + @InjectMock + EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE - Test data + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + testPayload.setFlowProfile(FlowProfile.BASIC); + } + + @Nested + @DisplayName("Tests for business-rules-publisher route") + class BusinessRulesPublisher { + + @Test + @DisplayName("Should successfully publish message to RabbitMQ") + void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception { + // ARRANGE + MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class); + mockRabbitMQ.expectedMessageCount(1); + mockRabbitMQ.expectedBodiesReceived(testPayload); + + // Replace real endpoint with mock for testing + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.replaceFromWith("direct:business-rules-publisher"); + advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockRabbitMQ.assertIsSatisfied(5000); + + assertThat(mockRabbitMQ.getExchanges()).hasSize(1); + assertThat(mockRabbitMQ.getExchanges().get(0).getIn().getBody(BusinessRulesPayload.class)) + .isEqualTo(testPayload); + } + + @Test + @DisplayName("Should handle marshalling to JSON") + void givenPayload_whenPublish_thenMarshalledToJson() throws Exception { + // ARRANGE + MockEndpoint mockMarshal = new MockEndpoint("mock:marshal"); + camelContext.addEndpoint("mock:marshal", mockMarshal); + mockMarshal.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.weaveAddLast().to("mock:marshal"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockMarshal.assertIsSatisfied(5000); + + String body = mockMarshal.getExchanges().get(0).getIn().getBody(String.class); + assertThat(body).contains("\"documentId\":1"); + assertThat(body).contains("\"flowProfile\":\"BASIC\""); + } + } + + @Nested + @DisplayName("Tests for document-processing route") + class DocumentProcessing { + + @Test + @DisplayName("Should route invoice to correct processor") + void givenInvoiceType_whenProcess_thenRoutesToInvoiceProcessor() throws Exception { + // ARRANGE + MockEndpoint mockInvoice = camelContext.getEndpoint("mock:invoice", MockEndpoint.class); + mockInvoice.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:process-invoice.*").replace().to("mock:invoice"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // ACT + producerTemplate.sendBodyAndHeader("direct:process-document", + testPayload, "documentType", "INVOICE"); + + // ASSERT + mockInvoice.assertIsSatisfied(5000); + } + + @Test + @DisplayName("Should handle validation errors gracefully") + void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception { + // ARRANGE + MockEndpoint mockError = camelContext.getEndpoint("mock:error", MockEndpoint.class); + mockError.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:validation-error-handler.*") + .replace().to("mock:error"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // Mock validator to throw exception + when(eventService.validate(any())).thenThrow(new ValidationException("Invalid document")); + + // ACT + producerTemplate.sendBody("direct:process-document", testPayload); + + // ASSERT + mockError.assertIsSatisfied(5000); + + Exception exception = mockError.getExchanges().get(0).getException(); + assertThat(exception).isInstanceOf(ValidationException.class); + assertThat(exception.getMessage()).contains("Invalid document"); + } + } +} +``` + +## Testing Event Services + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("EventService Unit Tests") +class EventServiceTest { + + @Mock + private EventRepository eventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + } + + @Nested + @DisplayName("Tests for createSuccessEvent") + class CreateSuccessEvent { + + @Test + @DisplayName("Should create success event with correct attributes") + void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception { + // ARRANGE + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED")); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("DOCUMENT_PROCESSED") && + event.getStatus() == EventStatus.SUCCESS && + event.getPayload().equals("{\"documentId\":1}") && + event.getTimestamp() != null + )); + } + + @Test + @DisplayName("Should throw exception when payload is null") + void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() { + // ARRANGE + Object nullPayload = null; + + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE") + ); + + assertThat(exception.getMessage()).isEqualTo("Payload cannot be null"); + verify(eventRepository, never()).persist(any()); + } + } + + @Nested + @DisplayName("Tests for createErrorEvent") + class CreateErrorEvent { + + @Test + @DisplayName("Should create error event with error message") + void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception { + // ARRANGE + String errorMessage = "Processing failed"; + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createErrorEvent(testPayload, "PROCESSING_ERROR", errorMessage)); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("PROCESSING_ERROR") && + event.getStatus() == EventStatus.ERROR && + event.getErrorMessage().equals(errorMessage) && + event.getPayload().equals("{\"documentId\":1}") + )); + } + + @ParameterizedTest + @DisplayName("Should reject invalid error messages") + @ValueSource(strings = {"", " "}) + void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) { + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage) + ); + + assertThat(exception.getMessage()).contains("Error message cannot be blank"); + } + } +} +``` + +## Testing CompletableFuture + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("FileStorageService Unit Tests") +class FileStorageServiceTest { + + @Mock + private S3Client s3Client; + + @Mock + private ExecutorService executorService; + + @InjectMocks + private FileStorageService fileStorageService; + + private InputStream testInputStream; + private LogContext testLogContext; + + @BeforeEach + void setUp() { + // ARRANGE + testInputStream = new ByteArrayInputStream("test content".getBytes()); + testLogContext = new LogContext(); + testLogContext.put("traceId", "trace-123"); + } + + @Nested + @DisplayName("Tests for uploadOriginalFile") + class UploadOriginalFile { + + @Test + @DisplayName("Should successfully upload file and return document info") + void givenValidFile_whenUpload_thenReturnsDocumentInfo() throws Exception { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return CompletableFuture.completedFuture(callable.call()); + }); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + StoredDocumentInfo result = future.join(); + + // ASSERT + assertThat(result).isNotNull(); + assertThat(result.getPath()).isNotBlank(); + assertThat(result.getSize()).isEqualTo(1024L); + assertThat(result.getUploadedAt()).isNotNull(); + + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("Should handle S3 upload failure") + void givenS3Failure_whenUpload_thenCompletableFutureFails() { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + return CompletableFuture.failedFuture(new StorageException("S3 unavailable")); + }); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + // ASSERT + assertThatThrownBy(() -> future.join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(StorageException.class) + .hasMessageContaining("S3 unavailable"); + } + + @Test + @DisplayName("Should propagate LogContext to async operation") + void givenLogContext_whenUpload_thenContextPropagated() throws Exception { + // ARRANGE + AtomicReference capturedContext = new AtomicReference<>(); + + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + capturedContext.set(CustomLog.getCurrentContext()); + return CompletableFuture.completedFuture(callable.call()); + }); + + // ACT + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL).join(); + + // ASSERT + assertThat(capturedContext.get()).isNotNull(); + assertThat(capturedContext.get().get("traceId")).isEqualTo("trace-123"); + } + } +} +``` + +## Resource Layer Tests (REST Assured) + +```java +@QuarkusTest +@DisplayName("DocumentResource API Tests") +class DocumentResourceTest { + + @InjectMock + DocumentService documentService; + + @Nested + @DisplayName("Tests for GET /api/documents") + class ListDocuments { + + @Test + @DisplayName("Should return list of documents") + void givenDocumentsExist_whenList_thenReturnsOk() { + // ARRANGE + List documents = List.of(createDocument(1L, "DOC-001")); + when(documentService.list(0, 20)).thenReturn(documents); + + // ACT & ASSERT + given() + .when().get("/api/documents") + .then() + .statusCode(200) + .body("$.size()", is(1)) + .body("[0].referenceNumber", equalTo("DOC-001")); + } + } + + @Nested + @DisplayName("Tests for POST /api/documents") + class CreateDocument { + + @Test + @DisplayName("Should create document and return 201") + void givenValidRequest_whenCreate_thenReturns201() { + // ARRANGE + Document document = createDocument(1L, "DOC-001"); + when(documentService.create(any())).thenReturn(document); + + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "DOC-001", + "description": "Test document", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .header("Location", containsString("/api/documents/1")) + .body("referenceNumber", equalTo("DOC-001")); + } + + @Test + @DisplayName("Should return 400 for invalid input") + void givenInvalidRequest_whenCreate_thenReturns400() { + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "", + "description": "Test" + } + """) + .when().post("/api/documents") + .then() + .statusCode(400); + } + } + + private Document createDocument(Long id, String referenceNumber) { + Document document = new Document(); + document.setId(id); + document.setReferenceNumber(referenceNumber); + document.setStatus(DocumentStatus.PENDING); + return document; + } +} +``` + +## Integration Tests with Real Database + +```java +@QuarkusTest +@TestProfile(IntegrationTestProfile.class) +@DisplayName("Document Integration Tests") +class DocumentIntegrationTest { + + @Test + @Transactional + @DisplayName("Should create and retrieve document via API") + void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() { + // ACT - Create via API + Long id = given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "INT-001", + "description": "Integration test", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .extract().path("id"); + + // ASSERT - Retrieve via API + given() + .when().get("/api/documents/" + id) + .then() + .statusCode(200) + .body("referenceNumber", equalTo("INT-001")); + } +} +``` + +## Coverage with JaCoCo + +### Maven Configuration (Complete) + +```xml + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + + prepare-agent + + prepare-agent + + + + + + report + verify + + report + + + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.70 + + + + + + + + +``` + +Run tests with coverage: +```bash +mvn clean test +mvn jacoco:report +mvn jacoco:check + +# Report at: target/site/jacoco/index.html +``` + +## Test Dependencies + +```xml + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + + + org.mockito + mockito-core + test + + + + + org.assertj + assertj-core + 3.24.2 + test + + + + + io.rest-assured + rest-assured + test + + + + + org.apache.camel.quarkus + camel-quarkus-junit5 + test + + +``` + +## Best Practices + +### Test Organization +- Use `@Nested` classes to group tests by method being tested +- Use `@DisplayName` for readable test descriptions visible in reports +- Follow `givenX_whenY_thenZ` naming convention for test methods +- Use `@BeforeEach` for common test data setup to reduce duplication + +### Test Structure +- Follow AAA pattern with explicit comments (`// ARRANGE`, `// ACT`, `// ASSERT`) +- Use `assertDoesNotThrow` for success scenarios +- Use `assertThrows` for exception scenarios with message validation +- Verify exception messages match expected values using AssertJ `contains()` or `isEqualTo()` + +### Test Coverage +- Test happy paths for all public methods +- Test null input handling +- Test edge cases (empty collections, boundary values, negative IDs, blank strings) +- Test exception scenarios comprehensively +- Mock all external dependencies (repositories, services, Camel endpoints) +- Aim for 80%+ line coverage, 70%+ branch coverage + +### Assertions +- **Always use AssertJ** (`assertThat`) instead of JUnit assertions +- Use fluent AssertJ API for readability: `assertThat(list).hasSize(3).contains(item)` +- For exceptions: `assertThatThrownBy(() -> ...).isInstanceOf(...).hasMessageContaining(...)` +- For collections: `extracting()`, `filteredOn()`, `containsExactly()` + +### Testing Integration +- Use `@QuarkusTest` for integration tests +- Use `@InjectMock` to mock dependencies in Quarkus tests +- Prefer REST Assured for API testing +- Use `@TestProfile` for test-specific configuration + +### Event-Driven Testing +- Test Camel routes with `AdviceWith` and `MockEndpoint` +- Use `@CamelQuarkusTest` annotation (if using standalone Camel tests) +- Verify message content, headers, and routing logic +- Test error handling routes separately +- Mock external systems (RabbitMQ, S3, databases) in unit tests + +### Camel Route Testing +- Use `MockEndpoint` for asserting message flow +- Use `AdviceWith` to modify routes for testing (replace endpoints with mocks) +- Test message transformation and marshalling +- Test exception handling and dead letter queues + +### Testing Async Operations +- Test CompletableFuture success and failure scenarios +- Use `.join()` in tests to wait for async completion +- Test exception propagation from CompletableFuture +- Verify LogContext propagation to async operations + +### Performance +- Keep tests fast and isolated +- Run tests in continuous mode: `mvn quarkus:test` +- Use parameterized tests (`@ParameterizedTest`) for input variations +- Build reusable test data builders or factory methods + +### Quarkus-Specific +- Stay on latest LTS version (Quarkus 3.x) +- Test native compilation compatibility periodically +- Use Quarkus test profiles for different scenarios +- Leverage Quarkus dev services for local testing +- Use `@InjectMock` instead of `@MockBean` (Quarkus-specific) + +### Verification Best Practices +- Always verify interactions on mocked dependencies +- Use `verify(mock, never())` to ensure methods are NOT called in error scenarios +- Use `argThat()` for complex argument matching +- Verify the order of calls when it matters: `InOrder` from Mockito diff --git a/docs/ja-JP/skills/quarkus-verification/SKILL.md b/docs/ja-JP/skills/quarkus-verification/SKILL.md new file mode 100644 index 00000000..8c5ea5a6 --- /dev/null +++ b/docs/ja-JP/skills/quarkus-verification/SKILL.md @@ -0,0 +1,481 @@ +--- +name: quarkus-verification +description: "Verification loop for Quarkus projects: build, static analysis, tests with coverage, security scans, native compilation, and diff review before release or PR." +origin: ECC +--- + +# Quarkus Verification Loop + +Run before PRs, after major changes, and pre-deploy. + +## When to Activate + +- Before opening a pull request for a Quarkus service +- After major refactoring or dependency upgrades +- Pre-deployment verification for staging or production +- Running full build → lint → test → security scan → native compilation pipeline +- Validating test coverage meets thresholds (80%+) +- Testing native image compatibility + +## Phase 1: Build + +```bash +# Maven +mvn clean verify -DskipTests + +# Gradle +./gradlew clean assemble -x test +``` + +If build fails, stop and fix compilation errors. + +## Phase 2: Static Analysis + +### Checkstyle, PMD, SpotBugs (Maven) + +```bash +mvn checkstyle:check pmd:check spotbugs:check +``` + +### SonarQube (if configured) + +```bash +mvn sonar:sonar \ + -Dsonar.projectKey=my-quarkus-project \ + -Dsonar.host.url=http://localhost:9000 \ + -Dsonar.login=${SONAR_TOKEN} +``` + +### Common Issues to Address + +- Unused imports or variables +- Complex methods (high cyclomatic complexity) +- Potential null pointer dereferences +- Security issues flagged by SpotBugs + +## Phase 3: Tests + Coverage + +```bash +# Run all tests +mvn clean test + +# Generate coverage report +mvn jacoco:report + +# Enforce coverage threshold (80%) +mvn jacoco:check + +# Or with Gradle +./gradlew test jacocoTestReport jacocoTestCoverageVerification +``` + +### Test Categories + +#### Unit Tests +Test service logic with mocked dependencies: + +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository userRepository; + @InjectMocks UserService userService; + + @Test + void createUser_validInput_returnsUser() { + var dto = new CreateUserDto("Alice", "alice@example.com"); + var expected = new User(); + expected.id = 1L; + expected.name = dto.name(); + + when(userRepository.persist(any(User.class))).thenReturn(expected); + + User result = userService.create(dto); + + assertThat(result.name).isEqualTo("Alice"); + verify(userRepository).persist(any(User.class)); + } +} +``` + +#### Integration Tests +Test with real database (Testcontainers): + +```java +@QuarkusTest +@QuarkusTestResource(PostgresTestResource.class) +class UserRepositoryIntegrationTest { + + @Inject + UserRepository userRepository; + + @Test + @Transactional + void findByEmail_existingUser_returnsUser() { + User user = new User(); + user.name = "Alice"; + user.email = "alice@example.com"; + userRepository.persist(user); + + Optional found = userRepository.findByEmail("alice@example.com"); + + assertThat(found).isPresent(); + assertThat(found.get().name).isEqualTo("Alice"); + } +} +``` + +#### API Tests +Test REST endpoints with REST Assured: + +```java +@QuarkusTest +class UserResourceTest { + + @Test + void createUser_validInput_returns201() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "alice@example.com"} + """) + .when().post("/api/users") + .then() + .statusCode(201) + .body("name", equalTo("Alice")); + } + + @Test + void createUser_invalidEmail_returns400() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "invalid"} + """) + .when().post("/api/users") + .then() + .statusCode(400); + } +} +``` + +### Coverage Report + +Check `target/site/jacoco/index.html` for detailed coverage: +- Overall line coverage (target: 80%+) +- Branch coverage (target: 70%+) +- Identify uncovered critical paths + +## Phase 4: Security Scanning + +### Dependency Vulnerabilities (Maven) + +```bash +mvn org.owasp:dependency-check-maven:check +``` + +Review `target/dependency-check-report.html` for CVEs. + +### Quarkus Security Audit + +```bash +# Check vulnerable extensions +mvn quarkus:audit + +# List all extensions +mvn quarkus:list-extensions +``` + +### OWASP ZAP (API Security Testing) + +```bash +docker run -t owasp/zap2docker-stable zap-api-scan.py \ + -t http://localhost:8080/q/openapi \ + -f openapi +``` + +### Common Security Checks + +- [ ] All secrets in environment variables (not in code) +- [ ] Input validation on all endpoints +- [ ] Authentication/authorization configured +- [ ] CORS properly configured +- [ ] Security headers set +- [ ] Passwords hashed with BCrypt +- [ ] SQL injection protection (parameterized queries) +- [ ] Rate limiting on public endpoints + +## Phase 5: Native Compilation + +Test GraalVM native image compatibility: + +```bash +# Build native executable +mvn package -Dnative + +# Or with container +mvn package -Dnative -Dquarkus.native.container-build=true + +# Test native executable +./target/*-runner + +# Run basic smoke tests +curl http://localhost:8080/q/health/live +curl http://localhost:8080/q/health/ready +``` + +### Native Image Troubleshooting + +Common issues: +- **Reflection**: Add reflection config for dynamic classes +- **Resources**: Include resources with `quarkus.native.resources.includes` +- **JNI**: Register JNI classes if using native libraries + +Example reflection config: +```java +@RegisterForReflection(targets = {MyDynamicClass.class}) +public class ReflectionConfiguration {} +``` + +## Phase 6: Performance Testing + +### Load Testing with K6 + +```javascript +// load-test.js +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '30s', target: 0 }, + ], +}; + +export default function () { + const res = http.get('http://localhost:8080/api/markets'); + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 200ms': (r) => r.timings.duration < 200, + }); +} +``` + +Run: +```bash +k6 run load-test.js +``` + +### Metrics to Monitor + +- Response time (p50, p95, p99) +- Throughput (requests/sec) +- Error rate +- Memory usage +- CPU usage + +## Phase 7: Health Checks + +```bash +# Liveness +curl http://localhost:8080/q/health/live + +# Readiness +curl http://localhost:8080/q/health/ready + +# All health checks +curl http://localhost:8080/q/health + +# Metrics (if enabled) +curl http://localhost:8080/q/metrics +``` + +Expected responses: +```json +{ + "status": "UP", + "checks": [ + { + "name": "Database connection", + "status": "UP" + } + ] +} +``` + +## Phase 8: Container Image Build + +```bash +# Build container image +mvn package -Dquarkus.container-image.build=true + +# Or with specific registry +mvn package \ + -Dquarkus.container-image.build=true \ + -Dquarkus.container-image.registry=docker.io \ + -Dquarkus.container-image.group=myorg \ + -Dquarkus.container-image.tag=1.0.0 + +# Test container +docker run -p 8080:8080 myorg/my-quarkus-app:1.0.0 +``` + +### Container Security Scan + +```bash +# Trivy +trivy image myorg/my-quarkus-app:1.0.0 + +# Grype +grype myorg/my-quarkus-app:1.0.0 +``` + +## Phase 9: Configuration Validation + +```bash +# Check all configuration properties +mvn quarkus:info + +# List all config sources +curl http://localhost:8080/q/dev/io.quarkus.quarkus-vertx-http/config +``` + +### Environment-Specific Checks + +- [ ] Database URLs configured per environment +- [ ] Secrets externalized (Vault, env vars) +- [ ] Logging levels appropriate +- [ ] CORS origins set correctly +- [ ] Rate limiting configured +- [ ] Monitoring/tracing enabled + +## Phase 10: Documentation Review + +- [ ] OpenAPI/Swagger docs up to date (`/q/swagger-ui`) +- [ ] README has setup instructions +- [ ] API changes documented +- [ ] Migration guide for breaking changes +- [ ] Configuration properties documented + +Generate OpenAPI spec: +```bash +curl http://localhost:8080/q/openapi -o openapi.json +``` + +## Verification Checklist + +### Code Quality +- [ ] Build passes without warnings +- [ ] Static analysis clean (no high/medium issues) +- [ ] Code follows team conventions +- [ ] No commented-out code or TODOs in PR + +### Testing +- [ ] All tests pass +- [ ] Code coverage ≥ 80% +- [ ] Integration tests with real database +- [ ] Security tests pass +- [ ] Performance within acceptable limits + +### Security +- [ ] No dependency vulnerabilities +- [ ] Authentication/authorization tested +- [ ] Input validation complete +- [ ] Secrets not in source code +- [ ] Security headers configured + +### Deployment +- [ ] Native compilation successful +- [ ] Container image builds +- [ ] Health checks respond correctly +- [ ] Configuration valid for target environment + +### Native Image +- [ ] Native executable builds +- [ ] Native tests pass +- [ ] Startup time < 100ms +- [ ] Memory footprint acceptable + +## Automated Verification Script + +```bash +#!/bin/bash +set -e + +echo "=== Phase 1: Build ===" +mvn clean verify -DskipTests + +echo "=== Phase 2: Static Analysis ===" +mvn checkstyle:check pmd:check spotbugs:check + +echo "=== Phase 3: Tests + Coverage ===" +mvn test jacoco:report jacoco:check + +echo "=== Phase 4: Security Scan ===" +mvn org.owasp:dependency-check-maven:check + +echo "=== Phase 5: Native Compilation ===" +mvn package -Dnative -Dquarkus.native.container-build=true + +echo "=== All Phases Complete ===" +echo "Review reports:" +echo " - Coverage: target/site/jacoco/index.html" +echo " - Security: target/dependency-check-report.html" +echo " - Native: target/*-runner" +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Verification + +on: [push, pull_request] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Build + run: mvn clean verify -DskipTests + + - name: Test with Coverage + run: mvn test jacoco:report jacoco:check + + - name: Security Scan + run: mvn org.owasp:dependency-check-maven:check + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: target/site/jacoco/jacoco.xml +``` + +## Best Practices + +- Run verification loop before every PR +- Automate in CI/CD pipeline +- Fix issues immediately; don't accumulate debt +- Keep coverage above 80% +- Update dependencies regularly +- Test native compilation periodically +- Monitor performance trends +- Document breaking changes +- Review security scan results +- Validate configuration for each environment diff --git a/docs/tr/agents/java-build-resolver.md b/docs/tr/agents/java-build-resolver.md index 54ee974f..38f9720a 100644 --- a/docs/tr/agents/java-build-resolver.md +++ b/docs/tr/agents/java-build-resolver.md @@ -150,4 +150,6 @@ Remaining errors: 1 Son: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` -Detaylı Java ve Spring Boot kalıpları için, `skill: springboot-patterns`'a bakın. +Detaylı Java kalıpları ve örnekler için: +- **[SPRING]**: `skill: springboot-patterns`'a bakın +- **[QUARKUS]**: `skill: quarkus-patterns`'a bakın diff --git a/docs/tr/agents/java-reviewer.md b/docs/tr/agents/java-reviewer.md index 9f4b05f6..213a7a1a 100644 --- a/docs/tr/agents/java-reviewer.md +++ b/docs/tr/agents/java-reviewer.md @@ -89,4 +89,6 @@ grep -rn "FetchType.EAGER" src/main/java --include="*.java" - **Uyarı**: Sadece MEDIUM sorunlar - **Bloke Et**: CRITICAL veya HIGH sorunlar bulundu -Detaylı Spring Boot kalıpları ve örnekleri için, `skill: springboot-patterns`'a bakın. +Detaylı kalıplar ve örnekler için: +- **[SPRING]**: `skill: springboot-patterns`'a bakın +- **[QUARKUS]**: `skill: quarkus-patterns`'a bakın diff --git a/docs/tr/skills/quarkus-patterns/SKILL.md b/docs/tr/skills/quarkus-patterns/SKILL.md new file mode 100644 index 00000000..117f02dc --- /dev/null +++ b/docs/tr/skills/quarkus-patterns/SKILL.md @@ -0,0 +1,754 @@ +--- +name: quarkus-patterns +description: Quarkus 3.x LTS architecture patterns with Camel for messaging, RESTful API design, CDI services, data access with Panache, and async processing. Use for Java Quarkus backend work with event-driven architectures. +origin: ECC +--- + +# Quarkus Development Patterns + +Quarkus 3.x architecture and API patterns for cloud-native, event-driven services with Apache Camel. + +## When to Activate + +- Building REST APIs with JAX-RS or RESTEasy Reactive +- Structuring resource → service → repository layers +- Implementing event-driven patterns with Apache Camel and RabbitMQ +- Configuring Hibernate Panache, caching, or reactive streams +- Adding validation, exception mapping, or pagination +- Setting up profiles for dev/staging/production environments (YAML config) +- Custom logging with LogContext and Logback/Logstash encoder +- Working with CompletableFuture for async operations +- Implementing conditional flow processing +- Working with GraalVM native compilation + +## Service Layer with Multiple Dependencies (Lombok) + +```java +@Slf4j +@ApplicationScoped +@RequiredArgsConstructor +public class As2ProcessingService { + + private final InvoiceFlowValidator invoiceFlowValidator; + private final EventService eventService; + private final DocumentJobService documentJobService; + private final BusinessRulesPublisher businessRulesPublisher; + private final FileStorageService fileStorageService; + + public void processFile(Path filePath) throws Exception { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + + String structureIdPartner = logContext.get(As2Constants.STRUCTURE_ID); + + // Conditional flow logic + boolean isChorusFlow = Boolean.parseBoolean(logContext.get(As2Constants.CHORUS_FLOW)); + log.info("Is CHORUS_FLOW message: {}", isChorusFlow); + + ValidationFlowConfig validationFlowConfig = isChorusFlow + ? ValidationFlowConfig.xsdOnly() + : ValidationFlowConfig.allValidations(); + + InvoiceValidationResult invoiceValidationResult = this.invoiceFlowValidator + .validateFlowWithConfig(filePath, validationFlowConfig, + EInvoiceSyntaxFormat.UBL, logContext); + + FlowProfile flowProfile = isChorusFlow ? + FlowProfile.EXTENDED_CTC_FR : + this.invoiceFlowValidator.computeFlowProfile(invoiceValidationResult, + invoiceValidationResult.getInvoiceDetails().invoiceFormat().getProfile()); + + log.info("Invoice validation completed. Message is valid"); + + // CompletableFuture async operation + try(InputStream inputStream = Files.newInputStream(filePath)) { + CompletableFuture documentInfoCompletableFuture = + fileStorageService.uploadOriginalFile(inputStream, + invoiceValidationResult.getSize(), logContext, + invoiceValidationResult.getInvoiceFormat()); + + StoredDocumentInfo documentInfo = documentInfoCompletableFuture.join(); + log.info("File uploaded successfully: {}", documentInfo.getPath()); + + if (StringUtils.isBlank(documentInfo.getPath())) { + String errorMsg = "File path is empty after upload"; + log.error(errorMsg); + this.eventService.createErrorEvent(documentInfo, "FILE_UPLOAD_FAILED", errorMsg); + throw new As2ServerProcessingException(errorMsg); + } + + this.eventService.createSuccessEvent(documentInfo, "PERSISTENCE_BLOB_EVENT_TYPE"); + + BusinessRulesPayload payload = this.documentJobService.createDocumentAndJobEntities( + documentInfo, originalFileName, structureIdPartner, + flowProfile, invoiceValidationResult.getDocumentHash()); + + // Async Camel publishing + businessRulesPublisher.publishAsync(payload); + this.eventService.createSuccessEvent(payload, "BUSINESS_RULES_MESSAGE_SENT"); + } + } + } +} +``` + +**Key Patterns:** +- `@RequiredArgsConstructor` for constructor injection via Lombok +- `@Slf4j` for Logback logging +- Scoped LogContext with try-with-resources +- Conditional flow logic based on runtime parameters +- CompletableFuture with `.join()` for async operations +- Event tracking for success/error scenarios +- Async Camel message publishing + +## Custom Logging Context Pattern (Logback) + +```java +@ApplicationScoped +public class ProcessingService { + + public void processDocument(Document doc) { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + // Add context to all log statements + logContext.put("documentId", doc.getId().toString()); + logContext.put("documentType", doc.getType()); + logContext.put("userId", SecurityContext.getUserId()); + + log.info("Starting document processing"); + + // All logs within this scope inherit the context + processInternal(doc); + + log.info("Document processing completed"); + } catch (Exception e) { + log.error("Document processing failed", e); + throw e; + } + } +} +``` + +**Logback Configuration (logback.xml):** + +```xml + + + + true + true + + + + + + + + +``` + +## Event Service Pattern + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class EventService { + private final EventRepository eventRepository; + + public void createSuccessEvent(Object payload, String eventType) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.SUCCESS); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.info("Success event created: {}", eventType); + } + + public void createErrorEvent(Object payload, String eventType, String errorMessage) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.ERROR); + event.setErrorMessage(errorMessage); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.error("Error event created: {} - {}", eventType, errorMessage); + } + + private String serializePayload(Object payload) { + // JSON serialization + return objectMapper.writeValueAsString(payload); + } +} +``` + +## Camel Message Publishing (RabbitMQ) + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class BusinessRulesPublisher { + private final ProducerTemplate producerTemplate; + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + public void publishAsync(BusinessRulesPayload payload) { + producerTemplate.asyncSendBody( + "direct:business-rules-publisher", + payload + ); + log.info("Message published to business rules queue: {}", payload.getDocumentId()); + } + + public void publishSync(BusinessRulesPayload payload) { + producerTemplate.sendBody( + "direct:business-rules-publisher", + payload + ); + } +} +``` + +**Camel Route Configuration:** + +```java +@ApplicationScoped +public class BusinessRulesRoute extends RouteBuilder { + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + @ConfigProperty(name = "rabbitmq.host") + String rabbitHost; + + @ConfigProperty(name = "rabbitmq.port") + Integer rabbitPort; + + @Override + public void configure() { + from("direct:business-rules-publisher") + .routeId("business-rules-publisher") + .log("Publishing message to RabbitMQ: ${body}") + .marshal().json(JsonLibrary.Jackson) + .toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d", + businessRulesQueue, rabbitHost, rabbitPort); + } +} +``` + +## Camel Direct Routes (In-Memory) + +```java +@ApplicationScoped +public class DocumentProcessingRoute extends RouteBuilder { + + @Override + public void configure() { + // Error handling + onException(ValidationException.class) + .handled(true) + .to("direct:validation-error-handler") + .log("Validation error: ${exception.message}"); + + // Main processing route + from("direct:process-document") + .routeId("document-processing") + .log("Processing document: ${header.documentId}") + .bean(DocumentValidator.class, "validate") + .bean(DocumentTransformer.class, "transform") + .choice() + .when(header("documentType").isEqualTo("INVOICE")) + .to("direct:process-invoice") + .when(header("documentType").isEqualTo("CREDIT_NOTE")) + .to("direct:process-credit-note") + .otherwise() + .to("direct:process-generic") + .end(); + + from("direct:validation-error-handler") + .bean(EventService.class, "createErrorEvent") + .log("Validation error handled"); + } +} +``` + +## Camel File Processing + +```java +@ApplicationScoped +public class FileMonitoringRoute extends RouteBuilder { + + @ConfigProperty(name = "file.input.directory") + String inputDirectory; + + @ConfigProperty(name = "file.processed.directory") + String processedDirectory; + + @ConfigProperty(name = "file.error.directory") + String errorDirectory; + + @Override + public void configure() { + from("file:" + inputDirectory + "?move=" + processedDirectory + + "&moveFailed=" + errorDirectory + "&delay=5000") + .routeId("file-monitor") + .log("Processing file: ${header.CamelFileName}") + .to("direct:process-file"); + + from("direct:process-file") + .bean(As2ProcessingService.class, "processFile") + .log("File processing completed"); + } +} +``` + +## Camel Bean Invocation + +```java +@ApplicationScoped +public class InvoiceRoute extends RouteBuilder { + + @Override + public void configure() { + from("direct:invoice-validation") + .bean(InvoiceFlowValidator.class, "validateFlowWithConfig") + .log("Validation result: ${body}"); + + from("direct:persist-and-publish") + .bean(DocumentJobService.class, "createDocumentAndJobEntities") + .bean(BusinessRulesPublisher.class, "publishAsync") + .bean(EventService.class, "createSuccessEvent(${body}, 'PUBLISHED')"); + } +} +``` + +## REST API Structure + +```java +@Path("/api/documents") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class DocumentResource { + private final DocumentService documentService; + + @GET + public Response list( + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("20") int size) { + PaginatedList documents = documentService.list(page, size); + return Response.ok(documents).build(); + } + + @POST + public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) { + Document document = documentService.create(request); + URI location = uriInfo.getAbsolutePathBuilder() + .path(String.valueOf(document.id)) + .build(); + return Response.created(location).entity(DocumentResponse.from(document)).build(); + } + + @GET + @Path("/{id}") + public Response getById(@PathParam("id") Long id) { + return documentService.findById(id) + .map(DocumentResponse::from) + .map(Response::ok) + .orElse(Response.status(Response.Status.NOT_FOUND)) + .build(); + } +} +``` + +## Repository Pattern (Panache Repository) + +```java +@ApplicationScoped +public class DocumentRepository implements PanacheRepository { + + public List findByStatus(DocumentStatus status, int page, int size) { + return find("status = ?1 order by createdAt desc", status) + .page(page, size) + .list(); + } + + public Optional findByReferenceNumber(String referenceNumber) { + return find("referenceNumber", referenceNumber).firstResultOptional(); + } + + public long countByStatusAndDate(DocumentStatus status, LocalDate date) { + return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay()); + } +} +``` + +## Service Layer with Transactions + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentService { + private final DocumentRepository repo; + private final EventService eventService; + + @Transactional + public Document create(CreateDocumentRequest request) { + Document document = new Document(); + document.setReferenceNumber(request.referenceNumber()); + document.setDescription(request.description()); + document.setStatus(DocumentStatus.PENDING); + document.setCreatedAt(Instant.now()); + + repo.persist(document); + + eventService.createSuccessEvent(document, "DOCUMENT_CREATED"); + + return document; + } + + public Optional findById(Long id) { + return repo.findByIdOptional(id); + } + + public PaginatedList list(int page, int size) { + return repo.findAll() + .page(page, size) + .list(); + } +} +``` + +## DTOs and Validation + +```java +public record CreateDocumentRequest( + @NotBlank @Size(max = 200) String referenceNumber, + @NotBlank @Size(max = 2000) String description, + @NotNull @FutureOrPresent Instant validUntil, + @NotEmpty List<@NotBlank String> categories) {} + +public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) { + public static DocumentResponse from(Document document) { + return new DocumentResponse(document.getId(), document.getReferenceNumber(), + document.getStatus()); + } +} +``` + +## Exception Mapping + +```java +@Provider +public class ValidationExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(ConstraintViolationException exception) { + String message = exception.getConstraintViolations().stream() + .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage()) + .collect(Collectors.joining(", ")); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "validation_error", "message", message)) + .build(); + } +} + +@Provider +@Slf4j +public class GenericExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(Exception exception) { + log.error("Unhandled exception", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "internal_error", "message", "An unexpected error occurred")) + .build(); + } +} +``` + +## CompletableFuture Async Operations + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class FileStorageService { + private final S3Client s3Client; + private final ExecutorService executorService; + + public CompletableFuture uploadOriginalFile( + InputStream inputStream, + long size, + LogContext logContext, + InvoiceFormat format) { + + return CompletableFuture.supplyAsync(() -> { + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + String path = generateStoragePath(format); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(path) + .contentLength(size) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, size)); + + log.info("File uploaded to S3: {}", path); + + return new StoredDocumentInfo(path, size, Instant.now()); + } catch (Exception e) { + log.error("Failed to upload file to S3", e); + throw new StorageException("Upload failed", e); + } + }, executorService); + } +} +``` + +## Caching + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentCacheService { + private final DocumentRepository repo; + + @CacheResult(cacheName = "document-cache") + public Optional getById(@CacheKey Long id) { + return repo.findByIdOptional(id); + } + + @CacheInvalidate(cacheName = "document-cache") + public void evict(@CacheKey Long id) {} + + @CacheInvalidateAll(cacheName = "document-cache") + public void evictAll() {} +} +``` + +## Configuration as YAML + +```yaml +# application.yml +"%dev": + quarkus: + datasource: + jdbc: + url: jdbc:postgresql://localhost:5432/dev_db + username: dev_user + password: dev_pass + hibernate-orm: + database: + generation: drop-and-create + + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + +"%test": + quarkus: + datasource: + jdbc: + url: jdbc:h2:mem:test + hibernate-orm: + database: + generation: drop-and-create + +"%prod": + quarkus: + datasource: + jdbc: + url: ${DATABASE_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + hibernate-orm: + database: + generation: validate + + rabbitmq: + host: ${RABBITMQ_HOST} + port: ${RABBITMQ_PORT} + username: ${RABBITMQ_USER} + password: ${RABBITMQ_PASSWORD} + +# Camel configuration +camel: + rabbitmq: + queue: + business-rules: business-rules-queue + invoice-processing: invoice-processing-queue +``` + +## Health Checks + +```java +@Readiness +@ApplicationScoped +@RequiredArgsConstructor +public class DatabaseHealthCheck implements HealthCheck { + private final AgroalDataSource dataSource; + + @Override + public HealthCheckResponse call() { + try (Connection conn = dataSource.getConnection()) { + boolean valid = conn.isValid(2); + return HealthCheckResponse.named("Database connection") + .status(valid) + .build(); + } catch (SQLException e) { + return HealthCheckResponse.down("Database connection"); + } + } +} + +@Liveness +@ApplicationScoped +public class CamelHealthCheck implements HealthCheck { + @Inject + CamelContext camelContext; + + @Override + public HealthCheckResponse call() { + boolean isStarted = camelContext.getStatus().isStarted(); + return HealthCheckResponse.named("Camel Context") + .status(isStarted) + .build(); + } +} +``` + +## Dependencies (Maven) + +```xml + + 3.27.0 + 1.18.42 + 3.24.2 + 0.8.13 + 17 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + org.apache.camel.quarkus + camel-quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-config-yaml + + + + + org.apache.camel.quarkus + camel-quarkus-spring-rabbitmq + + + org.apache.camel.quarkus + camel-quarkus-direct + + + org.apache.camel.quarkus + camel-quarkus-bean + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + io.quarkiverse.logging.logback + quarkus-logging-logback + + + net.logstash.logback + logstash-logback-encoder + + +``` + +## Best Practices + +### Architecture +- Use `@RequiredArgsConstructor` with Lombok for constructor injection +- Keep service layer thin; delegate complex logic to specialized classes +- Use Camel routes for message routing and integration patterns +- Prefer Panache Repository pattern for data access + +### Event-Driven +- Always track operations with EventService (success/error events) +- Use Camel `direct:` endpoints for in-memory routing +- Use `spring-rabbitmq` component for RabbitMQ integration +- Implement async publishing with `ProducerTemplate.asyncSendBody()` + +### Logging +- Use Logback with Logstash encoder for structured logging +- Propagate LogContext through service calls with `SafeAutoCloseable` +- Add contextual information to LogContext for request tracing +- Use `@Slf4j` instead of manual logger instantiation + +### Async Operations +- Use CompletableFuture for non-blocking I/O operations +- Call `.join()` when you need to wait for completion +- Handle exceptions from CompletableFuture properly +- Pass LogContext to async operations for tracing + +### Configuration +- Use YAML configuration (`quarkus-config-yaml`) +- Profile-aware configuration for dev/test/prod environments +- Externalize sensitive configuration to environment variables +- Use `@ConfigProperty` for type-safe config injection + +### Validation +- Validate at resource layer with `@Valid` +- Use Bean Validation annotations on DTOs +- Map exceptions to proper HTTP responses with `@Provider` + +### Transactions +- Use `@Transactional` on service methods that modify data +- Keep transactions short and focused +- Avoid calling async operations within transactions + +### Testing +- Use `camel-quarkus-junit5` for route testing +- Use AssertJ for assertions +- Mock all external dependencies +- Test conditional flow logic thoroughly + +### Quarkus-Specific +- Stay on latest LTS version (3.x) +- Use Quarkus dev mode for hot reload +- Add health checks for production readiness +- Test native compilation compatibility periodically diff --git a/docs/tr/skills/quarkus-security/SKILL.md b/docs/tr/skills/quarkus-security/SKILL.md new file mode 100644 index 00000000..b3fa9705 --- /dev/null +++ b/docs/tr/skills/quarkus-security/SKILL.md @@ -0,0 +1,453 @@ +--- +name: quarkus-security +description: Quarkus Security best practices for authentication, authorization, JWT/OIDC, RBAC, input validation, CSRF, secrets management, and dependency security. +origin: ECC +--- + +# Quarkus Security Review + +Best practices for securing Quarkus applications with authentication, authorization, and input validation. + +## When to Activate + +- Adding authentication (JWT, OIDC, Basic Auth) +- Implementing authorization with @RolesAllowed or SecurityIdentity +- Validating user input (Bean Validation, custom validators) +- Configuring CORS or security headers +- Managing secrets (Vault, environment variables, config sources) +- Adding rate limiting or brute-force protection +- Scanning dependencies for CVEs +- Working with MicroProfile JWT or SmallRye JWT + +## Authentication + +### JWT Authentication + +```java +// Resource protected with JWT +@Path("/api/protected") +@Authenticated +public class ProtectedResource { + + @Inject + JsonWebToken jwt; + + @Inject + SecurityIdentity securityIdentity; + + @GET + public Response getData() { + String username = jwt.getName(); + Set roles = jwt.getGroups(); + return Response.ok(Map.of( + "username", username, + "roles", roles, + "principal", securityIdentity.getPrincipal().getName() + )).build(); + } +} +``` + +Configuration (application.properties): +```properties +mp.jwt.verify.publickey.location=publicKey.pem +mp.jwt.verify.issuer=https://auth.example.com + +# OIDC +quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm +quarkus.oidc.client-id=backend-service +quarkus.oidc.credentials.secret=${OIDC_SECRET} +``` + +### Custom Authentication Filter + +```java +@Provider +@Priority(Priorities.AUTHENTICATION) +public class CustomAuthFilter implements ContainerRequestFilter { + + @Inject + SecurityIdentity identity; + + @Override + public void filter(ContainerRequestContext requestContext) { + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + // Validate token and set SecurityIdentity + if (!validateToken(token)) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + } + + private boolean validateToken(String token) { + // Token validation logic + return true; + } +} +``` + +## Authorization + +### Role-Based Access Control + +```java +@Path("/api/admin") +@RolesAllowed("ADMIN") +public class AdminResource { + + @GET + @Path("/users") + public List listUsers() { + return userService.findAll(); + } + + @DELETE + @Path("/users/{id}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) + public Response deleteUser(@PathParam("id") Long id) { + userService.delete(id); + return Response.noContent().build(); + } +} + +@Path("/api/users") +public class UserResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("/{id}") + @RolesAllowed("USER") + public Response getUser(@PathParam("id") Long id) { + // Check ownership + if (!securityIdentity.hasRole("ADMIN") && + !isOwner(id, securityIdentity.getPrincipal().getName())) { + return Response.status(Response.Status.FORBIDDEN).build(); + } + return Response.ok(userService.findById(id)).build(); + } + + private boolean isOwner(Long userId, String username) { + return userService.isOwner(userId, username); + } +} +``` + +### Programmatic Security + +```java +@ApplicationScoped +public class SecurityService { + + @Inject + SecurityIdentity securityIdentity; + + public boolean canAccessResource(Long resourceId) { + if (securityIdentity.isAnonymous()) { + return false; + } + + if (securityIdentity.hasRole("ADMIN")) { + return true; + } + + String userId = securityIdentity.getPrincipal().getName(); + return resourceRepository.isOwner(resourceId, userId); + } +} +``` + +## Input Validation + +### Bean Validation + +```java +// BAD: No validation +@POST +public Response createUser(UserDto dto) { + return Response.ok(userService.create(dto)).build(); +} + +// GOOD: Validated DTO +public record CreateUserDto( + @NotBlank @Size(max = 100) String name, + @NotBlank @Email String email, + @NotNull @Min(18) @Max(150) Integer age, + @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone +) {} + +@POST +@Path("/users") +public Response createUser(@Valid CreateUserDto dto) { + User user = userService.create(dto); + return Response.status(Response.Status.CREATED).entity(user).build(); +} +``` + +### Custom Validators + +```java +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +public @interface ValidUsername { + String message() default "Invalid username format"; + Class[] groups() default {}; + Class[] payload() default {}; +} + +public class UsernameValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return value.matches("^[a-zA-Z0-9_-]{3,20}$"); + } +} + +// Usage +public record CreateUserDto( + @ValidUsername String username, + @NotBlank @Email String email +) {} +``` + +## SQL Injection Prevention + +### Panache Active Record (Safe by Default) + +```java +// GOOD: Parameterized queries with Panache +List users = User.list("email = ?1 and active = ?2", email, true); + +Optional user = User.find("username", username).firstResultOptional(); + +// GOOD: Named parameters +List users = User.list("email = :email and age > :minAge", + Parameters.with("email", email).and("minAge", 18)); +``` + +### Native Queries (Use Parameters) + +```java +// BAD: String concatenation +@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true) + +// GOOD: Parameterized native query +@Entity +public class User extends PanacheEntity { + public static List findByEmailNative(String email) { + return getEntityManager() + .createNativeQuery("SELECT * FROM users WHERE email = :email", User.class) + .setParameter("email", email) + .getResultList(); + } +} +``` + +## Password Hashing + +```java +@ApplicationScoped +public class PasswordService { + + public String hash(String plainPassword) { + return BcryptUtil.bcryptHash(plainPassword); + } + + public boolean verify(String plainPassword, String hashedPassword) { + return BcryptUtil.matches(plainPassword, hashedPassword); + } +} + +// In service +@ApplicationScoped +public class UserService { + @Inject + PasswordService passwordService; + + @Transactional + public User register(CreateUserDto dto) { + String hashedPassword = passwordService.hash(dto.password()); + User user = new User(); + user.email = dto.email(); + user.password = hashedPassword; + user.persist(); + return user; + } + + public boolean authenticate(String email, String password) { + return User.find("email", email) + .firstResultOptional() + .map(u -> passwordService.verify(password, u.password)) + .orElse(false); + } +} +``` + +## CORS Configuration + +```properties +# application.properties +quarkus.http.cors=true +quarkus.http.cors.origins=https://app.example.com,https://admin.example.com +quarkus.http.cors.methods=GET,POST,PUT,DELETE +quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with +quarkus.http.cors.exposed-headers=Content-Disposition +quarkus.http.cors.access-control-max-age=24H +quarkus.http.cors.access-control-allow-credentials=true +``` + +## Secrets Management + +```properties +# application.properties - NO SECRETS HERE + +# Use environment variables +quarkus.datasource.username=${DB_USER} +quarkus.datasource.password=${DB_PASSWORD} +quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET} + +# Or use Vault +quarkus.vault.url=https://vault.example.com +quarkus.vault.authentication.kubernetes.role=my-role +``` + +### HashiCorp Vault Integration + +```java +@ApplicationScoped +public class SecretService { + + @ConfigProperty(name = "api-key") + String apiKey; // Fetched from Vault + + public String getSecret(String key) { + return ConfigProvider.getConfig().getValue(key, String.class); + } +} +``` + +## Rate Limiting + +```java +@ApplicationScoped +public class RateLimitFilter implements ContainerRequestFilter { + private final Map limiters = new ConcurrentHashMap<>(); + + @Override + public void filter(ContainerRequestContext requestContext) { + String clientId = getClientIdentifier(requestContext); + RateLimiter limiter = limiters.computeIfAbsent(clientId, + k -> RateLimiter.create(100.0)); // 100 requests per second + + if (!limiter.tryAcquire()) { + requestContext.abortWith( + Response.status(429) + .entity(Map.of("error", "Too many requests")) + .build() + ); + } + } + + private String getClientIdentifier(ContainerRequestContext ctx) { + // Use IP, API key, or user ID + return ctx.getHeaderString("X-Forwarded-For"); + } +} +``` + +## Security Headers + +```java +@Provider +public class SecurityHeadersFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext request, ContainerResponseContext response) { + MultivaluedMap headers = response.getHeaders(); + + // Prevent clickjacking + headers.putSingle("X-Frame-Options", "DENY"); + + // XSS protection + headers.putSingle("X-Content-Type-Options", "nosniff"); + headers.putSingle("X-XSS-Protection", "1; mode=block"); + + // HSTS + headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + + // CSP + headers.putSingle("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"); + } +} +``` + +## Audit Logging + +```java +@ApplicationScoped +public class AuditService { + private static final Logger LOG = Logger.getLogger(AuditService.class); + + @Inject + SecurityIdentity securityIdentity; + + public void logAccess(String resource, String action) { + String user = securityIdentity.isAnonymous() + ? "anonymous" + : securityIdentity.getPrincipal().getName(); + + LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s", + user, action, resource, Instant.now()); + } +} + +// Usage in resource +@Path("/api/sensitive") +public class SensitiveResource { + @Inject + AuditService auditService; + + @GET + @RolesAllowed("ADMIN") + public Response getData() { + auditService.logAccess("sensitive-data", "READ"); + return Response.ok(data).build(); + } +} +``` + +## Dependency Security Scanning + +```bash +# Maven +mvn org.owasp:dependency-check-maven:check + +# Gradle +./gradlew dependencyCheckAnalyze + +# Check Quarkus extensions +quarkus extension list --installable +``` + +## Best Practices + +- Always use HTTPS in production +- Enable JWT or OIDC for stateless authentication +- Use `@RolesAllowed` for declarative authorization +- Validate all input with Bean Validation +- Hash passwords with BCrypt (never plaintext) +- Store secrets in Vault or environment variables +- Use parameterized queries to prevent SQL injection +- Add security headers to all responses +- Implement rate limiting for public endpoints +- Audit sensitive operations +- Keep dependencies updated and scan for CVEs +- Use SecurityIdentity for programmatic checks +- Set appropriate CORS policies +- Test authentication and authorization paths diff --git a/docs/tr/skills/quarkus-tdd/SKILL.md b/docs/tr/skills/quarkus-tdd/SKILL.md new file mode 100644 index 00000000..c049ceda --- /dev/null +++ b/docs/tr/skills/quarkus-tdd/SKILL.md @@ -0,0 +1,908 @@ +--- +name: quarkus-tdd +description: Test-driven development for Quarkus 3.x LTS using JUnit 5, Mockito, REST Assured, Camel testing, and JaCoCo. Use when adding features, fixing bugs, or refactoring event-driven services. +origin: ECC +--- + +# Quarkus TDD Workflow + +TDD guidance for Quarkus 3.x services with 80%+ coverage (unit + integration). Optimized for event-driven architectures with Apache Camel. + +## When to Use + +- New features or REST endpoints +- Bug fixes or refactors +- Adding data access logic, security rules, or reactive streams +- Testing Apache Camel routes and event handlers +- Testing event-driven services with RabbitMQ +- Testing conditional flow logic +- Validating CompletableFuture async operations +- Testing LogContext propagation + +## Workflow + +1. Write tests first (they should fail) +2. Implement minimal code to pass +3. Refactor with tests green +4. Enforce coverage with JaCoCo (80%+ target) + +## Unit Tests with @Nested Organization + +Follow this structured approach for comprehensive, readable tests: + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("As2ProcessingService Unit Tests") +class As2ProcessingServiceTest { + + @Mock + private InvoiceFlowValidator invoiceFlowValidator; + + @Mock + private EventService eventService; + + @Mock + private DocumentJobService documentJobService; + + @Mock + private BusinessRulesPublisher businessRulesPublisher; + + @Mock + private FileStorageService fileStorageService; + + @InjectMocks + private As2ProcessingService as2ProcessingService; + + private Path testFilePath; + private LogContext testLogContext; + private InvoiceValidationResult validationResult; + private StoredDocumentInfo documentInfo; + + @BeforeEach + void setUp() { + // ARRANGE - Common test data + testFilePath = Path.of("/tmp/test-invoice.xml"); + + testLogContext = new LogContext(); + testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001"); + testLogContext.put(As2Constants.FILE_NAME, "invoice.xml"); + testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001"); + + validationResult = new InvoiceValidationResult(); + validationResult.setValid(true); + validationResult.setSize(1024L); + validationResult.setDocumentHash("abc123"); + + documentInfo = new StoredDocumentInfo(); + documentInfo.setPath("s3://bucket/path/invoice.xml"); + documentInfo.setSize(1024L); + } + + @Nested + @DisplayName("Tests for processFile") + class ProcessFile { + + @Test + @DisplayName("Should successfully process non-CHORUS file with all validations") + void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class), + eq("PERSISTENCE_BLOB_EVENT_TYPE")); + verify(eventService).createSuccessEvent(any(BusinessRulesPayload.class), + eq("BUSINESS_RULES_MESSAGE_SENT")); + verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class)); + } + + @Test + @DisplayName("Should bypass schematron validation for CHORUS_FLOW") + void givenChorusFlow_whenProcessFile_thenSchematronBypassed() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "true"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(documentJobService).createDocumentAndJobEntities( + any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), + any()); + } + + @Test + @DisplayName("Should create error event when file upload fails") + void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + documentInfo.setPath(""); // Blank path triggers error + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + // ACT & ASSERT + As2ServerProcessingException exception = assertThrows( + As2ServerProcessingException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + + assertThat(exception.getMessage()) + .contains("File path is empty after upload"); + + verify(eventService).createErrorEvent( + eq(documentInfo), + eq("FILE_UPLOAD_FAILED"), + contains("File path is empty")); + + verify(businessRulesPublisher, never()).publishAsync(any()); + } + + @Test + @DisplayName("Should handle CompletableFuture.join() failure") + void givenAsyncUploadFailure_whenProcessFile_thenExceptionThrown() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + CompletableFuture failedFuture = + CompletableFuture.failedFuture(new StorageException("S3 connection failed")); + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(failedFuture); + + // ACT & ASSERT + assertThrows( + CompletionException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + } + + @Test + @DisplayName("Should throw exception when file path is null") + void givenNullFilePath_whenProcessFile_thenThrowsException() { + // ARRANGE + Path nullPath = null; + + // ACT & ASSERT + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> as2ProcessingService.processFile(nullPath) + ); + + verify(invoiceFlowValidator, never()).validateFlowWithConfig(any(), any(), any(), any()); + } + } +} +``` + +### Key Testing Patterns + +1. **@Nested Classes**: Group tests by method being tested +2. **@DisplayName**: Provide readable test descriptions for test reports +3. **Naming Convention**: `givenX_whenY_thenZ` for clarity +4. **AAA Pattern**: Explicit `// ARRANGE`, `// ACT`, `// ASSERT` comments +5. **@BeforeEach**: Setup common test data to reduce duplication +6. **assertDoesNotThrow**: Test success scenarios without catching exceptions +7. **assertThrows**: Test exception scenarios with message validation using AssertJ +8. **Comprehensive Coverage**: Test happy paths, null inputs, edge cases, exceptions +9. **Verify Interactions**: Use Mockito `verify()` to ensure methods are called correctly +10. **Never Verify**: Use `never()` to ensure methods are NOT called in error scenarios + +## Testing Camel Routes + +```java +@QuarkusTest +@DisplayName("Business Rules Camel Route Tests") +class BusinessRulesRouteTest { + + @Inject + CamelContext camelContext; + + @Inject + ProducerTemplate producerTemplate; + + @InjectMock + EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE - Test data + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + testPayload.setFlowProfile(FlowProfile.BASIC); + } + + @Nested + @DisplayName("Tests for business-rules-publisher route") + class BusinessRulesPublisher { + + @Test + @DisplayName("Should successfully publish message to RabbitMQ") + void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception { + // ARRANGE + MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class); + mockRabbitMQ.expectedMessageCount(1); + mockRabbitMQ.expectedBodiesReceived(testPayload); + + // Replace real endpoint with mock for testing + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.replaceFromWith("direct:business-rules-publisher"); + advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockRabbitMQ.assertIsSatisfied(5000); + + assertThat(mockRabbitMQ.getExchanges()).hasSize(1); + assertThat(mockRabbitMQ.getExchanges().get(0).getIn().getBody(BusinessRulesPayload.class)) + .isEqualTo(testPayload); + } + + @Test + @DisplayName("Should handle marshalling to JSON") + void givenPayload_whenPublish_thenMarshalledToJson() throws Exception { + // ARRANGE + MockEndpoint mockMarshal = new MockEndpoint("mock:marshal"); + camelContext.addEndpoint("mock:marshal", mockMarshal); + mockMarshal.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.weaveAddLast().to("mock:marshal"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockMarshal.assertIsSatisfied(5000); + + String body = mockMarshal.getExchanges().get(0).getIn().getBody(String.class); + assertThat(body).contains("\"documentId\":1"); + assertThat(body).contains("\"flowProfile\":\"BASIC\""); + } + } + + @Nested + @DisplayName("Tests for document-processing route") + class DocumentProcessing { + + @Test + @DisplayName("Should route invoice to correct processor") + void givenInvoiceType_whenProcess_thenRoutesToInvoiceProcessor() throws Exception { + // ARRANGE + MockEndpoint mockInvoice = camelContext.getEndpoint("mock:invoice", MockEndpoint.class); + mockInvoice.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:process-invoice.*").replace().to("mock:invoice"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // ACT + producerTemplate.sendBodyAndHeader("direct:process-document", + testPayload, "documentType", "INVOICE"); + + // ASSERT + mockInvoice.assertIsSatisfied(5000); + } + + @Test + @DisplayName("Should handle validation errors gracefully") + void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception { + // ARRANGE + MockEndpoint mockError = camelContext.getEndpoint("mock:error", MockEndpoint.class); + mockError.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:validation-error-handler.*") + .replace().to("mock:error"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // Mock validator to throw exception + when(eventService.validate(any())).thenThrow(new ValidationException("Invalid document")); + + // ACT + producerTemplate.sendBody("direct:process-document", testPayload); + + // ASSERT + mockError.assertIsSatisfied(5000); + + Exception exception = mockError.getExchanges().get(0).getException(); + assertThat(exception).isInstanceOf(ValidationException.class); + assertThat(exception.getMessage()).contains("Invalid document"); + } + } +} +``` + +## Testing Event Services + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("EventService Unit Tests") +class EventServiceTest { + + @Mock + private EventRepository eventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + } + + @Nested + @DisplayName("Tests for createSuccessEvent") + class CreateSuccessEvent { + + @Test + @DisplayName("Should create success event with correct attributes") + void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception { + // ARRANGE + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED")); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("DOCUMENT_PROCESSED") && + event.getStatus() == EventStatus.SUCCESS && + event.getPayload().equals("{\"documentId\":1}") && + event.getTimestamp() != null + )); + } + + @Test + @DisplayName("Should throw exception when payload is null") + void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() { + // ARRANGE + Object nullPayload = null; + + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE") + ); + + assertThat(exception.getMessage()).isEqualTo("Payload cannot be null"); + verify(eventRepository, never()).persist(any()); + } + } + + @Nested + @DisplayName("Tests for createErrorEvent") + class CreateErrorEvent { + + @Test + @DisplayName("Should create error event with error message") + void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception { + // ARRANGE + String errorMessage = "Processing failed"; + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createErrorEvent(testPayload, "PROCESSING_ERROR", errorMessage)); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("PROCESSING_ERROR") && + event.getStatus() == EventStatus.ERROR && + event.getErrorMessage().equals(errorMessage) && + event.getPayload().equals("{\"documentId\":1}") + )); + } + + @ParameterizedTest + @DisplayName("Should reject invalid error messages") + @ValueSource(strings = {"", " "}) + void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) { + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage) + ); + + assertThat(exception.getMessage()).contains("Error message cannot be blank"); + } + } +} +``` + +## Testing CompletableFuture + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("FileStorageService Unit Tests") +class FileStorageServiceTest { + + @Mock + private S3Client s3Client; + + @Mock + private ExecutorService executorService; + + @InjectMocks + private FileStorageService fileStorageService; + + private InputStream testInputStream; + private LogContext testLogContext; + + @BeforeEach + void setUp() { + // ARRANGE + testInputStream = new ByteArrayInputStream("test content".getBytes()); + testLogContext = new LogContext(); + testLogContext.put("traceId", "trace-123"); + } + + @Nested + @DisplayName("Tests for uploadOriginalFile") + class UploadOriginalFile { + + @Test + @DisplayName("Should successfully upload file and return document info") + void givenValidFile_whenUpload_thenReturnsDocumentInfo() throws Exception { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return CompletableFuture.completedFuture(callable.call()); + }); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + StoredDocumentInfo result = future.join(); + + // ASSERT + assertThat(result).isNotNull(); + assertThat(result.getPath()).isNotBlank(); + assertThat(result.getSize()).isEqualTo(1024L); + assertThat(result.getUploadedAt()).isNotNull(); + + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("Should handle S3 upload failure") + void givenS3Failure_whenUpload_thenCompletableFutureFails() { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + return CompletableFuture.failedFuture(new StorageException("S3 unavailable")); + }); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + // ASSERT + assertThatThrownBy(() -> future.join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(StorageException.class) + .hasMessageContaining("S3 unavailable"); + } + + @Test + @DisplayName("Should propagate LogContext to async operation") + void givenLogContext_whenUpload_thenContextPropagated() throws Exception { + // ARRANGE + AtomicReference capturedContext = new AtomicReference<>(); + + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + capturedContext.set(CustomLog.getCurrentContext()); + return CompletableFuture.completedFuture(callable.call()); + }); + + // ACT + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL).join(); + + // ASSERT + assertThat(capturedContext.get()).isNotNull(); + assertThat(capturedContext.get().get("traceId")).isEqualTo("trace-123"); + } + } +} +``` + +## Resource Layer Tests (REST Assured) + +```java +@QuarkusTest +@DisplayName("DocumentResource API Tests") +class DocumentResourceTest { + + @InjectMock + DocumentService documentService; + + @Nested + @DisplayName("Tests for GET /api/documents") + class ListDocuments { + + @Test + @DisplayName("Should return list of documents") + void givenDocumentsExist_whenList_thenReturnsOk() { + // ARRANGE + List documents = List.of(createDocument(1L, "DOC-001")); + when(documentService.list(0, 20)).thenReturn(documents); + + // ACT & ASSERT + given() + .when().get("/api/documents") + .then() + .statusCode(200) + .body("$.size()", is(1)) + .body("[0].referenceNumber", equalTo("DOC-001")); + } + } + + @Nested + @DisplayName("Tests for POST /api/documents") + class CreateDocument { + + @Test + @DisplayName("Should create document and return 201") + void givenValidRequest_whenCreate_thenReturns201() { + // ARRANGE + Document document = createDocument(1L, "DOC-001"); + when(documentService.create(any())).thenReturn(document); + + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "DOC-001", + "description": "Test document", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .header("Location", containsString("/api/documents/1")) + .body("referenceNumber", equalTo("DOC-001")); + } + + @Test + @DisplayName("Should return 400 for invalid input") + void givenInvalidRequest_whenCreate_thenReturns400() { + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "", + "description": "Test" + } + """) + .when().post("/api/documents") + .then() + .statusCode(400); + } + } + + private Document createDocument(Long id, String referenceNumber) { + Document document = new Document(); + document.setId(id); + document.setReferenceNumber(referenceNumber); + document.setStatus(DocumentStatus.PENDING); + return document; + } +} +``` + +## Integration Tests with Real Database + +```java +@QuarkusTest +@TestProfile(IntegrationTestProfile.class) +@DisplayName("Document Integration Tests") +class DocumentIntegrationTest { + + @Test + @Transactional + @DisplayName("Should create and retrieve document via API") + void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() { + // ACT - Create via API + Long id = given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "INT-001", + "description": "Integration test", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .extract().path("id"); + + // ASSERT - Retrieve via API + given() + .when().get("/api/documents/" + id) + .then() + .statusCode(200) + .body("referenceNumber", equalTo("INT-001")); + } +} +``` + +## Coverage with JaCoCo + +### Maven Configuration (Complete) + +```xml + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + + prepare-agent + + prepare-agent + + + + + + report + verify + + report + + + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.70 + + + + + + + + +``` + +Run tests with coverage: +```bash +mvn clean test +mvn jacoco:report +mvn jacoco:check + +# Report at: target/site/jacoco/index.html +``` + +## Test Dependencies + +```xml + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + + + org.mockito + mockito-core + test + + + + + org.assertj + assertj-core + 3.24.2 + test + + + + + io.rest-assured + rest-assured + test + + + + + org.apache.camel.quarkus + camel-quarkus-junit5 + test + + +``` + +## Best Practices + +### Test Organization +- Use `@Nested` classes to group tests by method being tested +- Use `@DisplayName` for readable test descriptions visible in reports +- Follow `givenX_whenY_thenZ` naming convention for test methods +- Use `@BeforeEach` for common test data setup to reduce duplication + +### Test Structure +- Follow AAA pattern with explicit comments (`// ARRANGE`, `// ACT`, `// ASSERT`) +- Use `assertDoesNotThrow` for success scenarios +- Use `assertThrows` for exception scenarios with message validation +- Verify exception messages match expected values using AssertJ `contains()` or `isEqualTo()` + +### Test Coverage +- Test happy paths for all public methods +- Test null input handling +- Test edge cases (empty collections, boundary values, negative IDs, blank strings) +- Test exception scenarios comprehensively +- Mock all external dependencies (repositories, services, Camel endpoints) +- Aim for 80%+ line coverage, 70%+ branch coverage + +### Assertions +- **Always use AssertJ** (`assertThat`) instead of JUnit assertions +- Use fluent AssertJ API for readability: `assertThat(list).hasSize(3).contains(item)` +- For exceptions: `assertThatThrownBy(() -> ...).isInstanceOf(...).hasMessageContaining(...)` +- For collections: `extracting()`, `filteredOn()`, `containsExactly()` + +### Testing Integration +- Use `@QuarkusTest` for integration tests +- Use `@InjectMock` to mock dependencies in Quarkus tests +- Prefer REST Assured for API testing +- Use `@TestProfile` for test-specific configuration + +### Event-Driven Testing +- Test Camel routes with `AdviceWith` and `MockEndpoint` +- Use `@CamelQuarkusTest` annotation (if using standalone Camel tests) +- Verify message content, headers, and routing logic +- Test error handling routes separately +- Mock external systems (RabbitMQ, S3, databases) in unit tests + +### Camel Route Testing +- Use `MockEndpoint` for asserting message flow +- Use `AdviceWith` to modify routes for testing (replace endpoints with mocks) +- Test message transformation and marshalling +- Test exception handling and dead letter queues + +### Testing Async Operations +- Test CompletableFuture success and failure scenarios +- Use `.join()` in tests to wait for async completion +- Test exception propagation from CompletableFuture +- Verify LogContext propagation to async operations + +### Performance +- Keep tests fast and isolated +- Run tests in continuous mode: `mvn quarkus:test` +- Use parameterized tests (`@ParameterizedTest`) for input variations +- Build reusable test data builders or factory methods + +### Quarkus-Specific +- Stay on latest LTS version (Quarkus 3.x) +- Test native compilation compatibility periodically +- Use Quarkus test profiles for different scenarios +- Leverage Quarkus dev services for local testing +- Use `@InjectMock` instead of `@MockBean` (Quarkus-specific) + +### Verification Best Practices +- Always verify interactions on mocked dependencies +- Use `verify(mock, never())` to ensure methods are NOT called in error scenarios +- Use `argThat()` for complex argument matching +- Verify the order of calls when it matters: `InOrder` from Mockito diff --git a/docs/tr/skills/quarkus-verification/SKILL.md b/docs/tr/skills/quarkus-verification/SKILL.md new file mode 100644 index 00000000..8c5ea5a6 --- /dev/null +++ b/docs/tr/skills/quarkus-verification/SKILL.md @@ -0,0 +1,481 @@ +--- +name: quarkus-verification +description: "Verification loop for Quarkus projects: build, static analysis, tests with coverage, security scans, native compilation, and diff review before release or PR." +origin: ECC +--- + +# Quarkus Verification Loop + +Run before PRs, after major changes, and pre-deploy. + +## When to Activate + +- Before opening a pull request for a Quarkus service +- After major refactoring or dependency upgrades +- Pre-deployment verification for staging or production +- Running full build → lint → test → security scan → native compilation pipeline +- Validating test coverage meets thresholds (80%+) +- Testing native image compatibility + +## Phase 1: Build + +```bash +# Maven +mvn clean verify -DskipTests + +# Gradle +./gradlew clean assemble -x test +``` + +If build fails, stop and fix compilation errors. + +## Phase 2: Static Analysis + +### Checkstyle, PMD, SpotBugs (Maven) + +```bash +mvn checkstyle:check pmd:check spotbugs:check +``` + +### SonarQube (if configured) + +```bash +mvn sonar:sonar \ + -Dsonar.projectKey=my-quarkus-project \ + -Dsonar.host.url=http://localhost:9000 \ + -Dsonar.login=${SONAR_TOKEN} +``` + +### Common Issues to Address + +- Unused imports or variables +- Complex methods (high cyclomatic complexity) +- Potential null pointer dereferences +- Security issues flagged by SpotBugs + +## Phase 3: Tests + Coverage + +```bash +# Run all tests +mvn clean test + +# Generate coverage report +mvn jacoco:report + +# Enforce coverage threshold (80%) +mvn jacoco:check + +# Or with Gradle +./gradlew test jacocoTestReport jacocoTestCoverageVerification +``` + +### Test Categories + +#### Unit Tests +Test service logic with mocked dependencies: + +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository userRepository; + @InjectMocks UserService userService; + + @Test + void createUser_validInput_returnsUser() { + var dto = new CreateUserDto("Alice", "alice@example.com"); + var expected = new User(); + expected.id = 1L; + expected.name = dto.name(); + + when(userRepository.persist(any(User.class))).thenReturn(expected); + + User result = userService.create(dto); + + assertThat(result.name).isEqualTo("Alice"); + verify(userRepository).persist(any(User.class)); + } +} +``` + +#### Integration Tests +Test with real database (Testcontainers): + +```java +@QuarkusTest +@QuarkusTestResource(PostgresTestResource.class) +class UserRepositoryIntegrationTest { + + @Inject + UserRepository userRepository; + + @Test + @Transactional + void findByEmail_existingUser_returnsUser() { + User user = new User(); + user.name = "Alice"; + user.email = "alice@example.com"; + userRepository.persist(user); + + Optional found = userRepository.findByEmail("alice@example.com"); + + assertThat(found).isPresent(); + assertThat(found.get().name).isEqualTo("Alice"); + } +} +``` + +#### API Tests +Test REST endpoints with REST Assured: + +```java +@QuarkusTest +class UserResourceTest { + + @Test + void createUser_validInput_returns201() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "alice@example.com"} + """) + .when().post("/api/users") + .then() + .statusCode(201) + .body("name", equalTo("Alice")); + } + + @Test + void createUser_invalidEmail_returns400() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "invalid"} + """) + .when().post("/api/users") + .then() + .statusCode(400); + } +} +``` + +### Coverage Report + +Check `target/site/jacoco/index.html` for detailed coverage: +- Overall line coverage (target: 80%+) +- Branch coverage (target: 70%+) +- Identify uncovered critical paths + +## Phase 4: Security Scanning + +### Dependency Vulnerabilities (Maven) + +```bash +mvn org.owasp:dependency-check-maven:check +``` + +Review `target/dependency-check-report.html` for CVEs. + +### Quarkus Security Audit + +```bash +# Check vulnerable extensions +mvn quarkus:audit + +# List all extensions +mvn quarkus:list-extensions +``` + +### OWASP ZAP (API Security Testing) + +```bash +docker run -t owasp/zap2docker-stable zap-api-scan.py \ + -t http://localhost:8080/q/openapi \ + -f openapi +``` + +### Common Security Checks + +- [ ] All secrets in environment variables (not in code) +- [ ] Input validation on all endpoints +- [ ] Authentication/authorization configured +- [ ] CORS properly configured +- [ ] Security headers set +- [ ] Passwords hashed with BCrypt +- [ ] SQL injection protection (parameterized queries) +- [ ] Rate limiting on public endpoints + +## Phase 5: Native Compilation + +Test GraalVM native image compatibility: + +```bash +# Build native executable +mvn package -Dnative + +# Or with container +mvn package -Dnative -Dquarkus.native.container-build=true + +# Test native executable +./target/*-runner + +# Run basic smoke tests +curl http://localhost:8080/q/health/live +curl http://localhost:8080/q/health/ready +``` + +### Native Image Troubleshooting + +Common issues: +- **Reflection**: Add reflection config for dynamic classes +- **Resources**: Include resources with `quarkus.native.resources.includes` +- **JNI**: Register JNI classes if using native libraries + +Example reflection config: +```java +@RegisterForReflection(targets = {MyDynamicClass.class}) +public class ReflectionConfiguration {} +``` + +## Phase 6: Performance Testing + +### Load Testing with K6 + +```javascript +// load-test.js +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '30s', target: 0 }, + ], +}; + +export default function () { + const res = http.get('http://localhost:8080/api/markets'); + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 200ms': (r) => r.timings.duration < 200, + }); +} +``` + +Run: +```bash +k6 run load-test.js +``` + +### Metrics to Monitor + +- Response time (p50, p95, p99) +- Throughput (requests/sec) +- Error rate +- Memory usage +- CPU usage + +## Phase 7: Health Checks + +```bash +# Liveness +curl http://localhost:8080/q/health/live + +# Readiness +curl http://localhost:8080/q/health/ready + +# All health checks +curl http://localhost:8080/q/health + +# Metrics (if enabled) +curl http://localhost:8080/q/metrics +``` + +Expected responses: +```json +{ + "status": "UP", + "checks": [ + { + "name": "Database connection", + "status": "UP" + } + ] +} +``` + +## Phase 8: Container Image Build + +```bash +# Build container image +mvn package -Dquarkus.container-image.build=true + +# Or with specific registry +mvn package \ + -Dquarkus.container-image.build=true \ + -Dquarkus.container-image.registry=docker.io \ + -Dquarkus.container-image.group=myorg \ + -Dquarkus.container-image.tag=1.0.0 + +# Test container +docker run -p 8080:8080 myorg/my-quarkus-app:1.0.0 +``` + +### Container Security Scan + +```bash +# Trivy +trivy image myorg/my-quarkus-app:1.0.0 + +# Grype +grype myorg/my-quarkus-app:1.0.0 +``` + +## Phase 9: Configuration Validation + +```bash +# Check all configuration properties +mvn quarkus:info + +# List all config sources +curl http://localhost:8080/q/dev/io.quarkus.quarkus-vertx-http/config +``` + +### Environment-Specific Checks + +- [ ] Database URLs configured per environment +- [ ] Secrets externalized (Vault, env vars) +- [ ] Logging levels appropriate +- [ ] CORS origins set correctly +- [ ] Rate limiting configured +- [ ] Monitoring/tracing enabled + +## Phase 10: Documentation Review + +- [ ] OpenAPI/Swagger docs up to date (`/q/swagger-ui`) +- [ ] README has setup instructions +- [ ] API changes documented +- [ ] Migration guide for breaking changes +- [ ] Configuration properties documented + +Generate OpenAPI spec: +```bash +curl http://localhost:8080/q/openapi -o openapi.json +``` + +## Verification Checklist + +### Code Quality +- [ ] Build passes without warnings +- [ ] Static analysis clean (no high/medium issues) +- [ ] Code follows team conventions +- [ ] No commented-out code or TODOs in PR + +### Testing +- [ ] All tests pass +- [ ] Code coverage ≥ 80% +- [ ] Integration tests with real database +- [ ] Security tests pass +- [ ] Performance within acceptable limits + +### Security +- [ ] No dependency vulnerabilities +- [ ] Authentication/authorization tested +- [ ] Input validation complete +- [ ] Secrets not in source code +- [ ] Security headers configured + +### Deployment +- [ ] Native compilation successful +- [ ] Container image builds +- [ ] Health checks respond correctly +- [ ] Configuration valid for target environment + +### Native Image +- [ ] Native executable builds +- [ ] Native tests pass +- [ ] Startup time < 100ms +- [ ] Memory footprint acceptable + +## Automated Verification Script + +```bash +#!/bin/bash +set -e + +echo "=== Phase 1: Build ===" +mvn clean verify -DskipTests + +echo "=== Phase 2: Static Analysis ===" +mvn checkstyle:check pmd:check spotbugs:check + +echo "=== Phase 3: Tests + Coverage ===" +mvn test jacoco:report jacoco:check + +echo "=== Phase 4: Security Scan ===" +mvn org.owasp:dependency-check-maven:check + +echo "=== Phase 5: Native Compilation ===" +mvn package -Dnative -Dquarkus.native.container-build=true + +echo "=== All Phases Complete ===" +echo "Review reports:" +echo " - Coverage: target/site/jacoco/index.html" +echo " - Security: target/dependency-check-report.html" +echo " - Native: target/*-runner" +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Verification + +on: [push, pull_request] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Build + run: mvn clean verify -DskipTests + + - name: Test with Coverage + run: mvn test jacoco:report jacoco:check + + - name: Security Scan + run: mvn org.owasp:dependency-check-maven:check + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: target/site/jacoco/jacoco.xml +``` + +## Best Practices + +- Run verification loop before every PR +- Automate in CI/CD pipeline +- Fix issues immediately; don't accumulate debt +- Keep coverage above 80% +- Update dependencies regularly +- Test native compilation periodically +- Monitor performance trends +- Document breaking changes +- Review security scan results +- Validate configuration for each environment diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 851e9697..ed296689 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -333,6 +333,10 @@ everything-claude-code/ | |-- laravel-verification/ # Laravel 验证循环(新增) | |-- python-patterns/ # Python 习惯用法与最佳实践(新增) | |-- python-testing/ # 使用 pytest 的 Python 测试(新增) +| |-- quarkus-patterns/ # Java Quarkus 模式(新增) +| |-- quarkus-security/ # Quarkus 安全(新增) +| |-- quarkus-tdd/ # Quarkus TDD(新增) +| |-- quarkus-verification/ # Quarkus 验证(新增) | |-- springboot-patterns/ # Java Spring Boot 模式(新增) | |-- springboot-security/ # Spring Boot 安全(新增) | |-- springboot-tdd/ # Spring Boot TDD(新增) @@ -657,7 +661,7 @@ cp -r everything-claude-code/.agents/skills/* ~/.claude/skills/ cp -r everything-claude-code/skills/search-first ~/.claude/skills/ # Optional: add niche/framework-specific skills only when needed -# for s in django-patterns django-tdd laravel-patterns springboot-patterns; do +# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do # cp -r everything-claude-code/skills/$s ~/.claude/skills/ # done ``` diff --git a/docs/zh-CN/agents/java-build-resolver.md b/docs/zh-CN/agents/java-build-resolver.md index 81597bff..d2e49bab 100644 --- a/docs/zh-CN/agents/java-build-resolver.md +++ b/docs/zh-CN/agents/java-build-resolver.md @@ -151,4 +151,6 @@ grep -A5 "annotationProcessorPaths\|annotationProcessor" pom.xml build.gradle 最终:`Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` -有关详细的 Java 和 Spring Boot 模式,请参阅 `skill: springboot-patterns`。 +有关详细的模式和示例: +* **[SPRING]**:请参阅 `skill: springboot-patterns` +* **[QUARKUS]**:请参阅 `skill: quarkus-patterns` diff --git a/docs/zh-CN/agents/java-reviewer.md b/docs/zh-CN/agents/java-reviewer.md index 406dc21e..1ba1e61f 100644 --- a/docs/zh-CN/agents/java-reviewer.md +++ b/docs/zh-CN/agents/java-reviewer.md @@ -102,4 +102,6 @@ grep -rn "FetchType.EAGER" src/main/java --include="*.java" * **警告**:仅存在**中**优先级问题 * **阻止**:发现**关键**或**高**优先级问题 -有关详细的Spring Boot模式和示例,请参阅 `skill: springboot-patterns`。 +有关详细的模式和示例: +* **[SPRING]**:请参阅 `skill: springboot-patterns` +* **[QUARKUS]**:请参阅 `skill: quarkus-patterns` diff --git a/docs/zh-CN/rules/java/patterns.md b/docs/zh-CN/rules/java/patterns.md index b62a28e6..5c1992cf 100644 --- a/docs/zh-CN/rules/java/patterns.md +++ b/docs/zh-CN/rules/java/patterns.md @@ -144,4 +144,5 @@ public record ApiResponse(boolean success, T data, String error) { ## 参考 有关 Spring Boot 架构模式,请参见技能:`springboot-patterns`。 +有关使用 Camel 和 Panache 的 Quarkus 架构模式,请参见技能:`quarkus-patterns`。 有关实体设计和查询优化,请参见技能:`jpa-patterns`。 diff --git a/docs/zh-CN/rules/java/security.md b/docs/zh-CN/rules/java/security.md index 2114aad2..74937751 100644 --- a/docs/zh-CN/rules/java/security.md +++ b/docs/zh-CN/rules/java/security.md @@ -98,4 +98,5 @@ try { ## 参考 关于 Spring Security 认证与授权模式,请参见技能:`springboot-security`。 +关于使用 JWT/OIDC、RBAC 和 CDI 的 Quarkus 安全模式,请参见技能:`quarkus-security`。 关于通用安全检查清单,请参见技能:`security-review`。 diff --git a/docs/zh-CN/rules/java/testing.md b/docs/zh-CN/rules/java/testing.md index ae7e1a72..91984348 100644 --- a/docs/zh-CN/rules/java/testing.md +++ b/docs/zh-CN/rules/java/testing.md @@ -113,6 +113,7 @@ class OrderRepositoryIT { ``` 关于 Spring Boot 集成测试,请参阅技能:`springboot-tdd`。 +关于 Quarkus 集成测试,请参阅技能:`quarkus-tdd`。 ## 测试命名 @@ -130,4 +131,5 @@ class OrderRepositoryIT { ## 参考 关于使用 MockMvc 和 Testcontainers 的 Spring Boot TDD 模式,请参阅技能:`springboot-tdd`。 +关于使用 REST Assured 和 Camel 测试的 Quarkus TDD 模式,请参阅技能:`quarkus-tdd`。 关于测试期望,请参阅技能:`java-coding-standards`。 diff --git a/docs/zh-CN/skills/configure-ecc/SKILL.md b/docs/zh-CN/skills/configure-ecc/SKILL.md index 369c5a3b..7cd6b9b7 100644 --- a/docs/zh-CN/skills/configure-ecc/SKILL.md +++ b/docs/zh-CN/skills/configure-ecc/SKILL.md @@ -126,6 +126,10 @@ mkdir -p $TARGET/skills $TARGET/rules | `java-coding-standards` | Spring Boot 的 Java 编码标准:命名、不可变性、Optional、流 | | `python-patterns` | Pythonic 惯用法、PEP 8、类型提示、最佳实践 | | `python-testing` | 使用 pytest、TDD、夹具、模拟、参数化进行 Python 测试 | +| `quarkus-patterns` | Quarkus 架构、使用 Camel 的事件驱动模式、Panache 数据访问、CDI 服务 | +| `quarkus-security` | Quarkus 安全:JWT/OIDC 认证、RBAC、Bean 验证、CORS、密钥管理 | +| `quarkus-tdd` | 使用 JUnit 5、Mockito、REST Assured、Camel 测试进行 Quarkus TDD | +| `quarkus-verification` | Quarkus 验证:构建、静态分析、测试、安全扫描、原生编译 | | `springboot-patterns` | Spring Boot 架构、REST API、分层服务、缓存、异步处理 | | `springboot-security` | Spring Security:认证/授权、验证、CSRF、密钥、速率限制 | | `springboot-tdd` | 使用 JUnit 5、Mockito、MockMvc、Testcontainers 进行 Spring Boot TDD | @@ -274,6 +278,7 @@ grep -rn "skills/" $TARGET/skills/ * `django-tdd` 可能会引用 `django-patterns` * `laravel-tdd` 可能会引用 `laravel-patterns` +* `quarkus-tdd` 可能会引用 `quarkus-patterns` * `springboot-tdd` 可能会引用 `springboot-patterns` * `continuous-learning-v2` 引用 `~/.claude/homunculus/` 目录 * `python-testing` 可能会引用 `python-patterns` diff --git a/docs/zh-CN/skills/prompt-optimizer/SKILL.md b/docs/zh-CN/skills/prompt-optimizer/SKILL.md index 73a3fbf0..763329d0 100644 --- a/docs/zh-CN/skills/prompt-optimizer/SKILL.md +++ b/docs/zh-CN/skills/prompt-optimizer/SKILL.md @@ -117,6 +117,7 @@ metadata: | Python / Django | django-patterns, django-tdd, django-security, django-verification, python-patterns, python-testing | python-reviewer | | Go | golang-patterns, golang-testing | go-reviewer, go-build-resolver | | Spring Boot / Java | springboot-patterns, springboot-tdd, springboot-security, springboot-verification, java-coding-standards, jpa-patterns | code-reviewer | +| Quarkus / Java | quarkus-patterns, quarkus-tdd, quarkus-security, quarkus-verification, java-coding-standards, jpa-patterns | code-reviewer | | Kotlin / Android | kotlin-coroutines-flows, compose-multiplatform-patterns, android-clean-architecture | kotlin-reviewer | | TypeScript / React | frontend-patterns, backend-patterns, coding-standards | code-reviewer | | Swift / iOS | swiftui-patterns, swift-concurrency-6-2, swift-actor-persistence, swift-protocol-di-testing | code-reviewer | diff --git a/docs/zh-CN/skills/quarkus-patterns/SKILL.md b/docs/zh-CN/skills/quarkus-patterns/SKILL.md new file mode 100644 index 00000000..117f02dc --- /dev/null +++ b/docs/zh-CN/skills/quarkus-patterns/SKILL.md @@ -0,0 +1,754 @@ +--- +name: quarkus-patterns +description: Quarkus 3.x LTS architecture patterns with Camel for messaging, RESTful API design, CDI services, data access with Panache, and async processing. Use for Java Quarkus backend work with event-driven architectures. +origin: ECC +--- + +# Quarkus Development Patterns + +Quarkus 3.x architecture and API patterns for cloud-native, event-driven services with Apache Camel. + +## When to Activate + +- Building REST APIs with JAX-RS or RESTEasy Reactive +- Structuring resource → service → repository layers +- Implementing event-driven patterns with Apache Camel and RabbitMQ +- Configuring Hibernate Panache, caching, or reactive streams +- Adding validation, exception mapping, or pagination +- Setting up profiles for dev/staging/production environments (YAML config) +- Custom logging with LogContext and Logback/Logstash encoder +- Working with CompletableFuture for async operations +- Implementing conditional flow processing +- Working with GraalVM native compilation + +## Service Layer with Multiple Dependencies (Lombok) + +```java +@Slf4j +@ApplicationScoped +@RequiredArgsConstructor +public class As2ProcessingService { + + private final InvoiceFlowValidator invoiceFlowValidator; + private final EventService eventService; + private final DocumentJobService documentJobService; + private final BusinessRulesPublisher businessRulesPublisher; + private final FileStorageService fileStorageService; + + public void processFile(Path filePath) throws Exception { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + + String structureIdPartner = logContext.get(As2Constants.STRUCTURE_ID); + + // Conditional flow logic + boolean isChorusFlow = Boolean.parseBoolean(logContext.get(As2Constants.CHORUS_FLOW)); + log.info("Is CHORUS_FLOW message: {}", isChorusFlow); + + ValidationFlowConfig validationFlowConfig = isChorusFlow + ? ValidationFlowConfig.xsdOnly() + : ValidationFlowConfig.allValidations(); + + InvoiceValidationResult invoiceValidationResult = this.invoiceFlowValidator + .validateFlowWithConfig(filePath, validationFlowConfig, + EInvoiceSyntaxFormat.UBL, logContext); + + FlowProfile flowProfile = isChorusFlow ? + FlowProfile.EXTENDED_CTC_FR : + this.invoiceFlowValidator.computeFlowProfile(invoiceValidationResult, + invoiceValidationResult.getInvoiceDetails().invoiceFormat().getProfile()); + + log.info("Invoice validation completed. Message is valid"); + + // CompletableFuture async operation + try(InputStream inputStream = Files.newInputStream(filePath)) { + CompletableFuture documentInfoCompletableFuture = + fileStorageService.uploadOriginalFile(inputStream, + invoiceValidationResult.getSize(), logContext, + invoiceValidationResult.getInvoiceFormat()); + + StoredDocumentInfo documentInfo = documentInfoCompletableFuture.join(); + log.info("File uploaded successfully: {}", documentInfo.getPath()); + + if (StringUtils.isBlank(documentInfo.getPath())) { + String errorMsg = "File path is empty after upload"; + log.error(errorMsg); + this.eventService.createErrorEvent(documentInfo, "FILE_UPLOAD_FAILED", errorMsg); + throw new As2ServerProcessingException(errorMsg); + } + + this.eventService.createSuccessEvent(documentInfo, "PERSISTENCE_BLOB_EVENT_TYPE"); + + BusinessRulesPayload payload = this.documentJobService.createDocumentAndJobEntities( + documentInfo, originalFileName, structureIdPartner, + flowProfile, invoiceValidationResult.getDocumentHash()); + + // Async Camel publishing + businessRulesPublisher.publishAsync(payload); + this.eventService.createSuccessEvent(payload, "BUSINESS_RULES_MESSAGE_SENT"); + } + } + } +} +``` + +**Key Patterns:** +- `@RequiredArgsConstructor` for constructor injection via Lombok +- `@Slf4j` for Logback logging +- Scoped LogContext with try-with-resources +- Conditional flow logic based on runtime parameters +- CompletableFuture with `.join()` for async operations +- Event tracking for success/error scenarios +- Async Camel message publishing + +## Custom Logging Context Pattern (Logback) + +```java +@ApplicationScoped +public class ProcessingService { + + public void processDocument(Document doc) { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + // Add context to all log statements + logContext.put("documentId", doc.getId().toString()); + logContext.put("documentType", doc.getType()); + logContext.put("userId", SecurityContext.getUserId()); + + log.info("Starting document processing"); + + // All logs within this scope inherit the context + processInternal(doc); + + log.info("Document processing completed"); + } catch (Exception e) { + log.error("Document processing failed", e); + throw e; + } + } +} +``` + +**Logback Configuration (logback.xml):** + +```xml + + + + true + true + + + + + + + + +``` + +## Event Service Pattern + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class EventService { + private final EventRepository eventRepository; + + public void createSuccessEvent(Object payload, String eventType) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.SUCCESS); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.info("Success event created: {}", eventType); + } + + public void createErrorEvent(Object payload, String eventType, String errorMessage) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.ERROR); + event.setErrorMessage(errorMessage); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.error("Error event created: {} - {}", eventType, errorMessage); + } + + private String serializePayload(Object payload) { + // JSON serialization + return objectMapper.writeValueAsString(payload); + } +} +``` + +## Camel Message Publishing (RabbitMQ) + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class BusinessRulesPublisher { + private final ProducerTemplate producerTemplate; + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + public void publishAsync(BusinessRulesPayload payload) { + producerTemplate.asyncSendBody( + "direct:business-rules-publisher", + payload + ); + log.info("Message published to business rules queue: {}", payload.getDocumentId()); + } + + public void publishSync(BusinessRulesPayload payload) { + producerTemplate.sendBody( + "direct:business-rules-publisher", + payload + ); + } +} +``` + +**Camel Route Configuration:** + +```java +@ApplicationScoped +public class BusinessRulesRoute extends RouteBuilder { + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + @ConfigProperty(name = "rabbitmq.host") + String rabbitHost; + + @ConfigProperty(name = "rabbitmq.port") + Integer rabbitPort; + + @Override + public void configure() { + from("direct:business-rules-publisher") + .routeId("business-rules-publisher") + .log("Publishing message to RabbitMQ: ${body}") + .marshal().json(JsonLibrary.Jackson) + .toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d", + businessRulesQueue, rabbitHost, rabbitPort); + } +} +``` + +## Camel Direct Routes (In-Memory) + +```java +@ApplicationScoped +public class DocumentProcessingRoute extends RouteBuilder { + + @Override + public void configure() { + // Error handling + onException(ValidationException.class) + .handled(true) + .to("direct:validation-error-handler") + .log("Validation error: ${exception.message}"); + + // Main processing route + from("direct:process-document") + .routeId("document-processing") + .log("Processing document: ${header.documentId}") + .bean(DocumentValidator.class, "validate") + .bean(DocumentTransformer.class, "transform") + .choice() + .when(header("documentType").isEqualTo("INVOICE")) + .to("direct:process-invoice") + .when(header("documentType").isEqualTo("CREDIT_NOTE")) + .to("direct:process-credit-note") + .otherwise() + .to("direct:process-generic") + .end(); + + from("direct:validation-error-handler") + .bean(EventService.class, "createErrorEvent") + .log("Validation error handled"); + } +} +``` + +## Camel File Processing + +```java +@ApplicationScoped +public class FileMonitoringRoute extends RouteBuilder { + + @ConfigProperty(name = "file.input.directory") + String inputDirectory; + + @ConfigProperty(name = "file.processed.directory") + String processedDirectory; + + @ConfigProperty(name = "file.error.directory") + String errorDirectory; + + @Override + public void configure() { + from("file:" + inputDirectory + "?move=" + processedDirectory + + "&moveFailed=" + errorDirectory + "&delay=5000") + .routeId("file-monitor") + .log("Processing file: ${header.CamelFileName}") + .to("direct:process-file"); + + from("direct:process-file") + .bean(As2ProcessingService.class, "processFile") + .log("File processing completed"); + } +} +``` + +## Camel Bean Invocation + +```java +@ApplicationScoped +public class InvoiceRoute extends RouteBuilder { + + @Override + public void configure() { + from("direct:invoice-validation") + .bean(InvoiceFlowValidator.class, "validateFlowWithConfig") + .log("Validation result: ${body}"); + + from("direct:persist-and-publish") + .bean(DocumentJobService.class, "createDocumentAndJobEntities") + .bean(BusinessRulesPublisher.class, "publishAsync") + .bean(EventService.class, "createSuccessEvent(${body}, 'PUBLISHED')"); + } +} +``` + +## REST API Structure + +```java +@Path("/api/documents") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class DocumentResource { + private final DocumentService documentService; + + @GET + public Response list( + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("20") int size) { + PaginatedList documents = documentService.list(page, size); + return Response.ok(documents).build(); + } + + @POST + public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) { + Document document = documentService.create(request); + URI location = uriInfo.getAbsolutePathBuilder() + .path(String.valueOf(document.id)) + .build(); + return Response.created(location).entity(DocumentResponse.from(document)).build(); + } + + @GET + @Path("/{id}") + public Response getById(@PathParam("id") Long id) { + return documentService.findById(id) + .map(DocumentResponse::from) + .map(Response::ok) + .orElse(Response.status(Response.Status.NOT_FOUND)) + .build(); + } +} +``` + +## Repository Pattern (Panache Repository) + +```java +@ApplicationScoped +public class DocumentRepository implements PanacheRepository { + + public List findByStatus(DocumentStatus status, int page, int size) { + return find("status = ?1 order by createdAt desc", status) + .page(page, size) + .list(); + } + + public Optional findByReferenceNumber(String referenceNumber) { + return find("referenceNumber", referenceNumber).firstResultOptional(); + } + + public long countByStatusAndDate(DocumentStatus status, LocalDate date) { + return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay()); + } +} +``` + +## Service Layer with Transactions + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentService { + private final DocumentRepository repo; + private final EventService eventService; + + @Transactional + public Document create(CreateDocumentRequest request) { + Document document = new Document(); + document.setReferenceNumber(request.referenceNumber()); + document.setDescription(request.description()); + document.setStatus(DocumentStatus.PENDING); + document.setCreatedAt(Instant.now()); + + repo.persist(document); + + eventService.createSuccessEvent(document, "DOCUMENT_CREATED"); + + return document; + } + + public Optional findById(Long id) { + return repo.findByIdOptional(id); + } + + public PaginatedList list(int page, int size) { + return repo.findAll() + .page(page, size) + .list(); + } +} +``` + +## DTOs and Validation + +```java +public record CreateDocumentRequest( + @NotBlank @Size(max = 200) String referenceNumber, + @NotBlank @Size(max = 2000) String description, + @NotNull @FutureOrPresent Instant validUntil, + @NotEmpty List<@NotBlank String> categories) {} + +public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) { + public static DocumentResponse from(Document document) { + return new DocumentResponse(document.getId(), document.getReferenceNumber(), + document.getStatus()); + } +} +``` + +## Exception Mapping + +```java +@Provider +public class ValidationExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(ConstraintViolationException exception) { + String message = exception.getConstraintViolations().stream() + .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage()) + .collect(Collectors.joining(", ")); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "validation_error", "message", message)) + .build(); + } +} + +@Provider +@Slf4j +public class GenericExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(Exception exception) { + log.error("Unhandled exception", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "internal_error", "message", "An unexpected error occurred")) + .build(); + } +} +``` + +## CompletableFuture Async Operations + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class FileStorageService { + private final S3Client s3Client; + private final ExecutorService executorService; + + public CompletableFuture uploadOriginalFile( + InputStream inputStream, + long size, + LogContext logContext, + InvoiceFormat format) { + + return CompletableFuture.supplyAsync(() -> { + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + String path = generateStoragePath(format); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(path) + .contentLength(size) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, size)); + + log.info("File uploaded to S3: {}", path); + + return new StoredDocumentInfo(path, size, Instant.now()); + } catch (Exception e) { + log.error("Failed to upload file to S3", e); + throw new StorageException("Upload failed", e); + } + }, executorService); + } +} +``` + +## Caching + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentCacheService { + private final DocumentRepository repo; + + @CacheResult(cacheName = "document-cache") + public Optional getById(@CacheKey Long id) { + return repo.findByIdOptional(id); + } + + @CacheInvalidate(cacheName = "document-cache") + public void evict(@CacheKey Long id) {} + + @CacheInvalidateAll(cacheName = "document-cache") + public void evictAll() {} +} +``` + +## Configuration as YAML + +```yaml +# application.yml +"%dev": + quarkus: + datasource: + jdbc: + url: jdbc:postgresql://localhost:5432/dev_db + username: dev_user + password: dev_pass + hibernate-orm: + database: + generation: drop-and-create + + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + +"%test": + quarkus: + datasource: + jdbc: + url: jdbc:h2:mem:test + hibernate-orm: + database: + generation: drop-and-create + +"%prod": + quarkus: + datasource: + jdbc: + url: ${DATABASE_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + hibernate-orm: + database: + generation: validate + + rabbitmq: + host: ${RABBITMQ_HOST} + port: ${RABBITMQ_PORT} + username: ${RABBITMQ_USER} + password: ${RABBITMQ_PASSWORD} + +# Camel configuration +camel: + rabbitmq: + queue: + business-rules: business-rules-queue + invoice-processing: invoice-processing-queue +``` + +## Health Checks + +```java +@Readiness +@ApplicationScoped +@RequiredArgsConstructor +public class DatabaseHealthCheck implements HealthCheck { + private final AgroalDataSource dataSource; + + @Override + public HealthCheckResponse call() { + try (Connection conn = dataSource.getConnection()) { + boolean valid = conn.isValid(2); + return HealthCheckResponse.named("Database connection") + .status(valid) + .build(); + } catch (SQLException e) { + return HealthCheckResponse.down("Database connection"); + } + } +} + +@Liveness +@ApplicationScoped +public class CamelHealthCheck implements HealthCheck { + @Inject + CamelContext camelContext; + + @Override + public HealthCheckResponse call() { + boolean isStarted = camelContext.getStatus().isStarted(); + return HealthCheckResponse.named("Camel Context") + .status(isStarted) + .build(); + } +} +``` + +## Dependencies (Maven) + +```xml + + 3.27.0 + 1.18.42 + 3.24.2 + 0.8.13 + 17 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + org.apache.camel.quarkus + camel-quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-config-yaml + + + + + org.apache.camel.quarkus + camel-quarkus-spring-rabbitmq + + + org.apache.camel.quarkus + camel-quarkus-direct + + + org.apache.camel.quarkus + camel-quarkus-bean + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + io.quarkiverse.logging.logback + quarkus-logging-logback + + + net.logstash.logback + logstash-logback-encoder + + +``` + +## Best Practices + +### Architecture +- Use `@RequiredArgsConstructor` with Lombok for constructor injection +- Keep service layer thin; delegate complex logic to specialized classes +- Use Camel routes for message routing and integration patterns +- Prefer Panache Repository pattern for data access + +### Event-Driven +- Always track operations with EventService (success/error events) +- Use Camel `direct:` endpoints for in-memory routing +- Use `spring-rabbitmq` component for RabbitMQ integration +- Implement async publishing with `ProducerTemplate.asyncSendBody()` + +### Logging +- Use Logback with Logstash encoder for structured logging +- Propagate LogContext through service calls with `SafeAutoCloseable` +- Add contextual information to LogContext for request tracing +- Use `@Slf4j` instead of manual logger instantiation + +### Async Operations +- Use CompletableFuture for non-blocking I/O operations +- Call `.join()` when you need to wait for completion +- Handle exceptions from CompletableFuture properly +- Pass LogContext to async operations for tracing + +### Configuration +- Use YAML configuration (`quarkus-config-yaml`) +- Profile-aware configuration for dev/test/prod environments +- Externalize sensitive configuration to environment variables +- Use `@ConfigProperty` for type-safe config injection + +### Validation +- Validate at resource layer with `@Valid` +- Use Bean Validation annotations on DTOs +- Map exceptions to proper HTTP responses with `@Provider` + +### Transactions +- Use `@Transactional` on service methods that modify data +- Keep transactions short and focused +- Avoid calling async operations within transactions + +### Testing +- Use `camel-quarkus-junit5` for route testing +- Use AssertJ for assertions +- Mock all external dependencies +- Test conditional flow logic thoroughly + +### Quarkus-Specific +- Stay on latest LTS version (3.x) +- Use Quarkus dev mode for hot reload +- Add health checks for production readiness +- Test native compilation compatibility periodically diff --git a/docs/zh-CN/skills/quarkus-security/SKILL.md b/docs/zh-CN/skills/quarkus-security/SKILL.md new file mode 100644 index 00000000..b3fa9705 --- /dev/null +++ b/docs/zh-CN/skills/quarkus-security/SKILL.md @@ -0,0 +1,453 @@ +--- +name: quarkus-security +description: Quarkus Security best practices for authentication, authorization, JWT/OIDC, RBAC, input validation, CSRF, secrets management, and dependency security. +origin: ECC +--- + +# Quarkus Security Review + +Best practices for securing Quarkus applications with authentication, authorization, and input validation. + +## When to Activate + +- Adding authentication (JWT, OIDC, Basic Auth) +- Implementing authorization with @RolesAllowed or SecurityIdentity +- Validating user input (Bean Validation, custom validators) +- Configuring CORS or security headers +- Managing secrets (Vault, environment variables, config sources) +- Adding rate limiting or brute-force protection +- Scanning dependencies for CVEs +- Working with MicroProfile JWT or SmallRye JWT + +## Authentication + +### JWT Authentication + +```java +// Resource protected with JWT +@Path("/api/protected") +@Authenticated +public class ProtectedResource { + + @Inject + JsonWebToken jwt; + + @Inject + SecurityIdentity securityIdentity; + + @GET + public Response getData() { + String username = jwt.getName(); + Set roles = jwt.getGroups(); + return Response.ok(Map.of( + "username", username, + "roles", roles, + "principal", securityIdentity.getPrincipal().getName() + )).build(); + } +} +``` + +Configuration (application.properties): +```properties +mp.jwt.verify.publickey.location=publicKey.pem +mp.jwt.verify.issuer=https://auth.example.com + +# OIDC +quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm +quarkus.oidc.client-id=backend-service +quarkus.oidc.credentials.secret=${OIDC_SECRET} +``` + +### Custom Authentication Filter + +```java +@Provider +@Priority(Priorities.AUTHENTICATION) +public class CustomAuthFilter implements ContainerRequestFilter { + + @Inject + SecurityIdentity identity; + + @Override + public void filter(ContainerRequestContext requestContext) { + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + // Validate token and set SecurityIdentity + if (!validateToken(token)) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + } + + private boolean validateToken(String token) { + // Token validation logic + return true; + } +} +``` + +## Authorization + +### Role-Based Access Control + +```java +@Path("/api/admin") +@RolesAllowed("ADMIN") +public class AdminResource { + + @GET + @Path("/users") + public List listUsers() { + return userService.findAll(); + } + + @DELETE + @Path("/users/{id}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) + public Response deleteUser(@PathParam("id") Long id) { + userService.delete(id); + return Response.noContent().build(); + } +} + +@Path("/api/users") +public class UserResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("/{id}") + @RolesAllowed("USER") + public Response getUser(@PathParam("id") Long id) { + // Check ownership + if (!securityIdentity.hasRole("ADMIN") && + !isOwner(id, securityIdentity.getPrincipal().getName())) { + return Response.status(Response.Status.FORBIDDEN).build(); + } + return Response.ok(userService.findById(id)).build(); + } + + private boolean isOwner(Long userId, String username) { + return userService.isOwner(userId, username); + } +} +``` + +### Programmatic Security + +```java +@ApplicationScoped +public class SecurityService { + + @Inject + SecurityIdentity securityIdentity; + + public boolean canAccessResource(Long resourceId) { + if (securityIdentity.isAnonymous()) { + return false; + } + + if (securityIdentity.hasRole("ADMIN")) { + return true; + } + + String userId = securityIdentity.getPrincipal().getName(); + return resourceRepository.isOwner(resourceId, userId); + } +} +``` + +## Input Validation + +### Bean Validation + +```java +// BAD: No validation +@POST +public Response createUser(UserDto dto) { + return Response.ok(userService.create(dto)).build(); +} + +// GOOD: Validated DTO +public record CreateUserDto( + @NotBlank @Size(max = 100) String name, + @NotBlank @Email String email, + @NotNull @Min(18) @Max(150) Integer age, + @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone +) {} + +@POST +@Path("/users") +public Response createUser(@Valid CreateUserDto dto) { + User user = userService.create(dto); + return Response.status(Response.Status.CREATED).entity(user).build(); +} +``` + +### Custom Validators + +```java +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +public @interface ValidUsername { + String message() default "Invalid username format"; + Class[] groups() default {}; + Class[] payload() default {}; +} + +public class UsernameValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return value.matches("^[a-zA-Z0-9_-]{3,20}$"); + } +} + +// Usage +public record CreateUserDto( + @ValidUsername String username, + @NotBlank @Email String email +) {} +``` + +## SQL Injection Prevention + +### Panache Active Record (Safe by Default) + +```java +// GOOD: Parameterized queries with Panache +List users = User.list("email = ?1 and active = ?2", email, true); + +Optional user = User.find("username", username).firstResultOptional(); + +// GOOD: Named parameters +List users = User.list("email = :email and age > :minAge", + Parameters.with("email", email).and("minAge", 18)); +``` + +### Native Queries (Use Parameters) + +```java +// BAD: String concatenation +@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true) + +// GOOD: Parameterized native query +@Entity +public class User extends PanacheEntity { + public static List findByEmailNative(String email) { + return getEntityManager() + .createNativeQuery("SELECT * FROM users WHERE email = :email", User.class) + .setParameter("email", email) + .getResultList(); + } +} +``` + +## Password Hashing + +```java +@ApplicationScoped +public class PasswordService { + + public String hash(String plainPassword) { + return BcryptUtil.bcryptHash(plainPassword); + } + + public boolean verify(String plainPassword, String hashedPassword) { + return BcryptUtil.matches(plainPassword, hashedPassword); + } +} + +// In service +@ApplicationScoped +public class UserService { + @Inject + PasswordService passwordService; + + @Transactional + public User register(CreateUserDto dto) { + String hashedPassword = passwordService.hash(dto.password()); + User user = new User(); + user.email = dto.email(); + user.password = hashedPassword; + user.persist(); + return user; + } + + public boolean authenticate(String email, String password) { + return User.find("email", email) + .firstResultOptional() + .map(u -> passwordService.verify(password, u.password)) + .orElse(false); + } +} +``` + +## CORS Configuration + +```properties +# application.properties +quarkus.http.cors=true +quarkus.http.cors.origins=https://app.example.com,https://admin.example.com +quarkus.http.cors.methods=GET,POST,PUT,DELETE +quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with +quarkus.http.cors.exposed-headers=Content-Disposition +quarkus.http.cors.access-control-max-age=24H +quarkus.http.cors.access-control-allow-credentials=true +``` + +## Secrets Management + +```properties +# application.properties - NO SECRETS HERE + +# Use environment variables +quarkus.datasource.username=${DB_USER} +quarkus.datasource.password=${DB_PASSWORD} +quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET} + +# Or use Vault +quarkus.vault.url=https://vault.example.com +quarkus.vault.authentication.kubernetes.role=my-role +``` + +### HashiCorp Vault Integration + +```java +@ApplicationScoped +public class SecretService { + + @ConfigProperty(name = "api-key") + String apiKey; // Fetched from Vault + + public String getSecret(String key) { + return ConfigProvider.getConfig().getValue(key, String.class); + } +} +``` + +## Rate Limiting + +```java +@ApplicationScoped +public class RateLimitFilter implements ContainerRequestFilter { + private final Map limiters = new ConcurrentHashMap<>(); + + @Override + public void filter(ContainerRequestContext requestContext) { + String clientId = getClientIdentifier(requestContext); + RateLimiter limiter = limiters.computeIfAbsent(clientId, + k -> RateLimiter.create(100.0)); // 100 requests per second + + if (!limiter.tryAcquire()) { + requestContext.abortWith( + Response.status(429) + .entity(Map.of("error", "Too many requests")) + .build() + ); + } + } + + private String getClientIdentifier(ContainerRequestContext ctx) { + // Use IP, API key, or user ID + return ctx.getHeaderString("X-Forwarded-For"); + } +} +``` + +## Security Headers + +```java +@Provider +public class SecurityHeadersFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext request, ContainerResponseContext response) { + MultivaluedMap headers = response.getHeaders(); + + // Prevent clickjacking + headers.putSingle("X-Frame-Options", "DENY"); + + // XSS protection + headers.putSingle("X-Content-Type-Options", "nosniff"); + headers.putSingle("X-XSS-Protection", "1; mode=block"); + + // HSTS + headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + + // CSP + headers.putSingle("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"); + } +} +``` + +## Audit Logging + +```java +@ApplicationScoped +public class AuditService { + private static final Logger LOG = Logger.getLogger(AuditService.class); + + @Inject + SecurityIdentity securityIdentity; + + public void logAccess(String resource, String action) { + String user = securityIdentity.isAnonymous() + ? "anonymous" + : securityIdentity.getPrincipal().getName(); + + LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s", + user, action, resource, Instant.now()); + } +} + +// Usage in resource +@Path("/api/sensitive") +public class SensitiveResource { + @Inject + AuditService auditService; + + @GET + @RolesAllowed("ADMIN") + public Response getData() { + auditService.logAccess("sensitive-data", "READ"); + return Response.ok(data).build(); + } +} +``` + +## Dependency Security Scanning + +```bash +# Maven +mvn org.owasp:dependency-check-maven:check + +# Gradle +./gradlew dependencyCheckAnalyze + +# Check Quarkus extensions +quarkus extension list --installable +``` + +## Best Practices + +- Always use HTTPS in production +- Enable JWT or OIDC for stateless authentication +- Use `@RolesAllowed` for declarative authorization +- Validate all input with Bean Validation +- Hash passwords with BCrypt (never plaintext) +- Store secrets in Vault or environment variables +- Use parameterized queries to prevent SQL injection +- Add security headers to all responses +- Implement rate limiting for public endpoints +- Audit sensitive operations +- Keep dependencies updated and scan for CVEs +- Use SecurityIdentity for programmatic checks +- Set appropriate CORS policies +- Test authentication and authorization paths diff --git a/docs/zh-CN/skills/quarkus-tdd/SKILL.md b/docs/zh-CN/skills/quarkus-tdd/SKILL.md new file mode 100644 index 00000000..c049ceda --- /dev/null +++ b/docs/zh-CN/skills/quarkus-tdd/SKILL.md @@ -0,0 +1,908 @@ +--- +name: quarkus-tdd +description: Test-driven development for Quarkus 3.x LTS using JUnit 5, Mockito, REST Assured, Camel testing, and JaCoCo. Use when adding features, fixing bugs, or refactoring event-driven services. +origin: ECC +--- + +# Quarkus TDD Workflow + +TDD guidance for Quarkus 3.x services with 80%+ coverage (unit + integration). Optimized for event-driven architectures with Apache Camel. + +## When to Use + +- New features or REST endpoints +- Bug fixes or refactors +- Adding data access logic, security rules, or reactive streams +- Testing Apache Camel routes and event handlers +- Testing event-driven services with RabbitMQ +- Testing conditional flow logic +- Validating CompletableFuture async operations +- Testing LogContext propagation + +## Workflow + +1. Write tests first (they should fail) +2. Implement minimal code to pass +3. Refactor with tests green +4. Enforce coverage with JaCoCo (80%+ target) + +## Unit Tests with @Nested Organization + +Follow this structured approach for comprehensive, readable tests: + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("As2ProcessingService Unit Tests") +class As2ProcessingServiceTest { + + @Mock + private InvoiceFlowValidator invoiceFlowValidator; + + @Mock + private EventService eventService; + + @Mock + private DocumentJobService documentJobService; + + @Mock + private BusinessRulesPublisher businessRulesPublisher; + + @Mock + private FileStorageService fileStorageService; + + @InjectMocks + private As2ProcessingService as2ProcessingService; + + private Path testFilePath; + private LogContext testLogContext; + private InvoiceValidationResult validationResult; + private StoredDocumentInfo documentInfo; + + @BeforeEach + void setUp() { + // ARRANGE - Common test data + testFilePath = Path.of("/tmp/test-invoice.xml"); + + testLogContext = new LogContext(); + testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001"); + testLogContext.put(As2Constants.FILE_NAME, "invoice.xml"); + testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001"); + + validationResult = new InvoiceValidationResult(); + validationResult.setValid(true); + validationResult.setSize(1024L); + validationResult.setDocumentHash("abc123"); + + documentInfo = new StoredDocumentInfo(); + documentInfo.setPath("s3://bucket/path/invoice.xml"); + documentInfo.setSize(1024L); + } + + @Nested + @DisplayName("Tests for processFile") + class ProcessFile { + + @Test + @DisplayName("Should successfully process non-CHORUS file with all validations") + void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class), + eq("PERSISTENCE_BLOB_EVENT_TYPE")); + verify(eventService).createSuccessEvent(any(BusinessRulesPayload.class), + eq("BUSINESS_RULES_MESSAGE_SENT")); + verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class)); + } + + @Test + @DisplayName("Should bypass schematron validation for CHORUS_FLOW") + void givenChorusFlow_whenProcessFile_thenSchematronBypassed() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "true"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(documentJobService).createDocumentAndJobEntities( + any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), + any()); + } + + @Test + @DisplayName("Should create error event when file upload fails") + void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + documentInfo.setPath(""); // Blank path triggers error + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + // ACT & ASSERT + As2ServerProcessingException exception = assertThrows( + As2ServerProcessingException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + + assertThat(exception.getMessage()) + .contains("File path is empty after upload"); + + verify(eventService).createErrorEvent( + eq(documentInfo), + eq("FILE_UPLOAD_FAILED"), + contains("File path is empty")); + + verify(businessRulesPublisher, never()).publishAsync(any()); + } + + @Test + @DisplayName("Should handle CompletableFuture.join() failure") + void givenAsyncUploadFailure_whenProcessFile_thenExceptionThrown() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + CompletableFuture failedFuture = + CompletableFuture.failedFuture(new StorageException("S3 connection failed")); + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(failedFuture); + + // ACT & ASSERT + assertThrows( + CompletionException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + } + + @Test + @DisplayName("Should throw exception when file path is null") + void givenNullFilePath_whenProcessFile_thenThrowsException() { + // ARRANGE + Path nullPath = null; + + // ACT & ASSERT + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> as2ProcessingService.processFile(nullPath) + ); + + verify(invoiceFlowValidator, never()).validateFlowWithConfig(any(), any(), any(), any()); + } + } +} +``` + +### Key Testing Patterns + +1. **@Nested Classes**: Group tests by method being tested +2. **@DisplayName**: Provide readable test descriptions for test reports +3. **Naming Convention**: `givenX_whenY_thenZ` for clarity +4. **AAA Pattern**: Explicit `// ARRANGE`, `// ACT`, `// ASSERT` comments +5. **@BeforeEach**: Setup common test data to reduce duplication +6. **assertDoesNotThrow**: Test success scenarios without catching exceptions +7. **assertThrows**: Test exception scenarios with message validation using AssertJ +8. **Comprehensive Coverage**: Test happy paths, null inputs, edge cases, exceptions +9. **Verify Interactions**: Use Mockito `verify()` to ensure methods are called correctly +10. **Never Verify**: Use `never()` to ensure methods are NOT called in error scenarios + +## Testing Camel Routes + +```java +@QuarkusTest +@DisplayName("Business Rules Camel Route Tests") +class BusinessRulesRouteTest { + + @Inject + CamelContext camelContext; + + @Inject + ProducerTemplate producerTemplate; + + @InjectMock + EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE - Test data + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + testPayload.setFlowProfile(FlowProfile.BASIC); + } + + @Nested + @DisplayName("Tests for business-rules-publisher route") + class BusinessRulesPublisher { + + @Test + @DisplayName("Should successfully publish message to RabbitMQ") + void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception { + // ARRANGE + MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class); + mockRabbitMQ.expectedMessageCount(1); + mockRabbitMQ.expectedBodiesReceived(testPayload); + + // Replace real endpoint with mock for testing + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.replaceFromWith("direct:business-rules-publisher"); + advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockRabbitMQ.assertIsSatisfied(5000); + + assertThat(mockRabbitMQ.getExchanges()).hasSize(1); + assertThat(mockRabbitMQ.getExchanges().get(0).getIn().getBody(BusinessRulesPayload.class)) + .isEqualTo(testPayload); + } + + @Test + @DisplayName("Should handle marshalling to JSON") + void givenPayload_whenPublish_thenMarshalledToJson() throws Exception { + // ARRANGE + MockEndpoint mockMarshal = new MockEndpoint("mock:marshal"); + camelContext.addEndpoint("mock:marshal", mockMarshal); + mockMarshal.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.weaveAddLast().to("mock:marshal"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockMarshal.assertIsSatisfied(5000); + + String body = mockMarshal.getExchanges().get(0).getIn().getBody(String.class); + assertThat(body).contains("\"documentId\":1"); + assertThat(body).contains("\"flowProfile\":\"BASIC\""); + } + } + + @Nested + @DisplayName("Tests for document-processing route") + class DocumentProcessing { + + @Test + @DisplayName("Should route invoice to correct processor") + void givenInvoiceType_whenProcess_thenRoutesToInvoiceProcessor() throws Exception { + // ARRANGE + MockEndpoint mockInvoice = camelContext.getEndpoint("mock:invoice", MockEndpoint.class); + mockInvoice.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:process-invoice.*").replace().to("mock:invoice"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // ACT + producerTemplate.sendBodyAndHeader("direct:process-document", + testPayload, "documentType", "INVOICE"); + + // ASSERT + mockInvoice.assertIsSatisfied(5000); + } + + @Test + @DisplayName("Should handle validation errors gracefully") + void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception { + // ARRANGE + MockEndpoint mockError = camelContext.getEndpoint("mock:error", MockEndpoint.class); + mockError.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:validation-error-handler.*") + .replace().to("mock:error"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // Mock validator to throw exception + when(eventService.validate(any())).thenThrow(new ValidationException("Invalid document")); + + // ACT + producerTemplate.sendBody("direct:process-document", testPayload); + + // ASSERT + mockError.assertIsSatisfied(5000); + + Exception exception = mockError.getExchanges().get(0).getException(); + assertThat(exception).isInstanceOf(ValidationException.class); + assertThat(exception.getMessage()).contains("Invalid document"); + } + } +} +``` + +## Testing Event Services + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("EventService Unit Tests") +class EventServiceTest { + + @Mock + private EventRepository eventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + } + + @Nested + @DisplayName("Tests for createSuccessEvent") + class CreateSuccessEvent { + + @Test + @DisplayName("Should create success event with correct attributes") + void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception { + // ARRANGE + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED")); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("DOCUMENT_PROCESSED") && + event.getStatus() == EventStatus.SUCCESS && + event.getPayload().equals("{\"documentId\":1}") && + event.getTimestamp() != null + )); + } + + @Test + @DisplayName("Should throw exception when payload is null") + void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() { + // ARRANGE + Object nullPayload = null; + + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE") + ); + + assertThat(exception.getMessage()).isEqualTo("Payload cannot be null"); + verify(eventRepository, never()).persist(any()); + } + } + + @Nested + @DisplayName("Tests for createErrorEvent") + class CreateErrorEvent { + + @Test + @DisplayName("Should create error event with error message") + void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception { + // ARRANGE + String errorMessage = "Processing failed"; + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createErrorEvent(testPayload, "PROCESSING_ERROR", errorMessage)); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("PROCESSING_ERROR") && + event.getStatus() == EventStatus.ERROR && + event.getErrorMessage().equals(errorMessage) && + event.getPayload().equals("{\"documentId\":1}") + )); + } + + @ParameterizedTest + @DisplayName("Should reject invalid error messages") + @ValueSource(strings = {"", " "}) + void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) { + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage) + ); + + assertThat(exception.getMessage()).contains("Error message cannot be blank"); + } + } +} +``` + +## Testing CompletableFuture + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("FileStorageService Unit Tests") +class FileStorageServiceTest { + + @Mock + private S3Client s3Client; + + @Mock + private ExecutorService executorService; + + @InjectMocks + private FileStorageService fileStorageService; + + private InputStream testInputStream; + private LogContext testLogContext; + + @BeforeEach + void setUp() { + // ARRANGE + testInputStream = new ByteArrayInputStream("test content".getBytes()); + testLogContext = new LogContext(); + testLogContext.put("traceId", "trace-123"); + } + + @Nested + @DisplayName("Tests for uploadOriginalFile") + class UploadOriginalFile { + + @Test + @DisplayName("Should successfully upload file and return document info") + void givenValidFile_whenUpload_thenReturnsDocumentInfo() throws Exception { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return CompletableFuture.completedFuture(callable.call()); + }); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + StoredDocumentInfo result = future.join(); + + // ASSERT + assertThat(result).isNotNull(); + assertThat(result.getPath()).isNotBlank(); + assertThat(result.getSize()).isEqualTo(1024L); + assertThat(result.getUploadedAt()).isNotNull(); + + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("Should handle S3 upload failure") + void givenS3Failure_whenUpload_thenCompletableFutureFails() { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + return CompletableFuture.failedFuture(new StorageException("S3 unavailable")); + }); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + // ASSERT + assertThatThrownBy(() -> future.join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(StorageException.class) + .hasMessageContaining("S3 unavailable"); + } + + @Test + @DisplayName("Should propagate LogContext to async operation") + void givenLogContext_whenUpload_thenContextPropagated() throws Exception { + // ARRANGE + AtomicReference capturedContext = new AtomicReference<>(); + + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + capturedContext.set(CustomLog.getCurrentContext()); + return CompletableFuture.completedFuture(callable.call()); + }); + + // ACT + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL).join(); + + // ASSERT + assertThat(capturedContext.get()).isNotNull(); + assertThat(capturedContext.get().get("traceId")).isEqualTo("trace-123"); + } + } +} +``` + +## Resource Layer Tests (REST Assured) + +```java +@QuarkusTest +@DisplayName("DocumentResource API Tests") +class DocumentResourceTest { + + @InjectMock + DocumentService documentService; + + @Nested + @DisplayName("Tests for GET /api/documents") + class ListDocuments { + + @Test + @DisplayName("Should return list of documents") + void givenDocumentsExist_whenList_thenReturnsOk() { + // ARRANGE + List documents = List.of(createDocument(1L, "DOC-001")); + when(documentService.list(0, 20)).thenReturn(documents); + + // ACT & ASSERT + given() + .when().get("/api/documents") + .then() + .statusCode(200) + .body("$.size()", is(1)) + .body("[0].referenceNumber", equalTo("DOC-001")); + } + } + + @Nested + @DisplayName("Tests for POST /api/documents") + class CreateDocument { + + @Test + @DisplayName("Should create document and return 201") + void givenValidRequest_whenCreate_thenReturns201() { + // ARRANGE + Document document = createDocument(1L, "DOC-001"); + when(documentService.create(any())).thenReturn(document); + + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "DOC-001", + "description": "Test document", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .header("Location", containsString("/api/documents/1")) + .body("referenceNumber", equalTo("DOC-001")); + } + + @Test + @DisplayName("Should return 400 for invalid input") + void givenInvalidRequest_whenCreate_thenReturns400() { + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "", + "description": "Test" + } + """) + .when().post("/api/documents") + .then() + .statusCode(400); + } + } + + private Document createDocument(Long id, String referenceNumber) { + Document document = new Document(); + document.setId(id); + document.setReferenceNumber(referenceNumber); + document.setStatus(DocumentStatus.PENDING); + return document; + } +} +``` + +## Integration Tests with Real Database + +```java +@QuarkusTest +@TestProfile(IntegrationTestProfile.class) +@DisplayName("Document Integration Tests") +class DocumentIntegrationTest { + + @Test + @Transactional + @DisplayName("Should create and retrieve document via API") + void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() { + // ACT - Create via API + Long id = given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "INT-001", + "description": "Integration test", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .extract().path("id"); + + // ASSERT - Retrieve via API + given() + .when().get("/api/documents/" + id) + .then() + .statusCode(200) + .body("referenceNumber", equalTo("INT-001")); + } +} +``` + +## Coverage with JaCoCo + +### Maven Configuration (Complete) + +```xml + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + + prepare-agent + + prepare-agent + + + + + + report + verify + + report + + + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.70 + + + + + + + + +``` + +Run tests with coverage: +```bash +mvn clean test +mvn jacoco:report +mvn jacoco:check + +# Report at: target/site/jacoco/index.html +``` + +## Test Dependencies + +```xml + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + + + org.mockito + mockito-core + test + + + + + org.assertj + assertj-core + 3.24.2 + test + + + + + io.rest-assured + rest-assured + test + + + + + org.apache.camel.quarkus + camel-quarkus-junit5 + test + + +``` + +## Best Practices + +### Test Organization +- Use `@Nested` classes to group tests by method being tested +- Use `@DisplayName` for readable test descriptions visible in reports +- Follow `givenX_whenY_thenZ` naming convention for test methods +- Use `@BeforeEach` for common test data setup to reduce duplication + +### Test Structure +- Follow AAA pattern with explicit comments (`// ARRANGE`, `// ACT`, `// ASSERT`) +- Use `assertDoesNotThrow` for success scenarios +- Use `assertThrows` for exception scenarios with message validation +- Verify exception messages match expected values using AssertJ `contains()` or `isEqualTo()` + +### Test Coverage +- Test happy paths for all public methods +- Test null input handling +- Test edge cases (empty collections, boundary values, negative IDs, blank strings) +- Test exception scenarios comprehensively +- Mock all external dependencies (repositories, services, Camel endpoints) +- Aim for 80%+ line coverage, 70%+ branch coverage + +### Assertions +- **Always use AssertJ** (`assertThat`) instead of JUnit assertions +- Use fluent AssertJ API for readability: `assertThat(list).hasSize(3).contains(item)` +- For exceptions: `assertThatThrownBy(() -> ...).isInstanceOf(...).hasMessageContaining(...)` +- For collections: `extracting()`, `filteredOn()`, `containsExactly()` + +### Testing Integration +- Use `@QuarkusTest` for integration tests +- Use `@InjectMock` to mock dependencies in Quarkus tests +- Prefer REST Assured for API testing +- Use `@TestProfile` for test-specific configuration + +### Event-Driven Testing +- Test Camel routes with `AdviceWith` and `MockEndpoint` +- Use `@CamelQuarkusTest` annotation (if using standalone Camel tests) +- Verify message content, headers, and routing logic +- Test error handling routes separately +- Mock external systems (RabbitMQ, S3, databases) in unit tests + +### Camel Route Testing +- Use `MockEndpoint` for asserting message flow +- Use `AdviceWith` to modify routes for testing (replace endpoints with mocks) +- Test message transformation and marshalling +- Test exception handling and dead letter queues + +### Testing Async Operations +- Test CompletableFuture success and failure scenarios +- Use `.join()` in tests to wait for async completion +- Test exception propagation from CompletableFuture +- Verify LogContext propagation to async operations + +### Performance +- Keep tests fast and isolated +- Run tests in continuous mode: `mvn quarkus:test` +- Use parameterized tests (`@ParameterizedTest`) for input variations +- Build reusable test data builders or factory methods + +### Quarkus-Specific +- Stay on latest LTS version (Quarkus 3.x) +- Test native compilation compatibility periodically +- Use Quarkus test profiles for different scenarios +- Leverage Quarkus dev services for local testing +- Use `@InjectMock` instead of `@MockBean` (Quarkus-specific) + +### Verification Best Practices +- Always verify interactions on mocked dependencies +- Use `verify(mock, never())` to ensure methods are NOT called in error scenarios +- Use `argThat()` for complex argument matching +- Verify the order of calls when it matters: `InOrder` from Mockito diff --git a/docs/zh-CN/skills/quarkus-verification/SKILL.md b/docs/zh-CN/skills/quarkus-verification/SKILL.md new file mode 100644 index 00000000..8c5ea5a6 --- /dev/null +++ b/docs/zh-CN/skills/quarkus-verification/SKILL.md @@ -0,0 +1,481 @@ +--- +name: quarkus-verification +description: "Verification loop for Quarkus projects: build, static analysis, tests with coverage, security scans, native compilation, and diff review before release or PR." +origin: ECC +--- + +# Quarkus Verification Loop + +Run before PRs, after major changes, and pre-deploy. + +## When to Activate + +- Before opening a pull request for a Quarkus service +- After major refactoring or dependency upgrades +- Pre-deployment verification for staging or production +- Running full build → lint → test → security scan → native compilation pipeline +- Validating test coverage meets thresholds (80%+) +- Testing native image compatibility + +## Phase 1: Build + +```bash +# Maven +mvn clean verify -DskipTests + +# Gradle +./gradlew clean assemble -x test +``` + +If build fails, stop and fix compilation errors. + +## Phase 2: Static Analysis + +### Checkstyle, PMD, SpotBugs (Maven) + +```bash +mvn checkstyle:check pmd:check spotbugs:check +``` + +### SonarQube (if configured) + +```bash +mvn sonar:sonar \ + -Dsonar.projectKey=my-quarkus-project \ + -Dsonar.host.url=http://localhost:9000 \ + -Dsonar.login=${SONAR_TOKEN} +``` + +### Common Issues to Address + +- Unused imports or variables +- Complex methods (high cyclomatic complexity) +- Potential null pointer dereferences +- Security issues flagged by SpotBugs + +## Phase 3: Tests + Coverage + +```bash +# Run all tests +mvn clean test + +# Generate coverage report +mvn jacoco:report + +# Enforce coverage threshold (80%) +mvn jacoco:check + +# Or with Gradle +./gradlew test jacocoTestReport jacocoTestCoverageVerification +``` + +### Test Categories + +#### Unit Tests +Test service logic with mocked dependencies: + +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository userRepository; + @InjectMocks UserService userService; + + @Test + void createUser_validInput_returnsUser() { + var dto = new CreateUserDto("Alice", "alice@example.com"); + var expected = new User(); + expected.id = 1L; + expected.name = dto.name(); + + when(userRepository.persist(any(User.class))).thenReturn(expected); + + User result = userService.create(dto); + + assertThat(result.name).isEqualTo("Alice"); + verify(userRepository).persist(any(User.class)); + } +} +``` + +#### Integration Tests +Test with real database (Testcontainers): + +```java +@QuarkusTest +@QuarkusTestResource(PostgresTestResource.class) +class UserRepositoryIntegrationTest { + + @Inject + UserRepository userRepository; + + @Test + @Transactional + void findByEmail_existingUser_returnsUser() { + User user = new User(); + user.name = "Alice"; + user.email = "alice@example.com"; + userRepository.persist(user); + + Optional found = userRepository.findByEmail("alice@example.com"); + + assertThat(found).isPresent(); + assertThat(found.get().name).isEqualTo("Alice"); + } +} +``` + +#### API Tests +Test REST endpoints with REST Assured: + +```java +@QuarkusTest +class UserResourceTest { + + @Test + void createUser_validInput_returns201() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "alice@example.com"} + """) + .when().post("/api/users") + .then() + .statusCode(201) + .body("name", equalTo("Alice")); + } + + @Test + void createUser_invalidEmail_returns400() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "invalid"} + """) + .when().post("/api/users") + .then() + .statusCode(400); + } +} +``` + +### Coverage Report + +Check `target/site/jacoco/index.html` for detailed coverage: +- Overall line coverage (target: 80%+) +- Branch coverage (target: 70%+) +- Identify uncovered critical paths + +## Phase 4: Security Scanning + +### Dependency Vulnerabilities (Maven) + +```bash +mvn org.owasp:dependency-check-maven:check +``` + +Review `target/dependency-check-report.html` for CVEs. + +### Quarkus Security Audit + +```bash +# Check vulnerable extensions +mvn quarkus:audit + +# List all extensions +mvn quarkus:list-extensions +``` + +### OWASP ZAP (API Security Testing) + +```bash +docker run -t owasp/zap2docker-stable zap-api-scan.py \ + -t http://localhost:8080/q/openapi \ + -f openapi +``` + +### Common Security Checks + +- [ ] All secrets in environment variables (not in code) +- [ ] Input validation on all endpoints +- [ ] Authentication/authorization configured +- [ ] CORS properly configured +- [ ] Security headers set +- [ ] Passwords hashed with BCrypt +- [ ] SQL injection protection (parameterized queries) +- [ ] Rate limiting on public endpoints + +## Phase 5: Native Compilation + +Test GraalVM native image compatibility: + +```bash +# Build native executable +mvn package -Dnative + +# Or with container +mvn package -Dnative -Dquarkus.native.container-build=true + +# Test native executable +./target/*-runner + +# Run basic smoke tests +curl http://localhost:8080/q/health/live +curl http://localhost:8080/q/health/ready +``` + +### Native Image Troubleshooting + +Common issues: +- **Reflection**: Add reflection config for dynamic classes +- **Resources**: Include resources with `quarkus.native.resources.includes` +- **JNI**: Register JNI classes if using native libraries + +Example reflection config: +```java +@RegisterForReflection(targets = {MyDynamicClass.class}) +public class ReflectionConfiguration {} +``` + +## Phase 6: Performance Testing + +### Load Testing with K6 + +```javascript +// load-test.js +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '30s', target: 0 }, + ], +}; + +export default function () { + const res = http.get('http://localhost:8080/api/markets'); + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 200ms': (r) => r.timings.duration < 200, + }); +} +``` + +Run: +```bash +k6 run load-test.js +``` + +### Metrics to Monitor + +- Response time (p50, p95, p99) +- Throughput (requests/sec) +- Error rate +- Memory usage +- CPU usage + +## Phase 7: Health Checks + +```bash +# Liveness +curl http://localhost:8080/q/health/live + +# Readiness +curl http://localhost:8080/q/health/ready + +# All health checks +curl http://localhost:8080/q/health + +# Metrics (if enabled) +curl http://localhost:8080/q/metrics +``` + +Expected responses: +```json +{ + "status": "UP", + "checks": [ + { + "name": "Database connection", + "status": "UP" + } + ] +} +``` + +## Phase 8: Container Image Build + +```bash +# Build container image +mvn package -Dquarkus.container-image.build=true + +# Or with specific registry +mvn package \ + -Dquarkus.container-image.build=true \ + -Dquarkus.container-image.registry=docker.io \ + -Dquarkus.container-image.group=myorg \ + -Dquarkus.container-image.tag=1.0.0 + +# Test container +docker run -p 8080:8080 myorg/my-quarkus-app:1.0.0 +``` + +### Container Security Scan + +```bash +# Trivy +trivy image myorg/my-quarkus-app:1.0.0 + +# Grype +grype myorg/my-quarkus-app:1.0.0 +``` + +## Phase 9: Configuration Validation + +```bash +# Check all configuration properties +mvn quarkus:info + +# List all config sources +curl http://localhost:8080/q/dev/io.quarkus.quarkus-vertx-http/config +``` + +### Environment-Specific Checks + +- [ ] Database URLs configured per environment +- [ ] Secrets externalized (Vault, env vars) +- [ ] Logging levels appropriate +- [ ] CORS origins set correctly +- [ ] Rate limiting configured +- [ ] Monitoring/tracing enabled + +## Phase 10: Documentation Review + +- [ ] OpenAPI/Swagger docs up to date (`/q/swagger-ui`) +- [ ] README has setup instructions +- [ ] API changes documented +- [ ] Migration guide for breaking changes +- [ ] Configuration properties documented + +Generate OpenAPI spec: +```bash +curl http://localhost:8080/q/openapi -o openapi.json +``` + +## Verification Checklist + +### Code Quality +- [ ] Build passes without warnings +- [ ] Static analysis clean (no high/medium issues) +- [ ] Code follows team conventions +- [ ] No commented-out code or TODOs in PR + +### Testing +- [ ] All tests pass +- [ ] Code coverage ≥ 80% +- [ ] Integration tests with real database +- [ ] Security tests pass +- [ ] Performance within acceptable limits + +### Security +- [ ] No dependency vulnerabilities +- [ ] Authentication/authorization tested +- [ ] Input validation complete +- [ ] Secrets not in source code +- [ ] Security headers configured + +### Deployment +- [ ] Native compilation successful +- [ ] Container image builds +- [ ] Health checks respond correctly +- [ ] Configuration valid for target environment + +### Native Image +- [ ] Native executable builds +- [ ] Native tests pass +- [ ] Startup time < 100ms +- [ ] Memory footprint acceptable + +## Automated Verification Script + +```bash +#!/bin/bash +set -e + +echo "=== Phase 1: Build ===" +mvn clean verify -DskipTests + +echo "=== Phase 2: Static Analysis ===" +mvn checkstyle:check pmd:check spotbugs:check + +echo "=== Phase 3: Tests + Coverage ===" +mvn test jacoco:report jacoco:check + +echo "=== Phase 4: Security Scan ===" +mvn org.owasp:dependency-check-maven:check + +echo "=== Phase 5: Native Compilation ===" +mvn package -Dnative -Dquarkus.native.container-build=true + +echo "=== All Phases Complete ===" +echo "Review reports:" +echo " - Coverage: target/site/jacoco/index.html" +echo " - Security: target/dependency-check-report.html" +echo " - Native: target/*-runner" +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Verification + +on: [push, pull_request] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Build + run: mvn clean verify -DskipTests + + - name: Test with Coverage + run: mvn test jacoco:report jacoco:check + + - name: Security Scan + run: mvn org.owasp:dependency-check-maven:check + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: target/site/jacoco/jacoco.xml +``` + +## Best Practices + +- Run verification loop before every PR +- Automate in CI/CD pipeline +- Fix issues immediately; don't accumulate debt +- Keep coverage above 80% +- Update dependencies regularly +- Test native compilation periodically +- Monitor performance trends +- Document breaking changes +- Review security scan results +- Validate configuration for each environment diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 29c4b841..f1bb8900 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -147,6 +147,9 @@ "skills/python-testing", "skills/rust-patterns", "skills/rust-testing", + "skills/quarkus-patterns", + "skills/quarkus-tdd", + "skills/quarkus-verification", "skills/springboot-patterns", "skills/springboot-tdd", "skills/springboot-verification" @@ -248,6 +251,7 @@ "skills/security-review", "skills/security-scan", "skills/security-bounty-hunter", + "skills/quarkus-security", "skills/springboot-security", "skills/evm-token-decimals", "the-security-guide.md" diff --git a/rules/java/patterns.md b/rules/java/patterns.md index 570282bd..a07cd5c8 100644 --- a/rules/java/patterns.md +++ b/rules/java/patterns.md @@ -143,4 +143,5 @@ public record ApiResponse(boolean success, T data, String error) { ## References See skill: `springboot-patterns` for Spring Boot architecture patterns. +See skill: `quarkus-patterns` for Quarkus architecture patterns with Camel and Panache. See skill: `jpa-patterns` for entity design and query optimization. diff --git a/rules/java/security.md b/rules/java/security.md index 31ca61b6..cbedc120 100644 --- a/rules/java/security.md +++ b/rules/java/security.md @@ -97,4 +97,5 @@ try { ## References See skill: `springboot-security` for Spring Security authentication and authorization patterns. +See skill: `quarkus-security` for Quarkus security with JWT/OIDC, RBAC, and CDI. See skill: `security-review` for general security checklists. diff --git a/rules/java/testing.md b/rules/java/testing.md index aa2e91f3..7be629a7 100644 --- a/rules/java/testing.md +++ b/rules/java/testing.md @@ -112,6 +112,7 @@ class OrderRepositoryIT { ``` For Spring Boot integration tests, see skill: `springboot-tdd`. +For Quarkus integration tests, see skill: `quarkus-tdd`. ## Test Naming @@ -128,4 +129,5 @@ Use descriptive names with `@DisplayName`: ## References See skill: `springboot-tdd` for Spring Boot TDD patterns with MockMvc and Testcontainers. +See skill: `quarkus-tdd` for Quarkus TDD patterns with REST Assured and Camel testing. See skill: `java-coding-standards` for testing expectations. diff --git a/skills/configure-ecc/SKILL.md b/skills/configure-ecc/SKILL.md index aa4ff175..ab9d51be 100644 --- a/skills/configure-ecc/SKILL.md +++ b/skills/configure-ecc/SKILL.md @@ -87,7 +87,7 @@ There are 7 selectable category groups below. The detailed confirmation lists th ``` Question: "Which skill categories do you want to install?" Options: - - "Framework & Language" — "Django, Laravel, Spring Boot, Go, Python, Java, Frontend, Backend patterns" + - "Framework & Language" — "Django, Laravel, Spring Boot, Quarkus, Go, Python, Java, Frontend, Backend patterns" - "Database" — "PostgreSQL, ClickHouse, JPA/Hibernate patterns" - "Workflow & Quality" — "TDD, verification, learning, security review, compaction" - "Research & APIs" — "Deep research, Exa search, Claude API patterns" @@ -101,7 +101,7 @@ Options: For each selected category, print the full list of skills below and ask the user to confirm or deselect specific ones. If the list exceeds 4 items, print the list as text and use `AskUserQuestion` with an "Install all listed" option plus "Other" for the user to paste specific names. -**Category: Framework & Language (21 skills)** +**Category: Framework & Language (25 skills)** | Skill | Description | |-------|-------------| @@ -122,6 +122,10 @@ For each selected category, print the full list of skills below and ask the user | `java-coding-standards` | Java coding standards for Spring Boot: naming, immutability, Optional, streams | | `python-patterns` | Pythonic idioms, PEP 8, type hints, best practices | | `python-testing` | Python testing with pytest, TDD, fixtures, mocking, parametrization | +| `quarkus-patterns` | Quarkus architecture, Camel messaging, CDI services, Panache data access | +| `quarkus-security` | Quarkus security: JWT/OIDC, RBAC, input validation, secrets management | +| `quarkus-tdd` | Quarkus TDD with JUnit 5, Mockito, REST Assured, Camel testing | +| `quarkus-verification` | Quarkus verification: build, static analysis, tests, native compilation | | `springboot-patterns` | Spring Boot architecture, REST API, layered services, caching, async | | `springboot-security` | Spring Security: authn/authz, validation, CSRF, secrets, rate limiting | | `springboot-tdd` | Spring Boot TDD with JUnit 5, Mockito, MockMvc, Testcontainers | @@ -263,6 +267,7 @@ grep -rn "skills/" $TARGET/skills/ Some skills reference others. Verify these dependencies: - `django-tdd` may reference `django-patterns` - `laravel-tdd` may reference `laravel-patterns` +- `quarkus-tdd` may reference `quarkus-patterns` - `springboot-tdd` may reference `springboot-patterns` - `continuous-learning-v2` references `~/.claude/homunculus/` directory - `python-testing` may reference `python-patterns` diff --git a/skills/java-coding-standards/SKILL.md b/skills/java-coding-standards/SKILL.md index a66be23e..5aa0f28d 100644 --- a/skills/java-coding-standards/SKILL.md +++ b/skills/java-coding-standards/SKILL.md @@ -1,20 +1,29 @@ --- name: java-coding-standards -description: "Java coding standards for Spring Boot services: naming, immutability, Optional usage, streams, exceptions, generics, and project layout." +description: "Java coding standards for Spring Boot and Quarkus services: naming, immutability, Optional usage, streams, exceptions, generics, CDI, reactive patterns, and project layout. Automatically applies framework-specific conventions." origin: ECC --- # Java Coding Standards -Standards for readable, maintainable Java (17+) code in Spring Boot services. +Standards for readable, maintainable Java (17+) code in Spring Boot and Quarkus services. + +## Framework Detection + +Before applying standards, determine the framework from the build file: + +- Build file contains `quarkus` → apply **[QUARKUS]** conventions +- Build file contains `spring-boot` → apply **[SPRING]** conventions +- Neither detected → apply shared conventions only ## When to Activate -- Writing or reviewing Java code in Spring Boot projects +- Writing or reviewing Java code in Spring Boot or Quarkus projects - Enforcing naming, immutability, or exception handling conventions - Working with records, sealed classes, or pattern matching (Java 17+) - Reviewing use of Optional, streams, or generics - Structuring packages and project layout +- **[QUARKUS]**: Working with CDI scopes, Panache entities, or reactive pipelines ## Core Principles @@ -22,6 +31,7 @@ Standards for readable, maintainable Java (17+) code in Spring Boot services. - Immutable by default; minimize shared mutable state - Fail fast with meaningful exceptions - Consistent naming and package structure +- **[QUARKUS]**: Favor build-time over runtime processing; avoid runtime reflection where possible ## Naming @@ -36,6 +46,12 @@ public Market findBySlug(String slug) {} // PASS: Constants: UPPER_SNAKE_CASE private static final int MAX_PAGE_SIZE = 100; + +// PASS: [QUARKUS] JAX-RS resources named as *Resource, not *Controller +public class MarketResource {} + +// PASS: [SPRING] REST controllers named as *Controller +public class MarketController {} ``` ## Immutability @@ -49,14 +65,33 @@ public class Market { private final String name; // getters only, no setters } + +// PASS: [QUARKUS] Panache active-record entities use public fields (Quarkus convention) +@Entity +public class Market extends PanacheEntity { + public String name; + public MarketStatus status; + // Panache generates accessors at build time; public fields are idiomatic here +} + +// PASS: [QUARKUS] Panache MongoDB entities +@MongoEntity(collection = "markets") +public class Market extends PanacheMongoEntity { + public String name; + public MarketStatus status; +} ``` ## Optional Usage ```java // PASS: Return Optional from find* methods +// [SPRING] Optional market = marketRepository.findBySlug(slug); +// [QUARKUS] Panache +Optional market = Market.find("slug", slug).firstResultOptional(); + // PASS: Map/flatMap instead of get() return market .map(MarketResponse::from) @@ -75,6 +110,77 @@ List names = markets.stream() // FAIL: Avoid complex nested streams; prefer loops for clarity ``` +## Dependency Injection + +```java +// PASS: [SPRING] Constructor injection (preferred over @Autowired on fields) +@Service +public class MarketService { + private final MarketRepository marketRepository; + + public MarketService(MarketRepository marketRepository) { + this.marketRepository = marketRepository; + } +} + +// PASS: [QUARKUS] Constructor injection +@ApplicationScoped +public class MarketService { + private final MarketRepository marketRepository; + + @Inject + public MarketService(MarketRepository marketRepository) { + this.marketRepository = marketRepository; + } +} + +// PASS: [QUARKUS] Package-private field injection (acceptable in Quarkus — avoids proxy issues) +@ApplicationScoped +public class MarketService { + @Inject + MarketRepository marketRepository; +} + +// FAIL: [SPRING] Field injection with @Autowired +@Autowired +private MarketRepository marketRepository; // use constructor injection + +// FAIL: [QUARKUS] @Singleton when interception or lazy init is needed +@Singleton // non-proxyable — use @ApplicationScoped instead +public class MarketService {} +``` + +## Reactive Patterns [QUARKUS] + +```java +// PASS: Return Uni/Multi from reactive endpoints +@GET +@Path("/{slug}") +public Uni findBySlug(@PathParam("slug") String slug) { + return Market.find("slug", slug) + .firstResult() + .onItem().ifNull().failWith(() -> new MarketNotFoundException(slug)); +} + +// PASS: Non-blocking pipeline composition +public Uni placeOrder(OrderRequest req) { + return validateOrder(req) + .chain(valid -> persistOrder(valid)) + .chain(order -> notifyFulfillment(order)); +} + +// FAIL: Blocking call inside a Uni/Multi pipeline +public Uni find(String slug) { + Market m = Market.find("slug", slug).firstResult(); // BLOCKING — breaks event loop + return Uni.createFrom().item(m); +} + +// FAIL: Subscribing more than once to a shared Uni +Uni shared = fetchMarket(slug); +shared.subscribe().with(m -> log(m)); +shared.subscribe().with(m -> cache(m)); // double subscribe — use Uni.memoize() +``` + ## Exceptions - Use unchecked exceptions for domain errors; wrap technical exceptions with context @@ -85,6 +191,34 @@ List names = markets.stream() throw new MarketNotFoundException(slug); ``` +### Centralised Exception Handling + +```java +// [SPRING] +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(MarketNotFoundException.class) + public ResponseEntity handle(MarketNotFoundException ex) { + return ResponseEntity.status(404).body(ErrorResponse.from(ex)); + } +} + +// [QUARKUS] Option A: ExceptionMapper +@Provider +public class MarketNotFoundMapper implements ExceptionMapper { + @Override + public Response toResponse(MarketNotFoundException ex) { + return Response.status(404).entity(ErrorResponse.from(ex)).build(); + } +} + +// [QUARKUS] Option B: @ServerExceptionMapper (RESTEasy Reactive) +@ServerExceptionMapper +public RestResponse handle(MarketNotFoundException ex) { + return RestResponse.status(Status.NOT_FOUND, ErrorResponse.from(ex)); +} +``` + ## Generics and Type Safety - Avoid raw types; declare generic parameters @@ -94,7 +228,9 @@ throw new MarketNotFoundException(slug); public Map indexById(Collection items) { ... } ``` -## Project Structure (Maven/Gradle) +## Project Structure + +### [SPRING] Maven/Gradle ``` src/main/java/com/example/app/ @@ -110,6 +246,24 @@ src/main/resources/ src/test/java/... (mirrors main) ``` +### [QUARKUS] Maven/Gradle + +``` +src/main/java/com/example/app/ + config/ # @ConfigMapping, @ConfigProperty beans, Producers + resource/ # JAX-RS resources (not "controller") + service/ + repository/ # PanacheRepository implementations (if not using active record) + domain/ # JPA/Panache entities, MongoDB entities + dto/ + util/ + mapper/ # MapStruct mappers (if used) +src/main/resources/ + application.properties # Quarkus convention (YAML supported with quarkus-config-yaml) + import.sql # Hibernate auto-import for dev/test +src/test/java/... (mirrors main) +``` + ## Formatting and Style - Use 2 or 4 spaces consistently (project standard) @@ -124,24 +278,98 @@ src/test/java/... (mirrors main) - Magic numbers → named constants - Static mutable state → prefer dependency injection - Silent catch blocks → log and act or rethrow +- **[QUARKUS]**: `@Singleton` where `@ApplicationScoped` is intended — breaks proxying and interception +- **[QUARKUS]**: Mixing `quarkus-resteasy-reactive` and `quarkus-resteasy` (classic) — pick one stack +- **[QUARKUS]**: Panache active-record + repository pattern in the same bounded context — pick one ## Logging ```java +// [SPRING] SLF4J private static final Logger log = LoggerFactory.getLogger(MarketService.class); log.info("fetch_market slug={}", slug); log.error("failed_fetch_market slug={}", slug, ex); + +// [QUARKUS] JBoss Logging (default, zero-cost at build time) +private static final Logger log = Logger.getLogger(MarketService.class); +log.infof("fetch_market slug=%s", slug); +log.errorf(ex, "failed_fetch_market slug=%s", slug); + +// [QUARKUS] Alternative: simplified logging with @Inject +@Inject +Logger log; // CDI-injected, scoped to declaring class ``` ## Null Handling - Accept `@Nullable` only when unavoidable; otherwise use `@NonNull` - Use Bean Validation (`@NotNull`, `@NotBlank`) on inputs +- **[QUARKUS]**: Apply `@Valid` on `@BeanParam`, `@RestForm`, and request body parameters + +## Configuration + +```java +// [SPRING] @ConfigurationProperties +@ConfigurationProperties(prefix = "market") +public record MarketProperties(int maxPageSize, Duration cacheTtl) {} + +// [QUARKUS] @ConfigMapping (type-safe, build-time validated) +@ConfigMapping(prefix = "market") +public interface MarketConfig { + int maxPageSize(); + Duration cacheTtl(); +} + +// [QUARKUS] Simple values with @ConfigProperty +@ConfigProperty(name = "market.max-page-size", defaultValue = "100") +int maxPageSize; +``` ## Testing Expectations +### Shared - JUnit 5 + AssertJ for fluent assertions - Mockito for mocking; avoid partial mocks where possible - Favor deterministic tests; no hidden sleeps +### [SPRING] +- `@WebMvcTest` for controller slices, `@DataJpaTest` for repository slices +- `@SpringBootTest` reserved for full integration tests +- `@MockBean` for replacing beans in Spring context + +### [QUARKUS] +- Plain JUnit 5 + Mockito for unit tests (no `@QuarkusTest`) +- `@QuarkusTest` reserved for CDI integration tests +- `@InjectMock` for replacing CDI beans in integration tests +- Dev Services for database/Kafka/Redis — avoid manual Testcontainers setup when Dev Services suffice +- `@QuarkusTestResource` for custom external service lifecycle + +```java +// [SPRING] Controller test +@WebMvcTest(MarketController.class) +class MarketControllerTest { + @Autowired MockMvc mockMvc; + @MockBean MarketService marketService; +} + +// [QUARKUS] Integration test +@QuarkusTest +class MarketResourceTest { + @InjectMock + MarketService marketService; + + @Test + void should_return_404_when_market_not_found() { + given().when().get("/markets/unknown").then().statusCode(404); + } +} + +// [QUARKUS] Unit test (no CDI, no @QuarkusTest) +@ExtendWith(MockitoExtension.class) +class MarketServiceTest { + @Mock MarketRepository marketRepository; + @InjectMocks MarketService marketService; +} +``` + **Remember**: Keep code intentional, typed, and observable. Optimize for maintainability over micro-optimizations unless proven necessary. diff --git a/skills/prompt-optimizer/SKILL.md b/skills/prompt-optimizer/SKILL.md index 3241968c..053b57ce 100644 --- a/skills/prompt-optimizer/SKILL.md +++ b/skills/prompt-optimizer/SKILL.md @@ -135,6 +135,7 @@ Map intent + scope + tech stack (from Phase 0) to specific ECC components. | Python / Django | django-patterns, django-tdd, django-security, django-verification, python-patterns, python-testing | python-reviewer | | Go | golang-patterns, golang-testing | go-reviewer, go-build-resolver | | Spring Boot / Java | springboot-patterns, springboot-tdd, springboot-security, springboot-verification, java-coding-standards, jpa-patterns | code-reviewer | +| Quarkus / Java | quarkus-patterns, quarkus-tdd, quarkus-security, quarkus-verification, java-coding-standards | java-reviewer | | Kotlin / Android | kotlin-coroutines-flows, compose-multiplatform-patterns, android-clean-architecture | kotlin-reviewer | | TypeScript / React | frontend-patterns, backend-patterns, coding-standards | code-reviewer | | Swift / iOS | swiftui-patterns, swift-concurrency-6-2, swift-actor-persistence, swift-protocol-di-testing | code-reviewer | diff --git a/skills/quarkus-patterns/SKILL.md b/skills/quarkus-patterns/SKILL.md new file mode 100644 index 00000000..117f02dc --- /dev/null +++ b/skills/quarkus-patterns/SKILL.md @@ -0,0 +1,754 @@ +--- +name: quarkus-patterns +description: Quarkus 3.x LTS architecture patterns with Camel for messaging, RESTful API design, CDI services, data access with Panache, and async processing. Use for Java Quarkus backend work with event-driven architectures. +origin: ECC +--- + +# Quarkus Development Patterns + +Quarkus 3.x architecture and API patterns for cloud-native, event-driven services with Apache Camel. + +## When to Activate + +- Building REST APIs with JAX-RS or RESTEasy Reactive +- Structuring resource → service → repository layers +- Implementing event-driven patterns with Apache Camel and RabbitMQ +- Configuring Hibernate Panache, caching, or reactive streams +- Adding validation, exception mapping, or pagination +- Setting up profiles for dev/staging/production environments (YAML config) +- Custom logging with LogContext and Logback/Logstash encoder +- Working with CompletableFuture for async operations +- Implementing conditional flow processing +- Working with GraalVM native compilation + +## Service Layer with Multiple Dependencies (Lombok) + +```java +@Slf4j +@ApplicationScoped +@RequiredArgsConstructor +public class As2ProcessingService { + + private final InvoiceFlowValidator invoiceFlowValidator; + private final EventService eventService; + private final DocumentJobService documentJobService; + private final BusinessRulesPublisher businessRulesPublisher; + private final FileStorageService fileStorageService; + + public void processFile(Path filePath) throws Exception { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + + String structureIdPartner = logContext.get(As2Constants.STRUCTURE_ID); + + // Conditional flow logic + boolean isChorusFlow = Boolean.parseBoolean(logContext.get(As2Constants.CHORUS_FLOW)); + log.info("Is CHORUS_FLOW message: {}", isChorusFlow); + + ValidationFlowConfig validationFlowConfig = isChorusFlow + ? ValidationFlowConfig.xsdOnly() + : ValidationFlowConfig.allValidations(); + + InvoiceValidationResult invoiceValidationResult = this.invoiceFlowValidator + .validateFlowWithConfig(filePath, validationFlowConfig, + EInvoiceSyntaxFormat.UBL, logContext); + + FlowProfile flowProfile = isChorusFlow ? + FlowProfile.EXTENDED_CTC_FR : + this.invoiceFlowValidator.computeFlowProfile(invoiceValidationResult, + invoiceValidationResult.getInvoiceDetails().invoiceFormat().getProfile()); + + log.info("Invoice validation completed. Message is valid"); + + // CompletableFuture async operation + try(InputStream inputStream = Files.newInputStream(filePath)) { + CompletableFuture documentInfoCompletableFuture = + fileStorageService.uploadOriginalFile(inputStream, + invoiceValidationResult.getSize(), logContext, + invoiceValidationResult.getInvoiceFormat()); + + StoredDocumentInfo documentInfo = documentInfoCompletableFuture.join(); + log.info("File uploaded successfully: {}", documentInfo.getPath()); + + if (StringUtils.isBlank(documentInfo.getPath())) { + String errorMsg = "File path is empty after upload"; + log.error(errorMsg); + this.eventService.createErrorEvent(documentInfo, "FILE_UPLOAD_FAILED", errorMsg); + throw new As2ServerProcessingException(errorMsg); + } + + this.eventService.createSuccessEvent(documentInfo, "PERSISTENCE_BLOB_EVENT_TYPE"); + + BusinessRulesPayload payload = this.documentJobService.createDocumentAndJobEntities( + documentInfo, originalFileName, structureIdPartner, + flowProfile, invoiceValidationResult.getDocumentHash()); + + // Async Camel publishing + businessRulesPublisher.publishAsync(payload); + this.eventService.createSuccessEvent(payload, "BUSINESS_RULES_MESSAGE_SENT"); + } + } + } +} +``` + +**Key Patterns:** +- `@RequiredArgsConstructor` for constructor injection via Lombok +- `@Slf4j` for Logback logging +- Scoped LogContext with try-with-resources +- Conditional flow logic based on runtime parameters +- CompletableFuture with `.join()` for async operations +- Event tracking for success/error scenarios +- Async Camel message publishing + +## Custom Logging Context Pattern (Logback) + +```java +@ApplicationScoped +public class ProcessingService { + + public void processDocument(Document doc) { + LogContext logContext = CustomLog.getCurrentContext(); + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + // Add context to all log statements + logContext.put("documentId", doc.getId().toString()); + logContext.put("documentType", doc.getType()); + logContext.put("userId", SecurityContext.getUserId()); + + log.info("Starting document processing"); + + // All logs within this scope inherit the context + processInternal(doc); + + log.info("Document processing completed"); + } catch (Exception e) { + log.error("Document processing failed", e); + throw e; + } + } +} +``` + +**Logback Configuration (logback.xml):** + +```xml + + + + true + true + + + + + + + + +``` + +## Event Service Pattern + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class EventService { + private final EventRepository eventRepository; + + public void createSuccessEvent(Object payload, String eventType) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.SUCCESS); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.info("Success event created: {}", eventType); + } + + public void createErrorEvent(Object payload, String eventType, String errorMessage) { + Event event = new Event(); + event.setType(eventType); + event.setStatus(EventStatus.ERROR); + event.setErrorMessage(errorMessage); + event.setPayload(serializePayload(payload)); + event.setTimestamp(Instant.now()); + + eventRepository.persist(event); + log.error("Error event created: {} - {}", eventType, errorMessage); + } + + private String serializePayload(Object payload) { + // JSON serialization + return objectMapper.writeValueAsString(payload); + } +} +``` + +## Camel Message Publishing (RabbitMQ) + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class BusinessRulesPublisher { + private final ProducerTemplate producerTemplate; + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + public void publishAsync(BusinessRulesPayload payload) { + producerTemplate.asyncSendBody( + "direct:business-rules-publisher", + payload + ); + log.info("Message published to business rules queue: {}", payload.getDocumentId()); + } + + public void publishSync(BusinessRulesPayload payload) { + producerTemplate.sendBody( + "direct:business-rules-publisher", + payload + ); + } +} +``` + +**Camel Route Configuration:** + +```java +@ApplicationScoped +public class BusinessRulesRoute extends RouteBuilder { + + @ConfigProperty(name = "camel.rabbitmq.queue.business-rules") + String businessRulesQueue; + + @ConfigProperty(name = "rabbitmq.host") + String rabbitHost; + + @ConfigProperty(name = "rabbitmq.port") + Integer rabbitPort; + + @Override + public void configure() { + from("direct:business-rules-publisher") + .routeId("business-rules-publisher") + .log("Publishing message to RabbitMQ: ${body}") + .marshal().json(JsonLibrary.Jackson) + .toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d", + businessRulesQueue, rabbitHost, rabbitPort); + } +} +``` + +## Camel Direct Routes (In-Memory) + +```java +@ApplicationScoped +public class DocumentProcessingRoute extends RouteBuilder { + + @Override + public void configure() { + // Error handling + onException(ValidationException.class) + .handled(true) + .to("direct:validation-error-handler") + .log("Validation error: ${exception.message}"); + + // Main processing route + from("direct:process-document") + .routeId("document-processing") + .log("Processing document: ${header.documentId}") + .bean(DocumentValidator.class, "validate") + .bean(DocumentTransformer.class, "transform") + .choice() + .when(header("documentType").isEqualTo("INVOICE")) + .to("direct:process-invoice") + .when(header("documentType").isEqualTo("CREDIT_NOTE")) + .to("direct:process-credit-note") + .otherwise() + .to("direct:process-generic") + .end(); + + from("direct:validation-error-handler") + .bean(EventService.class, "createErrorEvent") + .log("Validation error handled"); + } +} +``` + +## Camel File Processing + +```java +@ApplicationScoped +public class FileMonitoringRoute extends RouteBuilder { + + @ConfigProperty(name = "file.input.directory") + String inputDirectory; + + @ConfigProperty(name = "file.processed.directory") + String processedDirectory; + + @ConfigProperty(name = "file.error.directory") + String errorDirectory; + + @Override + public void configure() { + from("file:" + inputDirectory + "?move=" + processedDirectory + + "&moveFailed=" + errorDirectory + "&delay=5000") + .routeId("file-monitor") + .log("Processing file: ${header.CamelFileName}") + .to("direct:process-file"); + + from("direct:process-file") + .bean(As2ProcessingService.class, "processFile") + .log("File processing completed"); + } +} +``` + +## Camel Bean Invocation + +```java +@ApplicationScoped +public class InvoiceRoute extends RouteBuilder { + + @Override + public void configure() { + from("direct:invoice-validation") + .bean(InvoiceFlowValidator.class, "validateFlowWithConfig") + .log("Validation result: ${body}"); + + from("direct:persist-and-publish") + .bean(DocumentJobService.class, "createDocumentAndJobEntities") + .bean(BusinessRulesPublisher.class, "publishAsync") + .bean(EventService.class, "createSuccessEvent(${body}, 'PUBLISHED')"); + } +} +``` + +## REST API Structure + +```java +@Path("/api/documents") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class DocumentResource { + private final DocumentService documentService; + + @GET + public Response list( + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("20") int size) { + PaginatedList documents = documentService.list(page, size); + return Response.ok(documents).build(); + } + + @POST + public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) { + Document document = documentService.create(request); + URI location = uriInfo.getAbsolutePathBuilder() + .path(String.valueOf(document.id)) + .build(); + return Response.created(location).entity(DocumentResponse.from(document)).build(); + } + + @GET + @Path("/{id}") + public Response getById(@PathParam("id") Long id) { + return documentService.findById(id) + .map(DocumentResponse::from) + .map(Response::ok) + .orElse(Response.status(Response.Status.NOT_FOUND)) + .build(); + } +} +``` + +## Repository Pattern (Panache Repository) + +```java +@ApplicationScoped +public class DocumentRepository implements PanacheRepository { + + public List findByStatus(DocumentStatus status, int page, int size) { + return find("status = ?1 order by createdAt desc", status) + .page(page, size) + .list(); + } + + public Optional findByReferenceNumber(String referenceNumber) { + return find("referenceNumber", referenceNumber).firstResultOptional(); + } + + public long countByStatusAndDate(DocumentStatus status, LocalDate date) { + return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay()); + } +} +``` + +## Service Layer with Transactions + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentService { + private final DocumentRepository repo; + private final EventService eventService; + + @Transactional + public Document create(CreateDocumentRequest request) { + Document document = new Document(); + document.setReferenceNumber(request.referenceNumber()); + document.setDescription(request.description()); + document.setStatus(DocumentStatus.PENDING); + document.setCreatedAt(Instant.now()); + + repo.persist(document); + + eventService.createSuccessEvent(document, "DOCUMENT_CREATED"); + + return document; + } + + public Optional findById(Long id) { + return repo.findByIdOptional(id); + } + + public PaginatedList list(int page, int size) { + return repo.findAll() + .page(page, size) + .list(); + } +} +``` + +## DTOs and Validation + +```java +public record CreateDocumentRequest( + @NotBlank @Size(max = 200) String referenceNumber, + @NotBlank @Size(max = 2000) String description, + @NotNull @FutureOrPresent Instant validUntil, + @NotEmpty List<@NotBlank String> categories) {} + +public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) { + public static DocumentResponse from(Document document) { + return new DocumentResponse(document.getId(), document.getReferenceNumber(), + document.getStatus()); + } +} +``` + +## Exception Mapping + +```java +@Provider +public class ValidationExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(ConstraintViolationException exception) { + String message = exception.getConstraintViolations().stream() + .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage()) + .collect(Collectors.joining(", ")); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "validation_error", "message", message)) + .build(); + } +} + +@Provider +@Slf4j +public class GenericExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(Exception exception) { + log.error("Unhandled exception", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "internal_error", "message", "An unexpected error occurred")) + .build(); + } +} +``` + +## CompletableFuture Async Operations + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class FileStorageService { + private final S3Client s3Client; + private final ExecutorService executorService; + + public CompletableFuture uploadOriginalFile( + InputStream inputStream, + long size, + LogContext logContext, + InvoiceFormat format) { + + return CompletableFuture.supplyAsync(() -> { + try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) { + String path = generateStoragePath(format); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(path) + .contentLength(size) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, size)); + + log.info("File uploaded to S3: {}", path); + + return new StoredDocumentInfo(path, size, Instant.now()); + } catch (Exception e) { + log.error("Failed to upload file to S3", e); + throw new StorageException("Upload failed", e); + } + }, executorService); + } +} +``` + +## Caching + +```java +@ApplicationScoped +@RequiredArgsConstructor +public class DocumentCacheService { + private final DocumentRepository repo; + + @CacheResult(cacheName = "document-cache") + public Optional getById(@CacheKey Long id) { + return repo.findByIdOptional(id); + } + + @CacheInvalidate(cacheName = "document-cache") + public void evict(@CacheKey Long id) {} + + @CacheInvalidateAll(cacheName = "document-cache") + public void evictAll() {} +} +``` + +## Configuration as YAML + +```yaml +# application.yml +"%dev": + quarkus: + datasource: + jdbc: + url: jdbc:postgresql://localhost:5432/dev_db + username: dev_user + password: dev_pass + hibernate-orm: + database: + generation: drop-and-create + + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + +"%test": + quarkus: + datasource: + jdbc: + url: jdbc:h2:mem:test + hibernate-orm: + database: + generation: drop-and-create + +"%prod": + quarkus: + datasource: + jdbc: + url: ${DATABASE_URL} + username: ${DB_USER} + password: ${DB_PASSWORD} + hibernate-orm: + database: + generation: validate + + rabbitmq: + host: ${RABBITMQ_HOST} + port: ${RABBITMQ_PORT} + username: ${RABBITMQ_USER} + password: ${RABBITMQ_PASSWORD} + +# Camel configuration +camel: + rabbitmq: + queue: + business-rules: business-rules-queue + invoice-processing: invoice-processing-queue +``` + +## Health Checks + +```java +@Readiness +@ApplicationScoped +@RequiredArgsConstructor +public class DatabaseHealthCheck implements HealthCheck { + private final AgroalDataSource dataSource; + + @Override + public HealthCheckResponse call() { + try (Connection conn = dataSource.getConnection()) { + boolean valid = conn.isValid(2); + return HealthCheckResponse.named("Database connection") + .status(valid) + .build(); + } catch (SQLException e) { + return HealthCheckResponse.down("Database connection"); + } + } +} + +@Liveness +@ApplicationScoped +public class CamelHealthCheck implements HealthCheck { + @Inject + CamelContext camelContext; + + @Override + public HealthCheckResponse call() { + boolean isStarted = camelContext.getStatus().isStarted(); + return HealthCheckResponse.named("Camel Context") + .status(isStarted) + .build(); + } +} +``` + +## Dependencies (Maven) + +```xml + + 3.27.0 + 1.18.42 + 3.24.2 + 0.8.13 + 17 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + org.apache.camel.quarkus + camel-quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-config-yaml + + + + + org.apache.camel.quarkus + camel-quarkus-spring-rabbitmq + + + org.apache.camel.quarkus + camel-quarkus-direct + + + org.apache.camel.quarkus + camel-quarkus-bean + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + io.quarkiverse.logging.logback + quarkus-logging-logback + + + net.logstash.logback + logstash-logback-encoder + + +``` + +## Best Practices + +### Architecture +- Use `@RequiredArgsConstructor` with Lombok for constructor injection +- Keep service layer thin; delegate complex logic to specialized classes +- Use Camel routes for message routing and integration patterns +- Prefer Panache Repository pattern for data access + +### Event-Driven +- Always track operations with EventService (success/error events) +- Use Camel `direct:` endpoints for in-memory routing +- Use `spring-rabbitmq` component for RabbitMQ integration +- Implement async publishing with `ProducerTemplate.asyncSendBody()` + +### Logging +- Use Logback with Logstash encoder for structured logging +- Propagate LogContext through service calls with `SafeAutoCloseable` +- Add contextual information to LogContext for request tracing +- Use `@Slf4j` instead of manual logger instantiation + +### Async Operations +- Use CompletableFuture for non-blocking I/O operations +- Call `.join()` when you need to wait for completion +- Handle exceptions from CompletableFuture properly +- Pass LogContext to async operations for tracing + +### Configuration +- Use YAML configuration (`quarkus-config-yaml`) +- Profile-aware configuration for dev/test/prod environments +- Externalize sensitive configuration to environment variables +- Use `@ConfigProperty` for type-safe config injection + +### Validation +- Validate at resource layer with `@Valid` +- Use Bean Validation annotations on DTOs +- Map exceptions to proper HTTP responses with `@Provider` + +### Transactions +- Use `@Transactional` on service methods that modify data +- Keep transactions short and focused +- Avoid calling async operations within transactions + +### Testing +- Use `camel-quarkus-junit5` for route testing +- Use AssertJ for assertions +- Mock all external dependencies +- Test conditional flow logic thoroughly + +### Quarkus-Specific +- Stay on latest LTS version (3.x) +- Use Quarkus dev mode for hot reload +- Add health checks for production readiness +- Test native compilation compatibility periodically diff --git a/skills/quarkus-security/SKILL.md b/skills/quarkus-security/SKILL.md new file mode 100644 index 00000000..b3fa9705 --- /dev/null +++ b/skills/quarkus-security/SKILL.md @@ -0,0 +1,453 @@ +--- +name: quarkus-security +description: Quarkus Security best practices for authentication, authorization, JWT/OIDC, RBAC, input validation, CSRF, secrets management, and dependency security. +origin: ECC +--- + +# Quarkus Security Review + +Best practices for securing Quarkus applications with authentication, authorization, and input validation. + +## When to Activate + +- Adding authentication (JWT, OIDC, Basic Auth) +- Implementing authorization with @RolesAllowed or SecurityIdentity +- Validating user input (Bean Validation, custom validators) +- Configuring CORS or security headers +- Managing secrets (Vault, environment variables, config sources) +- Adding rate limiting or brute-force protection +- Scanning dependencies for CVEs +- Working with MicroProfile JWT or SmallRye JWT + +## Authentication + +### JWT Authentication + +```java +// Resource protected with JWT +@Path("/api/protected") +@Authenticated +public class ProtectedResource { + + @Inject + JsonWebToken jwt; + + @Inject + SecurityIdentity securityIdentity; + + @GET + public Response getData() { + String username = jwt.getName(); + Set roles = jwt.getGroups(); + return Response.ok(Map.of( + "username", username, + "roles", roles, + "principal", securityIdentity.getPrincipal().getName() + )).build(); + } +} +``` + +Configuration (application.properties): +```properties +mp.jwt.verify.publickey.location=publicKey.pem +mp.jwt.verify.issuer=https://auth.example.com + +# OIDC +quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm +quarkus.oidc.client-id=backend-service +quarkus.oidc.credentials.secret=${OIDC_SECRET} +``` + +### Custom Authentication Filter + +```java +@Provider +@Priority(Priorities.AUTHENTICATION) +public class CustomAuthFilter implements ContainerRequestFilter { + + @Inject + SecurityIdentity identity; + + @Override + public void filter(ContainerRequestContext requestContext) { + String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + // Validate token and set SecurityIdentity + if (!validateToken(token)) { + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + } + + private boolean validateToken(String token) { + // Token validation logic + return true; + } +} +``` + +## Authorization + +### Role-Based Access Control + +```java +@Path("/api/admin") +@RolesAllowed("ADMIN") +public class AdminResource { + + @GET + @Path("/users") + public List listUsers() { + return userService.findAll(); + } + + @DELETE + @Path("/users/{id}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) + public Response deleteUser(@PathParam("id") Long id) { + userService.delete(id); + return Response.noContent().build(); + } +} + +@Path("/api/users") +public class UserResource { + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Path("/{id}") + @RolesAllowed("USER") + public Response getUser(@PathParam("id") Long id) { + // Check ownership + if (!securityIdentity.hasRole("ADMIN") && + !isOwner(id, securityIdentity.getPrincipal().getName())) { + return Response.status(Response.Status.FORBIDDEN).build(); + } + return Response.ok(userService.findById(id)).build(); + } + + private boolean isOwner(Long userId, String username) { + return userService.isOwner(userId, username); + } +} +``` + +### Programmatic Security + +```java +@ApplicationScoped +public class SecurityService { + + @Inject + SecurityIdentity securityIdentity; + + public boolean canAccessResource(Long resourceId) { + if (securityIdentity.isAnonymous()) { + return false; + } + + if (securityIdentity.hasRole("ADMIN")) { + return true; + } + + String userId = securityIdentity.getPrincipal().getName(); + return resourceRepository.isOwner(resourceId, userId); + } +} +``` + +## Input Validation + +### Bean Validation + +```java +// BAD: No validation +@POST +public Response createUser(UserDto dto) { + return Response.ok(userService.create(dto)).build(); +} + +// GOOD: Validated DTO +public record CreateUserDto( + @NotBlank @Size(max = 100) String name, + @NotBlank @Email String email, + @NotNull @Min(18) @Max(150) Integer age, + @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone +) {} + +@POST +@Path("/users") +public Response createUser(@Valid CreateUserDto dto) { + User user = userService.create(dto); + return Response.status(Response.Status.CREATED).entity(user).build(); +} +``` + +### Custom Validators + +```java +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +public @interface ValidUsername { + String message() default "Invalid username format"; + Class[] groups() default {}; + Class[] payload() default {}; +} + +public class UsernameValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return false; + return value.matches("^[a-zA-Z0-9_-]{3,20}$"); + } +} + +// Usage +public record CreateUserDto( + @ValidUsername String username, + @NotBlank @Email String email +) {} +``` + +## SQL Injection Prevention + +### Panache Active Record (Safe by Default) + +```java +// GOOD: Parameterized queries with Panache +List users = User.list("email = ?1 and active = ?2", email, true); + +Optional user = User.find("username", username).firstResultOptional(); + +// GOOD: Named parameters +List users = User.list("email = :email and age > :minAge", + Parameters.with("email", email).and("minAge", 18)); +``` + +### Native Queries (Use Parameters) + +```java +// BAD: String concatenation +@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true) + +// GOOD: Parameterized native query +@Entity +public class User extends PanacheEntity { + public static List findByEmailNative(String email) { + return getEntityManager() + .createNativeQuery("SELECT * FROM users WHERE email = :email", User.class) + .setParameter("email", email) + .getResultList(); + } +} +``` + +## Password Hashing + +```java +@ApplicationScoped +public class PasswordService { + + public String hash(String plainPassword) { + return BcryptUtil.bcryptHash(plainPassword); + } + + public boolean verify(String plainPassword, String hashedPassword) { + return BcryptUtil.matches(plainPassword, hashedPassword); + } +} + +// In service +@ApplicationScoped +public class UserService { + @Inject + PasswordService passwordService; + + @Transactional + public User register(CreateUserDto dto) { + String hashedPassword = passwordService.hash(dto.password()); + User user = new User(); + user.email = dto.email(); + user.password = hashedPassword; + user.persist(); + return user; + } + + public boolean authenticate(String email, String password) { + return User.find("email", email) + .firstResultOptional() + .map(u -> passwordService.verify(password, u.password)) + .orElse(false); + } +} +``` + +## CORS Configuration + +```properties +# application.properties +quarkus.http.cors=true +quarkus.http.cors.origins=https://app.example.com,https://admin.example.com +quarkus.http.cors.methods=GET,POST,PUT,DELETE +quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with +quarkus.http.cors.exposed-headers=Content-Disposition +quarkus.http.cors.access-control-max-age=24H +quarkus.http.cors.access-control-allow-credentials=true +``` + +## Secrets Management + +```properties +# application.properties - NO SECRETS HERE + +# Use environment variables +quarkus.datasource.username=${DB_USER} +quarkus.datasource.password=${DB_PASSWORD} +quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET} + +# Or use Vault +quarkus.vault.url=https://vault.example.com +quarkus.vault.authentication.kubernetes.role=my-role +``` + +### HashiCorp Vault Integration + +```java +@ApplicationScoped +public class SecretService { + + @ConfigProperty(name = "api-key") + String apiKey; // Fetched from Vault + + public String getSecret(String key) { + return ConfigProvider.getConfig().getValue(key, String.class); + } +} +``` + +## Rate Limiting + +```java +@ApplicationScoped +public class RateLimitFilter implements ContainerRequestFilter { + private final Map limiters = new ConcurrentHashMap<>(); + + @Override + public void filter(ContainerRequestContext requestContext) { + String clientId = getClientIdentifier(requestContext); + RateLimiter limiter = limiters.computeIfAbsent(clientId, + k -> RateLimiter.create(100.0)); // 100 requests per second + + if (!limiter.tryAcquire()) { + requestContext.abortWith( + Response.status(429) + .entity(Map.of("error", "Too many requests")) + .build() + ); + } + } + + private String getClientIdentifier(ContainerRequestContext ctx) { + // Use IP, API key, or user ID + return ctx.getHeaderString("X-Forwarded-For"); + } +} +``` + +## Security Headers + +```java +@Provider +public class SecurityHeadersFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext request, ContainerResponseContext response) { + MultivaluedMap headers = response.getHeaders(); + + // Prevent clickjacking + headers.putSingle("X-Frame-Options", "DENY"); + + // XSS protection + headers.putSingle("X-Content-Type-Options", "nosniff"); + headers.putSingle("X-XSS-Protection", "1; mode=block"); + + // HSTS + headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + + // CSP + headers.putSingle("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"); + } +} +``` + +## Audit Logging + +```java +@ApplicationScoped +public class AuditService { + private static final Logger LOG = Logger.getLogger(AuditService.class); + + @Inject + SecurityIdentity securityIdentity; + + public void logAccess(String resource, String action) { + String user = securityIdentity.isAnonymous() + ? "anonymous" + : securityIdentity.getPrincipal().getName(); + + LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s", + user, action, resource, Instant.now()); + } +} + +// Usage in resource +@Path("/api/sensitive") +public class SensitiveResource { + @Inject + AuditService auditService; + + @GET + @RolesAllowed("ADMIN") + public Response getData() { + auditService.logAccess("sensitive-data", "READ"); + return Response.ok(data).build(); + } +} +``` + +## Dependency Security Scanning + +```bash +# Maven +mvn org.owasp:dependency-check-maven:check + +# Gradle +./gradlew dependencyCheckAnalyze + +# Check Quarkus extensions +quarkus extension list --installable +``` + +## Best Practices + +- Always use HTTPS in production +- Enable JWT or OIDC for stateless authentication +- Use `@RolesAllowed` for declarative authorization +- Validate all input with Bean Validation +- Hash passwords with BCrypt (never plaintext) +- Store secrets in Vault or environment variables +- Use parameterized queries to prevent SQL injection +- Add security headers to all responses +- Implement rate limiting for public endpoints +- Audit sensitive operations +- Keep dependencies updated and scan for CVEs +- Use SecurityIdentity for programmatic checks +- Set appropriate CORS policies +- Test authentication and authorization paths diff --git a/skills/quarkus-tdd/SKILL.md b/skills/quarkus-tdd/SKILL.md new file mode 100644 index 00000000..c049ceda --- /dev/null +++ b/skills/quarkus-tdd/SKILL.md @@ -0,0 +1,908 @@ +--- +name: quarkus-tdd +description: Test-driven development for Quarkus 3.x LTS using JUnit 5, Mockito, REST Assured, Camel testing, and JaCoCo. Use when adding features, fixing bugs, or refactoring event-driven services. +origin: ECC +--- + +# Quarkus TDD Workflow + +TDD guidance for Quarkus 3.x services with 80%+ coverage (unit + integration). Optimized for event-driven architectures with Apache Camel. + +## When to Use + +- New features or REST endpoints +- Bug fixes or refactors +- Adding data access logic, security rules, or reactive streams +- Testing Apache Camel routes and event handlers +- Testing event-driven services with RabbitMQ +- Testing conditional flow logic +- Validating CompletableFuture async operations +- Testing LogContext propagation + +## Workflow + +1. Write tests first (they should fail) +2. Implement minimal code to pass +3. Refactor with tests green +4. Enforce coverage with JaCoCo (80%+ target) + +## Unit Tests with @Nested Organization + +Follow this structured approach for comprehensive, readable tests: + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("As2ProcessingService Unit Tests") +class As2ProcessingServiceTest { + + @Mock + private InvoiceFlowValidator invoiceFlowValidator; + + @Mock + private EventService eventService; + + @Mock + private DocumentJobService documentJobService; + + @Mock + private BusinessRulesPublisher businessRulesPublisher; + + @Mock + private FileStorageService fileStorageService; + + @InjectMocks + private As2ProcessingService as2ProcessingService; + + private Path testFilePath; + private LogContext testLogContext; + private InvoiceValidationResult validationResult; + private StoredDocumentInfo documentInfo; + + @BeforeEach + void setUp() { + // ARRANGE - Common test data + testFilePath = Path.of("/tmp/test-invoice.xml"); + + testLogContext = new LogContext(); + testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001"); + testLogContext.put(As2Constants.FILE_NAME, "invoice.xml"); + testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001"); + + validationResult = new InvoiceValidationResult(); + validationResult.setValid(true); + validationResult.setSize(1024L); + validationResult.setDocumentHash("abc123"); + + documentInfo = new StoredDocumentInfo(); + documentInfo.setPath("s3://bucket/path/invoice.xml"); + documentInfo.setSize(1024L); + } + + @Nested + @DisplayName("Tests for processFile") + class ProcessFile { + + @Test + @DisplayName("Should successfully process non-CHORUS file with all validations") + void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.allValidations()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class), + eq("PERSISTENCE_BLOB_EVENT_TYPE")); + verify(eventService).createSuccessEvent(any(BusinessRulesPayload.class), + eq("BUSINESS_RULES_MESSAGE_SENT")); + verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class)); + } + + @Test + @DisplayName("Should bypass schematron validation for CHORUS_FLOW") + void givenChorusFlow_whenProcessFile_thenSchematronBypassed() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "true"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class))) + .thenReturn(validationResult); + + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), any())) + .thenReturn(new BusinessRulesPayload()); + + // ACT + assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); + + // ASSERT + verify(invoiceFlowValidator).validateFlowWithConfig( + eq(testFilePath), + eq(ValidationFlowConfig.xsdOnly()), + eq(EInvoiceSyntaxFormat.UBL), + any(LogContext.class)); + + verify(documentJobService).createDocumentAndJobEntities( + any(), any(), any(), + eq(FlowProfile.EXTENDED_CTC_FR), + any()); + } + + @Test + @DisplayName("Should create error event when file upload fails") + void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + documentInfo.setPath(""); // Blank path triggers error + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(documentInfo)); + + // ACT & ASSERT + As2ServerProcessingException exception = assertThrows( + As2ServerProcessingException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + + assertThat(exception.getMessage()) + .contains("File path is empty after upload"); + + verify(eventService).createErrorEvent( + eq(documentInfo), + eq("FILE_UPLOAD_FAILED"), + contains("File path is empty")); + + verify(businessRulesPublisher, never()).publishAsync(any()); + } + + @Test + @DisplayName("Should handle CompletableFuture.join() failure") + void givenAsyncUploadFailure_whenProcessFile_thenExceptionThrown() throws Exception { + // ARRANGE + testLogContext.put(As2Constants.CHORUS_FLOW, "false"); + CustomLog.setCurrentContext(testLogContext); + + when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) + .thenReturn(validationResult); + + when(invoiceFlowValidator.computeFlowProfile(any(), any())) + .thenReturn(FlowProfile.BASIC); + + CompletableFuture failedFuture = + CompletableFuture.failedFuture(new StorageException("S3 connection failed")); + when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) + .thenReturn(failedFuture); + + // ACT & ASSERT + assertThrows( + CompletionException.class, + () -> as2ProcessingService.processFile(testFilePath) + ); + } + + @Test + @DisplayName("Should throw exception when file path is null") + void givenNullFilePath_whenProcessFile_thenThrowsException() { + // ARRANGE + Path nullPath = null; + + // ACT & ASSERT + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> as2ProcessingService.processFile(nullPath) + ); + + verify(invoiceFlowValidator, never()).validateFlowWithConfig(any(), any(), any(), any()); + } + } +} +``` + +### Key Testing Patterns + +1. **@Nested Classes**: Group tests by method being tested +2. **@DisplayName**: Provide readable test descriptions for test reports +3. **Naming Convention**: `givenX_whenY_thenZ` for clarity +4. **AAA Pattern**: Explicit `// ARRANGE`, `// ACT`, `// ASSERT` comments +5. **@BeforeEach**: Setup common test data to reduce duplication +6. **assertDoesNotThrow**: Test success scenarios without catching exceptions +7. **assertThrows**: Test exception scenarios with message validation using AssertJ +8. **Comprehensive Coverage**: Test happy paths, null inputs, edge cases, exceptions +9. **Verify Interactions**: Use Mockito `verify()` to ensure methods are called correctly +10. **Never Verify**: Use `never()` to ensure methods are NOT called in error scenarios + +## Testing Camel Routes + +```java +@QuarkusTest +@DisplayName("Business Rules Camel Route Tests") +class BusinessRulesRouteTest { + + @Inject + CamelContext camelContext; + + @Inject + ProducerTemplate producerTemplate; + + @InjectMock + EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE - Test data + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + testPayload.setFlowProfile(FlowProfile.BASIC); + } + + @Nested + @DisplayName("Tests for business-rules-publisher route") + class BusinessRulesPublisher { + + @Test + @DisplayName("Should successfully publish message to RabbitMQ") + void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception { + // ARRANGE + MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class); + mockRabbitMQ.expectedMessageCount(1); + mockRabbitMQ.expectedBodiesReceived(testPayload); + + // Replace real endpoint with mock for testing + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.replaceFromWith("direct:business-rules-publisher"); + advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockRabbitMQ.assertIsSatisfied(5000); + + assertThat(mockRabbitMQ.getExchanges()).hasSize(1); + assertThat(mockRabbitMQ.getExchanges().get(0).getIn().getBody(BusinessRulesPayload.class)) + .isEqualTo(testPayload); + } + + @Test + @DisplayName("Should handle marshalling to JSON") + void givenPayload_whenPublish_thenMarshalledToJson() throws Exception { + // ARRANGE + MockEndpoint mockMarshal = new MockEndpoint("mock:marshal"); + camelContext.addEndpoint("mock:marshal", mockMarshal); + mockMarshal.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("business-rules-publisher"); + AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { + advice.weaveAddLast().to("mock:marshal"); + }); + camelContext.getRouteController().startRoute("business-rules-publisher"); + + // ACT + producerTemplate.sendBody("direct:business-rules-publisher", testPayload); + + // ASSERT + mockMarshal.assertIsSatisfied(5000); + + String body = mockMarshal.getExchanges().get(0).getIn().getBody(String.class); + assertThat(body).contains("\"documentId\":1"); + assertThat(body).contains("\"flowProfile\":\"BASIC\""); + } + } + + @Nested + @DisplayName("Tests for document-processing route") + class DocumentProcessing { + + @Test + @DisplayName("Should route invoice to correct processor") + void givenInvoiceType_whenProcess_thenRoutesToInvoiceProcessor() throws Exception { + // ARRANGE + MockEndpoint mockInvoice = camelContext.getEndpoint("mock:invoice", MockEndpoint.class); + mockInvoice.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:process-invoice.*").replace().to("mock:invoice"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // ACT + producerTemplate.sendBodyAndHeader("direct:process-document", + testPayload, "documentType", "INVOICE"); + + // ASSERT + mockInvoice.assertIsSatisfied(5000); + } + + @Test + @DisplayName("Should handle validation errors gracefully") + void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception { + // ARRANGE + MockEndpoint mockError = camelContext.getEndpoint("mock:error", MockEndpoint.class); + mockError.expectedMessageCount(1); + + camelContext.getRouteController().stopRoute("document-processing"); + AdviceWith.adviceWith(camelContext, "document-processing", advice -> { + advice.weaveByToString(".*direct:validation-error-handler.*") + .replace().to("mock:error"); + }); + camelContext.getRouteController().startRoute("document-processing"); + + // Mock validator to throw exception + when(eventService.validate(any())).thenThrow(new ValidationException("Invalid document")); + + // ACT + producerTemplate.sendBody("direct:process-document", testPayload); + + // ASSERT + mockError.assertIsSatisfied(5000); + + Exception exception = mockError.getExchanges().get(0).getException(); + assertThat(exception).isInstanceOf(ValidationException.class); + assertThat(exception.getMessage()).contains("Invalid document"); + } + } +} +``` + +## Testing Event Services + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("EventService Unit Tests") +class EventServiceTest { + + @Mock + private EventRepository eventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private EventService eventService; + + private BusinessRulesPayload testPayload; + + @BeforeEach + void setUp() { + // ARRANGE + testPayload = new BusinessRulesPayload(); + testPayload.setDocumentId(1L); + } + + @Nested + @DisplayName("Tests for createSuccessEvent") + class CreateSuccessEvent { + + @Test + @DisplayName("Should create success event with correct attributes") + void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception { + // ARRANGE + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED")); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("DOCUMENT_PROCESSED") && + event.getStatus() == EventStatus.SUCCESS && + event.getPayload().equals("{\"documentId\":1}") && + event.getTimestamp() != null + )); + } + + @Test + @DisplayName("Should throw exception when payload is null") + void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() { + // ARRANGE + Object nullPayload = null; + + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE") + ); + + assertThat(exception.getMessage()).isEqualTo("Payload cannot be null"); + verify(eventRepository, never()).persist(any()); + } + } + + @Nested + @DisplayName("Tests for createErrorEvent") + class CreateErrorEvent { + + @Test + @DisplayName("Should create error event with error message") + void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception { + // ARRANGE + String errorMessage = "Processing failed"; + when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); + + // ACT + assertDoesNotThrow(() -> + eventService.createErrorEvent(testPayload, "PROCESSING_ERROR", errorMessage)); + + // ASSERT + verify(eventRepository).persist(argThat(event -> + event.getType().equals("PROCESSING_ERROR") && + event.getStatus() == EventStatus.ERROR && + event.getErrorMessage().equals(errorMessage) && + event.getPayload().equals("{\"documentId\":1}") + )); + } + + @ParameterizedTest + @DisplayName("Should reject invalid error messages") + @ValueSource(strings = {"", " "}) + void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) { + // ACT & ASSERT + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage) + ); + + assertThat(exception.getMessage()).contains("Error message cannot be blank"); + } + } +} +``` + +## Testing CompletableFuture + +```java +@ExtendWith(MockitoExtension.class) +@DisplayName("FileStorageService Unit Tests") +class FileStorageServiceTest { + + @Mock + private S3Client s3Client; + + @Mock + private ExecutorService executorService; + + @InjectMocks + private FileStorageService fileStorageService; + + private InputStream testInputStream; + private LogContext testLogContext; + + @BeforeEach + void setUp() { + // ARRANGE + testInputStream = new ByteArrayInputStream("test content".getBytes()); + testLogContext = new LogContext(); + testLogContext.put("traceId", "trace-123"); + } + + @Nested + @DisplayName("Tests for uploadOriginalFile") + class UploadOriginalFile { + + @Test + @DisplayName("Should successfully upload file and return document info") + void givenValidFile_whenUpload_thenReturnsDocumentInfo() throws Exception { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return CompletableFuture.completedFuture(callable.call()); + }); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + StoredDocumentInfo result = future.join(); + + // ASSERT + assertThat(result).isNotNull(); + assertThat(result.getPath()).isNotBlank(); + assertThat(result.getSize()).isEqualTo(1024L); + assertThat(result.getUploadedAt()).isNotNull(); + + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("Should handle S3 upload failure") + void givenS3Failure_whenUpload_thenCompletableFutureFails() { + // ARRANGE + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + return CompletableFuture.failedFuture(new StorageException("S3 unavailable")); + }); + + // ACT + CompletableFuture future = + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL); + + // ASSERT + assertThatThrownBy(() -> future.join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(StorageException.class) + .hasMessageContaining("S3 unavailable"); + } + + @Test + @DisplayName("Should propagate LogContext to async operation") + void givenLogContext_whenUpload_thenContextPropagated() throws Exception { + // ARRANGE + AtomicReference capturedContext = new AtomicReference<>(); + + when(executorService.submit(any(Callable.class))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + capturedContext.set(CustomLog.getCurrentContext()); + return CompletableFuture.completedFuture(callable.call()); + }); + + // ACT + fileStorageService.uploadOriginalFile(testInputStream, 1024L, + testLogContext, InvoiceFormat.UBL).join(); + + // ASSERT + assertThat(capturedContext.get()).isNotNull(); + assertThat(capturedContext.get().get("traceId")).isEqualTo("trace-123"); + } + } +} +``` + +## Resource Layer Tests (REST Assured) + +```java +@QuarkusTest +@DisplayName("DocumentResource API Tests") +class DocumentResourceTest { + + @InjectMock + DocumentService documentService; + + @Nested + @DisplayName("Tests for GET /api/documents") + class ListDocuments { + + @Test + @DisplayName("Should return list of documents") + void givenDocumentsExist_whenList_thenReturnsOk() { + // ARRANGE + List documents = List.of(createDocument(1L, "DOC-001")); + when(documentService.list(0, 20)).thenReturn(documents); + + // ACT & ASSERT + given() + .when().get("/api/documents") + .then() + .statusCode(200) + .body("$.size()", is(1)) + .body("[0].referenceNumber", equalTo("DOC-001")); + } + } + + @Nested + @DisplayName("Tests for POST /api/documents") + class CreateDocument { + + @Test + @DisplayName("Should create document and return 201") + void givenValidRequest_whenCreate_thenReturns201() { + // ARRANGE + Document document = createDocument(1L, "DOC-001"); + when(documentService.create(any())).thenReturn(document); + + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "DOC-001", + "description": "Test document", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .header("Location", containsString("/api/documents/1")) + .body("referenceNumber", equalTo("DOC-001")); + } + + @Test + @DisplayName("Should return 400 for invalid input") + void givenInvalidRequest_whenCreate_thenReturns400() { + // ACT & ASSERT + given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "", + "description": "Test" + } + """) + .when().post("/api/documents") + .then() + .statusCode(400); + } + } + + private Document createDocument(Long id, String referenceNumber) { + Document document = new Document(); + document.setId(id); + document.setReferenceNumber(referenceNumber); + document.setStatus(DocumentStatus.PENDING); + return document; + } +} +``` + +## Integration Tests with Real Database + +```java +@QuarkusTest +@TestProfile(IntegrationTestProfile.class) +@DisplayName("Document Integration Tests") +class DocumentIntegrationTest { + + @Test + @Transactional + @DisplayName("Should create and retrieve document via API") + void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() { + // ACT - Create via API + Long id = given() + .contentType(ContentType.JSON) + .body(""" + { + "referenceNumber": "INT-001", + "description": "Integration test", + "validUntil": "2030-01-01T00:00:00Z", + "categories": ["test"] + } + """) + .when().post("/api/documents") + .then() + .statusCode(201) + .extract().path("id"); + + // ASSERT - Retrieve via API + given() + .when().get("/api/documents/" + id) + .then() + .statusCode(200) + .body("referenceNumber", equalTo("INT-001")); + } +} +``` + +## Coverage with JaCoCo + +### Maven Configuration (Complete) + +```xml + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + + prepare-agent + + prepare-agent + + + + + + report + verify + + report + + + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.70 + + + + + + + + +``` + +Run tests with coverage: +```bash +mvn clean test +mvn jacoco:report +mvn jacoco:check + +# Report at: target/site/jacoco/index.html +``` + +## Test Dependencies + +```xml + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + + + org.mockito + mockito-core + test + + + + + org.assertj + assertj-core + 3.24.2 + test + + + + + io.rest-assured + rest-assured + test + + + + + org.apache.camel.quarkus + camel-quarkus-junit5 + test + + +``` + +## Best Practices + +### Test Organization +- Use `@Nested` classes to group tests by method being tested +- Use `@DisplayName` for readable test descriptions visible in reports +- Follow `givenX_whenY_thenZ` naming convention for test methods +- Use `@BeforeEach` for common test data setup to reduce duplication + +### Test Structure +- Follow AAA pattern with explicit comments (`// ARRANGE`, `// ACT`, `// ASSERT`) +- Use `assertDoesNotThrow` for success scenarios +- Use `assertThrows` for exception scenarios with message validation +- Verify exception messages match expected values using AssertJ `contains()` or `isEqualTo()` + +### Test Coverage +- Test happy paths for all public methods +- Test null input handling +- Test edge cases (empty collections, boundary values, negative IDs, blank strings) +- Test exception scenarios comprehensively +- Mock all external dependencies (repositories, services, Camel endpoints) +- Aim for 80%+ line coverage, 70%+ branch coverage + +### Assertions +- **Always use AssertJ** (`assertThat`) instead of JUnit assertions +- Use fluent AssertJ API for readability: `assertThat(list).hasSize(3).contains(item)` +- For exceptions: `assertThatThrownBy(() -> ...).isInstanceOf(...).hasMessageContaining(...)` +- For collections: `extracting()`, `filteredOn()`, `containsExactly()` + +### Testing Integration +- Use `@QuarkusTest` for integration tests +- Use `@InjectMock` to mock dependencies in Quarkus tests +- Prefer REST Assured for API testing +- Use `@TestProfile` for test-specific configuration + +### Event-Driven Testing +- Test Camel routes with `AdviceWith` and `MockEndpoint` +- Use `@CamelQuarkusTest` annotation (if using standalone Camel tests) +- Verify message content, headers, and routing logic +- Test error handling routes separately +- Mock external systems (RabbitMQ, S3, databases) in unit tests + +### Camel Route Testing +- Use `MockEndpoint` for asserting message flow +- Use `AdviceWith` to modify routes for testing (replace endpoints with mocks) +- Test message transformation and marshalling +- Test exception handling and dead letter queues + +### Testing Async Operations +- Test CompletableFuture success and failure scenarios +- Use `.join()` in tests to wait for async completion +- Test exception propagation from CompletableFuture +- Verify LogContext propagation to async operations + +### Performance +- Keep tests fast and isolated +- Run tests in continuous mode: `mvn quarkus:test` +- Use parameterized tests (`@ParameterizedTest`) for input variations +- Build reusable test data builders or factory methods + +### Quarkus-Specific +- Stay on latest LTS version (Quarkus 3.x) +- Test native compilation compatibility periodically +- Use Quarkus test profiles for different scenarios +- Leverage Quarkus dev services for local testing +- Use `@InjectMock` instead of `@MockBean` (Quarkus-specific) + +### Verification Best Practices +- Always verify interactions on mocked dependencies +- Use `verify(mock, never())` to ensure methods are NOT called in error scenarios +- Use `argThat()` for complex argument matching +- Verify the order of calls when it matters: `InOrder` from Mockito diff --git a/skills/quarkus-verification/SKILL.md b/skills/quarkus-verification/SKILL.md new file mode 100644 index 00000000..8c5ea5a6 --- /dev/null +++ b/skills/quarkus-verification/SKILL.md @@ -0,0 +1,481 @@ +--- +name: quarkus-verification +description: "Verification loop for Quarkus projects: build, static analysis, tests with coverage, security scans, native compilation, and diff review before release or PR." +origin: ECC +--- + +# Quarkus Verification Loop + +Run before PRs, after major changes, and pre-deploy. + +## When to Activate + +- Before opening a pull request for a Quarkus service +- After major refactoring or dependency upgrades +- Pre-deployment verification for staging or production +- Running full build → lint → test → security scan → native compilation pipeline +- Validating test coverage meets thresholds (80%+) +- Testing native image compatibility + +## Phase 1: Build + +```bash +# Maven +mvn clean verify -DskipTests + +# Gradle +./gradlew clean assemble -x test +``` + +If build fails, stop and fix compilation errors. + +## Phase 2: Static Analysis + +### Checkstyle, PMD, SpotBugs (Maven) + +```bash +mvn checkstyle:check pmd:check spotbugs:check +``` + +### SonarQube (if configured) + +```bash +mvn sonar:sonar \ + -Dsonar.projectKey=my-quarkus-project \ + -Dsonar.host.url=http://localhost:9000 \ + -Dsonar.login=${SONAR_TOKEN} +``` + +### Common Issues to Address + +- Unused imports or variables +- Complex methods (high cyclomatic complexity) +- Potential null pointer dereferences +- Security issues flagged by SpotBugs + +## Phase 3: Tests + Coverage + +```bash +# Run all tests +mvn clean test + +# Generate coverage report +mvn jacoco:report + +# Enforce coverage threshold (80%) +mvn jacoco:check + +# Or with Gradle +./gradlew test jacocoTestReport jacocoTestCoverageVerification +``` + +### Test Categories + +#### Unit Tests +Test service logic with mocked dependencies: + +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository userRepository; + @InjectMocks UserService userService; + + @Test + void createUser_validInput_returnsUser() { + var dto = new CreateUserDto("Alice", "alice@example.com"); + var expected = new User(); + expected.id = 1L; + expected.name = dto.name(); + + when(userRepository.persist(any(User.class))).thenReturn(expected); + + User result = userService.create(dto); + + assertThat(result.name).isEqualTo("Alice"); + verify(userRepository).persist(any(User.class)); + } +} +``` + +#### Integration Tests +Test with real database (Testcontainers): + +```java +@QuarkusTest +@QuarkusTestResource(PostgresTestResource.class) +class UserRepositoryIntegrationTest { + + @Inject + UserRepository userRepository; + + @Test + @Transactional + void findByEmail_existingUser_returnsUser() { + User user = new User(); + user.name = "Alice"; + user.email = "alice@example.com"; + userRepository.persist(user); + + Optional found = userRepository.findByEmail("alice@example.com"); + + assertThat(found).isPresent(); + assertThat(found.get().name).isEqualTo("Alice"); + } +} +``` + +#### API Tests +Test REST endpoints with REST Assured: + +```java +@QuarkusTest +class UserResourceTest { + + @Test + void createUser_validInput_returns201() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "alice@example.com"} + """) + .when().post("/api/users") + .then() + .statusCode(201) + .body("name", equalTo("Alice")); + } + + @Test + void createUser_invalidEmail_returns400() { + given() + .contentType(ContentType.JSON) + .body(""" + {"name": "Alice", "email": "invalid"} + """) + .when().post("/api/users") + .then() + .statusCode(400); + } +} +``` + +### Coverage Report + +Check `target/site/jacoco/index.html` for detailed coverage: +- Overall line coverage (target: 80%+) +- Branch coverage (target: 70%+) +- Identify uncovered critical paths + +## Phase 4: Security Scanning + +### Dependency Vulnerabilities (Maven) + +```bash +mvn org.owasp:dependency-check-maven:check +``` + +Review `target/dependency-check-report.html` for CVEs. + +### Quarkus Security Audit + +```bash +# Check vulnerable extensions +mvn quarkus:audit + +# List all extensions +mvn quarkus:list-extensions +``` + +### OWASP ZAP (API Security Testing) + +```bash +docker run -t owasp/zap2docker-stable zap-api-scan.py \ + -t http://localhost:8080/q/openapi \ + -f openapi +``` + +### Common Security Checks + +- [ ] All secrets in environment variables (not in code) +- [ ] Input validation on all endpoints +- [ ] Authentication/authorization configured +- [ ] CORS properly configured +- [ ] Security headers set +- [ ] Passwords hashed with BCrypt +- [ ] SQL injection protection (parameterized queries) +- [ ] Rate limiting on public endpoints + +## Phase 5: Native Compilation + +Test GraalVM native image compatibility: + +```bash +# Build native executable +mvn package -Dnative + +# Or with container +mvn package -Dnative -Dquarkus.native.container-build=true + +# Test native executable +./target/*-runner + +# Run basic smoke tests +curl http://localhost:8080/q/health/live +curl http://localhost:8080/q/health/ready +``` + +### Native Image Troubleshooting + +Common issues: +- **Reflection**: Add reflection config for dynamic classes +- **Resources**: Include resources with `quarkus.native.resources.includes` +- **JNI**: Register JNI classes if using native libraries + +Example reflection config: +```java +@RegisterForReflection(targets = {MyDynamicClass.class}) +public class ReflectionConfiguration {} +``` + +## Phase 6: Performance Testing + +### Load Testing with K6 + +```javascript +// load-test.js +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '30s', target: 0 }, + ], +}; + +export default function () { + const res = http.get('http://localhost:8080/api/markets'); + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 200ms': (r) => r.timings.duration < 200, + }); +} +``` + +Run: +```bash +k6 run load-test.js +``` + +### Metrics to Monitor + +- Response time (p50, p95, p99) +- Throughput (requests/sec) +- Error rate +- Memory usage +- CPU usage + +## Phase 7: Health Checks + +```bash +# Liveness +curl http://localhost:8080/q/health/live + +# Readiness +curl http://localhost:8080/q/health/ready + +# All health checks +curl http://localhost:8080/q/health + +# Metrics (if enabled) +curl http://localhost:8080/q/metrics +``` + +Expected responses: +```json +{ + "status": "UP", + "checks": [ + { + "name": "Database connection", + "status": "UP" + } + ] +} +``` + +## Phase 8: Container Image Build + +```bash +# Build container image +mvn package -Dquarkus.container-image.build=true + +# Or with specific registry +mvn package \ + -Dquarkus.container-image.build=true \ + -Dquarkus.container-image.registry=docker.io \ + -Dquarkus.container-image.group=myorg \ + -Dquarkus.container-image.tag=1.0.0 + +# Test container +docker run -p 8080:8080 myorg/my-quarkus-app:1.0.0 +``` + +### Container Security Scan + +```bash +# Trivy +trivy image myorg/my-quarkus-app:1.0.0 + +# Grype +grype myorg/my-quarkus-app:1.0.0 +``` + +## Phase 9: Configuration Validation + +```bash +# Check all configuration properties +mvn quarkus:info + +# List all config sources +curl http://localhost:8080/q/dev/io.quarkus.quarkus-vertx-http/config +``` + +### Environment-Specific Checks + +- [ ] Database URLs configured per environment +- [ ] Secrets externalized (Vault, env vars) +- [ ] Logging levels appropriate +- [ ] CORS origins set correctly +- [ ] Rate limiting configured +- [ ] Monitoring/tracing enabled + +## Phase 10: Documentation Review + +- [ ] OpenAPI/Swagger docs up to date (`/q/swagger-ui`) +- [ ] README has setup instructions +- [ ] API changes documented +- [ ] Migration guide for breaking changes +- [ ] Configuration properties documented + +Generate OpenAPI spec: +```bash +curl http://localhost:8080/q/openapi -o openapi.json +``` + +## Verification Checklist + +### Code Quality +- [ ] Build passes without warnings +- [ ] Static analysis clean (no high/medium issues) +- [ ] Code follows team conventions +- [ ] No commented-out code or TODOs in PR + +### Testing +- [ ] All tests pass +- [ ] Code coverage ≥ 80% +- [ ] Integration tests with real database +- [ ] Security tests pass +- [ ] Performance within acceptable limits + +### Security +- [ ] No dependency vulnerabilities +- [ ] Authentication/authorization tested +- [ ] Input validation complete +- [ ] Secrets not in source code +- [ ] Security headers configured + +### Deployment +- [ ] Native compilation successful +- [ ] Container image builds +- [ ] Health checks respond correctly +- [ ] Configuration valid for target environment + +### Native Image +- [ ] Native executable builds +- [ ] Native tests pass +- [ ] Startup time < 100ms +- [ ] Memory footprint acceptable + +## Automated Verification Script + +```bash +#!/bin/bash +set -e + +echo "=== Phase 1: Build ===" +mvn clean verify -DskipTests + +echo "=== Phase 2: Static Analysis ===" +mvn checkstyle:check pmd:check spotbugs:check + +echo "=== Phase 3: Tests + Coverage ===" +mvn test jacoco:report jacoco:check + +echo "=== Phase 4: Security Scan ===" +mvn org.owasp:dependency-check-maven:check + +echo "=== Phase 5: Native Compilation ===" +mvn package -Dnative -Dquarkus.native.container-build=true + +echo "=== All Phases Complete ===" +echo "Review reports:" +echo " - Coverage: target/site/jacoco/index.html" +echo " - Security: target/dependency-check-report.html" +echo " - Native: target/*-runner" +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Verification + +on: [push, pull_request] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Build + run: mvn clean verify -DskipTests + + - name: Test with Coverage + run: mvn test jacoco:report jacoco:check + + - name: Security Scan + run: mvn org.owasp:dependency-check-maven:check + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: target/site/jacoco/jacoco.xml +``` + +## Best Practices + +- Run verification loop before every PR +- Automate in CI/CD pipeline +- Fix issues immediately; don't accumulate debt +- Keep coverage above 80% +- Update dependencies regularly +- Test native compilation periodically +- Monitor performance trends +- Document breaking changes +- Review security scan results +- Validate configuration for each environment