diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2e0c302f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,234 @@ +# AGENTS.md + +## 프로젝트 정체성 + +이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다. + +기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 엔지니어링 작업 기록 게시판)에 격리한다. 새 프로젝트를 시작할 때는 도메인 이름, 패키지, 엔티티, 유스케이스를 교체할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다. + +## Prime Directive + +에이전트는 속도보다 아키텍처 보존을 우선한다. + +동작하는 코드라도 HARD-STOP 조건을 하나라도 위반하면 완료된 작업이 아니다. + +HARD-STOP 8개 항목의 SSOT 는 `.agents/plugins/ca-superpowers/rules/clean-architecture.md` §HARD-STOP 이다. 이 문서는 목록 사본을 유지하지 않는다 — 불일치 시 SSOT 파일이 우선한다. (항상 로드되는 요약 사본은 root `CLAUDE.md` Prime Directive 에 있다.) + +## Superpowers Workflow + +이 프로젝트에서 에이전트는 관련 Superpowers 스킬을 먼저 확인하고, 작업 성격에 맞는 스킬을 사용한다. 사용자 지시와 이 `AGENTS.md`가 로컬 프로젝트의 최상위 규칙이며, Superpowers는 그 규칙을 실행하기 위한 작업 방식이다. + +사용 가능한 주요 스킬과 트리거: + +- `superpowers:using-superpowers`: 대화나 작업을 시작할 때 관련 스킬을 확인한다. +- `superpowers:brainstorming`: 기능 설계, 구조 변경, 동작 변경, 새 문서 정책 수립 전에 사용한다. +- `superpowers:writing-plans`: 승인된 설계가 있고 작업이 여러 단계로 나뉠 때 사용한다. +- `superpowers:executing-plans`: 작성된 계획을 현재 세션에서 순차 실행할 때 사용한다. +- `superpowers:subagent-driven-development`: 계획을 작업 단위로 나누어 독립 에이전트에게 맡길 때 사용한다. +- `superpowers:dispatching-parallel-agents`: 서로 독립적인 조사나 구현을 병렬로 진행할 때 사용한다. +- `superpowers:test-driven-development`: 기능 추가와 버그 수정을 테스트 우선으로 진행할 때 사용한다. +- `superpowers:systematic-debugging`: 버그, 실패한 테스트, 예상 밖 동작을 다룰 때 사용한다. +- `superpowers:verification-before-completion`: 완료, 수정됨, 통과함을 주장하기 전에 사용한다. +- `superpowers:requesting-code-review`: 의미 있는 구현을 마친 뒤 병합 또는 PR 전에 사용한다. +- `superpowers:receiving-code-review`: 리뷰 피드백을 적용하기 전에 사용한다. +- `superpowers:finishing-a-development-branch`: 구현과 검증이 끝난 브랜치를 정리할 때 사용한다. +- `superpowers:using-git-worktrees`: 격리된 작업 공간이 필요할 때 사용한다. +- `superpowers:writing-skills`: 스킬을 만들거나 수정할 때 사용한다. + +## Harness 정책 SSOT + +- `.harness/manifest.yaml`: Java 21 / Spring Boot 4.0.0 프로젝트 identity와 task packet 진입점 +- `.harness/project/modules.yaml`: 19개 leaf의 ID, 소스 경로, Gradle path, 허용 edge, focused command +- `.harness/core/risk-policy.yaml`: change-surface 기반 risk 분류 +- `.harness/core/evidence-policy.yaml`: risk별 evidence/review profile +- `.harness/core/review-policy.yaml`: orchestration, option/counterargument, human-only commit 정책 +- `.harness/core/report-policy.yaml`: concise/durable report와 citation self-grep 정책 + +작업 시작 시 task packet을 한 번 resolve하고 stable packet/rule hash를 재사용한다. overlay나 관련 +hash가 바뀔 때만 다시 resolve하거나 rule 전문을 재정독한다. 파일 수는 risk 분류 기준이 아니다. +commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit/amend/push하지 않는다. + +## LLM Wiki 캡처 워크플로우 + +구현, 아키텍처, 빌드, 테스트, 런타임, 문서 워크플로우 변경처럼 의미 있는 작업을 끝낸 뒤에는 최종 응답 전에 LLM Wiki 기록을 갱신한다. + +기준 vault: + +```text +/home/donghyeon/workspace/ai-tool/llm-wiki-private/ +``` + +에이전트는 해당 vault의 `AGENTS.md`, `CLAUDE.md`, `rules/`, `.agents/`, `.claude/`, `.codex/` 지침을 확인한 뒤 작성한다. ca-tmpl 내부의 상세 실행 규칙은 `.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`를 따른다. + +필수 순서: + +1. `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/.md`를 생성하거나 갱신한다. +2. 구현 내용, 변경 파일, 의사결정, 검증 명령, 실패/차단 사항, 증거 등급을 branch-note에 기록한다. +3. 실제로 파생 자료가 있으면 다음 raw 문서를 생성하거나 갱신한다. + - `raw/errors/`: 오류, 실패한 테스트, 샌드박스/도구 문제, 재발 가능한 트러블슈팅 + - `raw/interviews/`: 이 작업에서 정직하게 뽑을 수 있는 면접 질문 + - `raw/blog-topics/`: 채용공고가 아닌 구현·설계·트러블슈팅 기반 블로그 글감 +4. 파생 문서는 `## Parent`에서 branch-note로 upward link하고, branch-note의 `## Cluster / 묶음`에는 파생 문서 wikilink를 되돌려 적는다. +5. canonical 추출 요청이 없는 한 `wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`, `wiki/concepts/`, `wiki/projects/`를 바로 만들지 않는다. + +파생 문서가 필요 없을 때도 그냥 생략하지 말고, branch-note의 cluster 섹션에 "없음" 또는 "추출할 별도 글감 없음"처럼 판단 결과를 남긴다. + +## 강제 수준 + +이 문서는 에이전트와 개발자가 따라야 할 작업 규칙을 정의하지만, 그 자체로 빌드나 테스트를 실패시키는 자동 강제 장치는 아니다. + +이 프로젝트의 규칙은 세 단계로 관리한다. + +1. `AGENTS.md`: 에이전트와 개발자가 따라야 할 아키텍처, 작업 순서, 검증 원칙을 정의한다. +2. ArchUnit/Gradle/Test: 모듈 의존성, 계층 침범, 설정 바인딩, 동작 회귀를 자동으로 탐지한다. +3. Code Review/CI: 문서와 자동 검증이 놓친 설계 품질, 운영 위험, 템플릿 일관성을 최종 확인한다. + +에이전트는 `AGENTS.md`를 따라 작업해야 하며, 현업 수준의 스켈레톤 완성도는 반드시 자동 검증과 리뷰로 보강해야 한다. + +## 모듈 책임 + +19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 의존성, focused test 명령은 +`.harness/project/modules.yaml`이 SSOT다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 +정의한다. 작업 파일에서는 가장 가까운 `src/**/CLAUDE.md`를 함께 읽는다. + +- `domain-core`: 순수 도메인 모델, 불변식, 이벤트, port. Spring/JPA/transport/IO 타입 금지. +- `application-core`: command, use case, application policy, transaction port, application 예외. + inbound DTO, persistence entity, adapter 타입 금지. +- `adapter:inbound:*`: HTTP/gRPC/GraphQL/WebSocket transport, DTO, validation, 인증·인가 매핑, + error mapping. repository 직접 호출과 비즈니스 정책 금지. +- `adapter:outbound:persistence-*`: JPA/PostgreSQL 또는 MongoDB persistence 구현과 mapping, + migration/vendor 동작. 유스케이스 정책 금지. +- 그 밖의 `adapter:outbound:*`: support, messaging, cache, notification, object storage, + file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는 + registry만 따른다. +- `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지. +- `sample-portfolio`: 샘플/fixture consumer. production leaf가 의존하면 안 된다. +- `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지. + +## 의존성 방향 + +family 수준 기본 방향: + +```text +app-bootstrap -> adapter:inbound:* -> application-core -> domain-core +app-bootstrap -> adapter:outbound:* -> application-core -> domain-core +runtime modules -> shared-contract +sample-portfolio -> registered runtime leaves (fixture consumer only) +``` + +개별 edge는 `.harness/project/modules.yaml`의 `allowed_dependencies`가 유일한 목록이다. +Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억에서 leaf edge를 추론하지 않는다. + +## 기능 개발 프로토콜 + +새 기능이나 동작 변경은 다음 순서로 진행한다. + +1. 요구사항을 읽고 소유 계층을 판단한다. +2. 구조나 동작이 바뀌면 `superpowers:brainstorming`으로 설계를 먼저 확정한다. +3. 여러 단계의 작업이면 `docs/superpowers/specs/`에 설계 문서를 남긴다. +4. 구현 전 `docs/superpowers/plans/`에 실행 계획을 작성한다. +5. 동작 변경은 테스트를 먼저 작성한다. +6. 새 비즈니스 개념은 `domain-core`에 먼저 둔다. +7. 유스케이스는 `application-core` command와 use case/service method로 표현한다. +8. 외부 연동은 registry가 가리키는 `adapter:outbound:*` leaf에서 port 구현으로 추가한다. +9. endpoint는 마지막에 해당 `adapter:inbound:*` leaf에서 얇게 연결한다. +10. focused test를 먼저 돌리고, 가능한 경우 전체 Gradle test를 돌린다. +11. LLM Wiki branch-note와 필요한 파생 raw 문서를 갱신한다. +12. 완료 응답에는 변경 파일, 검증 결과, Wiki capture 결과를 포함한다. + +## 테스트 전략 + +계층별 테스트 기준: + +- `domain-core`: Spring 없이 순수 unit test로 도메인 규칙을 검증한다. +- `application-core`: fake/in-memory port를 사용해 유스케이스 흐름을 검증한다. +- `adapter:inbound:*`: transport validation, auth mapping, status/response contract를 검증한다. +- `adapter:outbound:persistence-*`: persistence mapping, repository adapter, vendor/migration 동작을 검증한다. +- 그 밖의 `adapter:outbound:*`: external capability adapter contract를 검증한다. +- `app-bootstrap/settings`: configuration binding, validation, logging 설정을 검증한다. + +검증 원칙: + +- 완료를 주장하기 전에 `superpowers:verification-before-completion`을 사용한다. +- 실행한 명령과 결과를 최종 응답에 적는다. +- 테스트를 실행하지 못했다면 이유와 남은 위험을 솔직히 적는다. + +권장 절차: + +```bash +cd src +python3 ../.harness/validators/resolve_task.py +# resolved packet의 focused_commands를 실행 +./gradlew test +./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle) +./gradlew verifyCleanArchitectureDependencies +./gradlew verifyPublicPathSnapshot +./gradlew verifyEnvKeys +``` + +19개 leaf의 정확한 focused test 명령은 `.harness/project/modules.yaml`과 resolved task packet을 +따른다. root 문서에 별도 명령 목록을 복제하지 않는다. + +## 설정과 런타임 + +설정 규칙: + +- secrets를 코드, 테스트 fixture, 문서 예시에 하드코딩하지 않는다. +- 새 설정 그룹은 typed settings class로 만든다. +- 흩어진 `@Value`보다 configuration properties와 settings class를 선호한다. +- `.env.local`은 로컬 오버라이드로 취급한다. +- `application.yml`은 환경별로 안전한 기본값과 명확한 placeholder만 담는다. +- 새 settings class를 만들면 binding/validation 테스트를 추가한다. + +Docker/runtime 규칙: + +- Dockerfile 변경 시 build context와 runtime env 요구사항을 함께 확인한다. +- container 안에서 필요한 profile, port, env var를 문서나 예시 설정에 반영한다. +- local-only 경로와 운영 경로를 섞지 않는다. + +## 템플릿 재사용 체크리스트 + +이 저장소를 새 프로젝트 시작점으로 사용할 때: + +1. `src/settings.gradle`의 `rootProject.name`을 새 프로젝트명으로 바꾼다. +2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다. +3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다. +4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가하고, 예시 코드는 `sample-portfolio`에 격리한다. +5. 모듈 이름과 경계는 유지한다. +6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다. +7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다. +8. README와 운영 문서를 새 프로젝트 설명으로 갱신한다. +9. 전체 테스트를 실행한다. + +```bash +cd src +./gradlew test +``` + +## 금지된 지름길 + +에이전트는 다음을 하지 않는다. + +- `domain-core`에 Spring/JPA annotation 추가 +- controller에서 repository 직접 호출 +- application method가 web request DTO를 인자로 받게 만들기 +- application이나 domain에서 JPA entity 반환 +- mapper에 비즈니스 정책 넣기 +- filter/config/settings class에 유스케이스 넣기 +- 요청 범위 밖의 대규모 리팩터링 +- 사용자 변경사항 되돌리기 +- 명시적 요청 없는 destructive git command 실행 +- 테스트 미실행 상태에서 "완료"라고 말하기 + +## 작업 보고 규칙 + +최종 응답에는 다음을 포함한다. + +- 변경한 파일 +- 핵심 변경 내용 +- 실행한 검증 명령 +- 실패하거나 실행하지 못한 검증 +- LLM Wiki branch-note와 파생 문서 캡처 결과 +- 남은 위험 또는 후속 작업 + +짧은 작업이라도 검증 여부는 생략하지 않는다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e97c4df4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template. + +## Prime Directive + +Preserve architecture before optimizing for speed. The HARD-STOP SSOT is +`.agents/plugins/ca-superpowers/rules/clean-architecture.md`; its eight current stop conditions are: + +1. `domain-core` gains framework, transport, database, or cloud dependencies. +2. A controller directly uses a repository, Spring Data interface, or persistence entity. +3. An inbound DTO leaks into `application-core` or `domain-core`. +4. Business rules move into mappers, filters, configuration, settings, or controllers. +5. Project dependencies violate `.harness/project/modules.yaml` and Gradle verification. +6. Completion is claimed without the relevant verification or a named reason it could not run. +7. A corpus conclusion is made without evidence appropriate to the selected evidence profile. +8. Non-trivial work closes without the required LLM Wiki capture or a reported capture block. + +If this summary and the SSOT differ, the SSOT wins. + +## Harness policy authorities + +- Project manifest and stack: `.harness/manifest.yaml` +- Leaf modules, dependency edges, and focused commands: `.harness/project/modules.yaml` +- Risk classification: `.harness/core/risk-policy.yaml` +- Evidence selection: `.harness/core/evidence-policy.yaml` +- Orchestration and advisory depth: `.harness/core/review-policy.yaml` +- Durable/concise reporting: `.harness/core/report-policy.yaml` +- Physical parity check: `.harness/validators/validate_policy_parity.py` + +Resolve a task packet once and reuse its stable task-packet hash and relevant rule hashes. Rerun +resolution or reread a full rule only when the task overlay, packet hash, or rule hash changes. + +Commit policy is `human-only`: agents do not stage, commit, amend, or push implementation changes. + +## Risk-based orchestration + +- Low risk: implement inline or in a focused lane and run the focused check. +- Medium risk: use `ca-implementer`; select proportional review based on affected boundaries and + evidence needs. +- High risk: use `ca-implementer`, then the full chain after a human commit: + `ca-architect-sentinel` → `ca-spec-reviewer` → `ca-quality-reviewer` → `gradle-runner`. + +Risk comes from change surface and task flags, not file count. + +## Module families + +`.harness/project/modules.yaml` owns the complete 19-leaf list. Root guidance summarizes families; +the nearest `src/**/CLAUDE.md` owns local rules. + +| Family | Responsibility | Stable dependency direction | +| --- | --- | --- | +| `domain-core` | Pure domain model, invariants, events, ports | Java stdlib and registered value-only contracts | +| `application-core` | Commands, use cases, application policies, transaction ports | `domain-core`, `shared-contract` | +| `adapter:inbound:*` | HTTP, gRPC, GraphQL, WebSocket transport boundaries | application/domain/shared contracts | +| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered | +| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge | +| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only | +| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency | +| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | + +Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. +Read it from `.harness/project/modules.yaml` or from the resolved task packet. + +## Layer workflow + +For a full use case, work in this order: + +```text +domain-core + -> application-core + -> adapter:outbound:* (or persistence/identifier) + -> adapter:inbound:* + -> app-bootstrap wiring +``` + +Layer-only work stays inside that registered leaf plus its tests. If a required fix crosses a layer +or writable scope, stop and request context rather than expanding silently. + +## Testing + +- `domain-core`: pure JUnit unit tests. +- `application-core`: use-case tests with hand-rolled fakes; no web or persistence context. +- inbound adapters: focused transport slice/contract tests. +- persistence adapters: mapping/port contract tests; use a real datastore only when vendor semantics + require it. +- other outbound adapters: port contract tests with fake external systems; no real network. +- identifier: pure deterministic unit tests. +- bootstrap/settings: binding, validation, wiring, and architecture tests. + +From `src/`, run the exact focused command emitted in the task packet. Architecture-wide commands: + +```bash +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyPublicPathSnapshot --console=plain +./gradlew verifyEnvKeys --console=plain +``` + +Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires +the controller's workflow authorization. + +## Advisory and reporting + +Use dependency-DAG/topological reasoning and 3–5 materially distinct alternatives when that many +exist; use fewer when the option set is smaller. Judgment findings include falsifiable assumptions +and counterarguments. Deterministic compile, dependency, secret, or failed-test findings use +Rule → Evidence → Fix without manufactured debate. + +Citation verification is profile-based: none for `review-lite`, blocking citations for +`review-standard`, and all material citations for `audit-deep`/`regulated`. Durable reports are +triggered by high risk, at least 3 blocking findings, an architecture decision, explicit user request, +or the regulated profile. Otherwise a concise result is allowed. + +## LLM Wiki capture + +For non-trivial implementation or workflow changes, follow +`.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md`. If the controller explicitly excludes +wiki writes for a dispatched task, report the handoff instead of writing outside scope. diff --git a/README.md b/README.md index e65e6ab9..a6974739 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,108 @@ -# clean-architecture-backend-template +# ca-skeleton — Clean Architecture Spring Boot 템플릿 +ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스만 교체하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 작업 기록 게시판)에 격리합니다. + +이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다. + +## 아키텍처 한눈에 + +의존은 항상 바깥에서 안으로 흐릅니다. adapter가 core의 port에 의존하고, core는 adapter를 알지 못합니다. 이 방향이 유지되는 한 도메인 규칙과 기술 선택(웹 프레임워크, DB, 메시징)을 서로 독립적으로 바꿀 수 있습니다. + +```text +app-bootstrap -> adapter:inbound:* -> application-core -> domain-core +app-bootstrap -> adapter:outbound:* -> application-core -> domain-core +모든 런타임 모듈 -> shared-contract +sample-portfolio -> 등록된 런타임 리프 (fixture 소비자 전용) +``` + +family 수준의 책임은 다음과 같습니다. + +| 모듈 family | 책임 | +| --- | --- | +| `domain-core` | 순수 도메인 모델·불변식·이벤트·port. 프레임워크·transport·DB·IO 타입 금지 | +| `application-core` | command·유스케이스·application 정책·트랜잭션 port. inbound DTO·persistence entity 금지 | +| `adapter:inbound:*` | HTTP·gRPC·GraphQL·WebSocket transport 경계, DTO·validation·인증·에러 매핑 | +| `adapter:outbound:persistence-*` | JPA/PostgreSQL·MongoDB 영속 구현과 매핑·migration | +| `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 | +| `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 | +| `app-bootstrap` | Spring Boot entrypoint와 composition root | +| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 | + +정확한 19개 leaf 목록, 각 leaf의 Gradle path·허용 의존 edge·focused test 명령은 [.harness/project/modules.yaml](.harness/project/modules.yaml)이 SSOT입니다. root 문서나 기억에서 개별 leaf edge를 추론하지 않습니다. + +## 퀵스타트 + +전제조건은 Temurin 21(루트 [.tool-versions](.tool-versions)에 고정)과 Docker Engine 또는 Docker Desktop입니다. Gradle은 저장소 wrapper를 씁니다. 첫 실행 진입점은 하나입니다. + +```bash +cd src +./gradlew bootstrap +``` + +`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), sample 격리 검증, `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다. + +```bash +curl -fsS http://localhost:8080/api/healthcheck +``` + +로컬 스택을 내릴 때는 저장소 루트에서 실행합니다. + +```bash +docker compose -f docker-compose.yml -f docker-compose.local.yml down +``` + +`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다. + +## 새 프로젝트로 시작하기 + +이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다. + +1. [src/settings.gradle](src/settings.gradle)의 `rootProject.name`을 새 서비스 이름으로 바꿉니다. +2. 패키지 루트 `dev.caskeleton`을 조직·서비스 패키지로 바꿉니다. 소스뿐 아니라 빌드·설정 파일의 참조도 함께 바꿔야 `mainClass`·`group`이 어긋나 `bootstrap`이 깨지지 않습니다. + + ```bash + cd src + find . -type f \( -name '*.java' -o -name '*.gradle' -o -name '*.yml' \) -print0 | xargs -0 sed -i 's/dev.caskeleton/com.yourorg.yourservice/g' + ``` + + 레지스트리 `package_roots`와 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다. + +3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다. +4. 모듈 이름과 경계는 그대로 유지합니다. + +검증은 sample-on과 sample-off를 모두 통과시킵니다. + +```bash +cd src +./gradlew test +./gradlew :app-bootstrap:sampleOffTest +``` + +`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다. + +## 아키텍처 규칙과 검증 + +애플리케이션이 동작하더라도 아래를 어기면 병합하지 않습니다. 8개 HARD-STOP 조건의 SSOT는 [clean-architecture.md](.agents/plugins/ca-superpowers/rules/clean-architecture.md)입니다. + +- `domain-core`는 Spring·JPA·Servlet·HTTP·DB·cloud SDK 타입을 import하지 않습니다. +- controller는 repository를 직접 호출하거나 persistence entity를 반환하지 않습니다. +- inbound DTO는 `application-core`나 `domain-core`로 들어가지 않습니다. +- 비즈니스 정책은 mapper·filter·config·settings·controller에 두지 않습니다. +- 새 외부 시스템 연동은 domain/application port와 adapter 모듈로 표현합니다. + +이 규칙은 두 축으로 자동 강제합니다. ArchUnit `CleanArchitectureTest`가 컴파일된 소스 의존성을, `verifyCleanArchitectureDependencies` 게이트가 Gradle 프로젝트 의존성을 검사하며, 둘 다 registry의 허용 edge를 읽습니다. + +```bash +cd src +./gradlew verifyCleanArchitectureDependencies +``` + +두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다. + +## 더 알아보기 + +- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md) +- 모듈 레지스트리(19개 leaf SSOT): [.harness/project/modules.yaml](.harness/project/modules.yaml) +- 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md) +- 빌드·릴리스 공급망 파이프라인: [build-release-supply-chain.yml](.github/workflows/build-release-supply-chain.yml) +- 모듈별 설계 결정: [domain-core](src/domain-core/README.md) · [application-core](src/application-core/README.md) · [adapter:inbound:web](src/adapter/inbound/web/README.md) · [adapter:outbound:persistence-jpa](src/adapter/outbound/persistence-jpa/README.md) · [shared-contract](src/shared-contract/README.md) · [app-bootstrap](src/app-bootstrap/README.md) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..b36618e1 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,61 @@ +# ============================================================================= +# feature-container-runtime-contract — dev environment override +# +# Merge with base: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# +# Dev intent: +# - Relaxes read-only filesystem (writable, no tmpfs constraint) so developers +# can hot-swap files without image rebuilds. +# - Increases memory allowance for dev/debugging workloads. +# - Disables restart-unless-stopped so crash loops don't mask startup errors. +# - Enables JMX remote port (local only, never in prod). +# - Mounts a local source volume for fast iteration (optional; mount when IDE +# attaches to the running container). +# ============================================================================= + +services: + app: + # Relax read-only constraint for local development. + read_only: false + tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs + # More memory for dev profiling / heap dumps. + mem_limit: 1g + memswap_limit: 1g + environment: + TZ: "UTC" + LANG: "C.UTF-8" + LC_ALL: "C.UTF-8" + # Use a shorter drain timeout in dev so restarts are faster. + APP_SERVER_SHUTDOWN_TIMEOUT: "5s" + APP_SERVER_SHUTDOWN: "graceful" + # Remote JMX — bind to localhost only; NEVER expose in production. + JAVA_TOOL_OPTIONS: >- + -XX:MaxRAMPercentage=75 + -XX:+UseContainerSupport + -XX:+ExitOnOutOfMemoryError + -XX:+HeapDumpOnOutOfMemoryError + -XX:HeapDumpPath=/var/tmp/heap + -Dserver.tomcat.basedir=/tmp + -Dcom.sun.management.jmxremote + -Dcom.sun.management.jmxremote.port=9999 + -Dcom.sun.management.jmxremote.rmi.port=9999 + -Dcom.sun.management.jmxremote.authenticate=false + -Dcom.sun.management.jmxremote.ssl=false + -Djava.rmi.server.hostname=127.0.0.1 + ports: + - "8080:8080" + - "9001:9001" + - "127.0.0.1:9999:9999" # JMX — localhost only + # No health check constraint in dev; let the app start at its own pace. + healthcheck: + disable: true + # Do not restart automatically so crash loops stay visible. + restart: "no" + # Optional: mount heap dump directory to host for dev analysis. + volumes: + - type: bind + source: ./tmp/heap-dumps + target: /var/tmp/heap + bind: + create_host_path: true diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..e69b6f96 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,83 @@ +# ============================================================================= +# feature-container-runtime-contract — local infrastructure override +# +# Merge with base (and optionally dev): +# docker compose -f docker-compose.yml -f docker-compose.local.yml up +# +# Local intent: +# - Starts a local PostgreSQL database for integration testing without Testcontainers. +# - Wires the app environment to point at the local DB. +# - Keeps read-only filesystem and memory limits from the base compose. +# - Does NOT expose the DB port publicly; app and db communicate on the +# internal `caskeleton-local` network only. +# ============================================================================= + +services: + app: + env_file: + - ./src/.env + # Wire the app to the local Postgres service on the internal network. + environment: + TZ: "UTC" + LANG: "C.UTF-8" + LC_ALL: "C.UTF-8" + # fallback matches env-keys.yaml SSOT default (30s); base compose grace is 40s. + APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}" + APP_SERVER_SHUTDOWN: "graceful" + # Database connection — points to the local `db` service below. + # Override with your actual DB credentials in a local .env file. + APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}" + APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}" + APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}" + depends_on: + db: + condition: service_healthy + healthcheck: + test: + - "CMD" + - "wget" + - "--no-verbose" + - "--tries=1" + - "--spider" + - "http://localhost:8080/api/healthcheck" + interval: 5s + timeout: 3s + start_period: 20s + retries: 12 + networks: + - caskeleton-local + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}" + POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}" + POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}" + TZ: "UTC" + # Persist data between restarts; remove the volume to start fresh. + volumes: + - type: volume + source: caskeleton-db-data + target: /var/lib/postgresql/data + # No host port: startup Flyway runs in the app container over the internal network. + networks: + - caskeleton-local + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + +networks: + caskeleton-local: + driver: bridge + +volumes: + caskeleton-db-data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..87272a2d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +# ============================================================================= +# feature-container-runtime-contract — base docker-compose service definition +# +# Usage: +# docker compose up # base only +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up # dev override +# docker compose -f docker-compose.yml -f docker-compose.local.yml up # local override +# +# Graceful-shutdown sync table (feature-container-runtime-contract D5): +# App shutdown timeout : 30s (APP_SERVER_SHUTDOWN_TIMEOUT — SSOT: docs/registries/ +# env-keys.yaml, owner feature-env-driven-runtime-configuration) +# Container preStop : 5s (handled by the orchestrator / stop_grace_period offset) +# stop_grace_period : 40s (30s app drain + 5s preStop + 5s safety margin) +# +# Ownership: env-driven-config owns the app shutdown *value* (30s); this contract owns the +# *relationship* (stop_grace_period >= app timeout + preStop + margin). The grace is therefore +# sized UP to the env-keys SSOT value (30s) — you do not shrink the app drain window to fit an +# arbitrary grace. This resolves the earlier 20s/35s drift, which had assumed an app-timeout +# value this branch does not own (env-keys.yaml is the SSOT and sets 30s). +# +# Memory limit (D4): +# The container memory limit MUST be set for -XX:MaxRAMPercentage=75 to be meaningful. +# Default 512m suits a small service; adjust per deployment. +# ============================================================================= + +services: + app: + build: + context: src/ + dockerfile: Dockerfile + args: + RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}" + BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}" + GIT_SHA: "${GIT_SHA:-0000000}" + SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}" + image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000} + ports: + - "${APP_SERVER_PORT:-8080}:8080" + - "9001:9001" + environment: + TZ: "UTC" + LANG: "C.UTF-8" + LC_ALL: "C.UTF-8" + # Graceful shutdown — fallback matches the env-keys.yaml SSOT default (30s). + # Override via .env or docker compose --env-file if you need a different value. + APP_SERVER_SHUTDOWN_TIMEOUT: "${APP_SERVER_SHUTDOWN_TIMEOUT:-30s}" + APP_SERVER_SHUTDOWN: "graceful" + # ---- Read-only root filesystem (D2) ------------------------------------- + # The application must write only to the tmpfs mounts declared below. + # /tmp — Tomcat basedir (see -Dserver.tomcat.basedir=/tmp in Dockerfile). + # /var/tmp/heap — heap dump landing zone (see -XX:HeapDumpPath=/var/tmp/heap). + read_only: true + tmpfs: + - /tmp:mode=1777,size=128m + - /var/tmp/heap:mode=1777,size=512m + # ---- Memory limit (D4) -------------------------------------------------- + # Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound. + mem_limit: 512m + memswap_limit: 512m + # ---- Graceful shutdown (D5) --------------------------------------------- + # stop_grace_period = app drain (30s, env-keys SSOT) + preStop (5s) + margin (5s) = 40s. + stop_grace_period: 40s + # ---- Health check ------------------------------------------------------- + # Targets the actuator readiness probe on the management port (9001). + # CROSS-FEATURE COUPLING: /actuator/health/readiness is implemented by the + # parallel runtime-health + actuator branches. This healthcheck will report + # UNHEALTHY in this worktree until those branches are merged. + healthcheck: + test: + - "CMD" + - "wget" + - "--no-verbose" + - "--tries=1" + - "--spider" + - "http://localhost:9001/actuator/health/readiness" + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + restart: unless-stopped diff --git a/docs/registries/capabilities.yaml b/docs/registries/capabilities.yaml new file mode 100644 index 00000000..b3edb54a --- /dev/null +++ b/docs/registries/capabilities.yaml @@ -0,0 +1,120 @@ +# Registry: Repository Access Capabilities +# SSOT: wiki/projects/ca-tmpl/registries/capabilities.yaml +# Schema owner: feature-contract-registry-governance +# Owner branch: feature-repository-access-permission-contract +# Last updated: 2026-06-05 +# +# Notes +# - capability는 사용자 권한이 아니라 application use case가 infrastructure capability를 +# 사용할 수 있는지에 대한 계약 (feature-repository-access-permission-contract). +# - enforcement default = ArchUnit annotation-based rule. compile-time annotation processor는 +# alternative. runtime AOP는 forbidden. +# - capability 제거는 항상 breaking change. 추가는 additive (registry row 동반 시). +# - annotation 표기 (as-built, F1/F2 reconciled 2026-06-05): 코드 SSOT는 단일 +# `@UseCaseCapability` (TYPE target, typed attribute). 노트 D2/D11의 flat +# `@UseCaseRepositoryAccess(Capability[])` 모델은 superseded. 7 capability ↔ as-built 매핑: +# READ_REPOSITORY/WRITE_REPOSITORY → repositoryAccess, TRANSACTION_REQUIRED → transactionMode, +# EXTERNAL_OUTBOUND_ALLOWED → externalOutboundAllowed, SENSITIVE_READ → sensitiveRead, +# BULK_WRITE → bulkWrite, CROSS_TENANT_ADMIN → crossTenantAdmin. +# 각 row의 annotation: 필드는 아래에서 as-built 표기로 정합됨. + +capabilities: + + # source: feature-repository-access-permission-contract — 판정 기준 "Required capability: READ_REPOSITORY" + # source: feature-application-port-usecase-contract — "read-only query use case는 readOnly 와 READ_REPOSITORY capability만 선언 가능" + - name: READ_REPOSITORY + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(repositoryAccess = READ_REPOSITORY)" + semantics: "use case가 read-only repository operation을 호출하는 것을 허용. query use case의 기본 capability. write/sensitive/bulk 작업은 별도 capability 선언이 없으면 forbidden." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: null + threshold: null + compatibility_impact: breaking + required_test: architecture-enforcement:read-repository-capability + + # source: feature-repository-access-permission-contract — 판정 기준 "Required capability: WRITE_REPOSITORY" + # source: feature-application-port-usecase-contract — "write use case는 transactionMode, idempotency, repositoryAccess를 명시해야 함" + - name: WRITE_REPOSITORY + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(repositoryAccess = WRITE_REPOSITORY)" + semantics: "use case가 mutating repository operation(insert/update/delete)을 호출하는 것을 허용. 단일/소량 write 기준이며 batch size > 100은 BULK_WRITE 별도 선언 필요. read-only use case에서 이 capability 없이 write repository 접근하면 fail." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: null + threshold: null + compatibility_impact: breaking + required_test: architecture-enforcement:write-repository-capability + + # source: feature-repository-access-permission-contract — decisions 2026-05-22 + # "SENSITIVE_READ marker = registry-managed metadata table (entity FQN + field name 단위)" + - name: SENSITIVE_READ + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(sensitiveRead = true)" + semantics: "PII/credential 등 sensitive field를 읽는 use case가 선언해야 하는 capability. marker는 registry-managed metadata table(entity FQN + field name 단위)에서 lookup. domain annotation 또는 JPA entity annotation 형태는 forbidden(domain에 framework 의존 회피). pseudonymized data read는 documented 시에만 예외 허용." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: null + threshold: null + compatibility_impact: breaking + required_test: architecture-enforcement:sensitive-read-capability + + # source: feature-repository-access-permission-contract — decisions 2026-05-22 + # "BULK_WRITE threshold = N > 100 또는 batch size > 100. 미만은 일반 WRITE_REPOSITORY로 충분" + - name: BULK_WRITE + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(bulkWrite = true)" + semantics: "단일 transaction 내 N > 100 또는 batch size > 100 mutating operation을 수행하는 use case가 선언해야 하는 capability. 이 미만이면 일반 WRITE_REPOSITORY로 충분. lock 점유 시간, pool 영향, retry 비용이 큰 작업을 명시화." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: WRITE_REPOSITORY + threshold: 100 + compatibility_impact: breaking + required_test: architecture-enforcement:bulk-write-capability + + # source: feature-repository-access-permission-contract — decisions 2026-05-22 + # "TRANSACTION_REQUIRED는 application-port branch의 TransactionPort contract와 연결되어야 하며 Spring @Transactional 직접 import로 충족하지 않음" + # source: feature-application-port-usecase-contract — TransactionPort Contract + - name: TRANSACTION_REQUIRED + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(transactionMode = WRITE | READ_ONLY | REQUIRES_NEW)" + semantics: "use case가 TransactionPort(또는 TransactionalUseCaseRunner)를 통해 transactional boundary를 갖는 것을 강제. Spring @Transactional의 application package 직접 import는 forbidden. infrastructure가 Spring transaction implementation을 제공하고 application은 port만 호출." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: null + threshold: null + compatibility_impact: breaking + required_test: architecture-enforcement:transaction-required-capability + + # source: feature-repository-access-permission-contract — decisions 2026-05-22 + # "EXTERNAL_OUTBOUND_ALLOWED 분류 = outbox row INSERT는 in-process(불요), polling publisher의 broker publish는 outbound(필요)" + # source: feature-application-port-usecase-contract — "outbound adapter 호출 use case에 EXTERNAL_OUTBOUND_ALLOWED가 없으면 실패" + - name: EXTERNAL_OUTBOUND_ALLOWED + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(externalOutboundAllowed = true)" + semantics: "use case가 외부 HTTP/message broker로 outbound 호출을 발생시키는 것을 허용. outbox claim 분류: outbox row INSERT는 in-process이므로 본 capability 불요. polling publisher의 broker publish는 outbound이므로 필요. domain event without transport detail은 outbound 호출이 아니므로 별도 분류." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: null + threshold: null + compatibility_impact: breaking + required_test: architecture-enforcement:external-outbound-capability + + # source: feature-repository-access-permission-contract — decisions 2026-05-22 + # "CROSS_TENANT_ADMIN capability를 capability vocabulary에 추가 (tenant branch feature-tenant-context-policy와 cross-link)" + - name: CROSS_TENANT_ADMIN + scope: use_case_method + enforcement: archunit + annotation: "@UseCaseCapability(crossTenantAdmin = true)" + semantics: "tenant 경계를 넘어 데이터에 접근/변경하는 admin use case가 선언해야 하는 capability. tenant-context-policy의 cross-tenant 정책과 cross-link되어야 하며, 단일 tenant 범위 use case에서 이 capability를 선언하면 review에서 reject. SENSITIVE_READ가 동반될 가능성이 높지만 자동 결합은 아님." + owner_branch: feature-repository-access-permission-contract + bound_to_capability: null + threshold: null + compatibility_impact: breaking + required_test: architecture-enforcement:cross-tenant-admin-capability + +# Row count verification +# - feature-repository-access-permission-contract 판정 기준 "Required capability" 표에 명시된 7개: +# READ_REPOSITORY, WRITE_REPOSITORY, SENSITIVE_READ, BULK_WRITE, TRANSACTION_REQUIRED, +# EXTERNAL_OUTBOUND_ALLOWED, CROSS_TENANT_ADMIN. +# - source에 명시되지 않은 capability는 본 registry에 추가하지 않음 (추측 금지). diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml new file mode 100644 index 00000000..3b0a8fe7 --- /dev/null +++ b/docs/registries/env-keys.yaml @@ -0,0 +1,1429 @@ +# Registry: Env Keys +# SSOT: wiki/projects/ca-tmpl/registries/env-keys.yaml +# Schema owner: feature-contract-registry-governance +# Owner branch: feature-env-driven-runtime-configuration +# Last updated: 2026-06-06 +# +# Conventions: +# - Application-owned env uses `APP_` prefix, fully unified (D2, 2026-06-05): +# datasource/server/etc. keys that map onto Spring properties are renamed APP_* +# as well (e.g. SERVER_PORT -> APP_SERVER_PORT). +# - Only env the Spring runtime reads natively stays native: SPRING_* and +# SPRING_PROFILES_ACTIVE (D6 — profile selector is Spring-native, not APP_). +# - `classification: secret` rows only reference secrets-classification.yaml; full row owned there. +# - reload_policy default = `restart-only` (env-driven branch "config reload: no runtime reload"). +# - boolean encoding = true/false only (env-driven branch 2026-05-22). +# - Duration encoding = Spring shorthand (`30s`); ISO-8601 (`PT30S`) forbidden (env-driven branch 2026-05-22). +# - DataSize encoding = `10MB` (env-driven branch 2026-05-22). + +env_keys: + # === Profile / Identity (feature-env-driven-runtime-configuration) === + + - name: SPRING_PROFILES_ACTIVE + # source: feature-env-driven-runtime-configuration D6 (2026-06-06) + # Profile selector is Spring-native and sole (APP_PROFILE was dropped). Unset + # -> local fallback in application.yml for early Boot profile binding. + type: csv_list + default: local + allowed_values: [local, dev, staging, prod, sample] + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: env-contract:profile-active + + # APP_PROFILE intentionally removed (D6, 2026-06-06): profile = SPRING_PROFILES_ACTIVE + # alone. A duplicate APP_PROFILE would only add information doubling + mismatch-fail + # cost. See branch note feature-env-driven-runtime-configuration Claims (wont-fix). + + - name: APP_NAME + # source: feature-env-driven-runtime-configuration 2026-05-22 + # "Required config: APP_NAME, APP_PROFILE..." + type: string + default: null + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_empty_string + compatibility_impact: behavior-change + required_test: env-contract:app-name-present + + # === HTTP server (APP_ unified per D2) === + + - name: APP_SERVER_PORT + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — full APP_ + # unification; server.port maps from APP_SERVER_PORT. + # + feature-management-actuator-security-contract 2026-05-22 "management port default = 9001 (separate from app 8080)" + type: int + default: 8080 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: port_range_1_65535 + compatibility_impact: behavior-change + required_test: env-contract:server-port-bound + + - name: MANAGEMENT_SERVER_PORT + # source: feature-management-actuator-security-contract 2026-05-22 + # "management port default = 9001 (separate from app 8080). single-port는 platform ingress 보호 + 문서화 시만 허용." + type: int + default: 9001 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-management-actuator-security-contract + validation: port_range_1_65535 + compatibility_impact: behavior-change + required_test: actuator-contract:management-port-separated + + - name: APP_SERVER_SHUTDOWN + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (server.shutdown mode; graceful drains in-flight requests) + type: enum + default: graceful + allowed_values: [graceful, immediate] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: env-contract:server-shutdown-mode + + - name: APP_SERVER_SHUTDOWN_TIMEOUT + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification. + # Supersedes the old APP_SHUTDOWN_TIMEOUT row. Must stay <= k8s + # terminationGracePeriod (cross-ref feature-container-runtime-contract). + type: duration + default: 30s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: spring_duration_shorthand_le_termination_grace + compatibility_impact: behavior-change + required_test: env-contract:server-shutdown-timeout-aligned + + - name: APP_SERVER_FORWARD_HEADERS_STRATEGY + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (trust X-Forwarded-* when behind LB/proxy) + type: enum + default: framework + allowed_values: [none, native, framework] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: env-contract:server-forward-headers-strategy + + - name: APP_SERVER_TOMCAT_MAX_THREADS + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (ceiling on concurrent request workers) + type: int + default: 200 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: positive_int + compatibility_impact: behavior-change + required_test: env-contract:tomcat-max-threads-bounded + + - name: APP_SERVER_TOMCAT_MIN_SPARE_THREADS + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (idle worker pool floor) + type: int + default: 10 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_negative_int + compatibility_impact: behavior-change + required_test: env-contract:tomcat-min-spare-threads-valid + + - name: APP_SERVER_TOMCAT_ACCEPT_COUNT + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (OS-level backlog queue depth for incoming TCP connections) + type: int + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_negative_int + compatibility_impact: behavior-change + required_test: env-contract:tomcat-accept-count-valid + + - name: APP_SERVER_TOMCAT_MAX_CONNECTIONS + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (hard cap on simultaneously open connections) + type: int + default: 8192 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: positive_int + compatibility_impact: behavior-change + required_test: env-contract:tomcat-max-connections-bounded + + - name: APP_SERVER_TOMCAT_CONNECTION_TIMEOUT + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (connection timeout for the Tomcat connector) + type: duration + default: 20s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: env-contract:tomcat-connection-timeout-set + + - name: APP_SERVER_COMPRESSION_ENABLED + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (server.compression toggle) + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: boolean_strict + compatibility_impact: behavior-change + required_test: env-contract:server-compression-toggle + + - name: APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (payloads below this threshold are not compressed) + type: data_size + default: 1KB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: spring_data_size + compatibility_impact: behavior-change + required_test: env-contract:server-compression-min-size + + - name: APP_SERVER_ERROR_INCLUDE_STACKTRACE + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (spring.web.error.include-stacktrace policy) + type: enum + default: never + allowed_values: [always, never, on_param] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: env-contract:server-error-stacktrace-policy + + - name: APP_SERVER_ERROR_INCLUDE_MESSAGE + # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification + # (spring.web.error.include-message policy) + type: enum + default: never + allowed_values: [always, never, on_param] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: env-contract:server-error-message-policy + + # === Error exposure / body logging (env-driven branch "Required config: error exposure, log") === + + - name: APP_ERROR_DETAIL_EXPOSURE_ENABLED + # source: feature-env-driven-runtime-configuration 2026-05-22 + # "prod profile에서 body logging과 internal error detail exposure는 기본 금지" + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: prod_profile_must_be_false + compatibility_impact: behavior-change + required_test: env-contract:error-exposure-prod-safe + + - name: APP_LOG_BODY_CAPTURE_ENABLED + # source: feature-env-driven-runtime-configuration 2026-05-22 "prod profile에서 body logging ... 기본 금지" + # + feature-log-management-contract "request body capture filter — allowlist 없이는 capture 자체 금지" + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: prod_profile_must_be_false + compatibility_impact: behavior-change + required_test: env-contract:body-logging-prod-safe + + - name: APP_MULTI_INSTANCE_ENABLED + # source: feature-env-driven-runtime-configuration D8 (2026-06-06) + # When true, StartupSafetyValidator (SmartInitializingSingleton) requires the 5 + # instance-coordination beans (distributed lock / cache-stampede / outbox leader + # / rate-limiter / migration runner) to be present, else startup fails. Consumed + # by feature-runtime-health-lifecycle-contract, feature-background-job-async-contract, + # feature-cache-consistency-contract, feature-domain-event-outbox-contract, + # feature-rate-limit-idempotency-contract, feature-migration-startup-contract. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: requires_coordination_beans_when_true + compatibility_impact: behavior-change + required_test: env-contract:multi-instance-beans-present + + - name: APP_MIGRATION_ON_STARTUP + # source: feature-migration-startup-contract + # When true, Flyway migrations are executed automatically during application startup. + # When false, migrations are bypassed (delegated to out-of-app container/pipeline jobs). + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-migration-startup-contract + validation: boolean_strict + compatibility_impact: behavior-change + required_test: env-contract:migration-on-startup-toggle + + # === Datasource / pool (env-driven branch "Required config: datasource") === + + - name: APP_DATASOURCE_URL + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # + feature-persistence-failure-baseline "datasource/pool/timeout/connection exhaustion log field" + type: url + default: null + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: jdbc_url_pattern + compatibility_impact: behavior-change + required_test: env-contract:datasource-url-resolvable + + - name: APP_DATASOURCE_USERNAME + # source: feature-env-driven-runtime-configuration "Required config: datasource" + type: string + default: null + allowed_values: null + classification: sensitive-config + required: true + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_empty_string + compatibility_impact: behavior-change + required_test: env-contract:datasource-credential-present + + - name: APP_DATASOURCE_PASSWORD + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # + feature-secrets-config-source-contract — secret 분류로 secrets-classification.yaml에서 owns + type: string + default: null + allowed_values: null + classification: secret + required: true + reload_policy: restart-only + owner_branch: feature-secrets-config-source-contract + validation: must_not_be_local_dev_sentinel_in_prod + compatibility_impact: breaking + required_test: secrets-contract:db-password-not-leaked + + - name: APP_DATASOURCE_POOL_MAX_SIZE + # source: feature-env-driven-runtime-configuration "datasource/pool env" + # + feature-persistence-failure-baseline "Hikari metric 노출 기준" + type: int + default: 10 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: positive_int + compatibility_impact: behavior-change + required_test: env-contract:pool-bounded + + - name: APP_DATASOURCE_POOL_MIN_IDLE + # source: feature-env-driven-runtime-configuration "datasource/pool env" + type: int + default: 2 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_negative_int + compatibility_impact: behavior-change + required_test: env-contract:pool-min-idle-valid + + - name: APP_DATASOURCE_CONNECTION_TIMEOUT + # source: feature-env-driven-runtime-configuration "datasource/pool env" + # + feature-persistence-failure-baseline "Hikari Alert Threshold: pool wait p99 > 100ms" + type: duration + default: 5s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: env-contract:connection-timeout-set + + - name: APP_DATASOURCE_DRIVER + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # (JDBC driver class for the configured datasource URL) + type: string + default: org.postgresql.Driver + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_empty_string + compatibility_impact: behavior-change + required_test: env-contract:datasource-driver-set + + - name: APP_DATASOURCE_DDL_AUTO + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # (Hibernate ddl-auto; prod must be validate|none, local update convenient) + type: enum + default: validate + allowed_values: [none, validate, update, create, create-drop] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: env-contract:datasource-ddl-auto-safe + + - name: APP_DATASOURCE_SHOW_SQL + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # (echo SQL to logs) + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: boolean_strict + compatibility_impact: behavior-change + required_test: env-contract:datasource-show-sql-toggle + + - name: APP_DATASOURCE_FORMAT_SQL + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # (pretty-print SQL — only useful when SHOW_SQL=true) + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: boolean_strict + compatibility_impact: behavior-change + required_test: env-contract:datasource-format-sql-toggle + + - name: APP_DATASOURCE_OPEN_IN_VIEW + # source: feature-env-driven-runtime-configuration "Required config: datasource" + # (Hibernate OSIV — don't enable in prod) + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: boolean_strict + compatibility_impact: behavior-change + required_test: env-contract:datasource-open-in-view-toggle + + - name: APP_DATASOURCE_POOL_IDLE_TIMEOUT + # source: feature-env-driven-runtime-configuration "datasource/pool env" + # (Hikari milliseconds; idle connection eviction threshold) + type: int + default: 600000 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: non_negative_int + compatibility_impact: behavior-change + required_test: env-contract:pool-idle-timeout-valid + + - name: APP_DATASOURCE_POOL_MAX_LIFETIME + # source: feature-env-driven-runtime-configuration "datasource/pool env" + # (Hikari milliseconds; max lifetime — rotate before broker timeout) + type: int + default: 1800000 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: positive_int + compatibility_impact: behavior-change + required_test: env-contract:pool-max-lifetime-valid + + # === Outbound HTTP timeout / retry / circuit breaker (env-driven branch + outbound-http-client-baseline) === + + - name: APP_OUTBOUND_HTTP_CONNECT_TIMEOUT + # source: feature-outbound-http-client-baseline 2026-05-22 + # "outbound HTTP timeout default = connect 2s / read 5s / global call 10s" + type: duration + default: 2s + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: spring_duration_shorthand_non_zero + compatibility_impact: behavior-change + required_test: outbound-contract:connect-timeout-bounded + + - name: APP_OUTBOUND_HTTP_READ_TIMEOUT + # source: feature-outbound-http-client-baseline 2026-05-22 "read 5s" + type: duration + default: 5s + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: spring_duration_shorthand_non_zero + compatibility_impact: behavior-change + required_test: outbound-contract:read-timeout-bounded + + - name: APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT + # source: feature-outbound-http-client-baseline 2026-05-22 "global call 10s" + type: duration + default: 10s + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: spring_duration_shorthand_non_zero + compatibility_impact: behavior-change + required_test: outbound-contract:global-call-timeout-bounded + + - name: APP_OUTBOUND_HTTP_RETRY_ENABLED + # source: feature-outbound-http-client-baseline 2026-05-22 + # "retry 기본값은 disabled이며, 활성화 시 retryable registry error와 low-cardinality retry metric이 필수" + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: boolean_strict + compatibility_impact: behavior-change + required_test: outbound-contract:retry-metric-when-enabled + + - name: APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS + # source: feature-outbound-http-resilience-config 2026-06-12 + # "retry maxAttempts 외부화 — 기본값 3 (기존 하드코딩 보존)" + type: int + default: 3 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: positive_int + compatibility_impact: behavior-change + required_test: outbound-contract:retry-max-attempts-configurable + + - name: APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF + # source: feature-outbound-http-resilience-config 2026-06-12 + # "exponential backoff 시작 간격 100ms (기존 하드코딩 보존)" + type: duration + default: 100ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: spring_duration_shorthand_non_zero + compatibility_impact: behavior-change + required_test: outbound-contract:retry-initial-backoff-configurable + + - name: APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER + # source: feature-outbound-http-resilience-config 2026-06-12 + # "exponential backoff multiplier 2.0 (기존 하드코딩 보존)" + type: double + default: 2.0 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: double_ge_1 + compatibility_impact: behavior-change + required_test: outbound-contract:retry-backoff-multiplier-configurable + + - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED + # source: feature-outbound-http-client-baseline 2026-05-22 + # "circuit breaker | Resilience4j optional env | disabled local" + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: boolean_strict + compatibility_impact: behavior-change + required_test: outbound-contract:cb-metric-when-enabled + + - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD + # source: feature-outbound-http-resilience-config 2026-06-12 + # "failure rate threshold 50% (Resilience4j ofDefaults 보존)" + type: float + default: 50 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: float_in_0_exclusive_to_100 + compatibility_impact: behavior-change + required_test: outbound-contract:cb-failure-rate-configurable + + - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE + # source: feature-outbound-http-resilience-config 2026-06-12 + # "sliding window size 100 (COUNT_BASED, Resilience4j ofDefaults 보존)" + type: int + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: positive_int + compatibility_impact: behavior-change + required_test: outbound-contract:cb-sliding-window-configurable + + - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS + # source: feature-outbound-http-resilience-config 2026-06-12 + # "minimum number of calls 100 (Resilience4j ofDefaults 보존)" + type: int + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: positive_int + compatibility_impact: behavior-change + required_test: outbound-contract:cb-minimum-calls-configurable + + - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE + # source: feature-outbound-http-resilience-config 2026-06-12 + # "wait duration in open state 60s (Resilience4j ofDefaults 보존)" + type: duration + default: 60s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: spring_duration_shorthand_non_zero + compatibility_impact: behavior-change + required_test: outbound-contract:cb-wait-duration-configurable + + - name: APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN + # source: feature-outbound-http-resilience-config 2026-06-12 + # "permitted calls in half-open 10 (Resilience4j ofDefaults 보존)" + type: int + default: 10 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: positive_int + compatibility_impact: behavior-change + required_test: outbound-contract:cb-permitted-half-open-configurable + + - name: APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT + # source: feature-outbound-http-client-baseline 2026-05-22 + # "response size limit default = 10MB streaming threshold" + type: data_size + default: 10MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-outbound-http-client-baseline + validation: spring_data_size + compatibility_impact: behavior-change + required_test: outbound-contract:response-size-streaming + + # === Tracing / Observability (feature-distributed-tracing-contract) === + + - name: OTEL_EXPORTER_OTLP_ENDPOINT + # source: feature-distributed-tracing-contract 2026-05-22 + # "Micrometer Tracing + OpenTelemetry exporter를 기본 기준으로 둠" + type: url + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-distributed-tracing-contract + validation: url_or_empty + compatibility_impact: additive + required_test: tracing-contract:exporter-endpoint-resolvable + + - name: APP_TRACING_ENABLED + # source: feature-distributed-tracing-contract 2026-05-22 + # "tracing disabled profile에서도 envelope meta.traceId와 log traceId는 유지" + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-distributed-tracing-contract + validation: boolean_strict + compatibility_impact: behavior-change + required_test: tracing-contract:meta-traceid-when-disabled + + - name: APP_TRACING_SAMPLE_RATE + # source: feature-distributed-tracing-contract 2026-05-22 + # "trace sampling rate default = prod 1%, staging 10%, dev/local 100%" + # D-1 ISSUE-1 fix (2026-06-16): blank = per-profile resolver 기본값 사용. + # TracingSampleRateResolver SSOT (prod=0.01/staging=0.10/dev·local=1.0). + # TracingSamplingEnvironmentPostProcessor bridges resolved rate to + # management.tracing.sampling.probability. + type: string + default: "" + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-distributed-tracing-contract + validation: float_between_0_and_1_or_blank + compatibility_impact: behavior-change + required_test: tracing-contract:sample-rate-per-profile + + # === Log management (feature-log-management-contract) === + + # APP_LOG_LEVEL (single key) replaced by the 5 granular APP_LOG_LEVEL_* rows below + # (root / app / spring / web / sql) — as built by feature-log-management-contract. + + - name: APP_LOG_LEVEL_ROOT + # source: feature-log-management-contract — root logger level + type: enum + default: INFO + allowed_values: [TRACE, DEBUG, INFO, WARN, ERROR, OFF] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: log-contract:root-level + + - name: APP_LOG_LEVEL_APP + # source: feature-log-management-contract — application package level + type: enum + default: INFO + allowed_values: [TRACE, DEBUG, INFO, WARN, ERROR, OFF] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: log-contract:app-level + + - name: APP_LOG_LEVEL_SPRING + # source: feature-log-management-contract — Spring framework package level + type: enum + default: INFO + allowed_values: [TRACE, DEBUG, INFO, WARN, ERROR, OFF] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: log-contract:spring-level + + - name: APP_LOG_LEVEL_WEB + # source: feature-log-management-contract — web/MVC package level + type: enum + default: INFO + allowed_values: [TRACE, DEBUG, INFO, WARN, ERROR, OFF] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: log-contract:web-level + + - name: APP_LOG_LEVEL_SQL + # source: feature-log-management-contract — JPA/JDBC SQL package level (DEBUG prints SQL) + type: enum + default: WARN + allowed_values: [TRACE, DEBUG, INFO, WARN, ERROR, OFF] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: log-contract:sql-level + + - name: APP_LOG_FILE_ENABLED + # source: feature-log-management-contract — rolling JSON file appender toggle + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: boolean_strict + compatibility_impact: behavior-change + required_test: log-contract:file-appender-toggle + + - name: APP_LOG_FILE_PATH + # source: feature-log-management-contract — file path (relative to bootRun cwd or absolute) + type: string + default: logs/ca-skeleton.json + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: non_empty_string + compatibility_impact: behavior-change + required_test: log-contract:file-path-set + + - name: APP_LOG_FILE_MAX_SIZE + # source: feature-log-management-contract — per-file rolling size cap + type: data_size + default: 100MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: spring_data_size + compatibility_impact: behavior-change + required_test: log-contract:file-max-size + + - name: APP_LOG_FILE_MAX_HISTORY + # source: feature-log-management-contract — number of rolled archives to retain + type: int + default: 14 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: positive_int + compatibility_impact: behavior-change + required_test: log-contract:file-max-history + + - name: APP_LOG_FILE_TOTAL_SIZE_CAP + # source: feature-log-management-contract — total size cap across rolled files + type: data_size + default: 3GB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: spring_data_size + compatibility_impact: behavior-change + required_test: log-contract:file-total-size-cap + + - name: APP_LOG_ASYNC_ENABLED + # source: feature-log-management-contract — AsyncAppender wrap for non-blocking I/O + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: boolean_strict + compatibility_impact: behavior-change + required_test: log-contract:async-appender-toggle + + - name: APP_LOG_ASYNC_QUEUE_SIZE + # source: feature-log-management-contract — in-memory queue depth before back-pressure + type: int + default: 512 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: positive_int + compatibility_impact: behavior-change + required_test: log-contract:async-queue-size + + - name: APP_LOG_ASYNC_DISCARDING_THRESHOLD + # source: feature-log-management-contract — remaining-capacity floor below which + # TRACE/DEBUG/INFO events drop (WARN/ERROR always kept). 0 = never drop. + type: int + default: 20 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: non_negative_int + compatibility_impact: behavior-change + required_test: log-contract:async-discarding-threshold + + - name: APP_LOG_JSON_TIMEZONE + # source: feature-log-management-contract — IANA timezone for JSON encoder timestamps + type: string + default: UTC + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: non_empty_string + compatibility_impact: behavior-change + required_test: log-contract:json-timezone + + - name: APP_LOG_JSON_TIMESTAMP_PATTERN + # source: feature-log-management-contract — JSON encoder timestamp pattern (ISO 8601 default) + type: string + default: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: non_empty_string + compatibility_impact: behavior-change + required_test: log-contract:json-timestamp-pattern + + - name: APP_LOG_JSON_INCLUDE_CALLER_DATA + # source: feature-log-management-contract — include file/method/line (significant perf cost) + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: boolean_strict + compatibility_impact: behavior-change + required_test: log-contract:json-include-caller-data + + - name: APP_LOG_JSON_LOGGER_NAME_LENGTH + # source: feature-log-management-contract — logger name abbreviation (0 = full name) + type: int + default: 0 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: non_negative_int + compatibility_impact: behavior-change + required_test: log-contract:json-logger-name-length + + - name: APP_LOG_SAMPLING_RATE + # source: feature-log-management-contract 2026-05-22 + # "log sampling(prod 10%) > trace sampling(prod 1%)" + Sampling Policy 표 (prod 10%, staging/dev/local 100%) + type: string + default: "1.0" + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-log-management-contract + validation: float_between_0_and_1 + compatibility_impact: behavior-change + required_test: log-contract:sample-rate-per-profile + + # === Privacy: user_principal pseudonymization (feature-data-retention-privacy-contract) === + + - name: APP_PRIVACY_PSEUDONYMIZATION_SALT + # source: feature-data-retention-privacy-contract — "pseudonymization key = HMAC-SHA-256 + # with rotating salt (90d)". Consumed by feature-log-management-contract (DRIFT-6) + # to record user_principal in pseudonymized form. secret 분류로 secrets-classification.yaml에서 owns. + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: feature-data-retention-privacy-contract + validation: must_not_be_local_dev_sentinel_in_prod + compatibility_impact: breaking + required_test: secrets-contract:pseudonymization-salt-rotation + + # === Security / CORS / JWT (feature-security-operational-baseline) === + + - name: APP_SECURITY_CORS_ORIGINS + # source: feature-security-operational-baseline 2026-05-22 + # "allowlist origin은 env-driven runtime configuration의 APP_SECURITY_CORS_ORIGINS로 주입" + type: csv_list + default: null + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: csv_of_origins_no_wildcard_with_credentials + compatibility_impact: behavior-change + required_test: security-contract:cors-allowlist + + - name: APP_SECURITY_CORS_ENABLED + # source: feature-security-operational-baseline — CORS filter master toggle + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: boolean_strict + compatibility_impact: behavior-change + required_test: security-contract:cors-enabled-toggle + + - name: APP_SECURITY_CORS_ALLOWED_METHODS + # source: feature-security-operational-baseline — allowed methods + # (empty -> sensible method defaults in CorsSettings) + type: csv_list + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: csv_of_http_methods_or_empty + compatibility_impact: behavior-change + required_test: security-contract:cors-allowed-methods + + - name: APP_SECURITY_CORS_ALLOWED_HEADERS + # source: feature-security-operational-baseline — allowed request headers ("*" = any) + type: csv_list + default: "*" + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: csv_non_empty + compatibility_impact: behavior-change + required_test: security-contract:cors-allowed-headers + + - name: APP_SECURITY_CORS_ALLOW_CREDENTIALS + # source: feature-security-operational-baseline 2026-05-22 + # "CORS는 allowlist default, credentials false default" / "wildcard with credentials forbidden" + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: forbid_true_with_wildcard_origin + compatibility_impact: behavior-change + required_test: security-contract:cors-credentials-policy + + - name: APP_SECURITY_CORS_MAX_AGE + # source: feature-security-operational-baseline 2026-05-22 + # "preflight max-age 600s default" + type: duration + default: 600s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: security-contract:cors-preflight-max-age + + - name: APP_SECURITY_JWT_ISSUER + # source: feature-security-operational-baseline 2026-05-22 + # "issuer mismatch | 401 | AUTH_ISSUER_MISMATCH" + type: url + default: null + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: url_required + compatibility_impact: behavior-change + required_test: security-contract:jwt-issuer-set + + - name: APP_SECURITY_JWT_AUDIENCE + # source: feature-security-operational-baseline 2026-05-22 + # "audience mismatch | 401 | AUTH_AUDIENCE_MISMATCH" + type: csv_list + default: null + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: csv_non_empty + compatibility_impact: behavior-change + required_test: security-contract:jwt-audience-set + + - name: APP_SECURITY_JWT_JWKS_URI + # source: feature-security-operational-baseline 2026-05-22 + # "JWKS refresh interval = 10분, on-demand refresh on unknown kid" + type: url + default: null + allowed_values: null + classification: public-config + required: true + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: url_required + compatibility_impact: behavior-change + required_test: security-contract:jwks-uri-resolvable + + - name: APP_SECURITY_JWT_CLOCK_SKEW + # source: feature-security-operational-baseline 2026-05-22 + # "JWT clock skew tolerance = 60s (Spring Security JwtTimestampValidator leeway)" + type: duration + default: 60s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-security-operational-baseline + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: security-contract:jwt-clock-skew-applied + + - name: APP_SECURITY_JWT_SIGNING_KEY + # source: feature-security-operational-baseline 2026-05-22 + # "JWT signing key rotation의 운영 관측 (JWKS refresh, kid mismatch 분류)" + # secret 분류 — owner: secrets-classification.yaml + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: feature-secrets-config-source-contract + validation: must_not_be_local_dev_sentinel_in_prod + compatibility_impact: breaking + required_test: secrets-contract:jwt-signing-key-rotation-overlap + + # === Tenant context (feature-tenant-context-policy) === + + - name: APP_TENANT_ENABLED + # source: feature-tenant-context-policy 2026-05-22 + # "skeleton core는 multi-tenancy 미지원이 기본이며 tenant header는 기본 거부" + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-tenant-context-policy + validation: boolean_strict + compatibility_impact: behavior-change + required_test: tenant-contract:disabled-rejects-header + + # === Rate limit / Idempotency (feature-rate-limit-idempotency-contract) === + + - name: APP_RATE_LIMIT_ENABLED + # source: feature-rate-limit-idempotency-contract — "rate limit과 idempotency를 API/runtime 운영 표면에 포함" + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-rate-limit-idempotency-contract + validation: boolean_strict + compatibility_impact: behavior-change + required_test: rate-limit-contract:enabled-by-default + + - name: APP_RATE_LIMIT_CLIENT_IP_MODE + # source: feature-rate-limit-idempotency-contract — rate-limit client IP source policy. + # Use forwarded-headers-trusted only behind an ingress/LB that overwrites X-Forwarded-For. + type: enum + default: remote-addr-only + allowed_values: [remote-addr-only, forwarded-headers-trusted] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-rate-limit-idempotency-contract + validation: enum_in_allowed_values + compatibility_impact: behavior-change + required_test: rate-limit-contract:client-ip-mode + + - name: APP_IDEMPOTENCY_TTL + # source: feature-rate-limit-idempotency-contract 2026-05-22 + # "idempotency TTL default = 24h. long-running use case는 use case 선언으로 72h까지 override 가능" + type: duration + default: 24h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-rate-limit-idempotency-contract + validation: spring_duration_shorthand_le_72h + compatibility_impact: behavior-change + required_test: idempotency-contract:ttl-applied + + # === Cache / Redis (feature-cache-consistency-contract + integration-adapter-templates) === + + - name: APP_CACHE_REDIS_ENABLED + # source: feature-integration-adapter-templates 2026-05-22 + # "Redis | disabled optional module | cache consistency" + Adapter Template Defaults 표 + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: boolean_strict + compatibility_impact: behavior-change + required_test: adapter-contract:redis-disabled-default + + - name: APP_CACHE_REDIS_HOST + # source: feature-cache-consistency-contract — Redis adapter (활성화 시 endpoint 필요) + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-cache-consistency-contract + validation: non_empty_string_when_redis_enabled + compatibility_impact: behavior-change + required_test: cache-contract:redis-host-when-enabled + + - name: APP_CACHE_REDIS_PORT + # source: feature-cache-consistency-contract — Redis adapter endpoint + type: int + default: 6379 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-cache-consistency-contract + validation: port_range_1_65535 + compatibility_impact: behavior-change + required_test: cache-contract:redis-port-bound + + - name: APP_CACHE_DEFAULT_TTL + # source: feature-cache-consistency-contract 2026-05-22 + # "TTL | explicit per key family | no-cache for sensitive data | immortal cache forbidden" + type: duration + default: 300s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-cache-consistency-contract + validation: spring_duration_shorthand_non_zero + compatibility_impact: behavior-change + required_test: cache-contract:default-ttl-bounded + + - name: APP_CACHE_NEGATIVE_TTL + # source: feature-cache-consistency-contract 2026-05-22 + # "negative cache 정책 = 존재하지 않는 row는 짧은 TTL(60s) 캐싱 허용" + type: duration + default: 60s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-cache-consistency-contract + validation: spring_duration_shorthand + compatibility_impact: additive + required_test: cache-contract:negative-ttl-bounded + + # === Messaging / Kafka (feature-domain-event-outbox-contract + integration-adapter-templates) === + + - name: APP_MESSAGING_BROKER + # source: messaging multi-broker redesign 2026-06-16 — single active broker selection + # (app.messaging.broker=); blank = messaging disabled. Replaces the former + # Kafka-binary APP_MESSAGING_KAFKA_ENABLED. Adding a broker is new files only. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: none + compatibility_impact: behavior-change + required_test: adapter-contract:messaging-broker-selection + + - name: APP_MESSAGING_KAFKA_BROKERS + # source: feature-domain-event-outbox-contract — "Kafka는 optional integration adapter" + # (broker 활성화 시 endpoint 필요) + type: csv_list + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: csv_of_host_port_when_kafka_enabled + compatibility_impact: behavior-change + required_test: adapter-contract:kafka-brokers-when-enabled + + # === Notification adapters (feature-integration-adapter-templates) === + + - name: APP_NOTIFICATION_SLACK_PROVIDER + # source: notification provider-SPI redesign 2026-06-16 — single active Slack provider + # selection (app.notification.slack.provider=, e.g. webhook); blank = Slack disabled. + # Replaces the former provider-binary APP_NOTIFICATION_SLACK_ENABLED. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: none + compatibility_impact: behavior-change + required_test: adapter-contract:notification-slack-provider-selection + + - name: APP_NOTIFICATION_EMAIL_PROVIDER + # source: notification provider-SPI redesign 2026-06-16 — single active email provider + # selection (app.notification.email.provider=, e.g. google-email); blank = email disabled. + # Replaces the former provider-binary APP_NOTIFICATION_GOOGLE_EMAIL_ENABLED. + type: string + default: null + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-integration-adapter-templates + validation: none + compatibility_impact: behavior-change + required_test: adapter-contract:notification-email-provider-selection + + # === File / Upload (feature-file-resource-handling-contract) === + + - name: APP_FILE_UPLOAD_MAX_SIZE + # source: feature-file-resource-handling-contract 2026-05-22 + # "Spring spring.servlet.multipart.max-file-size 10MB" + type: data_size + default: 10MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-file-resource-handling-contract + validation: spring_data_size_le_10MB_by_default + compatibility_impact: behavior-change + required_test: file-contract:upload-size-enforced + + - name: APP_FILE_UPLOAD_GLOBAL_REQUEST_MAX_SIZE + # source: feature-file-resource-handling-contract 2026-05-22 + # "global request size 12MB" + type: data_size + default: 12MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-file-resource-handling-contract + validation: spring_data_size + compatibility_impact: behavior-change + required_test: file-contract:global-request-size-enforced + + # === Runtime / Lifecycle (feature-runtime-health-lifecycle-contract + container-runtime) === + + # APP_SHUTDOWN_TIMEOUT superseded by APP_SERVER_SHUTDOWN_TIMEOUT (HTTP server section, + # owner feature-env-driven-runtime-configuration). The k8s terminationGracePeriod + # alignment requirement (feature-container-runtime-contract) now applies to + # APP_SERVER_SHUTDOWN_TIMEOUT. + + # === Async executor (feature-background-job-async-contract) === + + - name: APP_ASYNC_EXECUTOR_CORE_SIZE + # source: feature-background-job-async-contract 2026-05-22 + # "executor pool sizing default = core=10, max=50, queue=200" + type: int + default: 10 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-background-job-async-contract + validation: positive_int + compatibility_impact: behavior-change + required_test: async-contract:executor-core-size + + - name: APP_ASYNC_EXECUTOR_MAX_SIZE + # source: feature-background-job-async-contract 2026-05-22 + # "core=10, max=50, queue=200" + type: int + default: 50 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-background-job-async-contract + validation: positive_int_ge_core + compatibility_impact: behavior-change + required_test: async-contract:executor-max-size + + - name: APP_ASYNC_EXECUTOR_QUEUE_CAPACITY + # source: feature-background-job-async-contract 2026-05-22 + # "queue=200" + "unbounded queue forbidden" + type: int + default: 200 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-background-job-async-contract + validation: positive_int_bounded + compatibility_impact: behavior-change + required_test: async-contract:executor-queue-bounded diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml new file mode 100644 index 00000000..06c865d4 --- /dev/null +++ b/docs/registries/error-codes.yaml @@ -0,0 +1,918 @@ +# Registry: Error Codes +# SSOT: wiki/projects/ca-tmpl/registries/error-codes.yaml +# Schema owner: feature-contract-registry-governance +# Category enum owner: feature-operational-error-observability-foundation +# Last updated: 2026-05-22 +# Note: 이 파일은 Phase B 산출물. Phase C2(ca-tmpl 실 코드)에서 generated Java constants의 source. +# +# Schema (per row): +# code: UPPER_SNAKE_CASE +# category: VALIDATION | AUTH | AUTHZ | NOT_FOUND | CONFLICT | +# RATE_LIMIT | TRANSIENT_DEPENDENCY | PERMANENT_DEPENDENCY | +# DATA_INTEGRITY | INTERNAL +# http_status: int (async-only failures use 500 placeholder) +# retryable: bool +# retry_after_seconds: int | null (RATE_LIMIT/TRANSIENT 권고 backoff) +# owner_branch: source branch (raw/branch-notes/feature-*.md) +# owner_layer: presentation | application | domain | infrastructure | crosscut +# client_safe_message: no token / no principal raw / no internal path / no stack trace +# log_level: ERROR | WARN | INFO +# runbook_link: runbook://area/scenario OR null (client-error만 null 허용) +# compatibility_impact: none | additive | behavior-change | breaking +# required_test: owning contract test identifier +# +# Runbook policy (operational-runbook-contract L80): +# retryable=false + category ∈ {AUTH, AUTHZ, RATE_LIMIT, INTERNAL, +# TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY} ⇒ runbook_link 필수. +# VALIDATION/NOT_FOUND/CONFLICT/DATA_INTEGRITY는 client-error로 runbook 면제 가능. +# retryable=true 인 모든 row는 runbook_link 필수. + +errors: + + # ============================================================ + # AUTH (feature-security-operational-baseline / Decision Matrix) + # ============================================================ + + # source: feature-security-operational-baseline L82 — "token 누락 | 401 | AUTH_TOKEN_MISSING | AUTH" + - code: AUTH_TOKEN_MISSING + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication required" + log_level: WARN + runbook_link: "runbook://auth/token-missing" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L83 — "token malformed (parse fail) | 401 | AUTH_TOKEN_MALFORMED | AUTH" + - code: AUTH_TOKEN_MALFORMED + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication failed" + log_level: WARN + runbook_link: "runbook://auth/token-malformed" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L84 — "token expired (clock skew tolerance 60s 초과) | 401 | AUTH_TOKEN_EXPIRED | AUTH" + - code: AUTH_TOKEN_EXPIRED + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication expired" + log_level: WARN + runbook_link: "runbook://auth/token-expired" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L85 — "invalid signature | 401 | AUTH_TOKEN_INVALID_SIGNATURE | AUTH" + - code: AUTH_TOKEN_INVALID_SIGNATURE + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication failed" + log_level: ERROR + runbook_link: "runbook://auth/token-invalid-signature" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L86 — "issuer mismatch | 401 | AUTH_ISSUER_MISMATCH | AUTH" + - code: AUTH_ISSUER_MISMATCH + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication failed" + log_level: ERROR + runbook_link: "runbook://auth/issuer-mismatch" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L87 — "audience mismatch | 401 | AUTH_AUDIENCE_MISMATCH | AUTH" + - code: AUTH_AUDIENCE_MISMATCH + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication failed" + log_level: ERROR + runbook_link: "runbook://auth/audience-mismatch" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L88 — "unknown kid (JWKS 미캐시) | 401 + Retry-After 5s | AUTH_KID_UNKNOWN | AUTH" + - code: AUTH_KID_UNKNOWN + category: AUTH + http_status: 401 + retryable: true # 2026-06-01: false→true. JWKS 키 회전 중 unknown kid 는 ~5s 후 JWKS refresh 로 해소 가능(transient). retry_after_seconds=5 + client_safe_message "please retry" 와 정합. 키 고정 정책으로 전환 시 false 복귀. + retry_after_seconds: 5 + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication failed, please retry" + log_level: WARN + runbook_link: "runbook://auth/kid-unknown" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L89 — "JWKS endpoint outage ... | AUTH_JWKS_UNAVAILABLE | TRANSIENT_DEPENDENCY" + - code: AUTH_JWKS_UNAVAILABLE + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 30 + owner_branch: feature-security-operational-baseline + owner_layer: infrastructure + client_safe_message: "Authentication service temporarily unavailable" + log_level: ERROR + runbook_link: "runbook://auth/jwks-unavailable" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-security-operational-baseline L90 — "claim mapping failure ... | 401 | AUTH_CLAIM_MAPPING_FAILED | AUTH" + - code: AUTH_CLAIM_MAPPING_FAILED + category: AUTH + http_status: 401 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Authentication failed" + log_level: ERROR + runbook_link: "runbook://auth/claim-mapping-failed" + compatibility_impact: none + required_test: contract-verification:auth-category + + # ============================================================ + # AUTHZ (feature-security-operational-baseline) + # ============================================================ + + # source: feature-security-operational-baseline L91 — "valid token + 권한 부족 | 403 | AUTHZ_INSUFFICIENT_PERMISSION | AUTHZ" + - code: AUTHZ_INSUFFICIENT_PERMISSION + category: AUTHZ + http_status: 403 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Permission denied" + log_level: WARN + runbook_link: "runbook://authz/insufficient-permission" + compatibility_impact: none + required_test: contract-verification:authz-category + + # source: feature-security-operational-baseline L92 — "valid token + tenant cross-access | 403 | AUTHZ_TENANT_MISMATCH | AUTHZ" + - code: AUTHZ_TENANT_MISMATCH + category: AUTHZ + http_status: 403 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: presentation + client_safe_message: "Permission denied" + log_level: ERROR + runbook_link: "runbook://authz/tenant-mismatch" + compatibility_impact: none + required_test: contract-verification:authz-category + + # ============================================================ + # INTERNAL (feature-security-operational-baseline + container-runtime) + # ============================================================ + + # source: feature-security-operational-baseline L93 — "public path misconfiguration ... | 500 + P1 alert | INTERNAL_AUTH_MISCONFIGURATION | INTERNAL" + - code: INTERNAL_AUTH_MISCONFIGURATION + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-security-operational-baseline + owner_layer: crosscut + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://auth/public-path-misconfiguration" + compatibility_impact: none + required_test: contract-verification:auth-category + + # source: feature-container-runtime-contract L113 — "JVM OutOfMemoryError → ExitOnOutOfMemoryError로 137 exit, log에 error.code=JVM_OOM 명시" + - code: JVM_OOM + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-container-runtime-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://runtime/jvm-oom" + compatibility_impact: none + required_test: contract-verification:container-runtime-oom + + # ============================================================ + # DB / Persistence (feature-persistence-failure-baseline / SQLState Matrix) + # ============================================================ + + # source: feature-persistence-failure-baseline L85 — "08* | all | TRANSIENT_DEPENDENCY | DB_UNAVAILABLE | true" + - code: DB_UNAVAILABLE + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 5 + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Service temporarily unavailable" + log_level: ERROR + runbook_link: "runbook://db/unavailable" + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L86 — "40001 | Postgres/MySQL | CONFLICT | DB_SERIALIZATION_FAILURE | true" + - code: DB_SERIALIZATION_FAILURE + category: CONFLICT + http_status: 409 + retryable: true + retry_after_seconds: 1 + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Request conflicted with another transaction, please retry" + log_level: WARN + runbook_link: "runbook://db/serialization-failure" + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L87 — "40P01 | Postgres | CONFLICT | DB_DEADLOCK | true (backoff)" + - code: DB_DEADLOCK + category: CONFLICT + http_status: 409 + retryable: true + retry_after_seconds: 1 + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Request conflicted, please retry" + log_level: WARN + runbook_link: "runbook://db/deadlock" + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L88 — "23502 | Postgres | DATA_INTEGRITY | DB_NULL_VIOLATION | false" + - code: DB_NULL_VIOLATION + category: DATA_INTEGRITY + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Request violates a required field constraint" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L89 — "23503 | Postgres | DATA_INTEGRITY | DB_FK_VIOLATION | false" + - code: DB_FK_VIOLATION + category: DATA_INTEGRITY + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Request references missing resource" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L90 — "23505 | Postgres | CONFLICT | DB_UNIQUE_VIOLATION | false (business mapping)" + - code: DB_UNIQUE_VIOLATION + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Resource already exists" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L91 — "23514 | Postgres | DATA_INTEGRITY | DB_CHECK_VIOLATION | false" + - code: DB_CHECK_VIOLATION + category: DATA_INTEGRITY + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Request violates a value constraint" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L92 — "25P03 | Postgres | TRANSIENT_DEPENDENCY | DB_IDLE_IN_TX_TIMEOUT | true" + - code: DB_IDLE_IN_TX_TIMEOUT + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 2 + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Service temporarily unavailable" + log_level: ERROR + runbook_link: "runbook://db/idle-in-tx-timeout" + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # source: feature-persistence-failure-baseline L93 — "57014 | Postgres | TRANSIENT_DEPENDENCY | DB_QUERY_CANCELED | false" + - code: DB_QUERY_CANCELED + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: false + retry_after_seconds: null + owner_branch: feature-persistence-failure-baseline + owner_layer: infrastructure + client_safe_message: "Request was canceled, please retry later" + log_level: WARN + runbook_link: "runbook://db/query-canceled" + compatibility_impact: none + required_test: contract-verification:persistence-mapping + + # ============================================================ + # Rate limit / Idempotency (feature-rate-limit-idempotency-contract) + # ============================================================ + + # source: feature-rate-limit-idempotency-contract — rate limit response/log 기준 / Retry-After header 기준 (scope L29, L33) + - code: RATE_LIMIT_EXCEEDED + category: RATE_LIMIT + http_status: 429 + retryable: true + retry_after_seconds: 1 + owner_branch: feature-rate-limit-idempotency-contract + owner_layer: presentation + client_safe_message: "Too many requests, please retry after the indicated interval" + log_level: WARN + runbook_link: "runbook://rate-limit/exceeded" + compatibility_impact: none + required_test: contract-verification:rate-limit + + # source: feature-rate-limit-idempotency-contract L71 — "200ms 초과 시 409 IDEMPOTENT_IN_FLIGHT (retryable=false, client는 polling)" + - code: IDEMPOTENT_IN_FLIGHT + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-rate-limit-idempotency-contract + owner_layer: application + client_safe_message: "A previous identical request is still being processed, please poll for result" + log_level: INFO + runbook_link: null + compatibility_impact: none + required_test: contract-verification:idempotency + + # source: feature-rate-limit-idempotency-contract L72 — "fingerprint mismatch (same key + different body) = 422 IDEMPOTENT_REQUEST_MISMATCH" + - code: IDEMPOTENT_REQUEST_MISMATCH + category: VALIDATION + http_status: 422 + retryable: false + retry_after_seconds: null + owner_branch: feature-rate-limit-idempotency-contract + owner_layer: application + client_safe_message: "Idempotency key reused with different request body" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:idempotency + + # ============================================================ + # File / Resource (feature-file-resource-handling-contract) + # ============================================================ + + # source: feature-file-resource-handling-contract L69 — "spring.servlet.multipart.max-file-size 10MB ... Spring 단의 enforcement가 실패 시 envelope 응답 보장" / 테스트 계약 "oversized upload가 generic 500으로 처리되면 실패" + - code: UPLOAD_SIZE_EXCEEDED + category: VALIDATION + http_status: 413 + retryable: false + retry_after_seconds: null + owner_branch: feature-file-resource-handling-contract + owner_layer: presentation + client_safe_message: "Uploaded file exceeds maximum size" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:file-upload + + # source: feature-file-resource-handling-contract L72 — "allowed content-type allowlist starting set ..." + - code: UPLOAD_CONTENT_TYPE_REJECTED + category: VALIDATION + http_status: 415 + retryable: false + retry_after_seconds: null + owner_branch: feature-file-resource-handling-contract + owner_layer: presentation + client_safe_message: "Uploaded content type is not allowed" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:file-upload + + # source: feature-file-resource-handling-contract — Decisionized Work Items "path traversal | normalized storage key only ... | traversal test" + - code: PATH_TRAVERSAL_DETECTED + category: VALIDATION + http_status: 400 + retryable: false + retry_after_seconds: null + owner_branch: feature-file-resource-handling-contract + owner_layer: presentation + client_safe_message: "Invalid file path" + log_level: ERROR + runbook_link: null + compatibility_impact: none + required_test: contract-verification:file-upload + + # source: feature-file-resource-handling-contract L73 — "streaming download backpressure = response timeout 60s, max stream 100MB. 초과 시 truncate + ERROR log" + - code: DOWNLOAD_STREAMING_FAILURE + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 5 + owner_branch: feature-file-resource-handling-contract + owner_layer: presentation + client_safe_message: "Download failed, please retry" + log_level: ERROR + runbook_link: "runbook://file/download-streaming-failure" + compatibility_impact: none + required_test: contract-verification:file-download + + # ============================================================ + # API contract transport-standard codes (feature-api-contract-baseline) + # ============================================================ + # NOTE: feature-api-contract-baseline owns the transport-shape failure + # classification (D8 413/414, D9 406/415, D12 405, D15 412). These rows mirror + # dev.caskeleton.shared.error.OperationalError; the D11 status-mapping + # consistency test (owner: this branch, producer) fails the build when a code's + # registry http_status and the enum httpStatus() drift apart. + + # source: feature-api-contract-baseline.md D12 — "405 Method Not Allowed + Allow header 의무" + - code: METHOD_NOT_ALLOWED + category: VALIDATION + http_status: 405 + retryable: false + retry_after_seconds: null + owner_branch: feature-api-contract-baseline + owner_layer: presentation + client_safe_message: "HTTP method not allowed for this resource" + log_level: INFO + runbook_link: null + compatibility_impact: none + required_test: contract-verification:api-contract-status-mapping + + # source: feature-api-contract-baseline.md D9 — "406 Not Acceptable = 응답 표현 협상 실패" + - code: NOT_ACCEPTABLE + category: VALIDATION + http_status: 406 + retryable: false + retry_after_seconds: null + owner_branch: feature-api-contract-baseline + owner_layer: presentation + client_safe_message: "No acceptable representation for the requested Accept header" + log_level: INFO + runbook_link: null + compatibility_impact: none + required_test: contract-verification:api-contract-status-mapping + + # source: feature-api-contract-baseline.md D15 — "If-Match mismatch 시 412 Precondition Failed" + - code: PRECONDITION_FAILED + category: CONFLICT + http_status: 412 + retryable: false + retry_after_seconds: null + owner_branch: feature-api-contract-baseline + owner_layer: presentation + client_safe_message: "Resource was modified by another request; refetch and retry" + log_level: INFO + runbook_link: null + compatibility_impact: none + required_test: contract-verification:api-contract-status-mapping + + # source: feature-api-contract-baseline.md D8 — "request size limit 실패 분류 (413)" + - code: PAYLOAD_TOO_LARGE + category: VALIDATION + http_status: 413 + retryable: false + retry_after_seconds: null + owner_branch: feature-api-contract-baseline + owner_layer: presentation + client_safe_message: "Request payload is too large" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:api-contract-status-mapping + + # source: feature-api-contract-baseline.md D8 형제 — "URI 길이 실패 분류 (414)" + # NOTE: enforcement is Tomcat/gateway-owned (rejected before Spring dispatch); + # this row + code exist for status-mapping consistency. End-to-end 414 contract + # test is `planned` (gateway/Tomcat maxHttpHeaderSize 8KB boundary). + - code: URI_TOO_LONG + category: VALIDATION + http_status: 414 + retryable: false + retry_after_seconds: null + owner_branch: feature-api-contract-baseline + owner_layer: presentation + client_safe_message: "Request URI is too long" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:api-contract-status-mapping + + # source: feature-api-contract-baseline.md D9 — "415 Unsupported Media Type = 요청 본문 format 미지원" + - code: UNSUPPORTED_MEDIA_TYPE + category: VALIDATION + http_status: 415 + retryable: false + retry_after_seconds: null + owner_branch: feature-api-contract-baseline + owner_layer: presentation + client_safe_message: "Request Content-Type is not supported" + log_level: INFO + runbook_link: null + compatibility_impact: none + required_test: contract-verification:api-contract-status-mapping + + # ============================================================ + # Tenant (feature-tenant-context-policy) + # ============================================================ + + # source: feature-tenant-context-policy L71 — "tenant 미지원 모드에서 X-Tenant-Id 헤더 수신 시 400 TENANT_NOT_SUPPORTED (filter 단계)" + - code: TENANT_NOT_SUPPORTED + category: VALIDATION + http_status: 400 + retryable: false + retry_after_seconds: null + owner_branch: feature-tenant-context-policy + owner_layer: presentation + client_safe_message: "Tenant context is not supported by this deployment" + log_level: WARN + runbook_link: null + compatibility_impact: none + required_test: contract-verification:tenant-policy + + # ============================================================ + # Validation / Business rule (feature-business-rule-validation-contract) + # ============================================================ + # NOTE: business-rule-validation branch는 mapping 규칙 SSOT (syntax→VALIDATION, + # policy→AUTHZ/CONFLICT, invariant→CONFLICT/VALIDATION, persistence→PERSISTENCE/CONFLICT) + # 이며 구체 code는 example로 VALIDATION_EMAIL_FORMAT만 등장 + # (feature-operational-error-observability-foundation L110). 실제 도메인별 code는 + # Phase D(도메인 feature 적용) 시 본 registry에 추가. + + # source: feature-operational-error-observability-foundation L110 — "code: VALIDATION_EMAIL_FORMAT, // registry-registered code" (validation field error JSON shape example) + - code: VALIDATION_EMAIL_FORMAT + category: VALIDATION + http_status: 400 + retryable: false + retry_after_seconds: null + owner_branch: feature-operational-error-observability-foundation + owner_layer: presentation + client_safe_message: "Invalid email format" + log_level: INFO + runbook_link: null + compatibility_impact: none + required_test: contract-verification:validation-envelope + + # ============================================================ + # Cache (feature-cache-consistency-contract) + # ============================================================ + + # source: feature-cache-consistency-contract — Decisionized Work Items "Redis unavailable | degrade only if declared | fail-fast for required cache | generic INTERNAL | unavailable mapping" / 테스트 "Redis unavailable이 degrade 가능 여부 없이 INTERNAL로 처리되면 실패" + - code: CACHE_UNAVAILABLE + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 2 + owner_branch: feature-cache-consistency-contract + owner_layer: infrastructure + client_safe_message: "Service temporarily unavailable" + log_level: ERROR + runbook_link: "runbook://cache/unavailable" + compatibility_impact: none + required_test: contract-verification:cache-consistency + + # source: feature-cache-consistency-contract L70 — "stampede 방지 default = single-instance Caffeine local lock, multi-instance HPA 시 Redisson RLock distributed mutex" / 테스트 "동일 key에 대해 동시 cache miss 시 backend 호출이 1회로 제한되는지 verify (stampede). 미충족 시 실패" + - code: CACHE_STAMPEDE_LOCK_TIMEOUT + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 1 + owner_branch: feature-cache-consistency-contract + owner_layer: infrastructure + client_safe_message: "Service temporarily unavailable" + log_level: WARN + runbook_link: "runbook://cache/stampede-lock-timeout" + compatibility_impact: none + required_test: contract-verification:cache-consistency + + # ============================================================ + # Outbound HTTP (feature-outbound-http-client-baseline) + # ============================================================ + + # source: feature-outbound-http-client-baseline L70 — "outbound HTTP timeout default = connect 2s / read 5s / global call 10s" + scope "timeout/connect/DNS failure 분류" / 테스트 "upstream timeout은 retryable dependency failure로 분류되어야 함" + - code: DEPENDENCY_TIMEOUT + category: TRANSIENT_DEPENDENCY + http_status: 504 + retryable: true + retry_after_seconds: 2 + owner_branch: feature-outbound-http-client-baseline + owner_layer: infrastructure + client_safe_message: "Upstream service did not respond in time, please retry" + log_level: ERROR + runbook_link: "runbook://dependency/timeout" + compatibility_impact: none + required_test: contract-verification:outbound-http + + # source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류" + L70 connect=2s timeout + - code: DEPENDENCY_CONNECT_FAILED + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 2 + owner_branch: feature-outbound-http-client-baseline + owner_layer: infrastructure + client_safe_message: "Upstream service unreachable, please retry" + log_level: ERROR + runbook_link: "runbook://dependency/connect-failed" + compatibility_impact: none + required_test: contract-verification:outbound-http + + # source: feature-outbound-http-client-baseline — scope "timeout/connect/DNS failure 분류" + - code: DEPENDENCY_DNS_FAILED + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 5 + owner_branch: feature-outbound-http-client-baseline + owner_layer: infrastructure + client_safe_message: "Upstream service unreachable, please retry" + log_level: ERROR + runbook_link: "runbook://dependency/dns-failed" + compatibility_impact: none + required_test: contract-verification:outbound-http + + # source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류" / 테스트 "401/403은 credential/scope/config 문제로 분류되어야 함" + - code: DEPENDENCY_4XX_CLIENT + category: PERMANENT_DEPENDENCY + http_status: 502 + retryable: false + retry_after_seconds: null + owner_branch: feature-outbound-http-client-baseline + owner_layer: infrastructure + client_safe_message: "Upstream service rejected the request" + log_level: ERROR + runbook_link: "runbook://dependency/4xx-client" + compatibility_impact: none + required_test: contract-verification:outbound-http + + # source: feature-outbound-http-client-baseline — scope "upstream 4xx/5xx 분류" + - code: DEPENDENCY_5XX_SERVER + category: TRANSIENT_DEPENDENCY + http_status: 502 + retryable: true + retry_after_seconds: 2 + owner_branch: feature-outbound-http-client-baseline + owner_layer: infrastructure + client_safe_message: "Upstream service error, please retry" + log_level: ERROR + runbook_link: "runbook://dependency/5xx-server" + compatibility_impact: none + required_test: contract-verification:outbound-http + + # source: feature-outbound-http-client-baseline L69 — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용" + Decisionized "circuit breaker | Resilience4j optional env" + - code: DEPENDENCY_CIRCUIT_OPEN + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 10 + owner_branch: feature-outbound-http-client-baseline + owner_layer: infrastructure + client_safe_message: "Upstream service temporarily unavailable, please retry later" + log_level: WARN + runbook_link: "runbook://dependency/circuit-open" + compatibility_impact: none + required_test: contract-verification:outbound-http + + # ============================================================ + # Outbox (feature-domain-event-outbox-contract) + # ============================================================ + + # source: feature-domain-event-outbox-contract L67 — "outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD" + scope "publish 실패 분류" / 판정 "publish 실패가 retry/DLQ/log/runbook 기준 없이 삼켜지면 실패" + - code: OUTBOX_PUBLISH_FAILED + category: TRANSIENT_DEPENDENCY + http_status: 500 + retryable: true + retry_after_seconds: 30 + owner_branch: feature-domain-event-outbox-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://outbox/publish-failed" + compatibility_impact: none + required_test: contract-verification:outbox-publish + + # source: feature-domain-event-outbox-contract L67 — outbox status enum "DEAD" / Outbox Defaults "DLQ | background-job branch owner" + - code: OUTBOX_DEAD_LETTER + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-domain-event-outbox-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://outbox/dead-letter" + compatibility_impact: none + required_test: contract-verification:outbox-dlq + + # ============================================================ + # Background job / Async (feature-background-job-async-contract) + # ============================================================ + + # source: feature-background-job-async-contract — Decisionized "saturation | bounded executor + rejection log" / L72 "saturation policy default = AbortPolicy" / 테스트 "executor rejection이 structured log 없이 발생하면 실패" + - code: JOB_EXECUTOR_REJECTED + category: TRANSIENT_DEPENDENCY + http_status: 503 + retryable: true + retry_after_seconds: 5 + owner_branch: feature-background-job-async-contract + owner_layer: infrastructure + client_safe_message: "Service temporarily unavailable" + log_level: ERROR + runbook_link: "runbook://job/executor-rejected" + compatibility_impact: none + required_test: contract-verification:async-saturation + + # source: feature-background-job-async-contract L69 — "기본 backoff는 exponential backoff with jitter, max attempts 3, DLQ after exhausted attempts" + scope "shutdown 중 job 처리 기준" / L73 graceful shutdown ≤19s + - code: JOB_TIMEOUT + category: TRANSIENT_DEPENDENCY + http_status: 500 + retryable: true + retry_after_seconds: 10 + owner_branch: feature-background-job-async-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://job/timeout" + compatibility_impact: none + required_test: contract-verification:async-timeout + + # source: feature-background-job-async-contract L69 — "DLQ after exhausted attempts" + Decisionized "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted | ... | infinite retry" + - code: JOB_DEAD_LETTER + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-background-job-async-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://job/dead-letter" + compatibility_impact: none + required_test: contract-verification:async-dlq + + # ============================================================ + # Distributed Lock (feature-distributed-lock-contract) + # ============================================================ + + # source: feature-distributed-lock-contract D7 — "lock 획득 실패/timeout 의 error code = + # LOCK_ACQUISITION_TIMEOUT (category CONFLICT, retryable true, client_safe true) + metric + # lock.acquisition" / D5 — "try-lock + 유한 waitTime + lease(TTL) 필수, 무한 blocking 금지". + # category CONFLICT 는 기존 enum 재사용; retryable=true — 락 보유자가 임계 구역을 빠져나오면 + # 동일 요청 재시도로 해소된다(transient contention). DB_DEADLOCK / DB_SERIALIZATION_FAILURE 와 + # 같은 retryable CONFLICT 계열(409). 본 코드는 distributedLockProvider 획득 timeout 전용이며 + # cache stampede lock 의 CACHE_STAMPEDE_LOCK_TIMEOUT(cache-consistency, TRANSIENT_DEPENDENCY 503) + # 과 의미가 구분된다 — 후자는 캐시 백엔드 의존성 timeout, 전자는 분산 상호배제 contention. + - code: LOCK_ACQUISITION_TIMEOUT + category: CONFLICT + http_status: 409 + retryable: true + retry_after_seconds: 1 + owner_branch: feature-distributed-lock-contract + owner_layer: infrastructure + client_safe_message: "Resource is busy, please retry" + log_level: WARN + runbook_link: "runbook://lock/acquisition-timeout" + compatibility_impact: none + required_test: contract-verification:lock-acquisition-timeout + + # ============================================================ + # Migration / Startup (feature-migration-startup-contract) + # ============================================================ + + # source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... migration 실패=70 ..." + Decisionized "startup failure log | structured log with startup.phase, error.code, error.category" + - code: MIGRATION_FAILED + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-migration-startup-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://migration/failed" + compatibility_impact: none + required_test: contract-verification:migration-startup + + # source: feature-migration-startup-contract L71 — "startup exit code 표준 = env 누락/malformed=78 ..." / 테스트 "required env 누락 시 startup이 성공하면 실패" + - code: STARTUP_VALIDATION_FAILED + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-migration-startup-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://startup/validation-failed" + compatibility_impact: none + required_test: contract-verification:migration-startup + + # source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... required adapter disabled=72" / 테스트 "disabled required adapter로 app이 뜨면 실패" + - code: REQUIRED_ADAPTER_DISABLED + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-migration-startup-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://startup/required-adapter-disabled" + compatibility_impact: none + required_test: contract-verification:migration-startup + + # source: feature-integration-adapter-templates §구현 가이드 §4 (Layer 3) + §Audit A2. + # Runtime-lifecycle fail-fast for an invoke against a DISABLED optional adapter + # (Kafka/Redis/Slack/Google Email). Deliberately distinct from the startup-lifecycle + # REQUIRED_ADAPTER_DISABLED above (exit 72): a runtime invoke ≠ a startup validation, + # so reusing the startup code would conflate two lifecycles (A2 resolution — new + # runtime code owned by this branch). retryable=false: the adapter stays disabled + # until redeploy, so retrying the same call never clears it. + - code: ADAPTER_DISABLED + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-integration-adapter-templates + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://adapter/adapter-disabled" + compatibility_impact: none + required_test: adapter-contract:adapter-disabled-runtime-call + + # source: feature-migration-startup-contract L71 — "startup exit code 표준 = ... profile mismatch=71" / 테스트 "prod profile에서 local-only 설정이 켜지면 실패" + - code: PROFILE_MISMATCH + category: INTERNAL + http_status: 500 + retryable: false + retry_after_seconds: null + owner_branch: feature-migration-startup-contract + owner_layer: infrastructure + client_safe_message: "Internal server error" + log_level: ERROR + runbook_link: "runbook://startup/profile-mismatch" + compatibility_impact: none + required_test: contract-verification:migration-startup + + # ============================================================ + # Management / Actuator (feature-management-actuator-security-contract) + # ============================================================ + + # source: feature-management-actuator-security-contract — Exposure Policy "env/configprops | forbidden" "heapdump/threaddump | forbidden unless break-glass runbook" "shutdown | forbidden" / 테스트 "prod에서 env/configprops endpoint가 노출되면 실패" + - code: ACTUATOR_FORBIDDEN + category: AUTHZ + http_status: 403 + retryable: false + retry_after_seconds: null + owner_branch: feature-management-actuator-security-contract + owner_layer: presentation + client_safe_message: "Permission denied" + log_level: WARN + runbook_link: "runbook://management/actuator-forbidden" + compatibility_impact: none + required_test: contract-verification:management-actuator diff --git a/docs/registries/headers.yaml b/docs/registries/headers.yaml new file mode 100644 index 00000000..bdc28571 --- /dev/null +++ b/docs/registries/headers.yaml @@ -0,0 +1,220 @@ +# Registry: HTTP Headers +# SSOT: wiki/projects/ca-tmpl/registries/headers.yaml +# Schema owner: feature-contract-registry-governance +# Last updated: 2026-05-22 +# +# Conventions: +# - HTTP header name: kebab-case (X-Request-Id, X-Tenant-Id) +# - W3C standard headers: lowercase (traceparent, tracestate) +# - mdc_key: snake_case (foundation SSOT) +# - envelope_meta_field: camelCase (envelope SSOT) + +headers: + # source: feature-operational-error-observability-foundation.md L97 + # "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id" + - name: X-Request-Id + direction: both + type: ulid + required: false + generated_if_missing: true + mdc_key: request_id + envelope_meta_field: requestId + owner_branch: feature-operational-error-observability-foundation + case_style: kebab + compatibility_impact: none + required_test: contract-verification:envelope-headers + + # source: feature-api-contract-baseline.md L67 + # "X-Api-Version은 실험/compatibility 보조 header이며 path version과 충돌하면 path가 우선" + - name: X-Api-Version + direction: inbound + type: string + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-api-contract-baseline + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:api-versioning + + # source: feature-api-contract-baseline.md L77 / feature-rate-limit-idempotency-contract.md L66-67 + # "idempotency header 이름은 Idempotency-Key" / "기본 scope는 (authenticatedPrincipal, idempotencyKey, useCaseName)" + - name: Idempotency-Key + direction: inbound + type: string + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-rate-limit-idempotency-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:idempotency-replay + + # source: feature-rate-limit-idempotency-contract.md L85 / foundation L85 + # "RATE_LIMIT | ... | 429 | true (Retry-After 이후)" / "retry-after 기준 없이 429를 반환하면 실패" + - name: Retry-After + direction: outbound + type: duration-seconds + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-rate-limit-idempotency-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:rate-limit-headers + + # source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준) + # rate-limit 응답 표면 (limit/remaining/reset 3종은 표준 rate-limit signaling) + - name: X-RateLimit-Limit + direction: outbound + type: numeric + required: false + generated_if_missing: true + mdc_key: null + envelope_meta_field: null + owner_branch: feature-rate-limit-idempotency-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:rate-limit-headers + + # source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준) + - name: X-RateLimit-Remaining + direction: outbound + type: numeric + required: false + generated_if_missing: true + mdc_key: null + envelope_meta_field: null + owner_branch: feature-rate-limit-idempotency-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:rate-limit-headers + + # source: feature-rate-limit-idempotency-contract.md scope L26 (rate limit response/log 기준) + - name: X-RateLimit-Reset + direction: outbound + type: rfc3339-date + required: false + generated_if_missing: true + mdc_key: null + envelope_meta_field: null + owner_branch: feature-rate-limit-idempotency-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:rate-limit-headers + + # source: feature-api-compatibility-deprecation-contract.md L87 + # "deprecation marker | OpenAPI deprecated: true + branch note | response header optional" + - name: Deprecation + direction: outbound + type: rfc3339-date + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-api-compatibility-deprecation-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:deprecation-marker + + # source: feature-api-compatibility-deprecation-contract.md L87 + # "deprecation marker | OpenAPI deprecated: true + branch note | response header optional" (RFC 8594 Sunset) + - name: Sunset + direction: outbound + type: rfc3339-date + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-api-compatibility-deprecation-contract + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:deprecation-marker + + # source: feature-distributed-tracing-contract.md L64, L85 + # "propagation header는 W3C traceparent default" / "HTTP | traceparent, tracestate (W3C)" + - name: traceparent + direction: both + type: string + required: false + generated_if_missing: true + mdc_key: trace_id + envelope_meta_field: traceId + owner_branch: feature-distributed-tracing-contract + case_style: kebab + compatibility_impact: none + required_test: contract-verification:trace-propagation + + # source: feature-distributed-tracing-contract.md L66, L85 + # "propagation format = W3C traceparent + tracestate only. B3 propagation은 forbidden" + - name: tracestate + direction: both + type: comma-separated + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-distributed-tracing-contract + case_style: kebab + compatibility_impact: none + required_test: contract-verification:trace-propagation + + # source: feature-operational-error-observability-foundation.md L100 + # "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id" + - name: X-Correlation-Id + direction: both + type: ulid + required: false + generated_if_missing: true + mdc_key: correlation_id + envelope_meta_field: correlationId + owner_branch: feature-operational-error-observability-foundation + case_style: kebab + compatibility_impact: none + required_test: contract-verification:envelope-headers + + # source: feature-tenant-context-policy.md L69, L101 (foundation) + # "tenant resolution 우선순위 = ... (2) 명시적 X-Tenant-Id 헤더 (admin/internal API only)" / + # "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)" + - name: X-Tenant-Id + direction: both + type: ulid + required: false + generated_if_missing: false + mdc_key: tenant_id + envelope_meta_field: null + owner_branch: feature-tenant-context-policy + case_style: kebab + compatibility_impact: additive + required_test: contract-verification:tenant-header-policy + + # source: feature-security-operational-baseline.md L66 + # "JWT Resource Server를 baseline security model로 둠" (Bearer token via Authorization header) + - name: Authorization + direction: inbound + type: bearer-token + required: false + generated_if_missing: false + mdc_key: null + envelope_meta_field: null + owner_branch: feature-security-operational-baseline + case_style: kebab + compatibility_impact: none + required_test: contract-verification:jwt-resource-server + + # source: feature-security-operational-baseline.md L83-90 (AuthN/AuthZ Decision Matrix) + # 401 응답 시 WWW-Authenticate (Bearer realm/error) — Spring Security JWT Resource Server 표준 challenge header + - name: WWW-Authenticate + direction: outbound + type: string + required: false + generated_if_missing: true + mdc_key: null + envelope_meta_field: null + owner_branch: feature-security-operational-baseline + case_style: kebab + compatibility_impact: none + required_test: contract-verification:jwt-resource-server diff --git a/docs/registries/mdc-keys.yaml b/docs/registries/mdc-keys.yaml new file mode 100644 index 00000000..616a6b41 --- /dev/null +++ b/docs/registries/mdc-keys.yaml @@ -0,0 +1,294 @@ +# Registry: MDC / Log Keys +# SSOT: wiki/projects/ca-tmpl/registries/mdc-keys.yaml +# Schema owner: feature-contract-registry-governance +# MDC SSOT: feature-operational-error-observability-foundation +# Last updated: 2026-05-22 +# +# Conventions: +# - MDC key naming: snake_case (foundation L93 "snake_case 강제. camelCase / dot.case 금지.") +# - cardinality_safe_for_metric=true 인 key만 metric tag로 사용 가능 +# - foundation L93-102 표 "MDC Key Standard (final)" 6개가 core SSOT + +mdc_keys: + # source: feature-operational-error-observability-foundation.md L97 + # "request_id | inbound filter (생성 또는 X-Request-Id 헤더) | response header X-Request-Id" + - key: request_id + type: ulid + source: inbound_filter + required_in: [request, dependency, security, application] + http_header_mapping: X-Request-Id + envelope_field: meta.requestId + propagation: [http, async, message] + owner_branch: feature-operational-error-observability-foundation + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-mdc-keys + + # source: feature-operational-error-observability-foundation.md L98 + # "trace_id | Micrometer Tracing | W3C traceparent header" + - key: trace_id + type: string + source: observation_context + required_in: [request, dependency, application] + http_header_mapping: traceparent + envelope_field: meta.traceId + propagation: [http, async, message] + owner_branch: feature-operational-error-observability-foundation + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-mdc-keys + + # source: feature-operational-error-observability-foundation.md L99 + # "span_id | Micrometer Tracing | W3C traceparent" + # NOTE: background-job-async-contract L71 "span_id는 Micrometer Observation context에서 자동 전파(MDC explicit copy 불필요)" + - key: span_id + type: string + source: observation_context + required_in: [request, dependency] + http_header_mapping: traceparent + envelope_field: null + propagation: [http, async] + owner_branch: feature-operational-error-observability-foundation + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-mdc-keys + + # source: feature-operational-error-observability-foundation.md L100 + # "correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, message header correlation_id" + - key: correlation_id + type: ulid + source: inbound_filter + required_in: [request, dependency, application] + http_header_mapping: X-Correlation-Id + envelope_field: meta.correlationId + propagation: [http, async, message] + owner_branch: feature-operational-error-observability-foundation + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-mdc-keys + + # source: feature-operational-error-observability-foundation.md L101 + feature-tenant-context-policy.md L70 + # "tenant_id | tenant context (활성 시) | downstream HTTP X-Tenant-Id (with allowlist)" / + # "tenant ID format = opaque ULID (26 chars Crockford base32)" + # NOTE: tenant L73 "tenant_id ULID 원본은 metric tag에 직접 사용 금지" + - key: tenant_id + type: ulid + source: security_context + required_in: [request, dependency, security, audit] + http_header_mapping: X-Tenant-Id + envelope_field: null + propagation: [http, async, message] + owner_branch: feature-tenant-context-policy + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: additive + required_test: contract-verification:tenant-leakage + + # source: feature-operational-error-observability-foundation.md L102 + # "user_principal | security context (pseudonymized only) | log only, headers forbidden" + - key: user_principal + type: string + source: security_context + required_in: [security, audit] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-operational-error-observability-foundation + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-mdc-keys + + # ── log type extensions (log-management-contract L101-109 "Log Type별 필수 필드") ── + + # source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms" + # NOTE: application-port-usecase-contract / business 측 operation 식별자 (uri_template과 별도 application-set) + - key: operation + type: string + source: application_set + required_in: [application, dependency] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms" + - key: method + type: string + source: inbound_filter + required_in: [request] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L105 "request | request_id, trace_id, method, uri_template, status, duration_ms" + # NOTE: metrics L86 "status_code | 7 (1xx-5xx + ok/other)" — bounded + - key: status + type: numeric + source: inbound_filter + required_in: [request] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L105-106 "request | ... duration_ms" / "dependency | ... duration_ms" + - key: duration_ms + type: numeric + source: application_set + required_in: [request, dependency] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, duration_ms, outcome, error_code" + # NOTE: metrics L88 "dependency_name | 50" — bounded + - key: dependency_name + type: string + source: application_set + required_in: [dependency] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L106 "dependency | dependency_name, dependency_type, ..." + - key: dependency_type + type: string + source: application_set + required_in: [dependency] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L106 + metrics L91 "outcome (resilience4j) | 5 (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED)" + - key: outcome + type: string + source: application_set + required_in: [dependency] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L106 "dependency | ... error_code (실패 시)" + # NOTE: metrics L89 "error_code | 100 — error registry row 상한과 정합" — bounded + - key: error_code + type: string + source: application_set + required_in: [dependency] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L107 + # "security | event_type, user_principal (pseudonymized), source_ip (anonymized — last octet zeroed)" + - key: event_type + type: string + source: application_set + required_in: [security, audit] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L107 "security | ... source_ip (anonymized — last octet zeroed)" + # NOTE: metrics L93 "high-cardinality 금지 tag: ... ip_address" + - key: source_ip_anon + type: string + source: inbound_filter + required_in: [security] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L108 "audit | actor, action, target, before_hash, after_hash, occurred_at" + - key: actor + type: string + source: security_context + required_in: [audit] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L108 "audit | actor, action, target, ..." + - key: action + type: string + source: application_set + required_in: [audit] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: true + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields + + # source: feature-log-management-contract.md L108 "audit | actor, action, target, ..." + - key: target + type: string + source: application_set + required_in: [audit] + http_header_mapping: null + envelope_field: null + propagation: [none] + owner_branch: feature-log-management-contract + cardinality_safe_for_metric: false + case_style: snake + compatibility_impact: none + required_test: contract-verification:log-fields diff --git a/docs/registries/metrics.yaml b/docs/registries/metrics.yaml new file mode 100644 index 00000000..59b59d1e --- /dev/null +++ b/docs/registries/metrics.yaml @@ -0,0 +1,540 @@ +# Registry: Metrics +# SSOT: wiki/projects/ca-tmpl/registries/metrics.yaml +# Schema owner: feature-contract-registry-governance +# Owner branch: feature-metrics-alerting-contract +# Last updated: 2026-05-22 +# +# Notes +# - Naming: Micrometer dot.case + unit suffix (.seconds | .bytes | .total). +# - Tag cardinality bounds are SSOT of feature-metrics-alerting-contract "Cardinality Bounds" table. +# - High-cardinality tags forbidden globally: user_id, request_id, raw_url, raw_query, +# raw_header_value, ip_address. These MUST NOT appear in any row. +# - tenant_id label is bounded mapping table id OR cohort bucket only (ULID raw forbidden). +# - error_code tag cardinality_limit follows error-codes.yaml row count (max 100). + +metrics: + + # === HTTP server (inbound) === + # source: feature-metrics-alerting-contract — Metric/Alert Defaults + # "HTTP metric | http.server.requests with method/status/uri-template | raw URL or user id tag" + - name: http.server.requests + type: timer + unit: seconds + tags: + - name: method + cardinality_limit: 8 + allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER] + - name: status + cardinality_limit: 7 + allowed_values: [1xx, 2xx, 3xx, 4xx, 5xx, ok, other] + - name: uri_template + cardinality_limit: 200 + validation: must_be_template_not_raw_uri + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p1: "error_rate > 5% for 5m OR > 10% for 1m" + p2: "error_rate > 1% for 10m" + p3: "error_rate > 0.1% for 1h" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [method, status, uri_template] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — P1/P2/P3 정량 기준 (HTTP latency p99) + # "P1: p99 > 5s 5분 / P2: p99 > 1s 10분 / P3: p99 > 500ms 30분" + - name: http.server.requests.latency + type: timer + unit: seconds + tags: + - name: method + cardinality_limit: 8 + allowed_values: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, OTHER] + - name: uri_template + cardinality_limit: 200 + validation: must_be_template_not_raw_uri + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p1: "p99 > 5s for 5m" + p2: "p99 > 1s for 10m" + p3: "p99 > 500ms for 30m" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [method, uri_template, duration_ms] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === HTTP client (outbound dependency) === + # source: feature-metrics-alerting-contract — Metric/Alert Defaults + # "dependency metric | dependency.client.requests with dependency.name/type/outcome | endpoint with secret tag" + # source: feature-outbound-http-client-baseline — "circuit breaker metric은 dependency.name, dependency.type, outcome까지만 tag로 허용" + - name: dependency.client.requests + type: timer + unit: seconds + tags: + - name: dependency_name + cardinality_limit: 50 + - name: dependency_type + cardinality_limit: 10 + allowed_values: [http, grpc, db, cache, queue, broker, other] + - name: outcome + cardinality_limit: 5 + allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED] + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p1: "required dep unavailable for 2m" + p2: "optional dep degraded for 5m" + p3: "spike alert (10x baseline)" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [dependency_name, dependency_type, outcome, duration_ms] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-outbound-http-client-baseline — decisions + # "retry/circuit breaker 기본 라이브러리는 Resilience4j" + # source: feature-metrics-alerting-contract — "retry/CB minimum: resilience4j.retry.calls{outcome}" + - name: resilience4j.retry.calls + type: counter + unit: total + tags: + - name: name + cardinality_limit: 50 + - name: outcome + cardinality_limit: 5 + allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "retry exhaustion rate > 1% for 10m" + owner_branch: feature-outbound-http-client-baseline + log_field_mapping: [dependency_name, outcome, retry_attempt] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.state" + - name: resilience4j.circuitbreaker.state + type: gauge + unit: total + tags: + - name: name + cardinality_limit: 50 + - name: state + cardinality_limit: 6 + allowed_values: [CLOSED, OPEN, HALF_OPEN, DISABLED, FORCED_OPEN, METRICS_ONLY] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "state == OPEN for required dependency for 2m" + p2: "state == OPEN for optional dependency for 5m" + owner_branch: feature-outbound-http-client-baseline + log_field_mapping: [dependency_name] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — "resilience4j.circuitbreaker.calls{outcome}" + - name: resilience4j.circuitbreaker.calls + type: timer + unit: seconds + tags: + - name: name + cardinality_limit: 50 + - name: outcome + cardinality_limit: 5 + allowed_values: [SUCCESS, FAILURE, CIRCUIT_OPEN, TIMEOUT, REJECTED] + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p2: "CIRCUIT_OPEN rate > 1% for 10m" + owner_branch: feature-outbound-http-client-baseline + log_field_mapping: [dependency_name, outcome, duration_ms] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === DB connection pool === + # source: feature-persistence-failure-baseline — Hikari Alert Threshold + # "pool wait p99 > 100ms 5분 지속 → P2 / pool exhaustion (active = max) > 1분 → P1" + # source: feature-metrics-alerting-contract — "hikaricp.connections.acquire{outcome='timeout'} p99 > 100ms" + - name: hikaricp.connections.acquire + type: timer + unit: seconds + tags: + - name: pool + cardinality_limit: 5 + - name: outcome + cardinality_limit: 3 + allowed_values: [SUCCESS, TIMEOUT, FAILURE] + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p1: "pool exhaustion (active == max) for 1m" + p2: "acquire p99 > 100ms for 5m" + owner_branch: feature-persistence-failure-baseline + log_field_mapping: [pool, outcome] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준" + Hikari Alert Threshold + - name: hikaricp.connections.usage + type: timer + unit: seconds + tags: + - name: pool + cardinality_limit: 5 + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p2: "usage p99 elevated > 10m" + owner_branch: feature-persistence-failure-baseline + log_field_mapping: [pool] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-persistence-failure-baseline — In scope "Hikari metric 노출 기준" + - name: hikaricp.connections.active + type: gauge + unit: total + tags: + - name: pool + cardinality_limit: 5 + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "active == max for 1m" + owner_branch: feature-persistence-failure-baseline + log_field_mapping: [pool] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — Histogram Buckets/Percentile "DB query: same" + - name: db.query.duration + type: timer + unit: seconds + tags: + - name: operation + cardinality_limit: 20 + allowed_values: [select, insert, update, delete, batch, ddl, other] + - name: outcome + cardinality_limit: 3 + allowed_values: [SUCCESS, FAILURE, TIMEOUT] + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p2: "p99 > 1s for 10m" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [operation, outcome, duration_ms] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === Background job / async executor === + # source: feature-background-job-async-contract — Decisionized Work Items "saturation policy" + # "AbortPolicy default (core=10, max=50, queue=200)" + - name: executor.saturation + type: gauge + unit: total + tags: + - name: executor_name + cardinality_limit: 10 + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "queue size > 80% capacity for 5m" + p1: "rejection rate > 0 for 1m" + owner_branch: feature-background-job-async-contract + log_field_mapping: [executor_name] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-background-job-async-contract — Decisionized Work Items "saturation | bounded executor + rejection log" + - name: executor.rejected.total + type: counter + unit: total + tags: + - name: executor_name + cardinality_limit: 10 + - name: policy + cardinality_limit: 3 + allowed_values: [AbortPolicy, CallerRunsPolicy, DiscardPolicy] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "rejection_count > 0 for 1m" + owner_branch: feature-background-job-async-contract + log_field_mapping: [executor_name, policy] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-background-job-async-contract — Decisionized Work Items "retry/DLQ | exp backoff jitter, max 3, DLQ exhausted" + - name: job.retry.total + type: counter + unit: total + tags: + - name: job_name + cardinality_limit: 50 + - name: outcome + cardinality_limit: 4 + allowed_values: [SUCCESS, RETRY, EXHAUSTED, DLQ] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "EXHAUSTED rate > 1% for 10m" + owner_branch: feature-background-job-async-contract + log_field_mapping: [job_name, outcome, retry_attempt] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-background-job-async-contract — "DLQ after exhausted attempts" + - name: job.dlq.total + type: counter + unit: total + tags: + - name: job_name + cardinality_limit: 50 + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "DLQ rate sustained > 0 for 5m" + owner_branch: feature-background-job-async-contract + log_field_mapping: [job_name] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === Distributed lock === + # source: feature-distributed-lock-contract D7 — "metric lock.acquisition (tag: outcome = + # acquired/timeout/error) — 신규 제안" / D5 — try-lock + 유한 waitTime + lease(TTL). 분산 + # 상호배제(distributedLockProvider) 획득 시도 결과를 센다. key 는 tag 로 넣지 않는다 + # (무한 cardinality — 위 전역 금지 규칙). timeout outcome 은 LOCK_ACQUISITION_TIMEOUT 발생과 1:1. + - name: lock.acquisition + type: counter + unit: total + tags: + - name: outcome + cardinality_limit: 3 + allowed_values: [acquired, timeout, error] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "timeout rate > 5% for 10m" + owner_branch: feature-distributed-lock-contract + log_field_mapping: [outcome] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-distributed-lock-contract §Edge / D5 (SI-LOCK-C5) — "lease 만료 후 unlock → + # ConcurrentModificationException — 삼킴 금지, 로그+metric 후 정상 흐름 복귀". Counts releases that + # found the lease already expired (the JdbcLock row was reclaimed by another instance before + # the holder called close()). A sustained nonzero rate means lease TTL is shorter than real + # critical-section duration — raise APP/lease TTL or shorten the protected work. Not an + # acquisition outcome, hence a separate counter from lock.acquisition. + - name: lock.lease.expired + type: counter + unit: total + tags: [] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "lease-expired rate sustained > 0 for 10m" + owner_branch: feature-distributed-lock-contract + log_field_mapping: [] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === Outbox publisher === + # source: feature-domain-event-outbox-contract — Outbox Defaults + # "DB outbox table with eventId, aggregateId, eventType, payload, occurredAt, status, attemptCount, nextAttemptAt" + - name: outbox.publisher.published.total + type: counter + unit: total + tags: + - name: event_type + cardinality_limit: 50 + - name: outcome + cardinality_limit: 4 + allowed_values: [PUBLISHED, FAILED, DEAD, IN_FLIGHT] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "FAILED rate > 1% for 10m" + owner_branch: feature-domain-event-outbox-contract + log_field_mapping: [event_type, outcome, event_id] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-domain-event-outbox-contract — row status enum PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD + - name: outbox.publisher.lag + type: gauge + unit: seconds + tags: + - name: event_type + cardinality_limit: 50 + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "lag > 60s for 10m" + p1: "lag > 300s for 5m" + owner_branch: feature-domain-event-outbox-contract + log_field_mapping: [event_type] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-domain-event-outbox-contract — row status enum + Outbox Defaults attemptCount + - name: outbox.pending.size + type: gauge + unit: total + tags: + - name: status + cardinality_limit: 5 + allowed_values: [PENDING, IN_FLIGHT, PUBLISHED, FAILED, DEAD] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "PENDING size growing for 10m" + owner_branch: feature-domain-event-outbox-contract + log_field_mapping: [status] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === Cache === + # source: feature-cache-consistency-contract — Decisionized Work Items "cache pattern | cache-aside default" + - name: cache.gets.total + type: counter + unit: total + tags: + - name: cache_name + cardinality_limit: 50 + - name: result + cardinality_limit: 3 + allowed_values: [hit, miss, error] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p3: "hit_ratio < baseline 0.5x for 1h" + owner_branch: feature-cache-consistency-contract + log_field_mapping: [cache_name, result] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-cache-consistency-contract — "invalidation = after-commit only", "invalidation 실패가 조용히 무시되면 실패" + - name: cache.invalidations.total + type: counter + unit: total + tags: + - name: cache_name + cardinality_limit: 50 + - name: outcome + cardinality_limit: 3 + allowed_values: [SUCCESS, FAILURE, SKIPPED] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "FAILURE rate > 0 for 5m" + owner_branch: feature-cache-consistency-contract + log_field_mapping: [cache_name, outcome] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === Log appender === + # source: feature-log-management-contract — Sampling Policy (final) + # "async appender overflow default: drop oldest INFO/DEBUG with counter metric (log.appender.dropped.total)" + - name: log.appender.dropped.total + type: counter + unit: total + tags: + - name: appender + cardinality_limit: 5 + - name: level + cardinality_limit: 2 + allowed_values: [INFO, DEBUG] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "dropped > 0 sustained for 10m" + owner_branch: feature-log-management-contract + log_field_mapping: [appender, level] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === Distributed tracing === + # source: feature-distributed-tracing-contract — decisions + # "trace sampling rate default = prod 1%, staging 10%, dev/local 100%" + - name: tracing.sampling.rate + type: gauge + unit: total + tags: + - name: profile + cardinality_limit: 4 + allowed_values: [prod, staging, dev, local] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p3: "effective rate deviates from configured for 1h" + owner_branch: feature-distributed-tracing-contract + log_field_mapping: [profile] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # === JVM baseline === + # source: feature-metrics-alerting-contract — In scope "JVM/process metric" + - name: jvm.memory.used + type: gauge + unit: bytes + tags: + - name: area + cardinality_limit: 2 + allowed_values: [heap, nonheap] + - name: id + cardinality_limit: 10 + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p2: "heap used / max > 0.85 for 10m" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [area, id] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — In scope "JVM/process metric" + - name: jvm.gc.pause + type: timer + unit: seconds + tags: + - name: action + cardinality_limit: 10 + - name: cause + cardinality_limit: 10 + percentiles: [0.5, 0.9, 0.95, 0.99] + histogram_buckets: slo_driven + alert_severity_thresholds: + p2: "p99 pause > 500ms for 10m" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [action, cause] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — In scope "JVM/process metric" + - name: jvm.threads.live + type: gauge + unit: total + tags: [] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p3: "thread count > 2x baseline for 30m" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality + + # source: feature-metrics-alerting-contract — In scope "JVM/process metric" (process uptime) + - name: process.uptime + type: gauge + unit: seconds + tags: [] + percentiles: null + histogram_buckets: null + alert_severity_thresholds: + p1: "uptime reset unexpectedly < 60s (crash loop signal)" + owner_branch: feature-metrics-alerting-contract + log_field_mapping: [] + compatibility_impact: additive + required_test: contract-verification:metrics-cardinality diff --git a/docs/registries/secrets-classification.yaml b/docs/registries/secrets-classification.yaml new file mode 100644 index 00000000..a8902818 --- /dev/null +++ b/docs/registries/secrets-classification.yaml @@ -0,0 +1,203 @@ +# Registry: Secrets Classification +# SSOT: wiki/projects/ca-tmpl/registries/secrets-classification.yaml +# Schema owner: feature-contract-registry-governance +# Owner branch: feature-secrets-config-source-contract +# Last updated: 2026-05-22 +# +# Conventions: +# - 3-tier classification (feature-secrets-config-source-contract 2026-05-22): +# public-config | sensitive-config | secret +# - `secret` rows: prod_default 항상 null. dev fake 식별자는 `__LOCAL_DEV_` prefix +# (feature-secrets-config-source-contract 2026-05-22: "dev/local sentinel value prefix = __LOCAL_DEV_"). +# - prod profile에서 `__LOCAL_DEV_` prefix 발견 시 startup fail +# (feature-secrets-config-source-contract 2026-05-22). +# - Masking 기본 = `full_except_last_4` (feature-secrets-config-source-contract 2026-05-22: +# "full mask except last 4 chars for non-secret tokens"). 진짜 secret(password/private key)은 `full`. +# - Naming suffix는 보조 신호 (feature-secrets-config-source-contract: "_TOKEN, _KEY, _PASSWORD"). +# - public-config 항목은 env-keys.yaml에서 직접 정의되며 본 파일에는 reference row만 둠. + +secrets: + + # === Tier 3: secret (true secret — password/private-key/HMAC-salt) === + + - name: APP_DATASOURCE_PASSWORD + # source: feature-secrets-config-source-contract 2026-05-22 + # "DB credential은 dual-bind 60s" + "__LOCAL_DEV_FAKE_DB_PASSWORD" 예시 + classification: secret + source: secret-manager + rotation_policy: dual-bind-60s + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full + compatibility_impact: breaking + required_test: secrets-contract:db-password-no-leak-in-actuator + + - name: APP_SECURITY_JWT_SIGNING_KEY + # source: feature-secrets-config-source-contract 2026-05-22 + # "JWT signing key는 24h overlap window 유지 (security branch와 cross-link)" + # + feature-security-operational-baseline "rotation overlap window = 새 kid 도입 → 24h 동안 old kid 병행" + classification: secret + source: secret-manager + rotation_policy: overlap-24h + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full + compatibility_impact: breaking + required_test: secrets-contract:jwt-signing-key-rotation-overlap + + - name: APP_SECURITY_OAUTH_CLIENT_SECRET + # source: feature-secrets-config-source-contract 2026-05-22 + # "secret classification은 ... naming pattern은 보조(suffix _TOKEN, _KEY, _PASSWORD)" + # + feature-security-operational-baseline "JWT Resource Server를 baseline security model" (OAuth 자격 증명 분류) + classification: secret + source: secret-manager + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full + compatibility_impact: breaking + required_test: secrets-contract:oauth-client-secret-no-leak + + - name: APP_EXTERNAL_API_KEY + # source: feature-secrets-config-source-contract 2026-05-22 + # "external API key는 application restart 시 reload" + # (per-dependency suffix는 adapter 등록 시 추가; 본 row는 baseline 분류 정의) + classification: secret + source: secret-manager + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full_except_last_4 + compatibility_impact: breaking + required_test: secrets-contract:external-api-key-no-leak + + - name: APP_CACHE_REDIS_PASSWORD + # source: feature-secrets-config-source-contract 2026-05-22 + # 3-tier classification "secret" + feature-integration-adapter-templates "Redis | disabled optional module" + # (Redis enabled + auth 사용 시 secret으로 분류) + classification: secret + source: secret-manager + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full + compatibility_impact: breaking + required_test: secrets-contract:redis-password-no-leak + + - name: APP_PRIVACY_PSEUDONYMIZATION_SALT + # source: feature-data-retention-privacy-contract 2026-05-22 + # "pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일. + # rotation 시 old salt 90일 retain (lookup 가능)." + # + feature-tenant-context-policy "tenant identifier는 raw PII가 아니어야 하며 ... pseudonymized id" + classification: secret + source: secret-manager + rotation_policy: salt-rotation-90d + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-data-retention-privacy-contract + masking_rule: full + compatibility_impact: breaking + required_test: secrets-contract:pseudonymization-salt-rotation + + # === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) === + + - name: APP_NOTIFICATION_SLACK_WEBHOOK_URL + # source: feature-integration-adapter-templates 2026-05-22 + # "Slack | disabled optional module | notification failure policy" + # Slack webhook URL은 token을 path에 포함하므로 sensitive-config (URL 형태이지만 secret과 동급 취급) + classification: sensitive-config + source: secret-manager + rotation_policy: manual + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full_except_last_4 + compatibility_impact: breaking + required_test: secrets-contract:slack-webhook-no-leak + + - name: APP_SECURITY_GOOGLE_OAUTH_CLIENT_ID + # source: feature-secrets-config-source-contract 2026-05-22 + # "sensitive-config" tier (id이지만 노출 제한) + # + feature-integration-adapter-templates "Google Email | disabled optional module" + classification: sensitive-config + source: mounted-env + rotation_policy: manual + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full_except_last_4 + compatibility_impact: behavior-change + required_test: secrets-contract:google-oauth-client-id-masked + + - name: APP_DATASOURCE_USERNAME + # source: feature-secrets-config-source-contract 2026-05-22 — "sensitive-config" tier + # (DB user는 password와 함께 노출되면 위험하므로 sensitive-config) + classification: sensitive-config + source: mounted-env + rotation_policy: dual-bind-60s + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full_except_last_4 + compatibility_impact: breaking + required_test: secrets-contract:datasource-username-masked-in-actuator + + - name: APP_DATASOURCE_URL + # source: feature-secrets-config-source-contract 2026-05-22 — JDBC URL은 host/db 포함하므로 sensitive-config + # (env-keys.yaml에서는 public-config 처리; 본 파일에서는 노출 통제 관점에서 sensitive로 재분류 — masking 기준 명시 목적) + classification: sensitive-config + source: mounted-env + rotation_policy: restart-only + prod_default: null + dev_sentinel_prefix: __LOCAL_DEV_ + owner_branch: feature-secrets-config-source-contract + masking_rule: full_except_last_4 + compatibility_impact: breaking + required_test: secrets-contract:datasource-url-masked-in-actuator + + # === Tier 1: public-config (reference only — full row in env-keys.yaml) === + + - name: APP_PROFILE + # source: feature-env-driven-runtime-configuration — public-config tier reference + classification: public-config + source: application-yml + owner_branch: feature-env-driven-runtime-configuration + masking_rule: none + reference: env-keys.yaml#APP_PROFILE + + - name: APP_NAME + # source: feature-env-driven-runtime-configuration — public-config tier reference + classification: public-config + source: application-yml + owner_branch: feature-env-driven-runtime-configuration + masking_rule: none + reference: env-keys.yaml#APP_NAME + + - name: SERVER_PORT + # source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference + classification: public-config + source: application-yml + owner_branch: feature-env-driven-runtime-configuration + masking_rule: none + reference: env-keys.yaml#SERVER_PORT + + - name: SPRING_PROFILES_ACTIVE + # source: feature-env-driven-runtime-configuration — Spring native, public-config tier reference + classification: public-config + source: application-yml + owner_branch: feature-env-driven-runtime-configuration + masking_rule: none + reference: env-keys.yaml#SPRING_PROFILES_ACTIVE + + - name: OTEL_EXPORTER_OTLP_ENDPOINT + # source: feature-distributed-tracing-contract — public-config tier reference + classification: public-config + source: application-yml + owner_branch: feature-distributed-tracing-contract + masking_rule: none + reference: env-keys.yaml#OTEL_EXPORTER_OTLP_ENDPOINT diff --git a/docs/runbooks/adapter-adapter-disabled.md b/docs/runbooks/adapter-adapter-disabled.md new file mode 100644 index 00000000..674e2299 --- /dev/null +++ b/docs/runbooks/adapter-adapter-disabled.md @@ -0,0 +1,34 @@ +--- +title: Runbook — ADAPTER_DISABLED (런타임 어댑터 비활성화 호출) +category: INTERNAL +error_codes: [ADAPTER_DISABLED] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: ADAPTER_DISABLED (`runbook://adapter/adapter-disabled`) + +## Symptoms + +- HTTP 500 with `error.code=ADAPTER_DISABLED` +- Code invoked an optional adapter (Kafka/Redis/Slack/Email) that is disabled in this deployment + +## Diagnosis + +- Check adapter name in log (`adapter_name` field) +- Review deployment config — which optional adapters are enabled? + +## Action + +- Enable the adapter in deployment configuration (env flag) +- Or update application logic to skip disabled-adapter paths + +## Escalation + +- Escalate to deployment team if adapter should be enabled but isn't + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-audience-mismatch.md b/docs/runbooks/auth-audience-mismatch.md new file mode 100644 index 00000000..3f079076 --- /dev/null +++ b/docs/runbooks/auth-audience-mismatch.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_AUDIENCE_MISMATCH (대상 불일치) +category: AUTH +error_codes: [AUTH_AUDIENCE_MISMATCH] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_AUDIENCE_MISMATCH (`runbook://auth/audience-mismatch`) + +## Symptoms + +- HTTP 401 with `error.code=AUTH_AUDIENCE_MISMATCH` +- Token `aud` claim does not include this service's expected audience + +## Diagnosis + +- Check token `aud` claim value +- Compare against configured `spring.security.oauth2.resourceserver.jwt.audiences` + +## Action + +- Verify client is requesting tokens scoped to the correct audience +- Update audience configuration if service identifier changed + +## Escalation + +- Escalate to auth-platform team if misconfiguration is system-wide + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-claim-mapping-failed.md b/docs/runbooks/auth-claim-mapping-failed.md new file mode 100644 index 00000000..eaee6ca0 --- /dev/null +++ b/docs/runbooks/auth-claim-mapping-failed.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_CLAIM_MAPPING_FAILED (클레임 매핑 실패) +category: AUTH +error_codes: [AUTH_CLAIM_MAPPING_FAILED] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_CLAIM_MAPPING_FAILED (`runbook://auth/claim-mapping-failed`) + +## Symptoms + +- HTTP 401 with `error.code=AUTH_CLAIM_MAPPING_FAILED` +- Token validated but required claims (sub, roles, tenant) missing or unexpected type + +## Diagnosis + +- Inspect token payload claims via logs +- Check claim extractor configuration + +## Action + +- Verify IdP token template includes required claims +- Update claim mapping configuration if IdP schema changed + +## Escalation + +- Escalate to auth-platform team if IdP changed claim schema + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-issuer-mismatch.md b/docs/runbooks/auth-issuer-mismatch.md new file mode 100644 index 00000000..1a173344 --- /dev/null +++ b/docs/runbooks/auth-issuer-mismatch.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_ISSUER_MISMATCH (발급자 불일치) +category: AUTH +error_codes: [AUTH_ISSUER_MISMATCH] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_ISSUER_MISMATCH (`runbook://auth/issuer-mismatch`) + +## Symptoms + +- HTTP 401 with `error.code=AUTH_ISSUER_MISMATCH` +- Token `iss` claim does not match configured expected issuer + +## Diagnosis + +- Compare token `iss` against `spring.security.oauth2.resourceserver.jwt.issuer-uri` +- Check if IdP environment changed + +## Action + +- Update issuer config if IdP migrated +- Reject tokens from unexpected issuers + +## Escalation + +- Escalate to platform-security if unexpected issuer detected + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-jwks-unavailable.md b/docs/runbooks/auth-jwks-unavailable.md new file mode 100644 index 00000000..42bcc829 --- /dev/null +++ b/docs/runbooks/auth-jwks-unavailable.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_JWKS_UNAVAILABLE (JWKS 엔드포인트 장애) +category: TRANSIENT_DEPENDENCY +error_codes: [AUTH_JWKS_UNAVAILABLE] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_JWKS_UNAVAILABLE (`runbook://auth/jwks-unavailable`) + +## Symptoms + +- HTTP 503 with `error.code=AUTH_JWKS_UNAVAILABLE` +- All authentication failing; JWKS refresh attempts failing + +## Diagnosis + +- Check IdP JWKS endpoint health: `curl -sf https:///.well-known/jwks.json` +- Check network connectivity from app pods to IdP + +## Action + +- Enable cached JWKS fallback if available +- Coordinate with IdP team for restoration + +## Escalation + +- P1 page: IdP team immediately if JWKS endpoint unreachable > 2 minutes + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-kid-unknown.md b/docs/runbooks/auth-kid-unknown.md new file mode 100644 index 00000000..1bb3ac15 --- /dev/null +++ b/docs/runbooks/auth-kid-unknown.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_KID_UNKNOWN (키 ID 미인식) +category: AUTH +error_codes: [AUTH_KID_UNKNOWN] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_KID_UNKNOWN (`runbook://auth/kid-unknown`) + +## Symptoms + +- HTTP 401 with `error.code=AUTH_KID_UNKNOWN`, `retryable=true` +- Token `kid` header not present in cached JWKS + +## Diagnosis + +- Check if IdP key rotation occurred recently +- Verify JWKS cache TTL and refresh timing + +## Action + +- Force JWKS cache refresh +- Confirm new key is published in IdP JWKS endpoint + +## Escalation + +- Escalate to IdP team if new kid not appearing in JWKS after 10 minutes + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-public-path-misconfiguration.md b/docs/runbooks/auth-public-path-misconfiguration.md new file mode 100644 index 00000000..df069a67 --- /dev/null +++ b/docs/runbooks/auth-public-path-misconfiguration.md @@ -0,0 +1,34 @@ +--- +title: Runbook — INTERNAL_AUTH_MISCONFIGURATION (공개 경로 설정 오류) +category: INTERNAL +error_codes: [INTERNAL_AUTH_MISCONFIGURATION] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: INTERNAL_AUTH_MISCONFIGURATION (`runbook://auth/public-path-misconfiguration`) + +## Symptoms + +- HTTP 500 with `error.code=INTERNAL_AUTH_MISCONFIGURATION` +- Security filter misconfiguration detected at runtime + +## Diagnosis + +- Check `verifyPublicPathSnapshot` output in CI +- Review recent changes to `SecurityConfig` or `application.yml` public path list + +## Action + +- Revert misconfigured public path change +- Run `./gradlew verifyPublicPathSnapshot` to compare snapshot + +## Escalation + +- P1 immediate: if auth bypass is possible due to misconfiguration + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-token-expired.md b/docs/runbooks/auth-token-expired.md new file mode 100644 index 00000000..215e86de --- /dev/null +++ b/docs/runbooks/auth-token-expired.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_TOKEN_EXPIRED (토큰 만료) +category: AUTH +error_codes: [AUTH_TOKEN_EXPIRED] +severity: P3 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_TOKEN_EXPIRED (`runbook://auth/token-expired`) + +## Symptoms + +- HTTP 401 with `error.code=AUTH_TOKEN_EXPIRED` +- Spike may indicate clock skew or long-lived token usage + +## Diagnosis + +- Check `exp` claim vs server clock +- Check NTP sync on token-issuing host + +## Action + +- Client must refresh tokens before expiry +- Verify clock skew tolerance is configured (default 60s) + +## Escalation + +- Escalate if spike is widespread or clock drift is confirmed + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-token-invalid-signature.md b/docs/runbooks/auth-token-invalid-signature.md new file mode 100644 index 00000000..ce057a1f --- /dev/null +++ b/docs/runbooks/auth-token-invalid-signature.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_TOKEN_INVALID_SIGNATURE (서명 검증 실패) +category: AUTH +error_codes: [AUTH_TOKEN_INVALID_SIGNATURE] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_TOKEN_INVALID_SIGNATURE (`runbook://auth/token-invalid-signature`) + +## Symptoms + +- HTTP 401 with `error.code=AUTH_TOKEN_INVALID_SIGNATURE` +- `log_level=ERROR` — may indicate forged tokens or wrong signing key + +## Diagnosis + +- Check if JWKS endpoint returned a new key set +- Check for token forgery attempts in logs + +## Action + +- Verify JWKS key IDs match token headers +- Alert security team if forgery suspected + +## Escalation + +- Immediate P1 escalation if forgery indicators present + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-token-malformed.md b/docs/runbooks/auth-token-malformed.md new file mode 100644 index 00000000..4881c498 --- /dev/null +++ b/docs/runbooks/auth-token-malformed.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_TOKEN_MALFORMED (토큰 파싱 실패) +category: AUTH +error_codes: [AUTH_TOKEN_MALFORMED] +severity: P3 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_TOKEN_MALFORMED (`runbook://auth/token-malformed`) + +## Symptoms + +- HTTP 401 responses with `error.code=AUTH_TOKEN_MALFORMED` +- Token present but fails JWT parse (not 3-part, non-base64, etc.) + +## Diagnosis + +- Inspect raw Authorization header value in logs +- Check if token generation tooling has a bug + +## Action + +- Identify source of malformed tokens +- Fix or update client token generation + +## Escalation + +- Escalate if spike suggests infrastructure issue + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-token-missing.md b/docs/runbooks/auth-token-missing.md new file mode 100644 index 00000000..78a74da5 --- /dev/null +++ b/docs/runbooks/auth-token-missing.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTH_TOKEN_MISSING (인증 토큰 누락) +category: AUTH +error_codes: [AUTH_TOKEN_MISSING] +severity: P3 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTH_TOKEN_MISSING (`runbook://auth/token-missing`) + +## Symptoms + +- HTTP 401 responses with `error.code=AUTH_TOKEN_MISSING` +- Client missing Authorization header or Bearer token + +## Diagnosis + +- Check request logs for missing Authorization header +- Verify client SDK configuration + +## Action + +- Confirm API clients are sending Authorization header +- Check gateway/proxy configuration for header stripping + +## Escalation + +- Escalate if widespread or affecting critical workflows + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/auth-token-rotation-failure.md b/docs/runbooks/auth-token-rotation-failure.md new file mode 100644 index 00000000..5bdc2417 --- /dev/null +++ b/docs/runbooks/auth-token-rotation-failure.md @@ -0,0 +1,69 @@ +--- +title: Runbook — JWT key rotation 시 인증 실패 spike +category: AUTH +error_codes: [AUTH_TOKEN_EXPIRED, AUTH_KID_UNKNOWN, AUTH_JWKS_UNAVAILABLE, AUTH_TOKEN_INVALID_SIGNATURE] +severity: P1 +owner: oncall +last_updated: 2026-05-22 +status: stub +--- + +# Runbook: JWT key rotation 시 인증 실패 spike + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `auth_401_error_rate_high` 또는 `jwks_refresh_failure_spike` +- alert payload 필수 field: `operation`, `error.code`, `error.category`, `runbook_link`, `dependency_name` +- 임계: 401 error rate > 5% 5분 지속 OR JWKS refresh failure count > 10건/분 + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. JWKS endpoint health check: `curl -sf https:///.well-known/jwks.json | jq '.keys | length'` +2. log query에서 `error.code` 분포 확인 — `AUTH_KID_UNKNOWN` 비중이 높으면 rotation 원인 강력 시사 +3. IdP rotation schedule 확인 (직전 24h 내 rotation 이벤트가 있었는지) + +### Step 2 — 임시 격리 +- JWKS cache TTL을 짧게(예: 60s) 강제하여 새 kid 전파 가속 +- 새 kid가 JWKS에 publish되어 있는지 확인. 누락이면 IdP에 republish 요청 + +## 3. Diagnosis + +- log query (Loki/CloudWatch): `{service="auth"} | error.category="AUTH" | dependency_name="jwks-endpoint"` +- metric panel: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total`, `auth_kid_unknown_total` +- trace: 실패한 request 1건에서 `traceId` 추출 → IdP outbound span 확인 +- 가능한 원인: + - 새 kid가 JWKS에 publish되기 전 token 발급 → 24h overlap window 안에 있는지 확인 + - JWKS endpoint 장애 (5xx, timeout) → IdP status page 확인 + - 시계 skew로 인한 만료 오판 → NTP sync 상태 확인 + +## 4. Mitigation + +- 단기: old kid를 임시 재허용 (rollback). overlap window를 48h로 일시 확장 +- IdP에 새 JWKS publish 재시도 요청 +- 장기: rotation 절차에 "publish → 24h 대기 → switch" 단계 강제. observability에 kid 분포 metric 추가 + +## 5. Escalation + +- P2 → P1 격상 조건: 401 error rate > 20% 또는 다중 tenant에 동시 발생 +- 다음 on-call로 page: 10분 내 회복 안 되면 IdP team 또는 platform-security team page + +## 6. Recovery / Verification + +- 회복 확인 metric: `auth_401_error_rate < 1%` 5분 지속, `AUTH_KID_UNKNOWN` 건수 0 +- post-incident: + - rotation 절차 RCA 작성 + - JWKS overlap window 정책 문서 업데이트 + - kid 분포 dashboard 영구화 + +## 7. Related + +- error-codes.yaml rows: `AUTH_TOKEN_EXPIRED`, `AUTH_KID_UNKNOWN`, `AUTH_JWKS_UNAVAILABLE`, `AUTH_TOKEN_INVALID_SIGNATURE` +- metrics.yaml: `auth_jwks_cache_hit_ratio`, `auth_jwks_refresh_failure_total` +- 관련 branch: [[feature-security-operational-baseline]] + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 IdP 종류·rotation 정책·JWKS endpoint URL·dashboard 링크로 보강 필요. diff --git a/docs/runbooks/authz-cross-tenant-violation.md b/docs/runbooks/authz-cross-tenant-violation.md new file mode 100644 index 00000000..b2a303c0 --- /dev/null +++ b/docs/runbooks/authz-cross-tenant-violation.md @@ -0,0 +1,72 @@ +--- +title: Runbook — cross-tenant 접근 시도 감지 +category: AUTHZ +error_codes: [AUTHZ_INSUFFICIENT_PERMISSION, AUTHZ_TENANT_MISMATCH] +severity: P2 +owner: oncall +last_updated: 2026-05-22 +status: stub +--- + +# Runbook: cross-tenant 접근 시도 감지 + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `authz_cross_tenant_violation` 또는 `authz_403_spike` +- alert payload 필수 field: `operation`, `error.code`, `error.category`, `principal_id_pseudonymized`, `tenant_id`, `runbook_link` +- 임계: + - P2: 403 with `error.code=AUTHZ_TENANT_MISMATCH` > 10건/5분 + - P1 격상: 동일 principal에서 3개 이상 tenant 시도 OR 5분 내 100건 초과 + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. log query로 위반 principal 식별 (pseudonymized): `error.code=AUTHZ_TENANT_MISMATCH` +2. principal의 정상 tenant scope 확인 (IdP claim 또는 entitlement table) +3. `CROSS_TENANT_ADMIN` capability 보유 여부 확인 — 보유자라면 false positive 가능성 + +### Step 2 — 임시 격리 +- 명백한 위반 패턴이면 principal session 강제 만료 (token revocation list 추가) +- security incident channel 통보 (`#sec-incident`) +- 위반 request의 source IP / user-agent 기록 + +## 3. Diagnosis + +- log query: `{service="api"} | error.category="AUTHZ" | principal_id_pseudonymized=""` +- metric panel: `authz_denied_total{reason="tenant_mismatch"}`, `authz_principal_tenant_distribution` +- trace: 위반 request의 `traceId`로 호출 chain 확인. token claim의 `tenant_id`와 요청 path의 `tenant_id` 비교 +- 가능한 원인: + - account takeover (계정 탈취) → 즉시 session revoke + 비밀번호 reset 요구 + - client bug (잘못된 tenant id 전송) → product team에 통보 + - 정상 admin operation 누락된 capability → entitlement 보정 + +## 4. Mitigation + +- 단기: principal session revoke, source IP rate-limit 강화 +- 위반이 client bug면 client patch release 협조 +- 장기: tenant boundary 검증 layer를 controller가 아닌 repository 진입점에서 강제 ([[feature-repository-access-permission-contract]]) + +## 5. Escalation + +- 다음 on-call로 page: 보안 incident channel 즉시 page. 5분 내 security on-call 응답 없으면 CISO escalation +- legal/compliance 통보 필요 여부 판단 (개인정보 noted시) + +## 6. Recovery / Verification + +- 회복 확인 metric: `AUTHZ_TENANT_MISMATCH` 건수 정상 baseline 복귀 +- post-incident: + - account takeover면 forensics 수행 + audit log 보존 + - cross-tenant 검증 unit test 추가 + - 위반 패턴 detection rule 영구화 + +## 7. Related + +- error-codes.yaml rows: `AUTHZ_INSUFFICIENT_PERMISSION`, `AUTHZ_TENANT_MISMATCH` +- metrics.yaml: `authz_denied_total`, `authz_principal_tenant_distribution` +- 관련 branch: [[feature-tenant-context-policy]], [[feature-repository-access-permission-contract]] + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 tenant 모델·capability 정의·security team 연락 체계로 보강 필요. diff --git a/docs/runbooks/authz-insufficient-permission.md b/docs/runbooks/authz-insufficient-permission.md new file mode 100644 index 00000000..5199ccbe --- /dev/null +++ b/docs/runbooks/authz-insufficient-permission.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTHZ_INSUFFICIENT_PERMISSION (권한 부족) +category: AUTHZ +error_codes: [AUTHZ_INSUFFICIENT_PERMISSION] +severity: P3 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTHZ_INSUFFICIENT_PERMISSION (`runbook://authz/insufficient-permission`) + +## Symptoms + +- HTTP 403 with `error.code=AUTHZ_INSUFFICIENT_PERMISSION` +- Valid token but missing required role or permission + +## Diagnosis + +- Check user's assigned roles in IdP +- Review endpoint's required permission annotation + +## Action + +- Grant correct role/permission to user +- Verify endpoint permission requirement is correct + +## Escalation + +- Escalate to access-management team if bulk users affected + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/authz-tenant-mismatch.md b/docs/runbooks/authz-tenant-mismatch.md new file mode 100644 index 00000000..1bd30beb --- /dev/null +++ b/docs/runbooks/authz-tenant-mismatch.md @@ -0,0 +1,34 @@ +--- +title: Runbook — AUTHZ_TENANT_MISMATCH (테넌트 cross-access 시도) +category: AUTHZ +error_codes: [AUTHZ_TENANT_MISMATCH] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: AUTHZ_TENANT_MISMATCH (`runbook://authz/tenant-mismatch`) + +## Symptoms + +- HTTP 403 with `error.code=AUTHZ_TENANT_MISMATCH` +- `log_level=ERROR` — cross-tenant access attempt detected + +## Diagnosis + +- Extract `traceId`, check `X-Tenant-Id` vs token tenant claim +- Determine if this is misconfigured client or intentional attack + +## Action + +- Block repeat offenders at gateway level +- Alert security team for investigation + +## Escalation + +- P1 if confirmed malicious cross-tenant access attempt + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/cache-stampede-lock-timeout.md b/docs/runbooks/cache-stampede-lock-timeout.md new file mode 100644 index 00000000..f5e4f171 --- /dev/null +++ b/docs/runbooks/cache-stampede-lock-timeout.md @@ -0,0 +1,34 @@ +--- +title: Runbook — CACHE_STAMPEDE_LOCK_TIMEOUT (캐시 스탬피드 락 타임아웃) +category: TRANSIENT_DEPENDENCY +error_codes: [CACHE_STAMPEDE_LOCK_TIMEOUT] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: CACHE_STAMPEDE_LOCK_TIMEOUT (`runbook://cache/stampede-lock-timeout`) + +## Symptoms + +- HTTP 503 with `error.code=CACHE_STAMPEDE_LOCK_TIMEOUT` +- Multiple concurrent cache misses on same key; lock contention + +## Diagnosis + +- Check cache hit ratio metrics +- Identify cache keys with high miss rates + +## Action + +- Verify stampede lock TTL is configured appropriately +- Pre-warm cache for high-traffic keys on startup + +## Escalation + +- Escalate if backend load spike accompanies stampede + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/cache-unavailable.md b/docs/runbooks/cache-unavailable.md new file mode 100644 index 00000000..ebada52b --- /dev/null +++ b/docs/runbooks/cache-unavailable.md @@ -0,0 +1,34 @@ +--- +title: Runbook — CACHE_UNAVAILABLE (캐시 연결 불가) +category: TRANSIENT_DEPENDENCY +error_codes: [CACHE_UNAVAILABLE] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: CACHE_UNAVAILABLE (`runbook://cache/unavailable`) + +## Symptoms + +- HTTP 503 with `error.code=CACHE_UNAVAILABLE` +- Redis connection errors in logs + +## Diagnosis + +- Check Redis cluster health +- Verify network connectivity from app to Redis + +## Action + +- Check Redis sentinel/cluster status +- Enable cache degradation path if configured for optional caches + +## Escalation + +- P1 if required cache is down and no degradation path exists + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/db-deadlock.md b/docs/runbooks/db-deadlock.md new file mode 100644 index 00000000..6ba72674 --- /dev/null +++ b/docs/runbooks/db-deadlock.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DB_DEADLOCK (데드락) +category: CONFLICT +error_codes: [DB_DEADLOCK] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DB_DEADLOCK (`runbook://db/deadlock`) + +## Symptoms + +- HTTP 409 with `error.code=DB_DEADLOCK` +- SQLState 40P01 in Postgres logs + +## Diagnosis + +- Check `pg_locks` and `pg_stat_activity` during deadlock +- Identify conflicting transaction lock order + +## Action + +- Client should retry (retryable=true) +- Fix lock ordering in code if recurring + +## Escalation + +- Escalate to DBA if deadlock rate is sustained > 1% of transactions + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/db-idle-in-tx-timeout.md b/docs/runbooks/db-idle-in-tx-timeout.md new file mode 100644 index 00000000..ade79237 --- /dev/null +++ b/docs/runbooks/db-idle-in-tx-timeout.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DB_IDLE_IN_TX_TIMEOUT (트랜잭션 idle 타임아웃) +category: TRANSIENT_DEPENDENCY +error_codes: [DB_IDLE_IN_TX_TIMEOUT] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DB_IDLE_IN_TX_TIMEOUT (`runbook://db/idle-in-tx-timeout`) + +## Symptoms + +- HTTP 503 with `error.code=DB_IDLE_IN_TX_TIMEOUT` +- SQLState 25P03; transaction held open too long without activity + +## Diagnosis + +- Check `idle_in_transaction_session_timeout` Postgres setting +- Look for application-level long-running transaction holders + +## Action + +- Reduce transaction scope in application code +- Verify `spring.jpa.properties.hibernate.connection.timeout` is bounded + +## Escalation + +- Escalate to DBA if connection pool exhaustion results + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/db-query-canceled.md b/docs/runbooks/db-query-canceled.md new file mode 100644 index 00000000..67ae9131 --- /dev/null +++ b/docs/runbooks/db-query-canceled.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DB_QUERY_CANCELED (쿼리 취소) +category: TRANSIENT_DEPENDENCY +error_codes: [DB_QUERY_CANCELED] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DB_QUERY_CANCELED (`runbook://db/query-canceled`) + +## Symptoms + +- HTTP 503 with `error.code=DB_QUERY_CANCELED` +- SQLState 57014; query exceeds statement timeout + +## Diagnosis + +- Check `statement_timeout` in Postgres +- Identify slow queries in `pg_stat_statements` + +## Action + +- Optimize slow query or add index +- Adjust statement timeout if query is legitimately long + +## Escalation + +- Escalate to DBA for query optimization if recurring + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/db-serialization-failure.md b/docs/runbooks/db-serialization-failure.md new file mode 100644 index 00000000..eb424eb6 --- /dev/null +++ b/docs/runbooks/db-serialization-failure.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DB_SERIALIZATION_FAILURE (직렬화 실패) +category: CONFLICT +error_codes: [DB_SERIALIZATION_FAILURE] +severity: P3 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DB_SERIALIZATION_FAILURE (`runbook://db/serialization-failure`) + +## Symptoms + +- HTTP 409 with `error.code=DB_SERIALIZATION_FAILURE` +- SQLState 40001; high concurrent transaction contention + +## Diagnosis + +- Check DB transaction isolation level +- Identify hot rows / hot tables under high concurrency + +## Action + +- Client should retry with exponential backoff (retryable=true) +- Optimize transaction scope if spike is sustained + +## Escalation + +- Escalate to DBA if sustained serialization failure rate > 5% + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/db-unavailable.md b/docs/runbooks/db-unavailable.md new file mode 100644 index 00000000..8b532c10 --- /dev/null +++ b/docs/runbooks/db-unavailable.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DB_UNAVAILABLE (데이터베이스 연결 불가) +category: TRANSIENT_DEPENDENCY +error_codes: [DB_UNAVAILABLE] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DB_UNAVAILABLE (`runbook://db/unavailable`) + +## Symptoms + +- HTTP 503 with `error.code=DB_UNAVAILABLE` +- SQLState 08* connection errors in logs + +## Diagnosis + +- Check DB server health and connection pool exhaustion +- Review network connectivity from app pods to DB + +## Action + +- Check DB primary health; failover to replica if available +- Drain connection pool and reconnect + +## Escalation + +- P1: immediate if DB primary is down + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-4xx-client.md b/docs/runbooks/dependency-4xx-client.md new file mode 100644 index 00000000..19078807 --- /dev/null +++ b/docs/runbooks/dependency-4xx-client.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DEPENDENCY_4XX_CLIENT (업스트림 클라이언트 오류) +category: PERMANENT_DEPENDENCY +error_codes: [DEPENDENCY_4XX_CLIENT] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DEPENDENCY_4XX_CLIENT (`runbook://dependency/4xx-client`) + +## Symptoms + +- HTTP 502 with `error.code=DEPENDENCY_4XX_CLIENT` +- Upstream returned 401/403/400 — credential, scope, or request format issue + +## Diagnosis + +- Check upstream response body in logs for error detail +- Verify API credentials and scopes are valid + +## Action + +- Rotate credentials if expired +- Fix request format if API contract changed + +## Escalation + +- Escalate to upstream API owner if contract change is suspected + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-5xx-server.md b/docs/runbooks/dependency-5xx-server.md new file mode 100644 index 00000000..62d8c6a3 --- /dev/null +++ b/docs/runbooks/dependency-5xx-server.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DEPENDENCY_5XX_SERVER (업스트림 서버 오류) +category: TRANSIENT_DEPENDENCY +error_codes: [DEPENDENCY_5XX_SERVER] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DEPENDENCY_5XX_SERVER (`runbook://dependency/5xx-server`) + +## Symptoms + +- HTTP 502 with `error.code=DEPENDENCY_5XX_SERVER` +- Upstream returned 5xx; transient server-side failure + +## Diagnosis + +- Check `dependency_name` tag for which upstream is failing +- Review upstream service status page + +## Action + +- Client should retry (retryable=true) +- Monitor upstream recovery + +## Escalation + +- P1 if critical upstream is in sustained 5xx state + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-circuit-open.md b/docs/runbooks/dependency-circuit-open.md new file mode 100644 index 00000000..9fc3e084 --- /dev/null +++ b/docs/runbooks/dependency-circuit-open.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DEPENDENCY_CIRCUIT_OPEN (서킷 브레이커 개방) +category: TRANSIENT_DEPENDENCY +error_codes: [DEPENDENCY_CIRCUIT_OPEN] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DEPENDENCY_CIRCUIT_OPEN (`runbook://dependency/circuit-open`) + +## Symptoms + +- HTTP 503 with `error.code=DEPENDENCY_CIRCUIT_OPEN` +- Circuit breaker (Resilience4j) in OPEN state for a dependency + +## Diagnosis + +- Check Resilience4j circuit breaker metrics for the dependency +- Check upstream health; circuit opens after failure threshold breached + +## Action + +- Wait for circuit half-open probe (automatic after wait duration) +- Resolve upstream issue to allow circuit to close + +## Escalation + +- P1 if circuit remains open > 5 minutes on a critical dependency + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-connect-failed.md b/docs/runbooks/dependency-connect-failed.md new file mode 100644 index 00000000..ee8aff29 --- /dev/null +++ b/docs/runbooks/dependency-connect-failed.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DEPENDENCY_CONNECT_FAILED (외부 의존성 연결 실패) +category: TRANSIENT_DEPENDENCY +error_codes: [DEPENDENCY_CONNECT_FAILED] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DEPENDENCY_CONNECT_FAILED (`runbook://dependency/connect-failed`) + +## Symptoms + +- HTTP 503 with `error.code=DEPENDENCY_CONNECT_FAILED` +- TCP connection refused or network unreachable to upstream + +## Diagnosis + +- Check `dependency_name` tag for which upstream is unreachable +- Verify network path and firewall rules + +## Action + +- Check upstream service availability +- Verify service discovery / DNS resolution + +## Escalation + +- P1 if upstream is a critical service dependency + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-dns-failed.md b/docs/runbooks/dependency-dns-failed.md new file mode 100644 index 00000000..de1dd9f1 --- /dev/null +++ b/docs/runbooks/dependency-dns-failed.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DEPENDENCY_DNS_FAILED (DNS 조회 실패) +category: TRANSIENT_DEPENDENCY +error_codes: [DEPENDENCY_DNS_FAILED] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DEPENDENCY_DNS_FAILED (`runbook://dependency/dns-failed`) + +## Symptoms + +- HTTP 503 with `error.code=DEPENDENCY_DNS_FAILED` +- DNS resolution failure for upstream hostname + +## Diagnosis + +- Test DNS resolution from app pod: `nslookup ` +- Check cluster DNS (CoreDNS) health + +## Action + +- Verify upstream hostname configuration +- Check CoreDNS / cluster DNS health + +## Escalation + +- P1 if cluster DNS is degraded + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-timeout.md b/docs/runbooks/dependency-timeout.md new file mode 100644 index 00000000..6d5a98f6 --- /dev/null +++ b/docs/runbooks/dependency-timeout.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DEPENDENCY_TIMEOUT (외부 의존성 타임아웃) +category: TRANSIENT_DEPENDENCY +error_codes: [DEPENDENCY_TIMEOUT] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DEPENDENCY_TIMEOUT (`runbook://dependency/timeout`) + +## Symptoms + +- HTTP 504 with `error.code=DEPENDENCY_TIMEOUT` +- Upstream service did not respond within configured timeout (default: global 10s) + +## Diagnosis + +- Check `dependency_name` in log for which upstream is timing out +- Review upstream service latency metrics + +## Action + +- Check upstream service health +- Verify timeout settings match SLA expectations + +## Escalation + +- P1 if critical upstream is timing out at scale + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/dependency-unavailable.md b/docs/runbooks/dependency-unavailable.md new file mode 100644 index 00000000..bd491f8f --- /dev/null +++ b/docs/runbooks/dependency-unavailable.md @@ -0,0 +1,81 @@ +--- +title: Runbook — 외부 의존성 unavailable +category: TRANSIENT_DEPENDENCY +error_codes: [DEPENDENCY_TIMEOUT, DEPENDENCY_CONNECT_FAILED, DEPENDENCY_DNS_FAILED, DEPENDENCY_CIRCUIT_OPEN, DEPENDENCY_5XX_SERVER, CACHE_UNAVAILABLE, DB_UNAVAILABLE] +severity: P1 +owner: oncall +last_updated: 2026-05-22 +status: stub +--- + +# Runbook: 외부 의존성 unavailable + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `dependency_error_rate_critical` 또는 `circuit_breaker_open` +- alert payload 필수 field: `operation`, `error.code`, `error.category`, `dependency_name`, `dependency_kind`(required|optional), `runbook_link` +- 임계: + - P1: required dependency의 error rate > 50% 1분 OR circuit_open state 활성 + - P2: optional dependency degraded (fail-open으로 동작 중) + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. `dependency_name` 별 status page 확인 (외부 SaaS면 vendor status, internal이면 해당 service dashboard) +2. log query로 실패 패턴 확인: timeout / connect / DNS / 5xx 중 어떤 모드인지 +3. runtime-health Dependency Matrix에서 required vs optional 분류 확인 +4. circuit breaker state 확인 (Resilience4j metric) + +### Step 2 — 임시 격리 +- required dep이면 readiness probe로 traffic 차단 (회복 대기) — cascade failure 방지 +- optional dep이면 fail-open with degraded mode 확인. degraded banner를 client에 노출 +- DNS failure면 resolver/coredns 상태 확인. cache 강제 flush 검토 + +## 3. Diagnosis + +- log query: `{service="app"} | dependency_name="" | stats count by error.code` +- metric panel: + - `resilience4j_circuitbreaker_state{name=""}` + - `resilience4j_retry_calls_total{kind="failed_without_retry"}` + - `hikaricp_connections_active`, `hikaricp_connections_pending` (DB_UNAVAILABLE) + - `http_client_requests_seconds_count{outcome="SERVER_ERROR"}` +- trace: 실패 request의 outbound span에서 timeout/connect/DNS 분류, target endpoint 확인 +- 가능한 원인: + - vendor outage → status page 확인, 회복 대기 + - 네트워크 문제 (DNS, security group, NAT) → infra team 확인 + - connection pool 고갈 (Hikari) → pool size/timeout 점검 + - circuit breaker open 후 half-open 전환 실패 → 수동 reset 검토 + - retry-storm으로 인한 self-DoS → retry budget 축소 + +## 4. Mitigation + +- 단기: required면 회복 대기 + traffic 차단, optional이면 degraded mode로 유지 +- pool 고갈이면 일시 pool size 상향 + leak detection 활성화 +- circuit이 stuck이면 수동 reset (`actuator/circuitbreakerevents`) +- 장기: retry budget·timeout·circuit 임계 재조정, fallback path 보강, vendor SLA 재협상 + +## 5. Escalation + +- 다음 on-call로 page: required dep 5분 내 회복 안 되면 외부 dep team 또는 vendor에 page +- 다중 dep 동시 장애면 incident commander 호출 (네트워크 전반 문제 의심) + +## 6. Recovery / Verification + +- 회복 확인 metric: dependency error rate < 1% 5분 지속, circuit_breaker_state = CLOSED, pool utilization 정상 +- post-incident: + - vendor postmortem 요청 (외부 SaaS면) + - timeout/retry/circuit 설정 재검토 + - degraded mode가 사용자 경험에 미친 영향 측정 + - chaos test에 해당 시나리오 추가 + +## 7. Related + +- error-codes.yaml rows: `DEPENDENCY_TIMEOUT`, `DEPENDENCY_CONNECT_FAILED`, `DEPENDENCY_DNS_FAILED`, `DEPENDENCY_CIRCUIT_OPEN`, `DEPENDENCY_5XX_SERVER`, `CACHE_UNAVAILABLE`, `DB_UNAVAILABLE` +- metrics.yaml: `resilience4j_circuitbreaker_state`, `hikaricp_connections_active`, `http_client_requests_seconds_count` +- 관련 branch: [[feature-outbound-http-client-baseline]], [[feature-persistence-failure-baseline]] + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 dependency 목록·required/optional 분류·vendor 연락 체계·circuit/timeout 임계로 보강 필요. diff --git a/docs/runbooks/file-download-streaming-failure.md b/docs/runbooks/file-download-streaming-failure.md new file mode 100644 index 00000000..c1275456 --- /dev/null +++ b/docs/runbooks/file-download-streaming-failure.md @@ -0,0 +1,34 @@ +--- +title: Runbook — DOWNLOAD_STREAMING_FAILURE (스트리밍 다운로드 실패) +category: TRANSIENT_DEPENDENCY +error_codes: [DOWNLOAD_STREAMING_FAILURE] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: DOWNLOAD_STREAMING_FAILURE (`runbook://file/download-streaming-failure`) + +## Symptoms + +- HTTP 503 with `error.code=DOWNLOAD_STREAMING_FAILURE` +- Streaming response truncated; backpressure or timeout (60s / 100MB limit) + +## Diagnosis + +- Check streaming response timeout configuration +- Review download size vs 100MB limit + +## Action + +- Verify storage backend is reachable +- Check for network congestion on download path + +## Escalation + +- Escalate to infra if storage backend is degraded + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/internal-error-spike.md b/docs/runbooks/internal-error-spike.md new file mode 100644 index 00000000..0f90c325 --- /dev/null +++ b/docs/runbooks/internal-error-spike.md @@ -0,0 +1,74 @@ +--- +title: Runbook — 5xx Internal error spike +category: INTERNAL +error_codes: [INTERNAL_ERROR, INTERNAL_AUTH_MISCONFIGURATION, JVM_OOM] +severity: P1 +owner: oncall +last_updated: 2026-05-22 +status: stub +--- + +# Runbook: 5xx Internal error spike + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `http_5xx_error_rate_critical` +- alert payload 필수 field: `operation`, `error.code`, `error.category`, `request_id`, `traceId`, `runbook_link` +- 임계: 5xx error rate > 5% 5분 지속 OR > 10% 1분 + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. 가장 최근 deploy 시각 확인 (CI/CD dashboard, artifact registry digest) +2. JVM metric 확인: heap usage, GC pause, CPU, thread count +3. log에서 실패 request 1건 추출 → `request_id`, `traceId` 확보 +4. error.code 분포 확인: `INTERNAL_ERROR` vs `JVM_OOM` vs `INTERNAL_AUTH_MISCONFIGURATION` + +### Step 2 — 임시 격리 +- 직전 deploy가 의심되면 즉시 rollback (artifact registry에서 직전 image digest pin) +- OOM 패턴이면 affected pod evict → ASG/HPA로 replacement 유도 +- LB에서 unhealthy pod 격리 (readiness probe failure 유도) + +## 3. Diagnosis + +- log query: `{service="app"} | http.status>=500 | stats count by error.code` +- metric panel: `jvm_memory_used_bytes{area="heap"}`, `jvm_gc_pause_seconds`, `process_cpu_seconds_total`, `http_server_requests_seconds_count{status=~"5.."}` +- trace: 실패 request의 `traceId`로 span chain 확인 → stack trace에서 root exception 추출 +- heap dump 위치: `/var/tmp/heap/heapdump-.hprof` (JVM ergonomics: `-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError`) +- 가능한 원인: + - 직전 deploy의 회귀 버그 → rollback + - JVM OOM (메모리 leak 또는 부하 증가) → heap dump 분석 + - 외부 의존성 설정 오류 (`INTERNAL_AUTH_MISCONFIGURATION`) → config secret 확인 + - thread starvation (pool 고갈) → thread dump (`jstack `) + +## 4. Mitigation + +- 단기: 직전 deploy rollback, OOM pod replacement, traffic 일시 감소(scale-out 또는 rate-limit 강화) +- config 오류면 secret/configmap rollback +- 장기: heap dump 기반 leak 수정, capacity planning 재검토 + +## 5. Escalation + +- 다음 on-call로 page: 10분 내 회복 안 되면 incident commander 호출, severity 1 incident 선언 +- 데이터 손상 의심되면 DBA team page + +## 6. Recovery / Verification + +- 회복 확인 metric: 5xx rate < 0.5% 5분 지속, JVM heap usage < 70%, GC pause p99 < 500ms +- post-incident: + - rollback 원인 RCA 작성 (배포 게이트 강화 필요 여부) + - heap dump 분석 결과 공유 + - JVM ergonomics(`-XX:MaxRAMPercentage`) 재검토 + - rollback 자동화 절차 점검 + +## 7. Related + +- error-codes.yaml rows: `INTERNAL_ERROR`, `INTERNAL_AUTH_MISCONFIGURATION`, `JVM_OOM` +- metrics.yaml: `jvm_memory_used_bytes`, `jvm_gc_pause_seconds`, `http_server_requests_seconds_count` +- 관련 branch: [[feature-operational-error-observability-foundation]], [[feature-container-runtime-contract]] + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 deploy 파이프라인·heap dump 보관 경로·rollback 자동화 명령으로 보강 필요. diff --git a/docs/runbooks/job-dead-letter.md b/docs/runbooks/job-dead-letter.md new file mode 100644 index 00000000..0dfdd840 --- /dev/null +++ b/docs/runbooks/job-dead-letter.md @@ -0,0 +1,67 @@ +--- +title: Runbook — background job dead letter +category: INTERNAL +error_codes: [JOB_DEAD_LETTER] +severity: P1 +owner: oncall +last_updated: 2026-06-13 +status: stub +--- + +# Runbook: background job dead letter (`runbook://job/dead-letter`) + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `job_dead_letter` +- alert payload 필수 field: `error.code=JOB_DEAD_LETTER`, `job_name`, `correlation_id`, `runbook_link` +- 임계: `job.dlq.total` > 0 for 5m (p1) — retry 소진 후 DLQ 진입은 자동 회복이 없으므로 점검 대상 + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. ERROR log에서 `JOB_DEAD_LETTER` 라인 확인: `job_name`, 최종 실패 원인 예외, `correlation_id` 추출 +2. `job.retry.total{outcome=EXHAUSTED}` 추이로 DLQ 유입 규모 파악 +3. DLQ 적재 위치(향후 retry carrier 확정 시 DB 테이블/큐) 확인 — 현재 skeleton은 vocabulary 단계 + +### Step 2 — 임시 격리 +- DLQ는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수 +- 비즈니스 크리티컬 job이면 §4의 수동 처분(재처리 또는 폐기)을 우선 수행 + +## 3. Diagnosis + +- log query: `{service="app"} | error.code="JOB_DEAD_LETTER" | stats count by job_name` +- metric panel: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome}` +- 최종 실패 원인 분류: + - poison input(직렬화/계약 위반) → 입력 결함, 재처리해도 실패 — 수정 후 재처리 또는 폐기 + - 외부 의존성 장기 outage 중 attempts 소진 → 의존성 회복 후 재처리로 해결 가능 + - non-transient error(권한/도메인/스키마)인데 retry된 경우 → 분류기 보강 필요(WAF-REL05-C3: 즉시 DLQ가 정답) + +## 4. Mitigation (수동 처분 — 둘 중 하나) + +- **재처리 (기본)**: 원인 해소 후 해당 job을 다시 enqueue. 소비자는 멱등(idempotencyKey dedupe) 의무가 있으므로 중복 처리 안전 +- **폐기 (영구)**: 작업이 더 이상 유효하지 않으면 DLQ에서 제거. ⚠ 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김 +- 장기: poison input 재발 방지(입력 계약 테스트 보강), non-transient error는 retry 없이 즉시 DLQ로 분류 + +## 5. Escalation + +- 처분 판단(재처리 vs 폐기)이 불가하면 해당 job의 비즈니스 오너에게 escalate +- DLQ 누적이 특정 `job_name`에 집중되면 해당 job 코드 오너에게 page + +## 6. Recovery / Verification + +- 회복 확인: `job.dlq.total` 증가 멈춤, 재처리분의 소비자 dedupe 동작 확인 +- post-incident: DLQ 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가 + +## 7. Related + +- error-codes.yaml rows: `JOB_DEAD_LETTER` (INTERNAL, 500, retryable=false) +- metrics.yaml: `job.dlq.total{job_name}`, `job.retry.total{job_name, outcome=DLQ}` +- 코드: `app-bootstrap` `async/BackgroundJobMetrics`(retry/DLQ vocabulary 기록 seam — D2/D4) +- 관련 runbook: [[job-executor-rejected]], [[job-timeout]], [[outbox-dead-letter]] +- 관련 branch: [[feature-background-job-async-contract]] (D4 retry/DLQ vocabulary SSOT — outbox/outbound가 consume) + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. retry carrier(Spring Retry / Resilience4j / 자체) 확정 후 DLQ 저장소·재처리 절차 보강 필요. diff --git a/docs/runbooks/job-executor-rejected.md b/docs/runbooks/job-executor-rejected.md new file mode 100644 index 00000000..c13db0d7 --- /dev/null +++ b/docs/runbooks/job-executor-rejected.md @@ -0,0 +1,71 @@ +--- +title: Runbook — async executor rejected +category: TRANSIENT_DEPENDENCY +error_codes: [JOB_EXECUTOR_REJECTED] +severity: P1 +owner: oncall +last_updated: 2026-06-13 +status: stub +--- + +# Runbook: async executor rejected (`runbook://job/executor-rejected`) + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `executor_rejected` +- alert payload 필수 field: `error.code=JOB_EXECUTOR_REJECTED`, `executor_name`, `policy`, `runbook_link` +- 임계: `executor.rejected.total` > 0 for 1m (p1) — bounded pool이 saturation으로 task를 거부 +- 보조 신호: `executor.saturation` gauge > queue capacity의 80% for 5m (p2) + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. ERROR log에서 `JOB_EXECUTOR_REJECTED` 라인 확인: `executor_name`, `policy=AbortPolicy`, `queue_size` 추출 +2. `executor.saturation` 패널에서 큐 점유율 추이 확인 — 일시적 burst인지 지속 saturation인지 판별 +3. 동시 유입 원인 파악: 신규 배포 / 트래픽 spike / 다운스트림 지연으로 worker가 장기 점유되는지 + +### Step 2 — 임시 격리 +- AbortPolicy 거부는 호출부에 `RejectedExecutionException`으로 surface됨 — fire-and-forget `@Async` 호출이면 호출부의 async-exception 처리(log/metric)로 흡수됐는지 확인 +- 지속 saturation이면 유입 측(트래픽/스케줄러 빈도)을 우선 감속 + +## 3. Diagnosis + +- log query: `{service="app"} | error.code="JOB_EXECUTOR_REJECTED" | stats count by executor_name` +- metric panel: `executor.saturation{executor_name}`, `executor.rejected.total{executor_name, policy}` +- 가능한 원인 우선순위: + - 다운스트림 의존성 지연 → worker가 반납되지 않아 큐 포화 (가장 흔함) + - 트래픽 spike → 정상 부하 한계 초과 + - pool 과소 설정 (`APP_ASYNC_EXECUTOR_*`) + - non-idempotent 작업이 retry로 누적 + +## 4. Mitigation + +- 단기: 유입 감속(상위 rate-limit / 스케줄러 interval 확대) 또는 다운스트림 의존성 회복 +- pool 재조정(restart-only): `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY` + — ⚠ queue를 무한정 키우지 말 것(unbounded 금지, D7). 부하테스트로 수치 검증 후 변경 +- CallerRunsPolicy로의 전환은 use-case 차원의 명시적 결정 필요(request thread latency 침식 — TPE-JDK21-C6) + +## 5. Escalation + +- 다운스트림 의존성 장애가 근본 원인이면 해당 의존성 오너에게 escalate +- pool 재조정으로도 saturation이 지속되면 용량 계획(capacity planning) 오너에게 page + +## 6. Recovery / Verification + +- 회복 확인: `executor.rejected.total` 증가 멈춤, `executor.saturation` < 80% 정상화 +- 거부된 작업의 재처리 경로(멱등 retry / 다음 스케줄 cycle) 정상 동작 확인 + +## 7. Related + +- error-codes.yaml rows: `JOB_EXECUTOR_REJECTED` (TRANSIENT_DEPENDENCY, 503, retryable=true, retry_after 5s) +- metrics.yaml: `executor.rejected.total{executor_name, policy}`, `executor.saturation{executor_name}` +- 코드: `app-bootstrap` `async/AsyncExecutorConfig`(bounded executor), `async/LoggingAbortPolicy`(reject log+metric), `async/BackgroundJobMetrics` +- env: `APP_ASYNC_EXECUTOR_CORE_SIZE` / `APP_ASYNC_EXECUTOR_MAX_SIZE` / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY` +- 관련 runbook: [[job-timeout]], [[job-dead-letter]] +- 관련 branch: [[feature-background-job-async-contract]] (D7 saturation policy) + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 부하 프로파일·alert 채널·pool 수치 확정 시 보강 필요. diff --git a/docs/runbooks/job-timeout.md b/docs/runbooks/job-timeout.md new file mode 100644 index 00000000..a474ab92 --- /dev/null +++ b/docs/runbooks/job-timeout.md @@ -0,0 +1,69 @@ +--- +title: Runbook — background job timeout +category: TRANSIENT_DEPENDENCY +error_codes: [JOB_TIMEOUT] +severity: P2 +owner: oncall +last_updated: 2026-06-13 +status: stub +--- + +# Runbook: background job timeout (`runbook://job/timeout`) + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `job_timeout` +- alert payload 필수 field: `error.code=JOB_TIMEOUT`, `job_name`, `correlation_id`, `runbook_link` +- 임계: `job.retry.total{outcome=RETRY}` 급증 또는 graceful-shutdown 중 in-flight job interrupt 발생 +- 연관: shutdown phase에서 19s await 초과로 interrupt된 job (D8) + +## 2. First Response (10분 이내) + +### Step 1 — 확인 +1. ERROR log에서 `JOB_TIMEOUT` 라인 확인: `job_name`, 마지막 단계, 소요 시간 추출 +2. timeout이 정상 실행 중 발생인지, graceful-shutdown(배포/스케일다운) 중 interrupt인지 구분 +3. 해당 job이 멱등(retry-on-next-cycle 안전)인지 확인 — 비멱등이면 §4에서 신중히 처리 + +### Step 2 — 임시 격리 +- shutdown 중 interrupt면: 다음 기동 시 재시도 대상인지(멱등 전제) 확인, 중복 부작용 여부 점검 +- 정상 실행 중 timeout이면: 해당 job의 외부 의존성(DB/HTTP) 지연 여부 확인 + +## 3. Diagnosis + +- log query: `{service="app"} | error.code="JOB_TIMEOUT" | stats count by job_name` +- metric panel: `job.retry.total{job_name, outcome}` +- 가능한 원인 우선순위: + - 외부 의존성(DB lock / 느린 HTTP) 지연으로 job p99 상승 + - job 작업량 증가로 단일 cycle이 19s 예산 초과 (D8 — interrupt 노출) + - interrupt 미반응 blocking call(JDBC 등) → awaitTermination 초과 (K8S-POD-LC-C2 SIGKILL 경로) + +## 4. Mitigation + +- 단기: 의존성 회복 / job 입력 배치 크기 축소 +- job p99가 구조적으로 19s를 넘으면: 작업을 분할하거나, grace period 연장 검토(parent project 운영 계약 소유자 승인 필요 — OUT_OF_BRANCH_SCOPE) +- 비멱등 job이 재시도로 중복 부작용을 내면 멱등키/dedupe 도입 우선 + +## 5. Escalation + +- 의존성 지연이 근본 원인이면 해당 의존성 오너에게 escalate +- shutdown 예산(20s) vs k8s `terminationGracePeriodSeconds`(30s) 정합 이슈면 플랫폼/런타임 오너에게 escalate + +## 6. Recovery / Verification + +- 회복 확인: `JOB_TIMEOUT` 신규 발생 멈춤, `job.retry.total{outcome=SUCCESS}` 정상 비율 회복 +- 멱등 재시도분의 부작용 중복 없음 확인 + +## 7. Related + +- error-codes.yaml rows: `JOB_TIMEOUT` (TRANSIENT_DEPENDENCY, 500, retryable=true, retry_after 10s) +- metrics.yaml: `job.retry.total{job_name, outcome}` +- 코드: `app-bootstrap` `async/AsyncExecutorConfig`(awaitTermination 19s — D8 graceful shutdown) +- env: `APP_SERVER_SHUTDOWN_TIMEOUT`(owner: feature-env-driven-runtime-configuration D2) +- 관련 runbook: [[job-executor-rejected]], [[job-dead-letter]] +- 관련 branch: [[feature-background-job-async-contract]] (D4 retry / D8 shutdown) + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 retry carrier·job p99·shutdown 예산 확정 시 보강 필요. diff --git a/docs/runbooks/lock-acquisition-timeout.md b/docs/runbooks/lock-acquisition-timeout.md new file mode 100644 index 00000000..ea42c774 --- /dev/null +++ b/docs/runbooks/lock-acquisition-timeout.md @@ -0,0 +1,34 @@ +--- +title: Runbook — LOCK_ACQUISITION_TIMEOUT (분산 락 획득 타임아웃) +category: CONFLICT +error_codes: [LOCK_ACQUISITION_TIMEOUT] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: LOCK_ACQUISITION_TIMEOUT (`runbook://lock/acquisition-timeout`) + +## Symptoms + +- HTTP 409 with `error.code=LOCK_ACQUISITION_TIMEOUT` +- Distributed lock wait exceeded configured timeout; high contention on a resource + +## Diagnosis + +- Check `lock.acquisition` metric for lock name and duration +- Identify lock holders (check DB `integration_lock` table) + +## Action + +- Client should retry with backoff (retryable=true) +- Optimize critical section holding time if lock contention is systemic + +## Escalation + +- Escalate if lock holder appears stuck (potential deadlock in distributed lock) + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/management-actuator-forbidden.md b/docs/runbooks/management-actuator-forbidden.md new file mode 100644 index 00000000..3aa96f73 --- /dev/null +++ b/docs/runbooks/management-actuator-forbidden.md @@ -0,0 +1,34 @@ +--- +title: Runbook — ACTUATOR_FORBIDDEN (Actuator 접근 거부) +category: AUTHZ +error_codes: [ACTUATOR_FORBIDDEN] +severity: P2 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: ACTUATOR_FORBIDDEN (`runbook://management/actuator-forbidden`) + +## Symptoms + +- HTTP 403 with `error.code=ACTUATOR_FORBIDDEN` +- Attempt to access restricted actuator endpoint (env/configprops/heapdump/shutdown) + +## Diagnosis + +- Identify which actuator endpoint was accessed +- Check caller identity (internal tooling vs external) + +## Action + +- Verify management port is not exposed externally +- For heapdump/threaddump: follow break-glass runbook procedure + +## Escalation + +- P1 if forbidden actuator access appears to be external attack + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/migration-failed.md b/docs/runbooks/migration-failed.md new file mode 100644 index 00000000..d33b6f9e --- /dev/null +++ b/docs/runbooks/migration-failed.md @@ -0,0 +1,35 @@ +--- +title: Runbook — MIGRATION_FAILED (DB 마이그레이션 실패) +category: INTERNAL +error_codes: [MIGRATION_FAILED] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: MIGRATION_FAILED (`runbook://migration/failed`) + +## Symptoms + +- Container exits with code 70 (migration failure exit) +- Structured log with `error.code=MIGRATION_FAILED`, `startup.phase=migration` +- App refuses to start (fail-fast) + +## Diagnosis + +- Check Flyway migration log for which script failed and why +- Review latest migration script for SQL errors + +## Action + +- Fix migration script or roll back to previous migration version +- Run migration manually in repair mode if checksum mismatch + +## Escalation + +- P1 immediate: app cannot start until migration is resolved + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/outbox-dead-letter.md b/docs/runbooks/outbox-dead-letter.md new file mode 100644 index 00000000..5b137700 --- /dev/null +++ b/docs/runbooks/outbox-dead-letter.md @@ -0,0 +1,73 @@ +--- +title: Runbook — outbox dead letter +category: INTERNAL +error_codes: [OUTBOX_DEAD_LETTER] +severity: P1 +owner: oncall +last_updated: 2026-06-11 +status: stub +--- + +# Runbook: outbox dead letter (`runbook://outbox/dead-letter`) + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `outbox_dead_letter` +- alert payload 필수 field: `error.code=OUTBOX_DEAD_LETTER`, `event_type`, `event_id`, `correlation_id`, `runbook_link` +- 임계: `outbox.publisher.published.total{outcome=DEAD}` > 0 (DEAD 전이는 자동 회복이 없으므로 단건도 점검 대상) + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. ERROR log에서 `OUTBOX_DEAD_LETTER` 라인 확인: `event_id`, `event_type`, `correlation_id`, 마지막 실패 원인 예외 추출 +2. DB에서 DEAD row 확인: `SELECT * FROM outbox_event WHERE status = 'DEAD' ORDER BY occurred_at;` +3. **차단 영향 파악 (중요)**: strict per-aggregate FIFO 정책상 DEAD row는 같은 `aggregate_id`의 후행 이벤트를 계속 차단함 — + `SELECT count(*) FROM outbox_event b WHERE b.status <> 'PUBLISHED' AND EXISTS (SELECT 1 FROM outbox_event d WHERE d.status='DEAD' AND d.aggregate_id=b.aggregate_id AND d.occurred_at < b.occurred_at);` + +### Step 2 — 임시 격리 +- DEAD는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수 +- 차단된 aggregate가 비즈니스 크리티컬하면 아래 §4의 수동 처분(재발행 또는 skip)을 우선 수행 + +## 3. Diagnosis + +- log query: `{service="app"} | error.code="OUTBOX_DEAD_LETTER" | stats count by event_type` +- 마지막 실패 원인 분류: + - poison event (payload 직렬화/계약 위반) → payload 자체 결함, 재발행해도 실패 — 수정 후 재발행 또는 skip + - broker 장기 outage 중 attempts 소진 → broker 회복 후 재발행으로 해결 가능 + - 구성 오류 (Kafka disabled 상태에서 producer 활성) → 구성 수정 후 재발행 +- 가능한 원인 우선순위: 구성 오류 > broker outage > poison payload + +## 4. Mitigation (수동 처분 — 둘 중 하나) + +- **재발행 (기본)**: 원인 해소 후 해당 row를 다시 claim 가능 상태로 되돌림 — + `UPDATE outbox_event SET status = 'PENDING', attempt_count = 0, next_attempt_at = now() WHERE event_id = '' AND status = 'DEAD';` + (consumer는 at-least-once + idempotencyKey dedupe 의무가 있으므로 중복 발행은 안전) +- **skip (영구 폐기)**: 이벤트가 더 이상 유효하지 않으면 PUBLISHED로 마킹해 FIFO 차단을 해제 — + `UPDATE outbox_event SET status = 'PUBLISHED' WHERE event_id = '' AND status = 'DEAD';` + ⚠️ skip은 다운스트림에 영구 이벤트 갭을 만든다 — 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김 +- 장기: poison event 재발 방지(payload 계약 테스트 보강), DEAD 빈발 event_type의 producer 검증 강화 + +## 5. Escalation + +- 처분 판단(재발행 vs skip)이 불가하면 해당 이벤트의 비즈니스 오너에게 escalate +- DEAD 누적이 특정 event_type에 집중되면 producer 코드 오너에게 page + +## 6. Recovery / Verification + +- 회복 확인: `SELECT count(*) FROM outbox_event WHERE status='DEAD';` = 0, 차단됐던 aggregate의 후행 이벤트가 PUBLISHED로 전이 +- `outbox.publisher.lag` 정상화(< 60s), 재발행분의 consumer dedupe 동작 확인 +- post-incident: DEAD 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가 + +## 7. Related + +- error-codes.yaml rows: `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false) +- metrics.yaml: `outbox.publisher.published.total{outcome=DEAD}`, `outbox.pending.size{status=DEAD}`, `outbox.publisher.lag` +- 코드: `application-core` `PublishPendingOutboxEventsUseCase`(FAILED→DEAD 전이), `adapter-persistence` `outbox/OutboxEventJpaRepository`(FIFO 게이트 — DEAD가 후행 차단) +- 관련 runbook: [[outbox-publish-failed]] +- 관련 branch: [[feature-domain-event-outbox-contract]], [[feature-background-job-async-contract]] (max attempts/DLQ vocabulary SSOT) + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 broker·DLQ 토픽·승인 체계 확정 시 보강 필요. diff --git a/docs/runbooks/outbox-publish-failed.md b/docs/runbooks/outbox-publish-failed.md new file mode 100644 index 00000000..06c14dea --- /dev/null +++ b/docs/runbooks/outbox-publish-failed.md @@ -0,0 +1,77 @@ +--- +title: Runbook — outbox publish 일시 실패 +category: TRANSIENT_DEPENDENCY +error_codes: [OUTBOX_PUBLISH_FAILED] +severity: P2 +owner: oncall +last_updated: 2026-06-11 +status: stub +--- + +# Runbook: outbox publish 일시 실패 (`runbook://outbox/publish-failed`) + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `outbox_publish_failed_rate` 또는 `outbox_publisher_lag` +- alert payload 필수 field: `error.code=OUTBOX_PUBLISH_FAILED`, `event_type`, `correlation_id`, `runbook_link` +- 임계 (metrics.yaml verbatim): + - P2: `outbox.publisher.published.total{outcome=FAILED}` rate > 1% for 10m + - P2: `outbox.publisher.lag` > 60s for 10m / P1: > 300s for 5m + - P2: `outbox.pending.size{status=PENDING}` growing for 10m + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출 +2. broker(기본 Kafka adapter) 상태 확인: `APP_MESSAGING_KAFKA_ENABLED` 값과 broker endpoint 가용성 + - Kafka disabled(default) 상태에서 outbox 이벤트가 append 되고 있으면 publish 경로가 `AdapterDisabledException`으로 전부 실패하는 구성 오류 — 이 경우 producer use case 쪽 활성화/구성을 먼저 의심 +3. `outbox.pending.size` status 분포 확인 (FAILED 누적 vs PENDING 누적) + +### Step 2 — 임시 격리 +- 일시 실패는 자동 backoff 재시도(30s × 2^(attempt-1) + jitter, max attempts 3)가 동작 — 즉시 수동 개입 불필요 +- broker 장기 다운이면 DEAD 전이 누적 전에 broker 회복을 우선 (max attempts 소진 시 `runbook://outbox/dead-letter`로 이관) +- relay 자체를 멈춰야 하면 `ca-skeleton.outbox.relay-enabled=false`로 스케줄러 비활성 (이벤트는 outbox 테이블에 안전하게 보존됨 — 유실 없음) + +## 3. Diagnosis + +- log query: `{service="app"} | error.code="OUTBOX_PUBLISH_FAILED" | stats count by event_type` +- metric panel: + - `outbox.publisher.published.total{outcome}` — FAILED 비율 + - `outbox.publisher.lag{event_type}` — 최고령 미발행 이벤트 age + - `outbox.pending.size{status}` — 상태별 분포 +- DB 확인: `SELECT status, count(*) FROM outbox_event GROUP BY status;` +- 가능한 원인: + - broker outage/네트워크 → broker 측 회복 대기 + - Kafka adapter 미구성(enabled인데 brokers 누락은 기동 시 차단됨) / disabled 상태에서 producer 활성화 + - poison event (직렬화 불가/payload 계약 위반) → 재시도 무의미, attempts 소진 후 DEAD로 흘러감 (의도된 동작) + - 동일 aggregate head 실패로 후행 이벤트가 FIFO 게이트에 차단되어 lag 증가 (strict per-aggregate FIFO — 설계 의도) + +## 4. Mitigation + +- 단기: broker 회복 후 backoff 만료 시 자동 재발행 — `outcome=PUBLISHED` 회복 확인 +- IN_FLIGHT orphan(claim 후 crash)은 in-flight-timeout(기본 PT5M) 경과 후 자동 재claim — at-least-once이므로 중복 발행 가능, consumer dedupe(idempotencyKey)가 흡수 +- 장기: `ca-skeleton.outbox.poll-interval`/`batch-size` 조정, broker 가용성 SLA 점검, 빈발 event_type의 payload 계약 검토 + +## 5. Escalation + +- P1 lag(>300s 5m) 지속 + broker 회복 불가면 broker/infra 팀에 page +- DEAD 전이가 발생하기 시작하면 `runbook://outbox/dead-letter` 절차로 이관 + +## 6. Recovery / Verification + +- 회복 확인 metric: `outcome=FAILED` rate < 1% 10분 지속, `outbox.publisher.lag` < 60s, `outbox.pending.size{status=FAILED}` 감소 추세 +- post-incident: 실패 구간의 DEAD row 유무 확인, consumer 측 중복 처리량 확인(dedupe 동작 검증), backoff/attempts 상수 재평가 + +## 7. Related + +- error-codes.yaml rows: `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after 30s) +- metrics.yaml: `outbox.publisher.published.total`, `outbox.publisher.lag`, `outbox.pending.size` +- 코드: `application-core` `PublishPendingOutboxEventsUseCase`(상태머신), `adapter-persistence` `outbox/OutboxEventJpaRepository`(SKIP LOCKED claim + FIFO 게이트), `adapter-outbound` `messaging/outbox/KafkaOutboxMessagePublishAdapter`(fail-closed) +- 관련 runbook: [[outbox-dead-letter]] +- 관련 branch: [[feature-domain-event-outbox-contract]], [[feature-background-job-async-contract]] (retry/DLQ vocabulary SSOT) + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 broker 채택·alert 라우팅·대시보드 링크 확정 시 보강 필요. diff --git a/docs/runbooks/rate-limit-exceeded.md b/docs/runbooks/rate-limit-exceeded.md new file mode 100644 index 00000000..e3be402d --- /dev/null +++ b/docs/runbooks/rate-limit-exceeded.md @@ -0,0 +1,73 @@ +--- +title: Runbook — Rate limit 초과 spike +category: RATE_LIMIT +error_codes: [RATE_LIMIT_EXCEEDED, IDEMPOTENT_IN_FLIGHT] +severity: P3 +owner: oncall +last_updated: 2026-05-22 +status: stub +--- + +# Runbook: Rate limit 초과 spike + +## 1. Trigger + +이 runbook은 다음 alert에서 발동됩니다. + +- alert name: `rate_limit_429_high` +- alert payload 필수 field: `operation`, `error.code`, `error.category`, `rate_limit_key_type`(ip|principal|tenant), `runbook_link` +- 임계: + - P3: 429 rate > 1% 10분 지속 (일상적 abuse 차단 효과 정상) + - P2 격상: 정상 client(known principal/tenant)에서 spike 또는 spike와 함께 5xx 동반 + +## 2. First Response (5분 이내) + +### Step 1 — 확인 +1. rate-limit key 분포 확인: IP/principal/tenant 중 어디서 spike가 발생했는지 + - log query: `error.code=RATE_LIMIT_EXCEEDED | stats count by rate_limit_key_type, rate_limit_key` +2. top-N offending key 추출 (상위 10건) +3. 정상 client 식별 — 알려진 partner/internal service면 P2 격상 + +### Step 2 — 임시 격리 +- abuse traffic 패턴이면 WAF/gateway에서 IP block (geo, ASN 단위) +- IDEMPOTENT_IN_FLIGHT 다발이면 client의 retry-storm 의심 → client에 retry-after 협조 요청 + +## 3. Diagnosis + +- log query: `{service="gateway"} | error.code="RATE_LIMIT_EXCEEDED" | stats count by rate_limit_key` +- metric panel: `gateway_rate_limit_dropped_total`, `gateway_rate_limit_bucket_utilization` +- trace: 429 응답의 `Retry-After` 헤더 값, `rate_limit_remaining` header 확인 +- 가능한 원인: + - abuse / bot traffic → IP/ASN block + - 정상 client의 traffic 증가 (캠페인, 신규 feature) → limit 일시 상향 + - retry-storm (client backoff 미적용) → client에 idempotency-key + exponential backoff 권고 + - limit 설정 오류 (잘못된 정량 threshold) → config rollback + +## 4. Mitigation + +- 단기: abuse면 IP/ASN block, 정상 client면 해당 key의 limit 일시 상향(예: 2x, 1시간 TTL) +- IDEMPOTENT_IN_FLIGHT 다발: idempotency-key 정책 점검, client 협조 요청 +- 장기: limit 정책을 tenant tier별 차등으로 재설계, abuse pattern detection 자동화 + +## 5. Escalation + +- 다음 on-call로 page: 30분 내 정상 client 회복 안 되면 product team 통보 +- 정상 client에 SLO 위반 가능성 있으면 CSM/계정담당 통보 + +## 6. Recovery / Verification + +- 회복 확인 metric: 429 rate < 0.5% 10분 지속, 정상 client의 success rate 정상화 +- post-incident: + - 일시 상향한 limit 원복 (TTL 만료 확인) + - abuse pattern을 detection rule에 영구 등록 + - retry-storm이면 client SDK 가이드 보완 + +## 7. Related + +- error-codes.yaml rows: `RATE_LIMIT_EXCEEDED`, `IDEMPOTENT_IN_FLIGHT` +- metrics.yaml: `gateway_rate_limit_dropped_total`, `gateway_rate_limit_bucket_utilization` +- 관련 branch: [[feature-rate-limit-idempotency-contract]] + +--- + +> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 도메인 도입 시 실제 gateway 제품(NGINX/Envoy/Kong 등)·tenant tier 정책·WAF 연동 절차로 보강 필요. diff --git a/docs/runbooks/runtime-jvm-oom.md b/docs/runbooks/runtime-jvm-oom.md new file mode 100644 index 00000000..b8210da6 --- /dev/null +++ b/docs/runbooks/runtime-jvm-oom.md @@ -0,0 +1,36 @@ +--- +title: Runbook — JVM_OOM (JVM OutOfMemoryError) +category: INTERNAL +error_codes: [JVM_OOM] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: JVM_OOM (`runbook://runtime/jvm-oom`) + +## Symptoms + +- Container exits with code 137 (ExitOnOutOfMemoryError triggered) +- Structured log entry with `error.code=JVM_OOM` before exit + +## Diagnosis + +- Check heap dump if `-XX:HeapDumpOnOutOfMemoryError` is configured +- Review memory usage trends before crash +- Check for memory leaks: large cache growth, unbounded lists, session accumulation + +## Action + +- Restart container immediately (k8s will auto-restart with liveness probe) +- If recurring: increase heap `-Xmx` or fix memory leak + +## Escalation + +- P1: immediate if multiple pods crashing simultaneously +- Page SRE / infra team for heap analysis + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/startup-profile-mismatch.md b/docs/runbooks/startup-profile-mismatch.md new file mode 100644 index 00000000..6fa27e8b --- /dev/null +++ b/docs/runbooks/startup-profile-mismatch.md @@ -0,0 +1,35 @@ +--- +title: Runbook — PROFILE_MISMATCH (프로파일 불일치) +category: INTERNAL +error_codes: [PROFILE_MISMATCH] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: PROFILE_MISMATCH (`runbook://startup/profile-mismatch`) + +## Symptoms + +- Container exits with code 71 (profile mismatch exit) +- Structured log with `error.code=PROFILE_MISMATCH`, `startup.phase=profile-check` +- Production profile active with local-only settings enabled + +## Diagnosis + +- Check active Spring profiles (`spring.profiles.active`) +- Identify which local-only setting is incorrectly enabled in prod profile + +## Action + +- Remove local-only setting from production deployment config +- Ensure prod profile does not inherit local/dev profile settings + +## Escalation + +- P1: security risk if local settings expose debug endpoints in production + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/startup-required-adapter-disabled.md b/docs/runbooks/startup-required-adapter-disabled.md new file mode 100644 index 00000000..341aa0cb --- /dev/null +++ b/docs/runbooks/startup-required-adapter-disabled.md @@ -0,0 +1,34 @@ +--- +title: Runbook — REQUIRED_ADAPTER_DISABLED (필수 어댑터 비활성화) +category: INTERNAL +error_codes: [REQUIRED_ADAPTER_DISABLED] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: REQUIRED_ADAPTER_DISABLED (`runbook://startup/required-adapter-disabled`) + +## Symptoms + +- Container exits with code 72 (required adapter disabled exit) +- Structured log with `error.code=REQUIRED_ADAPTER_DISABLED`, `startup.phase=adapter-check` + +## Diagnosis + +- Identify which adapter is disabled but required +- Check adapter enable flags in environment config + +## Action + +- Enable required adapter in deployment configuration +- If adapter is intentionally disabled, update the required/optional designation + +## Escalation + +- P1: app cannot start; coordinate with deployment team + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/startup-validation-failed.md b/docs/runbooks/startup-validation-failed.md new file mode 100644 index 00000000..fbeb5e8a --- /dev/null +++ b/docs/runbooks/startup-validation-failed.md @@ -0,0 +1,34 @@ +--- +title: Runbook — STARTUP_VALIDATION_FAILED (환경 변수 검증 실패) +category: INTERNAL +error_codes: [STARTUP_VALIDATION_FAILED] +severity: P1 +owner: oncall +last_updated: 2026-06-15 +status: stub +--- + +# Runbook: STARTUP_VALIDATION_FAILED (`runbook://startup/validation-failed`) + +## Symptoms + +- Container exits with code 78 (env validation failure exit) +- Structured log with `error.code=STARTUP_VALIDATION_FAILED`, `startup.phase=env-validation` + +## Diagnosis + +- Check which required env variable is missing or malformed +- Review container environment and secrets injection + +## Action + +- Supply missing environment variables to deployment +- Verify secrets are correctly mounted / injected + +## Escalation + +- P1: app cannot start; coordinate with deployment/secrets team + +--- + +> **Stub**: Phase D2 — author body after domain adoption. (feature-operational-runbook-contract D9) diff --git a/docs/runbooks/template.md b/docs/runbooks/template.md new file mode 100644 index 00000000..284ce7c4 --- /dev/null +++ b/docs/runbooks/template.md @@ -0,0 +1,42 @@ +--- +title: Runbook — +category: <CATEGORY> +error_codes: [<ERROR_CODE_1>, <ERROR_CODE_2>] +severity: <P1|P2|P3> +owner: oncall +last_updated: <YYYY-MM-DD> +status: <stub|active> +--- + +# Runbook: <TITLE> (`runbook://<area>/<scenario>`) + +## Symptoms + +- What observable signals trigger this runbook? +- Alert name, metric thresholds, log patterns + +## Diagnosis + +- Step-by-step diagnostic commands and queries +- Log queries (Loki/CloudWatch) +- Metric panels to check +- Trace investigation approach + +## Action + +- Immediate mitigation steps +- Configuration changes +- Manual intervention procedures + +## Escalation + +- Conditions for severity upgrade (e.g., P2 → P1) +- Who to page and when +- Fallback procedures if on-call cannot resolve + +--- + +> **Note**: This is the canonical runbook template. +> Copy this file, rename it to match the `runbook://area/scenario` pattern (→ `area-scenario.md`), +> fill in the frontmatter fields, replace section bodies with operational content, +> then set `status: active` and remove from `STUB_ALLOWLIST` in `RunbookCoverageContractTest`. diff --git a/docs/superpowers/plans/2026-07-20-harness-policy-engine.md b/docs/superpowers/plans/2026-07-20-harness-policy-engine.md new file mode 100644 index 00000000..55c3f2a3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-harness-policy-engine.md @@ -0,0 +1,127 @@ +# Harness Policy Engine Implementation Plan + +> **Spec:** `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md` + +**Goal:** Replace topology- and platform-specific duplicated harness rules with a registry, +strict evidence validators, generated platform variants, and risk-based review policies. + +**Working policy:** human-only commits. Each task leaves changes in the working tree. + +## Task 1 — Registry, resolver, and Gradle SSOT + +**Files:** + +- Add `.harness/project/modules.yaml` +- Add `.harness/lib/module_registry.py` +- Add `.harness/validators/validate_modules.py` +- Add `.harness/tests/test_module_registry.py` +- Modify `src/settings.gradle` +- Modify the dependency-verifier section of `src/build.gradle` + +**Steps:** + +- [ ] Write failing tests for 19-leaf loading, nested owner resolution, nearest `CLAUDE.md`, + unknown paths, and settings/registry parity. +- [ ] Add the registry and stdlib loader/resolver. +- [ ] Make Gradle settings and dependency verification consume registry data. +- [ ] Run Python tests and `./gradlew projects verifyCleanArchitectureDependencies`. + +## Task 2 — Registry-driven import gate and mutation suite + +**Files:** + +- Modify `.claude/hooks/ca_import_gate.py` +- Modify `.claude/hooks/test_ca_import_gate.py` +- Add `.harness/tests/test_import_gate_mutations.py` + +**Steps:** + +- [ ] Add failing real-path tests for every registered production module. +- [ ] Replace flat-path regex/prefix rules with registry owner and role policy. +- [ ] Normalize Claude snake_case and Antigravity camelCase tool events. +- [ ] Fail closed on malformed in-scope events and marker failures. +- [ ] Run all import-gate tests. + +## Task 3 — Verdict schema, evidence artifacts, and platform adapters + +**Files:** + +- Add `.harness/schemas/verdict.schema.json` +- Add `.harness/schemas/evidence.schema.json` +- Add `.harness/lib/verdict.py` +- Add `.harness/validators/validate_verdict.py` +- Add `.harness/validators/validate_evidence.py` +- Add `.harness/adapters/antigravity_hook.py` +- Add `.harness/tests/test_verdict.py` +- Modify `.claude/hooks/ca_verdict_gate.py` +- Modify `.claude/hooks/test_ca_verdict_gate.py` +- Add `.agents/plugins/ca-superpowers/hooks.json` + +**Steps:** + +- [ ] Write negative tests for missing required enums, negative counts, Gradle arithmetic, + behavior change without red, missing upstream artifacts, malformed input, and revision + mismatch. +- [ ] Implement strict validation and evidence recording with source/diff hashes. +- [ ] Adapt Claude fenced verdicts to the common model. +- [ ] Add Antigravity Stop/pre-tool adapter and plugin hook wiring. +- [ ] Run validator, adapter, and JSON syntax tests. + +## Task 4 — Canonical agents and deterministic rendering + +**Files:** + +- Add `.harness/agents/*.md` +- Add `.harness/project/platforms.yaml` +- Add `.harness/generators/render_agents.py` +- Add `.harness/tests/test_platform_parity.py` +- Regenerate `.claude/agents/*`, `.codex/agents/*.toml`, `.agents/agents/*/agent.json` +- Update `.agents/plugins/ca-superpowers/README.md` and `plugin.json` +- Update `.codex/agents/README.md` + +**Steps:** + +- [ ] Seed canonical sources from the newest human-only Claude policy, then update module + discovery and runner validation to use the registry. +- [ ] Add generated metadata and stable output ordering. +- [ ] Render all variants and add a `--check` parity mode. +- [ ] Assert commit policy, source hashes, tool permissions, and body parity in tests. + +## Task 5 — Risk/profile policies and guidance drift cleanup + +**Files:** + +- Add `.harness/manifest.yaml` +- Add `.harness/core/risk-policy.yaml`, `.harness/core/evidence-policy.yaml` +- Add current architecture/language/build/framework/capability profile files +- Add `.harness/validators/resolve_task.py` and tests +- Modify `AGENTS.md`, root `CLAUDE.md`, clean-architecture rule, workflow skill, + advisory-depth rule, reporting-standards rule, and plugin README +- Modify stale module `CLAUDE.md` files and add missing leaf-module guidance where useful + +**Steps:** + +- [ ] Add failing task-classification tests for high-risk one-file changes and low-risk + multi-file fixture/docs changes. +- [ ] Implement profile resolution. +- [ ] Replace `N!`, routine all-quote grep, file-count report split, and unconditional + counterargument policies with the design profiles. +- [ ] Replace flat module documentation and focused commands with registry-backed nested names. +- [ ] Run policy grep assertions and harness tests. + +## Task 6 — Full review and verification + +- [ ] Run harness unit/mutation/parity suite. +- [ ] Run `./gradlew projects` and `./gradlew verifyCleanArchitectureDependencies`. +- [ ] Run the focused ArchUnit suite. +- [ ] Run `./gradlew check`. +- [ ] Audit the working-tree diff in order: architecture → spec → quality. +- [ ] Fix findings and restart the review chain, up to three loops. + +## Task 7 — LLM Wiki capture + +- [ ] Read the LLM Wiki authority and branch-note template. +- [ ] Update/create the detached-HEAD branch note with implementation decisions, changed files, + verification evidence, failures, and open risks. +- [ ] Create/link derived error, interview, or blog-topic raw notes only when supported by the + completed work; otherwise record an explicit “none” judgment in the branch note. diff --git a/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md b/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md new file mode 100644 index 00000000..abab7270 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md @@ -0,0 +1,187 @@ +# Harness Policy Engine Refactoring Design + +- **Date:** 2026-07-20 +- **Status:** Approved by user request +- **Scope:** repository-local development harness (`.harness`, `.agents`, `.claude`, `.codex`, root/module guidance, Gradle module registry integration) +- **Source:** user-provided “개발 하네스 분석·리뷰” plus repository evidence gathered on 2026-07-20 + +## 1. Problem Statement + +The repository now has 19 nested Gradle leaf modules, but the write-time import gate, +agent prompts, runner allowlist, and root guidance still contain parts of the previous flat +module topology. Platform variants are copied manually, so commit policy and orchestration +already differ between Claude, Codex, and Antigravity. Verdict validation checks a text +summary but does not consistently require enum fields, non-negative counts, or arithmetic +balance. + +The harness must move from duplicated platform prompts to a small policy engine with one +project manifest, deterministic renderers, strict validators, and platform adapters. + +## 2. Goals + +1. Make the actual nested Gradle topology a single machine-readable source of truth. +2. Resolve a touched file to its nearest owning leaf module without assuming `src/<module>`. +3. Generate write-time import policy and focused Gradle task validation from that registry. +4. Validate machine verdicts with required fields, non-negative integers, arithmetic rules, + upstream evidence, revision identity, and TDD red evidence for behavior changes. +5. Materialize validated evidence as JSON artifacts that platform hooks can share. +6. Render Claude, Codex, and Antigravity agent variants from one canonical source and fail + parity checks when generated files drift. +7. Use one human-only commit policy on every platform. +8. Replace file-count and exhaustive-report rules with risk and review profiles. +9. Add mutation and cross-platform static parity tests. + +## 3. Non-Goals + +- This change does not run authenticated end-to-end golden tasks inside all three external + products. It supplies the deterministic fixtures and validators those runs will consume. +- It does not add application features or alter production Java behavior. +- It does not require PyYAML, jsonschema, Pydantic, or another runtime dependency. Harness + data files use JSON syntax, which is valid YAML, and validators use Python stdlib only. +- It does not make natural-language agent self-reports authoritative. Hooks convert accepted + reports into evidence artifacts; validators remain authoritative. + +## 4. Architecture + +```text +.harness/project/modules.yaml ──┬── Gradle settings/includes + ├── Gradle dependency verification + ├── owning-module resolver + ├── import gate + └── Gradle command validator + +.harness/agents/*.md + platforms.yaml + └── render_agents.py + ├── .claude/agents/*.md + ├── .codex/agents/*.toml + └── .agents/agents/*/agent.json + +Claude hook ───────────────┐ +Antigravity hook adapter ──┼── verdict validator ── evidence JSON +Codex validation command ──┘ +``` + +### 4.1 Project registry + +`modules.yaml` contains, per leaf module: + +- stable module id +- repository-relative source path +- Gradle path +- role +- Java package roots (informational and import-policy lookup only) +- allowed project dependencies +- focused test command +- profiles/capabilities +- owning `CLAUDE.md` when present +- an intentional mutation import used by gate tests + +`src/settings.gradle` reads the registry to declare projects. The +`verifyCleanArchitectureDependencies` task reads the same registry instead of maintaining a +second dependency map. + +### 4.2 Owning-module resolution + +Owner selection uses the longest filesystem-boundary match among registered leaf source +paths. Package prefixes never decide ownership because `support` owns a broad +`dev.caskeleton.adapter.outbound` package and sample code mirrors production packages. + +Instruction discovery walks upward from the touched file and returns the nearest +`CLAUDE.md`; if a leaf has none, root `CLAUDE.md` and `AGENTS.md` are the explicit fallback. + +### 4.3 Import gate + +The import gate first resolves the registered leaf module, then applies: + +- dependency-derived sibling module isolation +- role-specific framework rules for domain, application, inbound, outbound, persistence, + identifier, shared-contract, bootstrap, and sample roles +- global unsafe-pattern checks + +All registered production modules receive a mutation test using their real nested source +path. Sample-only exemptions are explicit registry data, not accidental regex misses. + +### 4.4 Verdict and evidence + +The canonical verdict schema requires `agent`, `verdict`, `task_id`, `revision`, and agent +specific evidence. Non-blocked verdicts require every declared field. Counts are non-negative. +Required equations include: + +- spec totals balance +- Gradle `run = passed + failed + skipped` +- ready Gradle results include at least one command and no failed command +- behavior-changing implementation requires at least one observed red test +- quality-ready references validated architecture and spec artifacts + +Claude's fenced `ca-verdict` remains a compatibility input, but its accepted form is converted +to the same JSON evidence model. Missing or malformed payloads for detected CA agents fail +closed. Evidence records include a source-message hash and current revision/diff identity. + +### 4.5 Platform rendering and hook adapters + +Canonical agent Markdown lives under `.harness/agents/`; platform metadata lives in +`.harness/project/platforms.yaml`. Generated files carry `generated_from`, `source_hash`, +`generator_version`, and `do_not_edit` metadata. + +Antigravity gains a plugin `hooks.json` and a platform adapter using the documented camelCase +stdin/stdout contract. Claude keeps its native hook entry points but calls the common library. +Codex variants instruct the runner/reviewer to invoke the common validation command because +the repository has no equivalent local lifecycle-hook registration surface. + +### 4.6 Risk and review profiles + +Risk is determined by change surface, not file count: + +- high: security, migration/schema, public contract, module dependency, architecture rule, + transaction/concurrency, CI/deployment +- medium: behavior, multiple modules, external integration +- low: docs/comments, test fixture, local refactor protected by characterization tests + +Review profiles: + +- `review-lite`: direct diff references; no saved report by default +- `review-standard`: verify blocking citations; one report only when risk or findings justify it +- `audit-deep`: verify all quotes and persist detailed findings +- `regulated`: immutable evidence and full traceability + +Option analysis uses a dependency DAG and at most 3–5 materially distinct alternatives. +Counterarguments are required for judgment-dependent findings, not deterministic failures. + +## 5. Commit Policy + +All platforms use `human-only`. Implementers never stage or commit. Reviewers may inspect a +working-tree diff before commit or an explicit immutable range after the human commits. + +## 6. Verification Strategy + +1. Stdlib unit tests for registry loading and owner resolution. +2. Mutation tests for every registered production module path. +3. Strict verdict negative tests: missing fields, negatives, arithmetic imbalance, missing + upstream evidence, revision mismatch, and behavior change without red evidence. +4. Golden renderer tests and `--check` parity validation. +5. JSON validation of generated Antigravity hook and agent files. +6. Gradle `projects`, architecture dependency verification, focused ArchUnit test, and full + `check` after harness tests pass. + +## 7. Migration and Compatibility + +- Existing fenced verdicts remain parseable only when they satisfy the new required fields. +- Generated platform files are overwritten only by the renderer and documented as generated. +- Root and module guidance is updated to the registered nested topology. +- Actual external cross-platform golden executions remain a follow-up; static parity and seeded + mutation coverage become mandatory in this change. + +## 8. Acceptance Criteria + +- A seeded forbidden import under every nested production module is rejected. +- No legacy flat adapter path remains in gate tests or agent task allowlists. +- `settings.gradle`, dependency verification, import gate, and Gradle runner resolve the same + 19 leaf modules from `modules.yaml`. +- Missing/negative/inconsistent ready verdicts fail validation. +- Claude and Antigravity adapters invoke the shared validator; accepted verdicts produce JSON + evidence. +- Rendering followed by `--check` reports no platform drift. +- Agent variants uniformly state human-only commit policy and risk-based orchestration. +- `N!` enumeration, all-quote routine grep, file-count report splitting, and unconditional + counterargument requirements are absent from active rules. +- Harness tests and Gradle checks pass, or every unrun/failing command is reported with risk. diff --git a/flaky-quarantine.yaml b/flaky-quarantine.yaml new file mode 100644 index 00000000..df190762 --- /dev/null +++ b/flaky-quarantine.yaml @@ -0,0 +1,27 @@ +# Flaky-test quarantine registry — feature-ci-quality-gates-contract §4 (D7 / D9). +# +# This branch is the flaky-quarantine SSOT. A test that flakes may be tagged with JUnit's built-in +# @Tag("quarantine") so it stops blocking the release gate (src/build.gradle: the main `test` task +# runs excludeTags 'quarantine'; the bucket runs separately via `./gradlew quarantineTest`, +# non-blocking). Quarantine is a TEMPORARY escape, never a parking lot — every quarantined test MUST +# be listed here and MUST leave quarantine within 14 days. +# +# The `verifyQuarantineSunset` Gradle gate (wired into `check`) enforces, on every build: +# - schema — each entry has test / quarantined_since / reason / tracking_issue; +# - sunset — quarantined_since is within 14 days (older → build fails); +# - drift — every @Tag("quarantine") test in src/**/test is registered here (and vice-versa, +# a registered test should carry the tag). +# +# This file lives at the repo ROOT (not docs/, which is gitignored) so it is committed and readable +# by CI — same rationale as .trivyignore.yaml. CODEOWNERS governs merge-time approval of changes. +# +# Schema (one list entry per quarantined test): +# +# quarantined: +# - test: "dev.caskeleton.bootstrap.contract.SomeFlakyContractTest" # FQN, optionally "...#method" +# quarantined_since: "2026-06-20" # ISO date; 14-day sunset +# reason: "intermittent timeout under shared CI load — suspected fixed-port bind race" +# tracking_issue: "https://github.com/<org>/<repo>/issues/123" +# +# The skeleton ships with an EMPTY bucket: no flaky tests are quarantined. +quarantined: [] diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..bf85ec60 --- /dev/null +++ b/renovate.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": [ + "feature-dependency-vulnerability-management-contract D6 / §4 — dependency SECURITY update automation.", + "Renovate is primary over Dependabot: with a gradle/libs.versions.toml version catalog plus Gradle", + "lockfiles, Dependabot does not refresh the lockfile (issue #12557), which conflicts with", + "feature-build-release-supply-chain-contract D8 (dependency locking). Dependabot stays a conditional", + "alternative for forks that standardize on it or do not use lockfiles.", + "Transitive vulnerabilities are NOT covered by either bot (direct deps only) — pin them with a Gradle", + "dependency constraint / resolutionStrategy.force (§4) and regenerate locks (supply-chain D8)." + ], + "extends": [ + "config:recommended", + "security:only-security-updates" + ], + "vulnerabilityAlerts": { + "description": "Stable GitHub vulnerability alerts → immediate security PR, ignoring schedule.", + "enabled": true, + "labels": ["security", "dependencies"] + }, + "osvVulnerabilityAlerts": true, + "packageRules": [ + { + "description": "UNSUPPORTED_IMPL_DECISION (team-policy): auto-merge patch-level security updates once CI is green — lowest breaking risk. Requires trustworthy CI coverage; a fork with weak CI should set this to false.", + "matchUpdateTypes": ["patch", "pin", "digest"], + "automerge": true, + "automergeType": "pr" + }, + { + "description": "minor/major security updates require human review (breaking-change risk).", + "matchUpdateTypes": ["minor", "major"], + "automerge": false + } + ] +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..c3044cd8 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "skills": { + "cavecrew": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/cavecrew/SKILL.md", + "computedHash": "505d836228d1c5e14834ff5d62aad72390c7d27f79c6aa7f9a7a55ed6606d6a2" + }, + "caveman": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman/SKILL.md", + "computedHash": "1902fa0b569912d0c05736d8d98a72097d9b82719aac88c0c1d03bb546f9176d" + }, + "caveman-commit": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-commit/SKILL.md", + "computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00" + }, + "caveman-compress": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-compress/SKILL.md", + "computedHash": "1e9b3e2bf68b75dc0252c4328c8514bea79d4d8f7d7259da616ba7d12cad6865" + }, + "caveman-help": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-help/SKILL.md", + "computedHash": "dd85267e76baad76995157e7b9f762dfa557cd58951ee92af0c283f48aa26537" + }, + "caveman-review": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-review/SKILL.md", + "computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1" + }, + "caveman-stats": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-stats/SKILL.md", + "computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553" + } + } +} diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 00000000..cdacdffa --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,59 @@ +# feature-container-runtime-contract — .dockerignore for src/ build context +# +# The docker build context is src/ (the Gradle root). This file excludes build +# noise that must not enter the image build context, while keeping everything +# the builder stage needs to resolve dependencies and run bootJar. + +# ---- Version control -------------------------------------------------------- +.git +.gitignore +.gitattributes + +# ---- Gradle build output ---------------------------------------------------- +# Exclude all module build/ directories; the builder stage produces them inside the container. +build/ +**/build/ +.gradle/ +**/.gradle/ + +# ---- IDE / editor files ----------------------------------------------------- +.idea/ +**/.idea/ +*.iml +.vscode/ +**/.vscode/ +*.eclipse +.project +.classpath +.settings/ + +# ---- Documentation / governance (not needed for image build) ---------------- +# Root CLAUDE.md and AGENTS.md are governance docs; not needed at build time. +*.md +docs/ + +# ---- Environment / secrets (NEVER bake secrets into image layers) ----------- +.env +**/.env +*.env + +# ---- OS artifacts ----------------------------------------------------------- +.DS_Store +**/.DS_Store +Thumbs.db + +# ---- Test reports and coverage ---------------------------------------------- +**/test-results/ +**/reports/ +**/jacoco/ + +# ---- Explicitly keep (negation rules to be safe) ---------------------------- +# The Gradle wrapper, source trees, and build configuration are needed. +# Negation rules are not strictly required because the above globs don't +# accidentally exclude src/*, but listed for clarity. +!gradlew +!gradlew.bat +!gradle/ +!**/src/ +!**/build.gradle +!settings.gradle diff --git a/src/.env b/src/.env new file mode 100644 index 00000000..b58d8bb4 --- /dev/null +++ b/src/.env @@ -0,0 +1,149 @@ +# ---------------------------------------------------------------------------- +# 외부화 설정의 단일 출처. spring-dotenv 가 src/.env 에서 로드합니다 +# (bootRun.workingDir = src/). 각 키의 허용값·결정 근거는 src/README.md 참조. +# ---------------------------------------------------------------------------- + +# ----- App identity ----- +APP_NAME=ca-skeleton +SPRING_PROFILES_ACTIVE=local + +# ----- Runtime safety (StartupSafetyValidator, D8) ----- +APP_ERROR_DETAIL_EXPOSURE_ENABLED=false +APP_LOG_BODY_CAPTURE_ENABLED=false +APP_MULTI_INSTANCE_ENABLED=false +APP_MIGRATION_ON_STARTUP=true +APP_RATE_LIMIT_ENABLED=true +APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only +APP_IDEMPOTENCY_TTL=24h + +# ----- Async executor ----- +APP_ASYNC_EXECUTOR_CORE_SIZE=10 +APP_ASYNC_EXECUTOR_MAX_SIZE=50 +APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200 + +# ----- Optional integration adapters (default: all disabled) ----- +APP_CACHE_REDIS_ENABLED=false +APP_MESSAGING_BROKER= +APP_MESSAGING_KAFKA_BROKERS= +APP_NOTIFICATION_SLACK_PROVIDER= +APP_NOTIFICATION_EMAIL_PROVIDER= + +# ----- Outbound HTTP client ----- +APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s +APP_OUTBOUND_HTTP_READ_TIMEOUT=5s +APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s +APP_OUTBOUND_HTTP_RETRY_ENABLED=false +APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS=3 +APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF=100ms +APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER=2.0 +APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false +APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD=50 +APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE=100 +APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS=100 +APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE=60s +APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN=10 +APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT=10MB + +# ----- Logging: root & app levels ----- +APP_LOG_LEVEL_ROOT=INFO +APP_LOG_LEVEL_APP=DEBUG + +# ----- Logging: per-package levels ----- +APP_LOG_LEVEL_SPRING=INFO +APP_LOG_LEVEL_WEB=INFO +APP_LOG_LEVEL_SQL=WARN + +# ----- Logging: file output + rolling ----- +APP_LOG_FILE_ENABLED=false +APP_LOG_FILE_PATH=logs/ca-skeleton.json +APP_LOG_FILE_MAX_SIZE=100MB +APP_LOG_FILE_MAX_HISTORY=14 +APP_LOG_FILE_TOTAL_SIZE_CAP=3GB + +# ----- Logging: async appender ----- +APP_LOG_ASYNC_ENABLED=true +APP_LOG_ASYNC_QUEUE_SIZE=512 +APP_LOG_ASYNC_DISCARDING_THRESHOLD=20 + +# ----- Logging: JSON encoder ----- +APP_LOG_JSON_TIMEZONE=UTC +APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX +APP_LOG_JSON_INCLUDE_CALLER_DATA=false +APP_LOG_JSON_LOGGER_NAME_LENGTH=0 + +# ----- Logging: sampling ----- +APP_LOG_SAMPLING_RATE=1.0 + +# ----- Distributed tracing ----- +OTEL_EXPORTER_OTLP_ENDPOINT= +APP_TRACING_ENABLED=true +APP_TRACING_SAMPLE_RATE= + +# ----- Privacy: user_principal pseudonymization ----- +APP_PRIVACY_PSEUDONYMIZATION_SALT=__LOCAL_DEV_pseudonymization_salt + +# ----- Spring Boot bootstrap ----- +SPRING_BANNER_MODE=console +SPRING_MAIN_LAZY_INITIALIZATION=false +SPRING_MAIN_LOG_STARTUP_INFO=true +SPRING_THREADS_VIRTUAL_ENABLED=true + +# ----- Jackson: deserialization policy ----- +SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES=true +SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES=true +SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES=true +SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL=false + +# ----- Jackson: serialization policy ----- +SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS=false + +# ----- Server / Tomcat ----- +APP_SERVER_PORT=8080 +APP_SERVER_SHUTDOWN=graceful +APP_SERVER_SHUTDOWN_TIMEOUT=30s +APP_SERVER_TOMCAT_MAX_THREADS=200 +APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10 +APP_SERVER_TOMCAT_ACCEPT_COUNT=100 +APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192 +APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s +APP_SERVER_COMPRESSION_ENABLED=true +APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1024 +APP_SERVER_FORWARD_HEADERS_STRATEGY=framework +APP_SERVER_ERROR_INCLUDE_STACKTRACE=never +APP_SERVER_ERROR_INCLUDE_MESSAGE=never + +# ----- Presentation ----- +PRESENTATION_API_BASE_PATH=/api + +# ----- Auth (OIDC resource server) ----- +APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton +APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api +SECURITY_PUBLIC_PATHS=/api/healthcheck + +# ----- CORS ----- +APP_SECURITY_CORS_ENABLED=true +APP_SECURITY_CORS_ORIGINS=http://localhost:3000 +APP_SECURITY_CORS_ALLOWED_METHODS= +APP_SECURITY_CORS_ALLOWED_HEADERS=* +APP_SECURITY_CORS_ALLOW_CREDENTIALS=true +APP_SECURITY_CORS_MAX_AGE=3600 + +# ----- Database (Postgres) ----- +APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton +APP_DATASOURCE_USERNAME=ca_skeleton +APP_DATASOURCE_PASSWORD=ca_skeleton +APP_DATASOURCE_DRIVER=org.postgresql.Driver +APP_DATASOURCE_DDL_AUTO=update +APP_DATASOURCE_SHOW_SQL=false +APP_DATASOURCE_FORMAT_SQL=false +APP_DATASOURCE_OPEN_IN_VIEW=false + +# ----- Database: HikariCP connection pool ----- +APP_DATASOURCE_POOL_MAX_SIZE=10 +APP_DATASOURCE_POOL_MIN_IDLE=2 +APP_DATASOURCE_CONNECTION_TIMEOUT=30000 +APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000 +APP_DATASOURCE_POOL_MAX_LIFETIME=1800000 + +# ----- Management / Actuator ----- +MANAGEMENT_SERVER_PORT=9001 diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 00000000..385efc53 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,14 @@ +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ +.project +.classpath +.settings/ + +# OS +.DS_Store diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 00000000..b35ce7fe --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,139 @@ +# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894 +# ============================================================================= +# feature-container-runtime-contract — multi-stage image build +# +# Build requirements: +# docker build -f src/Dockerfile src/ -t caskeleton:local \ +# --build-arg RELEASE_VERSION=1.2.3 \ +# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \ +# --build-arg GIT_SHA=a1b2c3d4e5f6 \ +# --build-arg SOURCE_URL=https://github.com/your-org/your-repo +# +# Design decisions (feature-container-runtime-contract D2/D3/D4): +# D2 — read-only root filesystem: writable mounts must be declared explicitly. +# D3 — non-root user, JRE-only final stage (no full JDK). +# D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError. +# ============================================================================= + +ARG RELEASE_VERSION +ARG BUILD_VERSION +ARG GIT_SHA +ARG SOURCE_URL + +# ---- Stage 1: builder ------------------------------------------------------- +# Uses the full JDK only in the build stage, never in the final image. +FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder + +ARG RELEASE_VERSION +ARG GIT_SHA + +WORKDIR /build + +# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST, +# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle +# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a +# single structure-preserving glob replaces the former per-module COPY list: new modules are +# picked up automatically and this stage never drifts out of sync with settings.gradle again. +# STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below. +# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.) +COPY gradlew ./ +COPY gradle/ gradle/ +COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./ + +# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This +# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries +# while still returning exit code 0 and therefore is not a release gate. +RUN test -n "${RELEASE_VERSION}" \ + && test -n "${GIT_SHA}" \ + && ./gradlew verifyDependencyLocks --no-daemon --quiet \ + -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" + +# Copy full source and build the JAR +COPY . . +RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \ + -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" + +# Locate the produced JAR (avoids hardcoding the version string) +RUN cp $(ls app-bootstrap/build/libs/*.jar | grep -v plain | head -1) /build/app.jar + +# ---- Stage 2: runtime image ------------------------------------------------- +# JRE-only slim image (D3: no full JDK in production image). +# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant. +FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime + +ARG BUILD_VERSION +ARG GIT_SHA +ARG SOURCE_URL + +# OCI image labels (build-arg placeholders — supply at docker build time). +LABEL org.opencontainers.image.title="caskeleton" \ + org.opencontainers.image.source="${SOURCE_URL}" \ + org.opencontainers.image.revision="${GIT_SHA}" \ + org.opencontainers.image.version="${BUILD_VERSION}" + +# A release image without source/version metadata is not an artifact this contract permits. +RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}" + +# ---- Locale / timezone (D4) ------------------------------------------------- +# C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages. +# Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image. +ENV TZ=UTC \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# ---- Writable HOME under read-only root fs (D2) ----------------------------- +# The app user is created with --no-create-home and the root filesystem is +# read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that +# write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches) +# do not fail with a read-only-filesystem error. +ENV HOME=/tmp + +# ---- JVM ergonomics (D4) ---------------------------------------------------- +# -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap. +# -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here). +# -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart. +# -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable +# /var/tmp/heap mount (see tmpfs mounts in compose files, D2). +# -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs). +ENV JAVA_TOOL_OPTIONS="\ + -XX:MaxRAMPercentage=75 \ + -XX:+UseContainerSupport \ + -XX:+ExitOnOutOfMemoryError \ + -XX:+HeapDumpOnOutOfMemoryError \ + -XX:HeapDumpPath=/var/tmp/heap \ + -Dserver.tomcat.basedir=/tmp" + +# ---- Filesystem layout (D2: read-only root filesystem) ---------------------- +# /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime. +# /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above). +# Both directories are declared here so tooling is aware of them; at runtime they +# MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files). +RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap + +# ---- Non-root user (D3) ----------------------------------------------------- +RUN groupadd --system --gid 1000 app \ + && useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app + +WORKDIR /app + +COPY --from=builder --chown=app:app /build/app.jar app.jar + +USER app + +# ---- Ports ------------------------------------------------------------------ +# 8080 — application HTTP port +# 9001 — management / actuator port (parallel actuator branch wires this endpoint) +EXPOSE 8080 9001 + +# ---- Health check ----------------------------------------------------------- +# Targets the actuator readiness probe on the management port (9001). +# CROSS-FEATURE COUPLING: the /actuator/health/readiness endpoint is implemented +# by the parallel runtime-health + actuator branches. The HEALTHCHECK is wired here +# (container-side) and will pass once those branches are merged. In this worktree +# the endpoint may return 404; the container will be UNHEALTHY until merged. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider \ + http://localhost:9001/actuator/health/readiness || exit 1 + +# ---- Entrypoint ------------------------------------------------------------- +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/src/Dockerfile.sample b/src/Dockerfile.sample new file mode 100644 index 00000000..51c141ef --- /dev/null +++ b/src/Dockerfile.sample @@ -0,0 +1,131 @@ +# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894 +# ============================================================================= +# sample-portfolio standalone demo image — twin of src/Dockerfile. +# +# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the +# production composition root (CaSkeletonApplication) that src/Dockerfile builds. +# The sample is the demo-friendly entrypoint: its application.yml self-provides +# defaults for every env placeholder, so the only external dependency it needs +# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations). +# +# Build (no build-args required — this is a disposable demo, not a release artifact): +# docker build -f src/Dockerfile.sample src/ -t ca-sample:local +# +# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown): +# docker run --rm -p 8080:8080 -p 9001:9001 \ +# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \ +# ca-sample:local +# +# The multi-stage build, --parents descriptor glob, STRICT lock verification, +# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics +# are all identical to src/Dockerfile — only the bootJar target differs. Keep the +# two files' builder stages in sync. +# ============================================================================= + +# Demo defaults so the image builds with zero build-args. Two format constraints from the root +# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9): +# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21). +# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop. +# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder. +ARG RELEASE_VERSION=0.0.0 +ARG BUILD_VERSION=0.0.0-sample +ARG GIT_SHA=0000000 +ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample + +# ---- Stage 1: builder ------------------------------------------------------- +# Uses the full JDK only in the build stage, never in the final image. +FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder + +ARG RELEASE_VERSION +ARG GIT_SHA + +WORKDIR /build + +# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST, +# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle +# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a +# single structure-preserving glob replaces a per-module COPY list: new modules are picked up +# automatically and this stage never drifts out of sync with settings.gradle. +# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.) +COPY gradlew ./ +COPY gradle/ gradle/ +COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./ + +# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either). +RUN test -n "${RELEASE_VERSION}" \ + && test -n "${GIT_SHA}" \ + && ./gradlew verifyDependencyLocks --no-daemon --quiet \ + -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" + +# Copy full source and build the sample JAR. +COPY . . +RUN ./gradlew :sample-portfolio:bootJar --no-daemon -x test \ + -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" + +# Locate the produced JAR (avoids hardcoding the version string). +RUN cp $(ls sample-portfolio/build/libs/*.jar | grep -v plain | head -1) /build/app.jar + +# ---- Stage 2: runtime image ------------------------------------------------- +# JRE-only slim image (no full JDK in the demo image either). +FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime + +ARG BUILD_VERSION +ARG GIT_SHA +ARG SOURCE_URL + +# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail +# on missing metadata — the ARG defaults above keep it buildable with no build-args. +LABEL org.opencontainers.image.title="caskeleton-sample" \ + org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \ + org.opencontainers.image.source="${SOURCE_URL}" \ + org.opencontainers.image.revision="${GIT_SHA}" \ + org.opencontainers.image.version="${BUILD_VERSION}" + +# ---- Locale / timezone ------------------------------------------------------ +ENV TZ=UTC \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# ---- Writable HOME under read-only root fs ---------------------------------- +ENV HOME=/tmp + +# ---- JVM ergonomics --------------------------------------------------------- +# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a +# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem. +ENV JAVA_TOOL_OPTIONS="\ + -XX:MaxRAMPercentage=75 \ + -XX:+UseContainerSupport \ + -XX:+ExitOnOutOfMemoryError \ + -XX:+HeapDumpOnOutOfMemoryError \ + -XX:HeapDumpPath=/var/tmp/heap \ + -Dserver.tomcat.basedir=/tmp" + +# ---- Filesystem layout (read-only root filesystem) -------------------------- +# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir). +RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap + +# ---- Non-root user ---------------------------------------------------------- +RUN groupadd --system --gid 1000 app \ + && useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app + +WORKDIR /app + +COPY --from=builder --chown=app:app /build/app.jar app.jar + +USER app + +# ---- Ports ------------------------------------------------------------------ +# 8080 — application HTTP port +# 9001 — management / actuator port +EXPOSE 8080 9001 + +# ---- Health check ----------------------------------------------------------- +# Actuator readiness probe on the management port (9001). Ignored by Kubernetes, +# which uses its own probes — kept for docker/compose parity with src/Dockerfile. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider \ + http://localhost:9001/actuator/health/readiness || exit 1 + +# ---- Entrypoint ------------------------------------------------------------- +# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest. +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/src/README.md b/src/README.md new file mode 100644 index 00000000..2e0d4452 --- /dev/null +++ b/src/README.md @@ -0,0 +1,428 @@ +# src — 빌드 스크립트 / 환경 변수 참조 + +`src/` 는 Gradle 멀티모듈 루트입니다. 모듈 경계·의존 방향 규칙은 루트 [CLAUDE.md](../CLAUDE.md) +와 [AGENTS.md](../AGENTS.md), 모듈별 규칙은 각 모듈의 `CLAUDE.md` 가 SSOT 입니다. + +이 문서는 [build.gradle](build.gradle) 과 [.env](.env) 의 코드 주석에서 덜어낸 **설정 항목 설명과 +결정 근거**를 모아둔 참조용 기록입니다. 두 파일에는 짧은 기능 주석과 "자세한 내용은 README 참조" +포인터만 남기고, "왜 이렇게 했나"는 여기서 풀어 설명합니다. + +--- + +## build.gradle — 빌드 / 검증 게이트 + +모든 모듈의 `check` 태스크는 아래 verify 게이트에 의존합니다. 빌드를 통과하려면 검사가 +모두 green 이어야 합니다. + +| 게이트 | 하는 일 | +| --- | --- | +| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 | +| `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 | +| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 | +| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 | +| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 | + +### Local bootstrap + +`./gradlew bootstrap`은 `bootstrapCompile` → `bootstrapDependencies` → +`bootstrapMigrateAndStart` → `bootstrapSampleContract` → `bootstrapSmoke`를 순서대로 실행합니다. +DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가 +끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의 +SSOT이고 bootstrap이 별도 env template을 만들지 않습니다. + +README command drift는 다음 명령으로 독립 실행할 수 있습니다. + +```bash +./gradlew verifyReadmeCommands +``` + +### Traceable version + dependency locking + +- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은 + `-PreleaseVersion`/`RELEASE_VERSION`, revision은 `-PgitRevision`/`GIT_SHA`/`GITHUB_SHA` 순으로 + 주입하고, 로컬에서는 현재 Git commit을 읽습니다. +- 모든 JAR manifest에 `Implementation-Version`과 `Build-Revision`을 기록합니다. Git metadata도 + revision property도 없는 상태는 traceable artifact를 만들 수 없으므로 configuration 단계에서 + 실패합니다. +- 모든 subproject가 `lockAllConfigurations()` + `LockMode.STRICT`를 사용하고, lock state는 Gradle + 기본 `<module>/gradle.lockfile`에 둡니다. 이 경로는 Renovate Gradle manager 기본 인식 경로와 + 같습니다. +- lock 갱신은 `./gradlew resolveAndLockAll --write-locks` 한 가지 명령으로 수행합니다. 이 task는 + `--write-locks`가 없으면 실패하며, 일반 build가 lock state를 조용히 다시 쓰지 못하게 합니다. + +### Reproducible archives (D10) + +모든 `AbstractArchiveTask`는 file timestamp 보존을 끄고, file order를 재현 가능하게 정렬하며, +directory/file mode를 각각 `0755`/`0644`로 고정합니다. Java toolchain은 21이고 로컬·CI patch +version은 root `.tool-versions`의 Temurin 값으로 맞춥니다. + +두 번의 clean, no-cache `bootJar` SHA-256 비교는 다음 명령으로 실행합니다. + +```bash +cd .. +bash .github/scripts/verify-reproducible-build.sh +``` + +이 검사는 동일 toolchain·동일 source revision 안에서 archive 재현성을 검증합니다. 서로 다른 JDK +vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 않습니다. + +### `-parameters` 컴파일러 플래그 + +- **결정.** 모든 subproject 의 Java 컴파일에 `-parameters` 플래그를 직접 설정합니다. +- **근거.** Spring MVC 는 `@PathVariable` / `@RequestParam` 의 이름을 reflection 의 parameter + metadata 에서 읽습니다. 이 플래그가 없으면 파라미터 이름이 `arg0`, `arg1` 로 컴파일되어 바인딩이 + 깨집니다. Spring Boot Gradle 플러그인은 이 플래그를 자동으로 켜 주지만, 이 프로젝트는 플러그인을 + `apply false` 로 두기 때문에 자동 적용이 일어나지 않습니다. 그래서 각 subproject 의 `JavaCompile` + 에 직접 설정합니다. + +### `verifyCleanArchitectureDependencies` + +- **하는 일.** `allowedProjectDependencies` 맵에 모듈별로 허용된 의존 대상을 선언하고, 실제 Gradle + 프로젝트 의존(`api` / `implementation` / `compileOnly` / `runtimeOnly`)이 그 범위를 벗어나면 빌드를 + 실패시킵니다. +- **이 맵이 의존 방향의 SSOT 입니다.** 새 모듈이나 새 의존 edge 를 추가하면 이 맵과 ArchUnit + 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. 모르는 코드를 검사하지 못하는 게이트는 + 보호 기능을 못 합니다. + +### `verifyOneTypePerFile` (code-conventions I6) + +- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이 + 파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`, + `module-info.java` 는 예외입니다. +- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된 + bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스 + 파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스 + 파일을 직접 스캔하는 별도 태스크로 만들어 `check` 에 연결했습니다. + +### `verifyEnvKeys` + +- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env` 세 곳을 lock-step(서로 + 어긋나지 않게) 으로 유지합니다. `env-keys.yaml` 이 `APP_` 키의 SSOT 이고, drift 가 생기면 빌드를 + 실패시킵니다. +- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가 + `.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것. +- **검사 항목.** + - **A.** `application.yml` 의 placeholder 중 inline default(`${VAR:default}`)가 없는 **필수** + placeholder(`${VAR}`)는 반드시 `.env` 에 존재해야 합니다. + - **B.** `.env` 의 모든 키는 `application.yml` 의 어떤 `${...}` placeholder 가 참조해야 합니다. + (아무도 안 쓰는 키는 orphan 으로 간주해 실패) + - **C.** `.env` 의 모든 `APP_` 키는 registry 에 `- name: <KEY>` 행이 있어야 합니다. +- **`SPRING_*` 키는 왜 registry 추적 대상이 아닌가.** `SPRING_*` 는 Spring Boot 가 정의한 native 키라 + 프로젝트가 소유한 계약이 아닙니다. 그래서 C 검사는 일부러 `APP_` prefix 로만 범위를 좁혔습니다. +- **비고.** `src/.env` 가 프로젝트의 커밋된 env 파일이며, 별도의 `.env.example` 템플릿은 두지 + 않습니다. + +### `verifyPublicPathSnapshot` + +- **하는 일.** deny-by-default public path 표면이 승인 없이 바뀌면 빌드를 실패시킵니다. +- **배경.** 이 앱은 deny-by-default 입니다. 즉 `SECURITY_PUBLIC_PATHS` 가 먹이는 명시적 `permitAll()` + 경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env` → `SecuritySettings.publicPaths()` → + `SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는 + 지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다. +- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로 + snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후 + 반영합니다. +- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시 + `SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection + 은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가 + `RequestMatcherDelegatingAuthorizationManager` 의 private 필드에 숨어 있음). 그래서 `permitAll()` + 을 실제로 먹이는 결정적 SSOT 인 `SECURITY_PUBLIC_PATHS` 자체를 snapshot 합니다. 탐지 목표(공개 경로 + 변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다. +- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. `docs/` 는 gitignore 대상이라, fresh + checkout 에서는 snapshot 이 없으므로 "처음엔 만들고 통과"한 뒤 이후 변경부터 감시합니다. + +### `verifyTrivyignore` + +- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지 + 않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고, + 하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다. +- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해 + 취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로 + 취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다. +- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(CI), `.github/CODEOWNERS` 는 *merge 승인*(GitHub + 네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가" + 만 잡으므로 둘은 대체재가 아니라 보완재입니다. +- **결정 — 90일 상한 (프로젝트 선택).** Trivy 문서는 `expired_at` 필드의 *존재*만 보장하고 + 기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는 + trade-off 에서 90일을 기본값으로 두었습니다. fork 는 `src/build.gradle` 의 `maxWindowDays` 로 + 조정합니다. +- **위치.** suppression 파일은 `docs/` 가 아니라 repo 루트(`.trivyignore.yaml`)에 둡니다 — Trivy 가 + 스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은 + `.github/dependency-vulnerability-policy.md`, CI 배선은 `.github/workflows/dependency-vulnerability.yml` + 에 있습니다. + +### `verifyQuarantineSunset` + 플래키 격리 + +- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인 + `test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가 + merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만 + 돕니다. +- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `verifyQuarantineSunset`(루트 태스크, `check` 에 + 연결)이 매 빌드마다 (1) 레지스트리 스키마(`test`/`quarantined_since`/`reason`/`tracking_issue`), + (2) **14일 sunset**(`quarantined_since` 가 14일을 넘으면 빌드 실패), (3) **drift**(소스에 + `@Tag("quarantine")` 가 달렸는데 레지스트리에 없으면 실패)를 검사합니다. +- **결정 — 14일 sunset (프로젝트 선택).** Spotify/Google/MS 사례는 격리 버킷의 정당성만 + 보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(`company-case-study` + 강도 — 공식 best practice 아님). fork 는 `src/build.gradle` 의 `sunsetDays` 로 조정합니다. +- **위치.** 레지스트리는 `docs/`(gitignore) 가 아니라 repo 루트 `flaky-quarantine.yaml` 에 둡니다 — + CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(`.trivyignore.yaml` 과 같은 이유). 스켈레톤은 빈 + 버킷(`quarantined: []`)으로 출고됩니다. + +### CI 게이트 배선 + +- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을 + 소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo + SSOT 가 `.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint` + 잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다. +- **워크플로.** `.github/workflows/ci-quality-gates.yml` 의 `release-gate` 잡이 모든 release-blocking + 게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서 + 제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며, + GitHub Actions 는 워크플로 간 `needs` 를 못 쓰므로 branch protection 의 required check 합집합으로 + 묶습니다. + +--- + +## .env — 환경 변수 레퍼런스 + +`spring-dotenv` 가 `src/.env` 를 읽어 외부화 설정을 주입합니다(`bootRun` 의 working dir 가 `src/` 라 +이 파일이 잡힙니다). 아래는 섹션별 키 설명입니다. 따로 표기가 없으면 `restart-only`(값 변경 시 재기동 +필요)로 간주하세요. + +### App identity + +- **`APP_NAME`** — `spring.application.name` 과 JSON 로그의 `app` 필드. 자유 문자열. +- **`SPRING_PROFILES_ACTIVE`** — 활성 Spring profile. 보통 `local` | `dev` | `stage` | `prod`. JSON + 로그의 `profile` 필드도 이 값을 씁니다. + +### Runtime safety (기동 시 `StartupSafetyValidator` 가 fail-fast 검사, D8) + +- **`APP_ERROR_DETAIL_EXPOSURE_ENABLED`** — 응답에 내부 에러 상세를 노출할지. `true` | `false`. + **`prod` 프로필에서는 반드시 `false`** 여야 하며, 아니면 기동이 실패합니다. +- **`APP_LOG_BODY_CAPTURE_ENABLED`** — 요청/응답 body 를 로그에 캡처할지. `true` | `false`. + **`prod` 에서는 반드시 `false`**, 아니면 기동 실패. +- **`APP_MULTI_INSTANCE_ENABLED`** — `true` 면 인스턴스 협조용 빈 5종(lock / cache-stampede / + leader / rate-limit / migration)이 모두 있어야 하며, 하나라도 없으면 기동이 실패합니다. +- **`APP_RATE_LIMIT_ENABLED`** — fixed-window rate-limit interceptor 활성화 + (429 + `Retry-After` + `X-RateLimit-*` 응답). +- **`APP_RATE_LIMIT_CLIENT_IP_MODE`** — 클라이언트 IP 판별 방식. `remote-addr-only` | + `forwarded-headers-trusted`. **신뢰된 ingress/LB 가 `X-Forwarded-For` 를 앱 도달 전에 덮어쓸 때만** + `forwarded-headers-trusted` 를 쓰세요. 아니면 IP 위조에 노출됩니다. +- **`APP_IDEMPOTENCY_TTL`** — idempotency 레코드 기본 TTL. duration(예: `24h`, `72h`). 오래 도는 use + case 는 최대 72h 까지 override 가능. (D6) + +### Async executor + +`@Async` `ThreadPoolTaskExecutor` 풀 크기 설정입니다. + +- **`APP_ASYNC_EXECUTOR_CORE_SIZE`** — 항상 살아있는 워커 수. 1 이상 정수. +- **`APP_ASYNC_EXECUTOR_MAX_SIZE`** — 워커 수 상한. core-size 이상. +- **`APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`** — 백로그 큐 용량. **bounded(유한) 필수, unbounded 금지(D7)**. + 1 이상 정수. + +### Optional integration adapters + +선택형 Kafka / Redis / Slack / Google Email 어댑터 템플릿입니다. **기본은 전부 비활성**(비활성 = 선택 +모듈의 기본값). Layer 1 의 `@ConditionalOnProperty` 가 enabled 일 때만 실제 어댑터를 등록하고, 아니면 +fail-fast sentinel 이 포트를 충족합니다(Layer 3). + +- **`APP_CACHE_REDIS_ENABLED`** — Redis 캐시 어댑터 on/off. `true` | `false`. +- **`APP_MESSAGING_BROKER`** — 활성 메시지 브로커 id(예: `kafka`). 빈 값 = 메시징 비활성(사용 시 + fail-fast). +- **`APP_MESSAGING_KAFKA_BROKERS`** — `host:port` CSV. `APP_MESSAGING_BROKER=kafka` 일 때만 필수, + 아니면 빈 값. +- **`APP_NOTIFICATION_SLACK_PROVIDER`** — 활성 Slack provider id(예: `webhook`). 빈 값 = Slack 비활성. +- **`APP_NOTIFICATION_EMAIL_PROVIDER`** — 활성 email provider id(예: `google-email`). 빈 값 = email + 비활성. + +### Outbound HTTP client + +- **결정 — timeout 은 필수(D5).** timeout 미설정 또는 무한 timeout 은 금지이며, 기동 시 0 이 아닌 값을 + 강제합니다. 무한 timeout 은 네트워크 호출이 영원히 매달릴 수 있어 런타임 장애가 아니라 설정 실수로 + 보고 즉시 기동을 실패시킵니다. +- **`APP_OUTBOUND_HTTP_CONNECT_TIMEOUT`** — TCP connect timeout. duration(예: `2s`), 필수, non-zero. +- **`APP_OUTBOUND_HTTP_READ_TIMEOUT`** — socket read timeout. duration(예: `5s`), 필수, non-zero. +- **`APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT`** — retry 를 포함한 end-to-end 마감 예산. duration(예: + `10s`), 필수, non-zero. +- **`APP_OUTBOUND_HTTP_RETRY_ENABLED`** — retry 데코레이터 on/off. `true` 로 켜면 `MeterRegistry` 빈이 + 있어야 하며(D3 가드), 없으면 기동 실패. +- retry 튜닝(아래 3개는 `retry-enabled=true` 일 때 적용, 기본값은 기존 하드코딩 동작 보존): + - **`..._RETRY_MAX_ATTEMPTS`** — 총 시도 횟수(최초 시도 포함). 1 이상 정수. + - **`..._RETRY_INITIAL_BACKOFF`** — exponential backoff 시작 간격. duration, non-zero. + - **`..._RETRY_BACKOFF_MULTIPLIER`** — backoff 배수. 1.0 이상 double. +- **`APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`** — circuit breaker on/off. `true` 로 켜면 + `MeterRegistry` 빈 필요(D3), 없으면 기동 실패. +- circuit breaker 튜닝(아래는 `circuit-breaker-enabled=true` 일 때 적용, 기본값은 Resilience4j + `ofDefaults()`): + - **`..._FAILURE_RATE_THRESHOLD`** — open 으로 전환되는 실패율 임계치(%). (0, 100] 범위 float. + - **`..._SLIDING_WINDOW_SIZE`** — COUNT_BASED sliding window 크기. 1 이상 정수. + - **`..._MINIMUM_NUMBER_OF_CALLS`** — 실패율 계산을 시작하는 최소 호출 수. 1 이상 정수. + - **`..._WAIT_DURATION_IN_OPEN_STATE`** — open 상태 유지 시간. duration, non-zero. + - **`..._PERMITTED_CALLS_IN_HALF_OPEN`** — half-open 에서 허용하는 시험 호출 수. 1 이상 정수. +- **`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT`** — 메모리에 받는 응답 본문 최대 크기(예: `10MB`). 이를 + 넘는 응답은 streaming API 를 써야 합니다(D7). + +### Logging + +**Root / app 레벨** — 허용값은 모두 `TRACE` | `DEBUG` | `INFO` | `WARN` | `ERROR` | `OFF`. + +- **`APP_LOG_LEVEL_ROOT`** — root 로거 레벨. +- **`APP_LOG_LEVEL_APP`** — 앱 패키지 레벨. + +**패키지별 레벨**(root 를 덮어씀) — 동일 허용값. + +- **`APP_LOG_LEVEL_SPRING`** / **`APP_LOG_LEVEL_WEB`** — 각 패키지 레벨. +- **`APP_LOG_LEVEL_SQL`** — `DEBUG` 로 두면 JPA/jdbc 연결 후 SQL 문이 출력됩니다. + +**파일 출력 + rolling** + +- **`APP_LOG_FILE_ENABLED`** — `true` 면 rolling JSON 파일 appender 를 붙입니다. +- **`APP_LOG_FILE_PATH`** — `bootRun` working dir(`src/`) 기준 상대 경로 또는 절대 경로. +- **`APP_LOG_FILE_MAX_SIZE`** — 파일 1개 최대 크기(단위 `KB` | `MB` | `GB`). +- **`APP_LOG_FILE_MAX_HISTORY`** — 보관할 rolled archive 개수. 1 이상 정수. +- **`APP_LOG_FILE_TOTAL_SIZE_CAP`** — 전체 rolled 파일 용량 상한(단위 `KB` | `MB` | `GB`, `0` = 비활성). + +**Async appender** + +- **`APP_LOG_ASYNC_ENABLED`** — `true` 면 appender 를 `AsyncAppender` 로 감싸 non-blocking I/O. +- **`APP_LOG_ASYNC_QUEUE_SIZE`** — back-pressure 전 in-memory 큐 깊이. 1 이상 정수. +- **`APP_LOG_ASYNC_DISCARDING_THRESHOLD`** — 남은 큐 용량이 이 값 미만이면 `TRACE`/`DEBUG`/`INFO` + 이벤트를 버립니다(`WARN`/`ERROR` 는 항상 유지). `0` = 절대 버리지 않음. 0 이상 정수. + +**JSON 인코더 세부** + +- **`APP_LOG_JSON_TIMEZONE`** — IANA timezone(예: `UTC`, `Asia/Seoul`) 또는 `default`(JVM 기본). +- **`APP_LOG_JSON_TIMESTAMP_PATTERN`** — 타임스탬프 패턴. 보통 ISO 8601 + (`yyyy-MM-dd'T'HH:mm:ss.SSSXXX`). +- **`APP_LOG_JSON_INCLUDE_CALLER_DATA`** — `true` 면 file/method/line 을 추가. **성능 비용이 큽니다.** +- **`APP_LOG_JSON_LOGGER_NAME_LENGTH`** — `0` = 로거 이름 전체, 양수 = 패키지 축약(예: `36` → + `dev.caskeleton.bootstrap.Foo` 가 `d.c.bootstrap.Foo` 로). + +**Sampling (`SamplingTurboFilter`)** + +- **`APP_LOG_SAMPLING_RATE`** — `INFO` 이하 로그를 남길 확률. [0.0, 1.0] float. prod 는 `0.1`(10% + 샘플링)이 권장, `WARN`/`ERROR` 는 항상 유지. `1.0` = 샘플링 없음(dev/local/staging 기본). + +### Distributed tracing + +- **`OTEL_EXPORTER_OTLP_ENDPOINT`** — OTLP exporter endpoint. 빈 값 = exporter off(스켈레톤에서 OTel + SEAM 미활성). 값이 있으면 유효한 URL 이어야 하며 기동 시 `TracingProperties` 가 검증합니다. +- **`APP_TRACING_ENABLED`** — `false` 면 tracing seam 은 꺼지지만, `meta.traceId` 는 + `RequestLoggingFilter` 가 W3C `traceparent` 로 여전히 생성합니다(D4 disabled-fallback 보장). +- **`APP_TRACING_SAMPLE_RATE`** — per-profile 기본값을 덮어쓰는 샘플 비율. [0.0, 1.0] float. + - per-profile 기본값(D6): prod = `0.01`, staging = `0.10`, dev/local = `1.0`. + - **결정 — 빈 값으로 두는 이유(D-1 ISSUE-1 fix).** 빈 값이어야 per-profile resolver 의 기본값이 + 실제 tracer sampler 까지 도달합니다(`TracingSampleRateResolver` 가 SSOT). 값을 박으면 프로필별 + 기본값이 무시되므로, 특정 비율을 강제하고 싶을 때만 채웁니다. + +### Privacy: user_principal pseudonymization + +- **`APP_PRIVACY_PSEUDONYMIZATION_SALT`** — secret 등급 HMAC-SHA-256 salt. `__LOCAL_DEV_` prefix 는 로컬 전용 sentinel 값이며, + **prod 에서는 secret-manager 가 주입하는 실제 값**을 써야 합니다. + +### Spring Boot bootstrap + +- **`SPRING_BANNER_MODE`** — `off` | `console` | `log`. +- **`SPRING_MAIN_LAZY_INITIALIZATION`** — `true` 면 빈 생성을 첫 사용 시점까지 지연. +- **`SPRING_MAIN_LOG_STARTUP_INFO`** — `true` 면 `Starting`/`Started` 로그 출력. +- **`SPRING_THREADS_VIRTUAL_ENABLED`** — `true` 면 Tomcat 요청 처리에 Java 21 virtual threads 사용. + +### Jackson — deserialization policy + +모든 request DTO 는 Jackson 경계를 지납니다. 아래 4개 스위치는 잘못된 입력을 **조용히 강제 변환하지 +않고 즉시 실패**하게 만듭니다. 개별 DTO 에 클래스 레벨 `@JsonIgnoreProperties(ignoreUnknown = true)` +로 이 정책을 완화하는 것은 **금지**이며 ArchUnit 규칙으로 막혀 있습니다. + +- **`..._FAIL_ON_UNKNOWN_PROPERTIES`** — `true`: 타입에 선언되지 않은 JSON 키를 거부(Jackson 2.13+ + 기본). +- **`..._FAIL_ON_NULL_FOR_PRIMITIVES`** — `true`: primitive 필드에 JSON `null` 이 와도 `0`/`false` + 로 강제 변환하지 않고 "필수 필드 누락" 에러로 노출. 또는 wrapper 타입(`Integer`/`Long`/`Boolean`) + 과 `Optional<T>` 를 쓰세요. +- **`..._FAIL_ON_IGNORED_PROPERTIES`** — `true`: JSON 에 `@JsonIgnore` 처리된 필드가 들어오면 throw + (조용히 버리는 대신 계약 drift 를 노출). +- **`..._READ_UNKNOWN_ENUM_VALUES_AS_NULL`** — `false`(Jackson 기본 유지): 모르는 enum 값이 조용히 + `null` 이 되지 않고 throw 되어 `VALIDATION_FAILED` 로 드러나게 합니다. + +### Jackson — serialization policy + +응답 생성 쪽 정책입니다. 현재 Jackson/Spring Boot 기본값과 같지만 **명시적으로 못박아**, 미래에 Spring +Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니다(`spring.mvc.problemdetails.enabled=false` +와 같은 근거). `JacksonSerializationPolicyTest` 가 강제하며, `new BigDecimal(double)` 생성자는 +`no_bigdecimal_double_constructor` ArchUnit 규칙으로 금지됩니다. + +- **`..._WRITE_DATES_AS_TIMESTAMPS`** — `false`(D2 / RFC 3339): `java.time` 값을 ISO-8601 문자열로 + 직렬화(`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`). `true` 면 epoch 숫자나 + `[y,m,d,...]` 배열로 나가 datetime 계약이 깨집니다. +- **BigDecimal plain output** — Jackson 3에는 별도 `WRITE_BIGDECIMAL_AS_PLAIN` 설정 키가 없습니다. + `JacksonSerializationPolicyTest` 가 `"12300000000.00"` plain 출력을 직접 검증하고, + `no_bigdecimal_double_constructor` ArchUnit 규칙이 부정확한 `new BigDecimal(double)` 생성을 + 금지합니다. 엔드포인트별 string vs number 선택은 그대로 명시적으로 둡니다(공개/금융 API 는 string, + 내부 API 는 number+plain). + +### Server / Tomcat + +- **`APP_SERVER_PORT`** — 1~65535 정수. +- **`APP_SERVER_SHUTDOWN`** — `graceful` | `immediate`. +- **`APP_SERVER_SHUTDOWN_TIMEOUT`** — duration(예: `30s` | `1m` | `500ms`). +- **`APP_SERVER_TOMCAT_MAX_THREADS`** — 동시 요청 워커 상한. 1 이상 정수. +- **`APP_SERVER_TOMCAT_MIN_SPARE_THREADS`** — idle 워커 풀 하한. 0 이상 정수. +- **`APP_SERVER_TOMCAT_ACCEPT_COUNT`** — 들어오는 TCP 연결의 OS backlog 큐 깊이. 0 이상 정수. +- **`APP_SERVER_TOMCAT_MAX_CONNECTIONS`** — 동시에 열 수 있는 연결 수 상한. 1 이상 정수. +- **`APP_SERVER_TOMCAT_CONNECTION_TIMEOUT`** — duration(예: `20s` | `1m`). +- **`APP_SERVER_COMPRESSION_ENABLED`** — `true` | `false`. +- **`APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE`** — 이 크기 미만 payload 는 압축하지 않음(bytes 또는 + 단위, 예: `1024` | `1KB` | `2KB`). +- **`APP_SERVER_FORWARD_HEADERS_STRATEGY`** — `none` | `native` | `framework`. LB/proxy 뒤에서 + `X-Forwarded-*` 를 신뢰할지. +- **`APP_SERVER_ERROR_INCLUDE_STACKTRACE`** — `always` | `never` | `on_param`. +- **`APP_SERVER_ERROR_INCLUDE_MESSAGE`** — `always` | `never` | `on_param`. + +### Presentation + +- **`PRESENTATION_API_BASE_PATH`** — 모든 controller 앞에 붙는 leading-slash 경로(예: `/api` | `/v1` | + `""`). + +### Auth (OIDC resource server) + +- **`APP_SECURITY_JWT_ISSUER`** — OIDC issuer URI(Keycloak realm, Auth0 tenant 등). **필수** — 없으면 + 기동 실패. 예: `https://keycloak.example.com/realms/ca-skeleton`. +- **`APP_SECURITY_JWT_AUDIENCE`** — 기대하는 `aud` claim. 빈 값으로 두면 audience 검증을 건너뜁니다. +- **`SECURITY_PUBLIC_PATHS`** — 인증을 우회하는 경로 CSV. `api-base-path` 뒤의 full path 를 씁니다(예: + `/api/healthcheck`). 이 값의 변경은 `verifyPublicPathSnapshot` 게이트가 감시합니다(위 build.gradle + 설명 참조). + +### CORS + +- **`APP_SECURITY_CORS_ENABLED`** — `true` | `false`. +- **`APP_SECURITY_CORS_ORIGINS`** — 허용 origin CSV(예: + `http://localhost:3000,https://app.example.com`). +- **`APP_SECURITY_CORS_ALLOWED_METHODS`** — 허용 메서드 CSV. 빈 값이면 기본값 사용(GET, POST, PATCH, + PUT, DELETE, OPTIONS). +- **`APP_SECURITY_CORS_ALLOWED_HEADERS`** — 허용 헤더 CSV. `*` = 모든 헤더 허용. +- **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`. +- **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초). + +### Database (Postgres) + +- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`). +- **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정. +- **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`). +- **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod + 는 `validate` 또는 `none`**, local 은 `update` 가 편리합니다. +- **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo. +- **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음). +- **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.** + +**HikariCP 커넥션 풀** + +- **`APP_DATASOURCE_POOL_MAX_SIZE`** — DB 동시 연결 최대 수. 1 이상 정수. +- **`APP_DATASOURCE_POOL_MIN_IDLE`** — warm 하게 유지하는 최소 idle 연결 수. 0 이상 정수. +- **`APP_DATASOURCE_CONNECTION_TIMEOUT`** — `acquire()` 가 실패하기 전 대기 시간(ms). +- **`APP_DATASOURCE_POOL_IDLE_TIMEOUT`** — idle 연결 회수 임계 시간(ms). +- **`APP_DATASOURCE_POOL_MAX_LIFETIME`** — 연결의 최대 수명(ms). broker timeout 전에 rotate 하도록 + 설정합니다. + +### Management / Actuator + +- **결정 — management 포트 분리.** actuator 엔드포인트를 앱 API 와 **같은 소켓에 노출하지 않으려고** + 별도 management 포트를 둡니다. +- **`MANAGEMENT_SERVER_PORT`** — 1~65535 정수. **`APP_SERVER_PORT`(8080)와 달라야 합니다.** diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md new file mode 100644 index 00000000..ee11bfd0 --- /dev/null +++ b/src/adapter/inbound/graphql/CLAUDE.md @@ -0,0 +1,60 @@ +# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery) + +## Registered identity + +- Module ID: `adapter-inbound-graphql` +- Gradle path: `:adapter:inbound:graphql` +- Focused test: `./gradlew :adapter:inbound:graphql:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.inbound.graphql`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 +규칙 SSOT). + +## Responsibility + +- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`, + 프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를 + 자동 합성/바인딩하도록 얹는 얇은 계층이다. +- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/ + `@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.** + +## Allowed + +- `:application-core`, `:domain-core`, `:shared-contract`. +- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310` + (전부 Spring Boot BOM 관리 — 버전 명시 없음). + +## Forbidden + +- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드 + 포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit + `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이 + 모듈을 자동 커버 — per-module 규칙 추가 불필요). +- 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의 + `HealthcheckController` 와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다. +- 모듈별 `yml` — 설정은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에 산다. + +## Error mapping (`Category → ErrorType`) + +feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)를 던지면 +`GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다. +비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md). + +## Feature 기여 방법 + +- **스키마**: `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합쳐진다. +- **핸들러**: `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다. +- **도메인 예외 매핑**: sample 이 자신의 `DataFetcherExceptionResolver` 를 추가해 도메인 예외를 + `PortfolioErrorCode` 로 매핑한다(스켈레톤 resolver 보다 앞 순서). 스켈레톤은 `ApiErrorCarrier` 만 처리. + +`sample-portfolio` 를 지워도 스켈레톤은 health 스키마만으로 부팅한다 (disposability). + +## Test + +```bash +cd src +./gradlew :adapter:inbound:graphql:test +``` diff --git a/src/adapter/inbound/graphql/README.md b/src/adapter/inbound/graphql/README.md new file mode 100644 index 00000000..aa02d8ab --- /dev/null +++ b/src/adapter/inbound/graphql/README.md @@ -0,0 +1,76 @@ +# adapter-graphql — 설계 결정 참조 + +인바운드 GraphQL 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트: +`dev.caskeleton.adapter.inbound.graphql`. + +허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은 +[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 +모아둔 참조용 기록이다. + +--- + +## 왜 스켈레톤에 health 스키마 + 컨트롤러만 두는가 + +Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하므로 스켈레톤은 +`src/main/resources/graphql/skeleton.graphqls` 에 최소 스키마(`type Query { _health: String! }`)를 +싣고, `HealthGraphqlController` 가 그 필드를 상태 토큰(`UP`)으로 resolve 한다. web 어댑터의 +`HealthcheckController` 와 동일 원칙 — 스켈레톤은 **RPC/쿼리 0개**의 feature 로도 health 표면만으로 +부팅한다. 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두지 않는다. + +## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리 + +스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** Spring for GraphQL 이 두 축으로 자동 합성한다: + +- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의 + `worklog.graphqls` 가 스켈레톤의 `skeleton.graphqls` 와 자동으로 합쳐진다. +- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping` + 메서드를 바인딩한다. sample 의 `WorkLogGraphqlController` 가 스켈레톤을 수정하지 않고 등록된다. + +`sample-portfolio` 를 지우면 스켈레톤은 여전히 health 스키마만으로 부팅한다(web 과 동일한 +disposability 보장). + +## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제 + +`GraphqlExceptionResolver` 는 `DataFetcherExceptionResolverAdapter` 를 확장해, 데이터 페처가 +동기적으로 던진 예외 중 안정적 `ApiErrorCode` 를 실은 것(전송-중립 hook `ApiErrorCarrier` 구현)을 +`GraphQLError` 로 변환한다. 데이터 페처는 web 컨트롤러처럼 "그냥 던지기만" 하고, 이 resolver 가 +와이어 계약을 단일 소유한다. + +- **`ErrorType` 분류**: `errorCode().category()` 를 GraphQL `ErrorType` 으로 매핑한다(아래 표). + 정확한 `code`/`category` 는 error `extensions{code, category}` 로 실어 클라이언트가 switch 하게 + 한다(gRPC 가 status trailer 에 싣는 것과 동형). +- **`ApiErrorCode` 추출**: shared-contract 의 `PersistenceFailureException` / + `DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)과 feature 예외(자신의 도메인 + `ApiErrorCode` 를 실은 것)를 단일 `instanceof ApiErrorCarrier` 분기로 인식한다. +- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 error message/extensions 로 노출하고, raw + 예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. +- **비-`ApiErrorCarrier`** 예외는 `null` 을 반환해 다른 + `DataFetcherExceptionResolver` 빈(예: sample 의 도메인 예외 resolver)과 Spring 기본 처리로 + 넘긴다. + +`Category → ErrorType` 표(설계 스펙 Error Mapping SSOT): + +| `Category` | GraphQL `ErrorType` | +|---|---| +| VALIDATION | BAD_REQUEST | +| AUTH | UNAUTHORIZED | +| AUTHZ | FORBIDDEN | +| NOT_FOUND | NOT_FOUND | +| CONFLICT | BAD_REQUEST | +| RATE_LIMIT | BAD_REQUEST | +| TRANSIENT_DEPENDENCY | INTERNAL_ERROR | +| PERMANENT_DEPENDENCY | INTERNAL_ERROR | +| DATA_INTEGRITY | INTERNAL_ERROR | +| INTERNAL | INTERNAL_ERROR | + +## 의존성 버전 — strict locking + +gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한다. 그래서 이 모듈은 +버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만 +선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다. + +## 설정 — 프레임워크 `spring.graphql.*` + +이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema +location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다 +(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다. diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle new file mode 100644 index 00000000..6c3aaf78 --- /dev/null +++ b/src/adapter/inbound/graphql/build.gradle @@ -0,0 +1,28 @@ +// Driving adapter: GraphQL API (skeleton machinery, transport-only). +// +// Spring for GraphQL is schema-first: schema files live in src/main/resources/graphql/*.graphqls +// and are merged from classpath:graphql/** at boot. This skeleton ships ONLY the minimal health +// schema + @Controller so the module boots standalone with zero features (an empty schema fails to +// start); feature schema/controllers live in the sample module and compose automatically. +// +// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit +// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc +// coordinates the BOM does not manage). +description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)' + +dependencies { + implementation project(':application-core') + implementation project(':domain-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter-graphql' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema + + // controller through a real AnnotatedControllerConfigurer and drives it with an + // ExecutionGraphQlServiceTester. + testImplementation 'org.springframework.boot:spring-boot-starter-graphql-test' +} diff --git a/src/adapter/inbound/graphql/gradle.lockfile b/src/adapter/inbound/graphql/gradle.lockfile new file mode 100644 index 00000000..89eb9bf5 --- /dev/null +++ b/src/adapter/inbound/graphql/gradle.lockfile @@ -0,0 +1,171 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor +org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolver.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolver.java new file mode 100644 index 00000000..2d88b309 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolver.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.graphql; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.schema.DataFetchingEnvironment; +import java.util.Map; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.stereotype.Component; + +/** + * Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates + * any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link + * ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus + * machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web + * adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code + * GrpcExceptionHandlingInterceptor}. + * + * <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code + * PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an + * outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}), + * so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable + * returns {@code null}: Spring for GraphQL then merges the other {@link + * org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own + * resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link + * ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a + * SQLState or upstream detail. + */ +@Component +public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter { + + @Override + protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + if (!(ex instanceof ApiErrorCarrier carrier)) { + return null; // fall through to other resolvers / Spring's default handling + } + ApiErrorCode code = carrier.errorCode(); + var builder = + GraphqlErrorBuilder.newError() + .errorType(classify(code.category())) + .message(code.code()) + .extensions(Map.of("code", code.code(), "category", code.category().name())); + // A real GraphQL execution always supplies the environment; a unit test may pass null. Only + // attach the field path/location when they are present. + if (env != null) { + builder.path(env.getExecutionStepInfo().getPath()); + if (env.getField() != null) { + builder.location(env.getField().getSourceLocation()); + } + } + return builder.build(); + } + + /** + * Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design + * Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile + * until a mapping decision is made. + */ + private static ErrorType classify(Category category) { + return switch (category) { + case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST; + case AUTH -> ErrorType.UNAUTHORIZED; + case AUTHZ -> ErrorType.FORBIDDEN; + case NOT_FOUND -> ErrorType.NOT_FOUND; + case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL -> + ErrorType.INTERNAL_ERROR; + }; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlController.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlController.java new file mode 100644 index 00000000..a188644f --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlController.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.graphql; + +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +/** + * Minimal GraphQL health surface so the skeleton module boots standalone with zero features — the + * GraphQL sibling of the web adapter's {@code HealthcheckController}. The {@code _health} query + * resolves the {@code skeleton.graphqls} field of the same name to a fixed liveness token. Feature + * queries/mutations are contributed by the sample module's own {@code @Controller} beans and merged + * by Spring for GraphQL; this controller never names a feature type. + */ +@Controller +public class HealthGraphqlController { + + /** Stable liveness token, matching the web adapter's {@code status=UP} health semantics. */ + static final String STATUS_UP = "UP"; + + // The schema field is `_health` (a conventional meta-field name); the Java method is `health` so + // it satisfies the method-name checkstyle rule, with the field bound explicitly via `name`. + @QueryMapping(name = "_health") + public String health() { + return STATUS_UP; + } +} diff --git a/src/adapter/inbound/graphql/src/main/resources/graphql/skeleton.graphqls b/src/adapter/inbound/graphql/src/main/resources/graphql/skeleton.graphqls new file mode 100644 index 00000000..3f2faceb --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/resources/graphql/skeleton.graphqls @@ -0,0 +1,10 @@ +# Minimal GraphQL schema for the skeleton machinery module (schema-first). +# +# Spring for GraphQL merges every classpath:graphql/**/*.graphqls file at boot, so this health +# schema composes automatically with any feature schema the sample module contributes. It exists so +# the module boots standalone with zero features: Spring for GraphQL refuses to start on an empty +# schema, and the skeleton must never name a feature type (mirrors web's HealthcheckController). +type Query { + "Liveness token for the GraphQL transport — mirrors the web adapter's /healthcheck." + _health: String! +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolverTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolverTest.java new file mode 100644 index 00000000..57e2e31c --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/GraphqlExceptionResolverTest.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.inbound.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; +import graphql.GraphQLError; +import java.util.EnumSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.graphql.execution.ErrorType; + +/** + * Pins the 10-value {@link Category} → {@link ErrorType} classification table (design + * Error-Mapping) and the {@code code} / {@code category} extensions. One assertion per Category + * value guards against a silent remap on a Spring/graphql-java upgrade. Driven directly against + * {@code resolveToSingleError} with a null environment (no GraphQL engine needed), so it is a pure + * mapping unit test — the boot-level wiring is covered by {@link HealthGraphqlControllerTest}. + */ +class GraphqlExceptionResolverTest { + + private final GraphqlExceptionResolver resolver = new GraphqlExceptionResolver(); + + @ParameterizedTest + @CsvSource({ + "VALIDATION,BAD_REQUEST", + "AUTH,UNAUTHORIZED", + "AUTHZ,FORBIDDEN", + "NOT_FOUND,NOT_FOUND", + "CONFLICT,BAD_REQUEST", + "RATE_LIMIT,BAD_REQUEST", + "TRANSIENT_DEPENDENCY,INTERNAL_ERROR", + "PERMANENT_DEPENDENCY,INTERNAL_ERROR", + "DATA_INTEGRITY,INTERNAL_ERROR", + "INTERNAL,INTERNAL_ERROR", + }) + void mapsEachCategoryToItsErrorTypeWithExtensions(Category category, ErrorType expected) { + GraphQLError error = + resolver.resolveToSingleError(new CarrierException("SOME_CODE", category), null); + + assertThat(error).isNotNull(); + assertThat(error.getErrorType()).isEqualTo(expected); + assertThat(error.getExtensions()) + .containsEntry("code", "SOME_CODE") + .containsEntry("category", category.name()); + } + + @Test + void coversEveryCategoryValue() { + // Fails the moment a new Category is added without a mapping decision (switch is exhaustive). + for (Category category : EnumSet.allOf(Category.class)) { + assertThat(resolver.resolveToSingleError(new CarrierException("C", category), null)) + .isNotNull(); + } + } + + @Test + void surfacesOnlyTheStableCodeAsTheMessageNotTheRawException() { + GraphQLError error = + resolver.resolveToSingleError( + new CarrierException("WORKLOG_NOT_FOUND", Category.NOT_FOUND), null); + + assertThat(error.getMessage()).isEqualTo("WORKLOG_NOT_FOUND"); + } + + @Test + void returnsNullForNonCarrierExceptionSoOtherResolversHandleIt() { + assertThat(resolver.resolveToSingleError(new IllegalStateException("boom"), null)).isNull(); + } + + /** + * Feature-style throwable carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}. + */ + private static final class CarrierException extends RuntimeException implements ApiErrorCarrier { + private final ApiErrorCode errorCode; + + CarrierException(String code, Category category) { + super(code); + this.errorCode = new TestErrorCode(code, category); + } + + @Override + public ApiErrorCode errorCode() { + return errorCode; + } + } + + /** Minimal {@link ApiErrorCode} — only {@code code} / {@code category} matter for the mapping. */ + private record TestErrorCode(String code, Category category) implements ApiErrorCode { + @Override + public int httpStatus() { + return 0; + } + + @Override + public boolean retryable() { + return false; + } + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlControllerTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlControllerTest.java new file mode 100644 index 00000000..c0e9452f --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/HealthGraphqlControllerTest.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.graphql; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.graphql.ExecutionGraphQlService; +import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; +import org.springframework.graphql.execution.DefaultExecutionGraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; +import org.springframework.graphql.test.tester.GraphQlTester; + +/** + * Assembles the skeleton schema ({@code graphql/skeleton.graphqls}) and the {@link + * HealthGraphqlController}'s {@code @QueryMapping} through a real {@link + * AnnotatedControllerConfigurer} — the same wiring Spring for GraphQL uses at runtime — and drives + * the {@code _health} query with a {@link GraphQlTester}. Self-contained (no Spring Boot context), + * so it proves the module stands up a working GraphQL surface (schema + controller binding) with + * zero features, the GraphQL sibling of the gRPC skeleton's health boot test. + */ +class HealthGraphqlControllerTest { + + @Test + void healthQueryReturnsUpLivenessToken() { + graphQlTester() + .document("{ _health }") + .execute() + .path("_health") + .entity(String.class) + .isEqualTo("UP"); + } + + private static GraphQlTester graphQlTester() { + AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext(); + appContext.registerBean(HealthGraphqlController.class); + appContext.refresh(); + + AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer(); + controllerConfigurer.setApplicationContext(appContext); + controllerConfigurer.afterPropertiesSet(); + + GraphQlSource source = + GraphQlSource.schemaResourceBuilder() + .schemaResources(new ClassPathResource("graphql/skeleton.graphqls")) + .configureRuntimeWiring(controllerConfigurer) + .build(); + + ExecutionGraphQlService service = new DefaultExecutionGraphQlService(source); + return ExecutionGraphQlServiceTester.create(service); + } +} diff --git a/src/adapter/inbound/grpc/CLAUDE.md b/src/adapter/inbound/grpc/CLAUDE.md new file mode 100644 index 00000000..419404e6 --- /dev/null +++ b/src/adapter/inbound/grpc/CLAUDE.md @@ -0,0 +1,64 @@ +# adapter:inbound:grpc — inbound gRPC adapter (skeleton machinery) + +## Registered identity + +- Module ID: `adapter-inbound-grpc` +- Gradle path: `:adapter:inbound:grpc` +- Focused test: `./gradlew :adapter:inbound:grpc:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.inbound.grpc`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 +규칙 SSOT). + +## Responsibility + +- gRPC 전송 인프라만: 서버 수명주기(`GrpcServerRunner`), 타입드 설정(`GrpcServerProperties`), + 프로토콜 에러 매핑(`GrpcStatusMapper` + `GrpcExceptionHandlingInterceptor`), 그리고 `.proto` + 없이도 부팅하는 최소 표면(standard health + reflection). +- feature-agnostic: 모든 `io.grpc.BindableService` 빈을 generic 하게 등록한다. **WorkLog 등 + 구체 기능을 이름으로 알지 않는다.** + +## Allowed + +- `:application-core`, `:domain-core`, `:shared-contract`. +- `io.grpc:*` (grpc-netty-shaded / grpc-protobuf / grpc-stub / grpc-services), `spring-boot-starter`. + +## Forbidden + +- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드 + 포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit + `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`). +- 이 스켈레톤 모듈에서의 `com.google.protobuf` 플러그인 / `.proto` — 스키마와 서비스는 feature + (sample) 모듈이 소유한다. +- 프로덕션 feature RPC 를 스켈레톤에 두는 것 — health/reflection 표면만 (web 의 + `HealthcheckController` 와 동일 원칙). + +## Config knobs (`ca-skeleton.grpc.*`) + +타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다 +(모듈별 `yml` 없음). + +| key | default | 의미 | +|---|---|---| +| `enabled` | `true` | gRPC 서버 기동 여부. 프로덕션 composition root 는 property 로 끌 수 있다 | +| `port` | `9090` | 바인딩 TCP 포트. `0` 이면 ephemeral 포트(테스트) | +| `reflectionEnabled` | `true` | v1 server reflection 노출(grpcurl/Postman 편의; 프로덕션에선 끄기) | +| `shutdownGraceSeconds` | `5` | graceful shutdown 시 in-flight RPC 대기 초 | + +## Feature 기여 방법 + +feature 모듈은 `io.grpc.BindableService` 를 `@Bean` 으로 등록하기만 하면 +`GrpcServerRunner` 의 `ObjectProvider` 가 자동으로 인터셉터 뒤에 등록한다. 에러는 +`ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)로 던지면 +`GrpcExceptionHandlingInterceptor` 가 매핑한다. `sample-portfolio` 를 지워도 스켈레톤은 +health + reflection 만으로 부팅한다 (disposability). + +## Test + +```bash +cd src +./gradlew :adapter:inbound:grpc:test +``` diff --git a/src/adapter/inbound/grpc/README.md b/src/adapter/inbound/grpc/README.md new file mode 100644 index 00000000..8ca580dd --- /dev/null +++ b/src/adapter/inbound/grpc/README.md @@ -0,0 +1,85 @@ +# adapter-grpc — 설계 결정 참조 + +인바운드 gRPC 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트: +`dev.caskeleton.adapter.inbound.grpc`. + +허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은 +[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 +모아둔 참조용 기록이다. + +--- + +## 왜 self-managed Netty 인가 + +- **third-party grpc-spring-boot starter 를 쓰지 않는다.** `GrpcServerRunner` 가 io.grpc Netty + `Server` 를 Spring `SmartLifecycle` 빈으로 직접 소유한다. starter 를 쓰면 Spring Boot 릴리스에 + 버전이 커플링되는데, 스켈레톤은 io.grpc 런타임에만 의존해 그 커플링을 피한다(web 어댑터가 + third-party 없이 서블릿 컨테이너를 쓰는 것과 같은 정신). +- **`getPhase()` = `Integer.MAX_VALUE - 1`.** web 서버가 뜬 **뒤에** 시작하고 종료 시 web 서버 + **전에** 멈춘다(SmartLifecycle: 높은 phase 가 늦게 시작·먼저 종료). gRPC 는 web 과 별개의 TCP + 포트를 소유하는 부가 전송이므로 애플리케이션 수명주기 맨 바깥에 둔다. +- **graceful shutdown.** `shutdownGraceSeconds` 동안 in-flight RPC 를 기다린 뒤 + `shutdownNow()`. 종료 진입 시 health 를 `enterTerminalState()`(NOT_SERVING)로 뒤집어 + 로드밸런서가 드레이닝을 인지하게 한다. +- **insecure bind (기본).** 스켈레톤은 참조 포스처와 동일하게 평문으로 바인딩하고 mTLS 는 + 범위 밖(문서화된 knob). 프로덕션 fork 가 전송 보안을 얹는다. + +## 왜 `.proto` 도 protobuf 플러그인도 없는가 + +이 모듈은 protobuf 를 **하나도 컴파일하지 않는다** — `com.google.protobuf` 플러그인도, +`src/main/proto` 도 없다. health(`grpc.health.v1`) 와 v1 server reflection 은 `grpc-services` +런타임 jar 에 이미 컴파일된 채 들어 있어, 스켈레톤은 **RPC 0개**로도 동작하는 health + +reflection 표면을 갖고 부팅한다. 기능(feature)의 `.proto`/서비스/매퍼는 `sample-portfolio` 의 +gRPC 어댑터가 `com.google.protobuf` 플러그인과 함께 소유한다. + +`compileOnly org.apache.tomcat:annotations-api` 는 생성된 stub 이 참조하는 +`javax.annotation.Generated` 때문 — 스켈레톤 자체는 stub 을 생성하지 않지만 feature 모듈과의 +패리티를 위해 선언한다. + +## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리 + +스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** `GrpcServerRunner` 는 생성자에서 +`ObjectProvider<io.grpc.BindableService>` 를 받아, 컨텍스트에 존재하는 **모든** +`BindableService` 빈을 `ServerInterceptors.intercept(service, exceptionInterceptor)` 로 감싸 +등록한다. 그래서 sample 의 `WorkLogGrpcService` 같은 feature 서비스가 스켈레톤을 수정하지 않고 +자동 등록된다. `sample-portfolio` 를 지우면 스켈레톤은 여전히 health + reflection 만으로 부팅한다 +(web 과 동일한 disposability 보장). + +## 에러 매핑 — web `GlobalExceptionHandler` 의 gRPC 형제 + +`GrpcExceptionHandlingInterceptor` 가 핸들러에서 동기적으로 던져진 `RuntimeException` 을 잡아 +`ServerCall.close(status, trailers)` 로 변환한다. 서비스 구현은 web 컨트롤러처럼 "그냥 던지기만" +하고, 이 인터셉터가 와이어 계약을 단일 소유한다. + +- **와이어 status(coarse)** 는 `GrpcStatusMapper.toStatus(Category)` 가 결정한다(HTTP status 가 + coarse 인 것과 동형). 정확한 `code`/`category` 는 `Status` trailer `Metadata`(`error-code` / + `error-category`)에 실어 클라이언트가 switch 하게 한다. +- **`ApiErrorCode` 추출**: feature 예외는 `ApiErrorCarrier`(이 모듈이 제공하는 전송-중립 hook)를 + 구현해 자신의 `ApiErrorCode` 를 노출한다. shared-contract 의 `PersistenceFailureException` / + `DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)도 직접 인식한다. +- **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 status description/trailer 로 노출하고, raw + 예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. 인식되지 + 않은 `RuntimeException` 은 `Status.INTERNAL` + `INTERNAL_ERROR` 로 폴백한다. + +`Category → Status` 표(설계 스펙 Error Mapping SSOT): + +| `Category` | gRPC `Status` | +|---|---| +| VALIDATION | INVALID_ARGUMENT | +| AUTH | UNAUTHENTICATED | +| AUTHZ | PERMISSION_DENIED | +| NOT_FOUND | NOT_FOUND | +| CONFLICT | ABORTED | +| RATE_LIMIT | RESOURCE_EXHAUSTED | +| TRANSIENT_DEPENDENCY | UNAVAILABLE | +| PERMANENT_DEPENDENCY | INTERNAL | +| DATA_INTEGRITY | INTERNAL | +| INTERNAL | INTERNAL | + +## 의존성 버전 — strict locking + +Spring Boot BOM 은 `io.grpc:*`/protobuf 버전을 관리하지 않고 이 저장소엔 version catalog 도 +없다. 그래서 `io.grpc:grpc-bom` + `com.google.protobuf:protobuf-bom` 을 **이 모듈의** +`dependencyManagement` 에서 platform 으로 import 한다(루트 `ext.grpcVersion`/`ext.protobufVersion` +가 단일 SSOT). 모듈 스코프로 두어 strict per-module lockfile 의 blast radius 를 이 모듈에만 +가둔다 — 공유 루트 dependencyManagement 블록은 io.grpc-free 로 유지된다. diff --git a/src/adapter/inbound/grpc/build.gradle b/src/adapter/inbound/grpc/build.gradle new file mode 100644 index 00000000..dedfafd9 --- /dev/null +++ b/src/adapter/inbound/grpc/build.gradle @@ -0,0 +1,36 @@ +// Driving adapter: gRPC API (skeleton machinery, transport-only). +// +// A SmartLifecycle bean (GrpcServerRunner) owns the io.grpc Netty server, so this module depends on +// NO third-party grpc-spring-boot starter (no Spring Boot version coupling). The skeleton compiles +// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here — health + reflection +// come from grpc-services at runtime, and feature `.proto`/services live in the sample module. +// +// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version +// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root +// `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to +// this module (the shared root dependencyManagement block stays io.grpc-free). +dependencyManagement { + imports { + mavenBom "io.grpc:grpc-bom:${grpcVersion}" + mavenBom "com.google.protobuf:protobuf-bom:${protobufVersion}" + } +} + +dependencies { + implementation project(':application-core') + implementation project(':domain-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter' + + implementation 'io.grpc:grpc-netty-shaded' + implementation 'io.grpc:grpc-protobuf' + implementation 'io.grpc:grpc-stub' + implementation 'io.grpc:grpc-services' // health + reflection (grpc.health.v1 / reflection) + + // grpc-java generated stubs reference javax.annotation.Generated; kept compileOnly for parity + // with the feature module (the skeleton itself generates no stubs). + compileOnly 'org.apache.tomcat:annotations-api:6.0.53' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} diff --git a/src/adapter/inbound/grpc/gradle.lockfile b/src/adapter/inbound/grpc/gradle.lockfile new file mode 100644 index 00000000..e5a81077 --- /dev/null +++ b/src/adapter/inbound/grpc/gradle.lockfile @@ -0,0 +1,173 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath +com.google.api.grpc:proto-google-common-protos:2.41.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=runtimeClasspath,spotbugs,testRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=runtimeClasspath,spotbugs,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.2.1-android=compileClasspath,testCompileClasspath +com.google.guava:guava:33.2.1-jre=runtimeClasspath,testRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:2.8=runtimeClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath +com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-context:1.68.1=runtimeClasspath,testRuntimeClasspath +io.grpc:grpc-core:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-netty-shaded:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-protobuf-lite:1.68.1=runtimeClasspath,testRuntimeClasspath +io.grpc:grpc-protobuf:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat:annotations-api:6.0.53=compileClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorException.java b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorException.java new file mode 100644 index 00000000..e808c723 --- /dev/null +++ b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorException.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; + +/** + * Adapter-level throwable a feature gRPC service throws after mapping a domain exception to a + * stable {@link ApiErrorCode} (typically a feature code such as {@code + * PortfolioErrorCode.WORKLOG_NOT_FOUND} or a skeleton {@code OperationalError}). It implements the + * shared-contract {@link ApiErrorCarrier} hook so the {@link GrpcExceptionHandlingInterceptor} + * translates it to the matching gRPC {@code Status} plus {@code code} / {@code category} trailers + * through the same single carrier branch that handles the shared-contract infra exceptions. + * + * <p>This is the gRPC sibling of "the web adapter service just throws and one handler owns the wire + * mapping": a feature service does the domain-exception → {@code ApiErrorCode} mapping (its own + * concern) and throws this; the transport mapping stays in the interceptor. The supplied message is + * server-log-only detail — only {@link #errorCode()} reaches the client. + */ +public class ApiErrorException extends RuntimeException implements ApiErrorCarrier { + + private static final long serialVersionUID = 1L; + + private final transient ApiErrorCode errorCode; + + /** + * @param errorCode the classified, client-facing code surfaced on the gRPC status trailers + * @param message server-log-only diagnostic detail — never surfaced to the client + */ + public ApiErrorException(ApiErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + @Override + public ApiErrorCode errorCode() { + return errorCode; + } +} diff --git a/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptor.java b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptor.java new file mode 100644 index 00000000..74fdec5e --- /dev/null +++ b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptor.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.OperationalError; +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Centralises the gRPC error contract: a feature service just throws, and this {@link + * ServerInterceptor} translates a synchronous {@link RuntimeException} from the handler into a + * {@code ServerCall#close(Status, Metadata)} carrying the mapped {@link Status} plus {@code code} / + * {@code category} trailers — the gRPC sibling of the web adapter's {@code GlobalExceptionHandler}. + * + * <p>A stable {@link ApiErrorCode} is recognised through the shared-contract {@link + * ApiErrorCarrier} hook — implemented by a feature throwable (the gRPC {@link ApiErrorException} + * carrying a mapped domain code) and by the shared-contract {@code PersistenceFailureException} / + * {@code DependencyFailureException}, so a single {@code instanceof ApiErrorCarrier} branch covers + * them all. An unrecognised {@link RuntimeException} maps to {@link Status#INTERNAL} with {@link + * OperationalError#INTERNAL_ERROR}. Only the stable code string reaches the client (via the status + * description and trailers) — a raw exception message, which may carry a SQLState or upstream + * detail, is never surfaced. + */ +public class GrpcExceptionHandlingInterceptor implements ServerInterceptor { + + private final GrpcStatusMapper statusMapper; + + public GrpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) { + this.statusMapper = statusMapper; + } + + @Override + public <REQT, RESPT> ServerCall.Listener<REQT> interceptCall( + ServerCall<REQT, RESPT> call, Metadata headers, ServerCallHandler<REQT, RESPT> next) { + AtomicBoolean closed = new AtomicBoolean(false); + ServerCall.Listener<REQT> delegate; + try { + delegate = next.startCall(call, headers); + } catch (RuntimeException e) { + closeWithError(call, closed, e); + return new ServerCall.Listener<>() {}; + } + return new SimpleForwardingServerCallListener<>(delegate) { + @Override + public void onMessage(REQT message) { + runGuarded(() -> super.onMessage(message)); + } + + @Override + public void onHalfClose() { + runGuarded(super::onHalfClose); + } + + @Override + public void onReady() { + runGuarded(super::onReady); + } + + private void runGuarded(Runnable action) { + try { + action.run(); + } catch (RuntimeException e) { + closeWithError(call, closed, e); + } + } + }; + } + + private void closeWithError( + ServerCall<?, ?> call, AtomicBoolean closed, RuntimeException exception) { + if (!closed.compareAndSet(false, true)) { + return; // the call was already closed once — never double-close. + } + ApiErrorCode code = errorCodeOf(exception); + Status status; + if (code != null) { + status = statusMapper.toStatus(code.category()).withDescription(code.code()); + } else { + code = OperationalError.INTERNAL_ERROR; + status = Status.INTERNAL.withDescription(code.code()); + } + call.close(status, statusMapper.trailersFor(code)); + } + + private static ApiErrorCode errorCodeOf(Throwable throwable) { + if (throwable instanceof ApiErrorCarrier carrier) { + return carrier.errorCode(); + } + return null; + } +} diff --git a/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerConfig.java b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerConfig.java new file mode 100644 index 00000000..99d717ef --- /dev/null +++ b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerConfig.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import io.grpc.BindableService; +import io.grpc.protobuf.services.HealthStatusManager; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires the gRPC transport machinery, only when {@code ca-skeleton.grpc.enabled=true} (default). + * All collaborators are plain objects composed here, mirroring the clean DI style used across the + * skeleton. Feature {@link BindableService} beans are injected via {@link ObjectProvider} and + * registered generically by {@link GrpcServerRunner}. See README. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "ca-skeleton.grpc", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +@EnableConfigurationProperties(GrpcServerProperties.class) +public class GrpcServerConfig { + + @Bean + GrpcStatusMapper grpcStatusMapper() { + return new GrpcStatusMapper(); + } + + @Bean + GrpcExceptionHandlingInterceptor grpcExceptionHandlingInterceptor(GrpcStatusMapper statusMapper) { + return new GrpcExceptionHandlingInterceptor(statusMapper); + } + + @Bean + HealthStatusManager grpcHealthStatusManager() { + return new HealthStatusManager(); + } + + @Bean + GrpcServerRunner grpcServerRunner( + ObjectProvider<BindableService> services, + GrpcServerProperties properties, + GrpcExceptionHandlingInterceptor exceptionInterceptor, + HealthStatusManager healthStatusManager) { + return new GrpcServerRunner(services, properties, exceptionInterceptor, healthStatusManager); + } +} diff --git a/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerProperties.java b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerProperties.java new file mode 100644 index 00000000..5c8ef8b3 --- /dev/null +++ b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerProperties.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * gRPC server settings bound from {@code ca-skeleton.grpc.*}. Typed configuration only (no + * per-module {@code yml}); values live in the composition-root {@code application.yml}, matching + * the ca-skeleton config convention. See README for the self-managed-Netty rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.grpc") +public class GrpcServerProperties { + + /** Whether to start the gRPC server at all (feature composition can keep it off by property). */ + private boolean enabled = true; + + /** TCP port the gRPC server binds to. Set to {@code 0} to bind an ephemeral port (tests). */ + private int port = 9090; + + /** Expose server reflection (handy for grpcurl / Postman; disable in production). */ + private boolean reflectionEnabled = true; + + /** Seconds to wait for in-flight RPCs to finish on graceful shutdown. */ + private int shutdownGraceSeconds = 5; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public boolean isReflectionEnabled() { + return reflectionEnabled; + } + + public void setReflectionEnabled(boolean reflectionEnabled) { + this.reflectionEnabled = reflectionEnabled; + } + + public int getShutdownGraceSeconds() { + return shutdownGraceSeconds; + } + + public void setShutdownGraceSeconds(int shutdownGraceSeconds) { + this.shutdownGraceSeconds = shutdownGraceSeconds; + } +} diff --git a/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunner.java b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunner.java new file mode 100644 index 00000000..4f98d723 --- /dev/null +++ b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunner.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import io.grpc.BindableService; +import io.grpc.Grpc; +import io.grpc.InsecureServerCredentials; +import io.grpc.Server; +import io.grpc.ServerInterceptors; +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; +import io.grpc.protobuf.services.ProtoReflectionServiceV1; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.SmartLifecycle; + +/** + * Owns the io.grpc Netty {@link Server} lifecycle as a Spring {@link SmartLifecycle} bean — start + * on context refresh, graceful shutdown on close. Deliberately avoids any third-party + * grpc-spring-boot starter so the skeleton has no Spring Boot version coupling. + * + * <p>Feature services are discovered generically: every {@link BindableService} bean is registered + * behind the {@link GrpcExceptionHandlingInterceptor}, so a feature (e.g. the sample WorkLog + * service) auto-registers without the skeleton naming it. The skeleton also registers the standard + * {@code grpc.health.v1} health service and, when enabled, the v1 server reflection service, so it + * boots with a working surface and ZERO {@code .proto}. See README. + */ +public class GrpcServerRunner implements SmartLifecycle { + + private static final Logger log = LoggerFactory.getLogger(GrpcServerRunner.class); + + private final ObjectProvider<BindableService> services; + private final GrpcServerProperties properties; + private final GrpcExceptionHandlingInterceptor exceptionInterceptor; + private final HealthStatusManager healthStatusManager; + private volatile Server server; + + public GrpcServerRunner( + ObjectProvider<BindableService> services, + GrpcServerProperties properties, + GrpcExceptionHandlingInterceptor exceptionInterceptor, + HealthStatusManager healthStatusManager) { + this.services = services; + this.properties = properties; + this.exceptionInterceptor = exceptionInterceptor; + this.healthStatusManager = healthStatusManager; + } + + @Override + public void start() { + if (isRunning()) { + return; + } + var builder = + Grpc.newServerBuilderForPort(properties.getPort(), InsecureServerCredentials.create()); + + int registered = 0; + for (BindableService service : services) { + builder.addService(ServerInterceptors.intercept(service, exceptionInterceptor)); + registered++; + } + + healthStatusManager.setStatus( + HealthStatusManager.SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING); + builder.addService(healthStatusManager.getHealthService()); + if (properties.isReflectionEnabled()) { + builder.addService(ProtoReflectionServiceV1.newInstance()); + } + + try { + server = builder.build().start(); + log.info( + "gRPC server started on port {} ({} feature service(s), reflection={})", + server.getPort(), + registered, + properties.isReflectionEnabled()); + } catch (IOException e) { + throw new UncheckedIOException( + "failed to start gRPC server on port " + properties.getPort(), e); + } + } + + @Override + public void stop() { + Server current = this.server; + if (current == null) { + return; + } + healthStatusManager.enterTerminalState(); + try { + current.shutdown(); + if (!current.awaitTermination(properties.getShutdownGraceSeconds(), TimeUnit.SECONDS)) { + current.shutdownNow(); + } + log.info("gRPC server stopped"); + } catch (InterruptedException e) { + current.shutdownNow(); + Thread.currentThread().interrupt(); + } finally { + this.server = null; + } + } + + @Override + public boolean isRunning() { + Server current = this.server; + return current != null && !current.isShutdown(); + } + + /** + * Actual bound port — useful when configured with port 0 for tests; {@code -1} when not started. + */ + public int getListeningPort() { + Server current = this.server; + return current != null ? current.getPort() : -1; + } + + @Override + public int getPhase() { + // Start after the web server is up, stop before it during shutdown. + return Integer.MAX_VALUE - 1; + } +} diff --git a/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapper.java b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapper.java new file mode 100644 index 00000000..07efccf3 --- /dev/null +++ b/src/adapter/inbound/grpc/src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapper.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; +import io.grpc.Metadata; +import io.grpc.Status; + +/** + * Pure translation of the 10-value operational {@link Category} SSOT to an {@link io.grpc.Status}, + * plus helpers to carry the machine-readable {@code code} / {@code category} on the response + * trailer {@link Metadata}. This is the gRPC sibling of the web adapter's error contract: the wire + * status (like an HTTP status) is coarse, while the exact {@link ApiErrorCode#code()} and the + * category name ride in the trailers for the client to switch on. + * + * <p>The classification table is fixed by the design spec's Error Mapping section; see README. + */ +public class GrpcStatusMapper { + + /** + * Trailer key carrying the stable {@link ApiErrorCode#code()} (e.g. {@code WORKLOG_NOT_FOUND}). + */ + static final Metadata.Key<String> CODE_KEY = + Metadata.Key.of("error-code", Metadata.ASCII_STRING_MARSHALLER); + + /** Trailer key carrying the {@link Category} enum name (e.g. {@code NOT_FOUND}). */ + static final Metadata.Key<String> CATEGORY_KEY = + Metadata.Key.of("error-category", Metadata.ASCII_STRING_MARSHALLER); + + /** + * Maps an operational {@link Category} to its gRPC {@link Status} (design Error-Mapping table). + */ + public Status toStatus(Category category) { + return switch (category) { + case VALIDATION -> Status.INVALID_ARGUMENT; + case AUTH -> Status.UNAUTHENTICATED; + case AUTHZ -> Status.PERMISSION_DENIED; + case NOT_FOUND -> Status.NOT_FOUND; + case CONFLICT -> Status.ABORTED; + case RATE_LIMIT -> Status.RESOURCE_EXHAUSTED; + case TRANSIENT_DEPENDENCY -> Status.UNAVAILABLE; + case PERMANENT_DEPENDENCY -> Status.INTERNAL; + case DATA_INTEGRITY -> Status.INTERNAL; + case INTERNAL -> Status.INTERNAL; + }; + } + + /** + * Writes the {@code code} and {@code category} of {@code errorCode} onto a fresh trailer {@link + * Metadata}, returned for {@code ServerCall#close(Status, Metadata)}. + */ + public Metadata trailersFor(ApiErrorCode errorCode) { + Metadata trailers = new Metadata(); + trailers.put(CODE_KEY, errorCode.code()); + trailers.put(CATEGORY_KEY, errorCode.category().name()); + return trailers; + } +} diff --git a/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorExceptionTest.java b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorExceptionTest.java new file mode 100644 index 00000000..e69571c2 --- /dev/null +++ b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorExceptionTest.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.OperationalError; +import org.junit.jupiter.api.Test; + +/** + * Verifies {@link ApiErrorException} carries its {@link dev.caskeleton.shared.error.ApiErrorCode} + * through the shared-contract {@link ApiErrorCarrier} hook unchanged, and keeps the diagnostic + * message off the carrier surface. + */ +class ApiErrorExceptionTest { + + @Test + void errorCodeRoundTripsThroughTheCarrierHook() { + ApiErrorException exception = + new ApiErrorException(OperationalError.BAD_PARAMETER, "server-log-only detail"); + + assertThat(exception).isInstanceOf(ApiErrorCarrier.class); + assertThat(exception.errorCode()).isEqualTo(OperationalError.BAD_PARAMETER); + assertThat(exception.getMessage()).isEqualTo("server-log-only detail"); + } +} diff --git a/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptorTest.java b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptorTest.java new file mode 100644 index 00000000..6ef2876d --- /dev/null +++ b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptorTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.Status; +import org.junit.jupiter.api.Test; + +/** + * Verifies the interceptor translates a synchronous handler exception into a mapped {@code + * close(status, trailers)} — recognising the {@link ApiErrorCarrier} feature hook and the + * shared-contract {@link PersistenceFailureException}, and falling back to {@link Status#INTERNAL} + * for an unrecognised {@link RuntimeException}. Driven with a capturing fake {@link ServerCall}, so + * no channel/server is needed. + */ +class GrpcExceptionHandlingInterceptorTest { + + private final GrpcExceptionHandlingInterceptor interceptor = + new GrpcExceptionHandlingInterceptor(new GrpcStatusMapper()); + + @Test + void mapsApiErrorCarrierToItsCategoryStatusWithTrailers() { + CapturingServerCall call = + closeAfterThrowing(new CarrierException(OperationalError.BAD_PARAMETER)); + + assertThat(call.status.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("BAD_PARAMETER"); + assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("VALIDATION"); + } + + @Test + void mapsApiErrorExceptionByItsCarriedCode() { + CapturingServerCall call = + closeAfterThrowing( + new ApiErrorException(OperationalError.ROUTE_NOT_FOUND, "server-log-only detail")); + + assertThat(call.status.getCode()).isEqualTo(Status.Code.NOT_FOUND); + assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("ROUTE_NOT_FOUND"); + assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("NOT_FOUND"); + } + + @Test + void mapsSharedPersistenceFailureByItsClassifiedCode() { + CapturingServerCall call = + closeAfterThrowing( + new PersistenceFailureException(OperationalError.DB_UNAVAILABLE, "08006", null)); + + assertThat(call.status.getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("DB_UNAVAILABLE"); + assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("TRANSIENT_DEPENDENCY"); + } + + @Test + void mapsUnknownRuntimeExceptionToInternal() { + CapturingServerCall call = closeAfterThrowing(new IllegalStateException("boom")); + + assertThat(call.status.getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(call.trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("INTERNAL_ERROR"); + assertThat(call.trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("INTERNAL"); + } + + private CapturingServerCall closeAfterThrowing(RuntimeException thrown) { + CapturingServerCall call = new CapturingServerCall(); + ServerCallHandler<String, String> handler = + (serverCall, headers) -> + new ServerCall.Listener<>() { + @Override + public void onHalfClose() { + throw thrown; + } + }; + ServerCall.Listener<String> listener = interceptor.interceptCall(call, new Metadata(), handler); + listener.onHalfClose(); + return call; + } + + /** + * Feature-style exception carrying an {@link ApiErrorCode} through the {@link ApiErrorCarrier}. + */ + private static final class CarrierException extends RuntimeException implements ApiErrorCarrier { + private final ApiErrorCode errorCode; + + CarrierException(ApiErrorCode errorCode) { + super(errorCode.code()); + this.errorCode = errorCode; + } + + @Override + public ApiErrorCode errorCode() { + return errorCode; + } + } + + /** Minimal {@link ServerCall} that records the {@code close(status, trailers)} arguments. */ + private static final class CapturingServerCall extends ServerCall<String, String> { + private Status status; + private Metadata trailers; + + @Override + public void request(int numMessages) {} + + @Override + public void sendHeaders(Metadata headers) {} + + @Override + public void sendMessage(String message) {} + + @Override + public void close(Status status, Metadata trailers) { + this.status = status; + this.trailers = trailers; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public MethodDescriptor<String, String> getMethodDescriptor() { + return null; + } + } +} diff --git a/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunnerBootTest.java b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunnerBootTest.java new file mode 100644 index 00000000..107c30bd --- /dev/null +++ b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunnerBootTest.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.health.v1.HealthCheckRequest; +import io.grpc.health.v1.HealthCheckResponse; +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.health.v1.HealthGrpc; +import io.grpc.reflection.v1.ServerReflectionGrpc; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.reflection.v1.ServiceResponse; +import io.grpc.stub.StreamObserver; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Boots the skeleton gRPC machinery in a real Spring context on an ephemeral port ({@code + * ca-skeleton.grpc.port=0}) with ZERO feature services and proves it stands up a working surface: + * the {@link GrpcServerRunner} SmartLifecycle starts, the standard {@code grpc.health.v1} health + * service reports SERVING, and v1 server reflection lists the built-in services. A real Netty + * channel exercises the wire, so this is a genuine transport smoke test, not a wiring mock. + */ +class GrpcServerRunnerBootTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withUserConfiguration(GrpcServerConfig.class) + .withPropertyValues("ca-skeleton.grpc.port=0"); + + @Test + void skeletonServerStartsAndServesHealthAndReflectionWithNoFeatures() { + contextRunner.run( + context -> { + GrpcServerRunner runner = context.getBean(GrpcServerRunner.class); + assertThat(runner.isRunning()).isTrue(); + + int port = runner.getListeningPort(); + assertThat(port).isGreaterThan(0); + + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + try { + HealthCheckResponse health = + HealthGrpc.newBlockingStub(channel).check(HealthCheckRequest.newBuilder().build()); + assertThat(health.getStatus()).isEqualTo(ServingStatus.SERVING); + + assertThat(listServicesViaReflection(channel)) + .contains("grpc.health.v1.Health", "grpc.reflection.v1.ServerReflection"); + } finally { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + }); + } + + private static List<String> listServicesViaReflection(ManagedChannel channel) + throws InterruptedException { + List<String> services = new ArrayList<>(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference<Throwable> error = new AtomicReference<>(); + + StreamObserver<ServerReflectionRequest> requests = + ServerReflectionGrpc.newStub(channel) + .serverReflectionInfo( + new StreamObserver<>() { + @Override + public void onNext(ServerReflectionResponse response) { + for (ServiceResponse service : + response.getListServicesResponse().getServiceList()) { + services.add(service.getName()); + } + } + + @Override + public void onError(Throwable t) { + error.set(t); + done.countDown(); + } + + @Override + public void onCompleted() { + done.countDown(); + } + }); + requests.onNext(ServerReflectionRequest.newBuilder().setListServices("").build()); + requests.onCompleted(); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(error.get()).isNull(); + return services; + } +} diff --git a/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapperTest.java b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapperTest.java new file mode 100644 index 00000000..c32c0f1e --- /dev/null +++ b/src/adapter/inbound/grpc/src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapperTest.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.grpc; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import io.grpc.Metadata; +import io.grpc.Status; +import java.util.EnumSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Pins the 10-value {@link Category} → {@link Status} classification table (design Error-Mapping) + * and the {@code code} / {@code category} trailer helper. One assertion per Category value guards + * against a silent remap on a Spring/grpc upgrade. + */ +class GrpcStatusMapperTest { + + private final GrpcStatusMapper mapper = new GrpcStatusMapper(); + + @ParameterizedTest + @CsvSource({ + "VALIDATION,INVALID_ARGUMENT", + "AUTH,UNAUTHENTICATED", + "AUTHZ,PERMISSION_DENIED", + "NOT_FOUND,NOT_FOUND", + "CONFLICT,ABORTED", + "RATE_LIMIT,RESOURCE_EXHAUSTED", + "TRANSIENT_DEPENDENCY,UNAVAILABLE", + "PERMANENT_DEPENDENCY,INTERNAL", + "DATA_INTEGRITY,INTERNAL", + "INTERNAL,INTERNAL", + }) + void mapsEachCategoryToItsGrpcStatusCode(Category category, Status.Code expected) { + assertThat(mapper.toStatus(category).getCode()).isEqualTo(expected); + } + + @Test + void coversEveryCategoryValue() { + // Fails the moment a new Category is added without a mapping decision (switch is exhaustive). + for (Category category : EnumSet.allOf(Category.class)) { + assertThat(mapper.toStatus(category)).isNotNull(); + } + } + + @Test + void trailersCarryStableCodeAndCategoryName() { + Metadata trailers = mapper.trailersFor(OperationalError.RATE_LIMIT_EXCEEDED); + + assertThat(trailers.get(GrpcStatusMapper.CODE_KEY)).isEqualTo("RATE_LIMIT_EXCEEDED"); + assertThat(trailers.get(GrpcStatusMapper.CATEGORY_KEY)).isEqualTo("RATE_LIMIT"); + } +} diff --git a/src/adapter/inbound/web/CLAUDE.md b/src/adapter/inbound/web/CLAUDE.md new file mode 100644 index 00000000..0757a878 --- /dev/null +++ b/src/adapter/inbound/web/CLAUDE.md @@ -0,0 +1,198 @@ +# adapter:inbound:web — inbound HTTP adapter + +## Registered identity + +- Module ID: `adapter-inbound-web` +- Gradle path: `:adapter:inbound:web` +- Focused test: `./gradlew :adapter:inbound:web:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.inbound.web`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT). + +## Responsibility + +- HTTP controllers. +- Request/response DTOs. +- Request DTO to application command mapping. +- Authentication, validation, error mapping, filters, and web/security settings. + +## Allowed + +- `:application-core` +- `:domain-core` +- `:shared-contract` +- Spring Web/Security/Validation dependencies. + +## Forbidden + +- Direct dependency on `adapter-persistence` or `adapter-outbound`. +- Direct repository or JPA entity access from controllers. +- Core business rules in controller, filter, config, mapper, or settings code. +- DTO leakage into application or domain. + +## Boundary validation & mapper contract + +`feature-boundary-validation-mapping-contract` (LLM Wiki branch note) fixes the +behaviour at this layer's boundaries. The repo-level guardrails (ArchUnit + +Jackson config + handler) only catch the static violations — the contract below +also drives the runtime patterns reference implementations must follow. + +- **B1 — Jackson policy at the request boundary.** `spring.jackson.deserialization` + pins `fail-on-unknown-properties`, `fail-on-null-for-primitives`, + `fail-on-ignored-properties` to `true` and `read-unknown-enum-values-as-null` + to `false`. Do NOT undo this per-DTO with class-level + `@JsonIgnoreProperties(ignoreUnknown = true)` — ArchUnit rule + `request_dtos_do_not_silence_unknown_fields` blocks it. Use wrapper types + (`Integer`, `Long`, `Boolean`, `Optional<T>`) in request records so JSON + `null` cannot become primitive `0`. +- **B2 — PATCH semantics.** Do not adopt RFC 7396 `application/merge-patch+json` + (`null = deletion`). PATCH endpoints must distinguish *absent* (no change), + *explicit null* (clear field), and *value* (replace). Use + `org.openapitools:jackson-databind-nullable` (`JsonNullable<T>`) or + `Optional<T>` wrappers on request records. +- **B3 — Mapper-internal failures.** Map record canonical-constructor + `IllegalArgumentException`, MapStruct generated NPE, ACL normalization + failures, etc. by throwing `MappingException` (sample implementation in + `sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400), + never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException` + remains `BAD_PARAMETER` for non-mapper callers. +- **B4 — Validation layering.** Class-level Bean Validation constraints belong + to the *syntax* layer (request DTO). Domain invariants belong to + `application-core` / `domain-core`. Use `@GroupSequence(...)` to short-circuit + invariant evaluation when syntax fails. Keep `@Valid` cascade depth ≤ 3. +- **B5 — Polymorphic deserialization.** Calling + `ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping()` or + referencing `LaissezFaireSubTypeValidator` is the CVE-2019-14379 RCE entry + point and is blocked by ArchUnit (`no_jackson_laissez_faire_subtype_validator`, + `no_jackson_enable_default_typing_call`). Sealed `Command` types must use + `@JsonTypeInfo(use = NAME)` + `@JsonSubTypes`, or a + `BasicPolymorphicTypeValidator` allowlist. +- **B6 — Virtual thread context propagation.** With + `spring.threads.virtual.enabled=true`, do not use `InheritableThreadLocal` + (ArchUnit rule `no_inheritable_thread_local`). Filters and interceptors must + propagate `requestId` / `traceId` via SLF4J 2.0+ MDC or + `RequestContextHolder`. +- **B7 — Outbound ACL mapper scope.** Outbound HTTP / messaging adapter + responses must pass through an ACL mapper (normalization, masking, public + field selection) before reaching `application-core` or `domain-core` — the + same boundary contract as inbound. Raw external response types must not leak + into `domain-core`. +- **B8 — Bulk endpoint partial success.** Envelope `success = true` only when + every item succeeded. Partial failure responds with `success = false` + + `error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` (per-item array) — + a different shape from the single-item endpoint. Document the shape divergence + in the OpenAPI spec. + +Domain `@RestControllerAdvice` in a consuming module must be annotated +`@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this +module's base `GlobalExceptionHandler`), because the base handler's catch-all +`@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions +to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the +pattern. + +The base operational handler (`error/GlobalExceptionHandler`), the error-code +contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the +`error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in +production modules (`adapter:inbound:web` / `shared-contract`), so the running application +provides them without depending on `sample-portfolio`. Domain-specific exception +handlers and error codes live in the consuming module (see sample's +`DomainExceptionHandler` / `PortfolioErrorCode`). + +## Schema / serialization contract + +`feature-schema-serialization-contract` (LLM Wiki branch note) fixes the +*response producer* side of the wire contract — the sibling of the B1 *request +consumer* policy above. The deserialization switches (B1) and the +null/empty/missing 3-state (`Patch<T>` + `JsonNullable`, B2) already cover the +inbound side; the rules below cover the outbound side. The Jackson properties +live in `app-bootstrap` (`application.yml` `spring.jackson.serialization.*` / +`spring.jackson.generator.*`); ArchUnit + effective-config tests live in +`app-bootstrap` (`JacksonSerializationPolicyTest`, `no_bigdecimal_double_constructor`). + +- **S1 — Date / time / timezone (D2).** `WRITE_DATES_AS_TIMESTAMPS=false` is + pinned, so `java.time` values serialize as ISO-8601 strings via `JavaTimeModule` + (`OffsetDateTime` → `"...Z"`, `LocalDate` → `"YYYY-MM-DD"`), never a numeric + epoch or `[y,m,d,...]` array. Server timezone is **UTC**: emit instants as + `OffsetDateTime`/`Instant` with a `Z` offset. Use `LocalDate` only for + date-only calendar fields. Do **not** put timezone-less `LocalDateTime` on a + response DTO — it serializes without an offset and breaks the contract. +- **S2 — Money / BigDecimal (D3).** Default scale 2, rounding `HALF_UP` unless + the domain documents otherwise (KRW/JPY = scale 0 with a schema note). + `WRITE_BIGDECIMAL_AS_PLAIN=true` is pinned so values never serialize in + scientific notation. Pick **one** JSON representation per API and state it in + the OpenAPI schema: **string** (`@JsonSerialize(using = ToStringSerializer.class)`) + for public / financial endpoints (client parses, no precision loss), or + **number + plain** for internal service-to-service endpoints. Never rely on + the default — decide at endpoint design time. +- **S3 — `new BigDecimal(double)` is banned.** The `double`/`float` constructors + capture binary floating-point error (`new BigDecimal(0.1)` ≠ `0.1`). Build from + a `String` (`new BigDecimal("0.1")`) or `BigDecimal.valueOf(double)`. Enforced + by the `no_bigdecimal_double_constructor` ArchUnit rule (D3 / SBMS-C3). +- **S4 — Enum / null·empty·missing.** Request-side unknown enum → + `VALIDATION_FAILED` (B1 `read-unknown-enum-values-as-null=false`); legacy values + map through an explicit adapter, never a silent fallback. The + absent / explicit-null / value distinction is owned by the inbound web mapper + (Controller DTO → Command), expressed with `Patch<T>` (B2); `domain-core` and + `application-core` receive the already-resolved 3-state, never a wire type. +- **S5 — Out of this branch's scope.** OpenAPI drift enforcement (D5) is owned by + the verification suite / api-contract-baseline; removed-field-reuse ban tooling + (D6, `x-removed-fields` vs markdown catalog) is `needs-confirmation`; Avro + Schema Registry for outbox/event (D7) and response field rename/versioning + (`feature-api-compatibility-deprecation-contract`) are separate branches. + +## Business rule validation contract + +`feature-business-rule-validation-contract` (LLM Wiki branch note) fixes **which +rule is validated at which boundary**, so "validation" does not collapse into the +controller DTO or a DB constraint. It sits on top of the boundary/mapping contract +above and is enforced by ArchUnit + contract tests (not new runtime mechanism). + +| Layer | Owner | Validates | `error.category` | Enforced by | +|---|---|---|---|---| +| syntax / shape | `adapter:inbound:web` request DTO (`@Valid` / `jakarta.validation`) | request shape, types, required fields | `VALIDATION` | `validation_constraints_stay_at_web_boundary` ArchUnit rule | +| use case policy | `application-core` | authorization, cross-aggregate policy, state preconditions | `AUTHZ` / `CONFLICT` | `BusinessRuleValidationContractTest` | +| domain invariant | `domain-core` model / value object **constructor** | business invariants (e.g. end ≥ start) | `CONFLICT` / `VALIDATION` | domain unit tests (e.g. `PeriodTest`) — constructor is the sole, immutable construction path | +| persistence integrity | `adapter-persistence` (translator owned by `feature-persistence-failure-baseline`) | unique / FK / check / serialization | `DATA_INTEGRITY` / `CONFLICT` | `BusinessRuleValidationContractTest` + leak test | + +- **C1 — Validation annotations stay at the web boundary.** `jakarta.validation` + (`@NotNull`, `@Valid`, …) must appear only in `adapter:inbound:web`. `domain-core` and + `application-core` express invariants and policy as plain Java. The + `validation_constraints_stay_at_web_boundary` ArchUnit rule fails the build if a + Bean Validation annotation leaks into `..domain..` or `..application..`. +- **C2 — Business invariants live in the domain, un-bypassable.** Enforce invariants + in the value-object / entity **constructor** (the sole construction path) and keep + the type immutable, so no application-service or persistence path can hand out an + invariant-violating instance. A DB constraint is a backstop, never the only check + (Forbidden: "DB constraint as only invariant"). +- **C3 / C7 / D9 — Persistence integrity maps to an operational error, leak-free.** A + unique/FK/check/serialization failure maps to `DATA_INTEGRITY` / `CONFLICT` with a + **client-safe message only**. The raw SQL, constraint/index name, SQLState code, + exception class, and stack frame must never reach `error.message` or + `error.details`. The base `GlobalExceptionHandler` catch-all already replaces the + message with `"Internal server error"` and emits `null` details; the + category-correct mapping (23505 → `CONFLICT/DB_UNIQUE_VIOLATION`, …) is owned by + `feature-persistence-failure-baseline`'s persistence-adapter translator. +- **C8 — Duplicate validation needs a canonical owner.** The same rule MAY be + pre-checked at another layer for UX / performance (e.g. an application pre-check + mirroring a DB unique constraint), but the **canonical owner** of the rule must be + named in a code comment or the relevant `CLAUDE.md`. A duplicate validator with no + documented owner is a review failure (silent contradiction risk). This is a process + gate (PR review), not an automated rule — `needs-confirmation` until an owner-marker + annotation is justified. + +Out of this branch's scope (cross-referenced, not re-implemented here): the +SQLState→code 9-row matrix and the `DataAccessException` translator +(`feature-persistence-failure-baseline`); the Jackson B1/B2 request-boundary switches +and mapper sentinel (`feature-boundary-validation-mapping-contract`); the envelope, +`Category` enum, and `OperationalError` codes +(`feature-operational-error-observability-foundation`). + +## Test + +```bash +cd src +./gradlew :adapter:inbound:web:test --console=plain +``` diff --git a/src/adapter/inbound/web/README.md b/src/adapter/inbound/web/README.md new file mode 100644 index 00000000..b2292973 --- /dev/null +++ b/src/adapter/inbound/web/README.md @@ -0,0 +1,414 @@ +# adapter-web — 설계 결정 참조 + +인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`. + +허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8), +테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 +덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 +때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 +풀어 둔다. + +--- + +## auth — 인증 (OIDC resource server) + +### SecurityConfig +- **Spring Security 기본 Cache-Control writer 비활성화.** 이 모듈이 + HTTP cache 헤더 정책을 소유한다(`CacheControlFilter` 가 `Cache-Control: no-store` + `Vary` 방출). + 헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다. +- **AuthN/AuthZ 분류기를 `exceptionHandling` 과 `oauth2ResourceServer` 양쪽에 설정.** entry point 는 + missing-token(authorization-layer)과 invalid-token(bearer-filter-layer) 실패를, access-denied + handler 는 403 을 담당한다. 두 곳 모두에 설정해야 bearer filter 와 authorization filter 가 동일한 + Envelope writer 로 귀결된다. + +### JwtDecoderConfig +- Spring Boot auto-config decoder 를 대체해 validator chain 을 기본값 의존이 아닌 **명시적 구성**으로 만든다. +- **D2 — clock skew 60s 명시 고정**(`JwtTimestampValidator`). framework 기본값에 의존하면 Spring + 업그레이드로 기본값이 바뀔 때 silent-drift 위험이 있어 여기서 못박는다. +- **D4 — issuer 검증**(`SecuritySettings.issuerUri()`). **D3 — audience 검증**(`SecuritySettings.audience()`), + 단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치). +- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode + 시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작). +- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는 + IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다. +- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고. +- audience validator 의 오류 description(`"The aud claim is not valid"`)은 `SecurityErrorClassifier` 의 + "aud claim" 휴리스틱과 매칭되어 `AUTH_AUDIENCE_MISMATCH` 로 분류되도록 **의도적으로 맞춘 문자열 계약**이다. + +### JwtToAuthenticatedPrincipalConverter +- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며 + `ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인). + Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다. + +### SecurityErrorClassifier +- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가 + 선언한 세분화 코드를 방출한다. +- **메커니즘 & 트레이드오프**: Spring Security 는 JWT 실패에 단일 typed reason 을 노출하지 않으므로, + classifier 가 예외 그래프와 validator/Nimbus 메시지 텍스트를 검사한다. 매핑: + - missing token → `InsufficientAuthenticationException` → `AUTH_TOKEN_MISSING` + - claim validators(`JwtValidationException`) → description 에 따라 `AUTH_TOKEN_EXPIRED` / + `AUTH_ISSUER_MISMATCH` / `AUTH_AUDIENCE_MISMATCH` + - decode/signature/unknown-kid(`BadJwtException`/`JwtException` cause) → + `AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_MALFORMED` / `AUTH_KID_UNKNOWN` + - JWKS endpoint 장애 → `AUTH_JWKS_UNAVAILABLE` (503, transient) +- 텍스트 휴리스틱은 의도적으로 **좁고 순서가 있다**. 매핑되지 않은 실패는 500 이 아니라 안전한 + `AUTH_TOKEN_MALFORMED`(401)로 폴백한다. +- `AUTHZ_TENANT_MISMATCH` 는 여기서 추론 불가 — application-layer 의 cross-tenant 결정이며, 일반 `AccessDeniedException` 에는 `AUTHZ_INSUFFICIENT_PERMISSION` + 만 방출한다. + +### AuthErrorResponseWriter +- **토큰/PII 리댁션.** 응답 본문엔 해당 + 코드의 일반 `client_safe_message` 만 담고, 원시 예외 텍스트·`Authorization` 헤더·issuer·audience 는 + 절대 포함하지 않는다. 로그 라인엔 code/category/요청 path 만 기록하고 bearer token 은 절대 로깅하지 + 않는다(leak 테스트가 강제하는 계약). 전체 로그 마스킹 필터는 별도 log-management 영역에서 다룬다. +- **WWW-Authenticate(RFC 9110 §15.5.2).** 401 응답은 반드시 WWW-Authenticate 헤더를 갖되, `error_description` + 으로 issuer/token 세부가 새지 않도록 최소한으로 유지한다. + +### EnvelopeAuthenticationEntryPoint +- AuthN matrix 구현(인증 실패를 세분화 `OperationalError` 로 분류). +- resource-server 인증 실패는 filter layer(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서 + 처리되어 `@RestControllerAdvice` 에 도달하지 않는다. 따라서 세분화 분류는 `GlobalExceptionHandler` 가 + 아니라 반드시 이 entry point 에 위치해야 한다. + +### EnvelopeAccessDeniedHandler +- AuthN/AuthZ decision matrix 의 AuthZ 분기(유효 토큰 + 권한 부족 → + `AUTHZ_INSUFFICIENT_PERMISSION` 403). +- `AUTHZ_TENANT_MISMATCH` 는 application-layer 의 cross-tenant 결정이라 + 일반 Spring `AccessDeniedException` 으로는 추론 불가 — 여기서 방출하지 않는다. + +--- + +## authz — 인가 (`@RequiresPermission` 강제) + +### MethodSecurityConfig +- `RequiresPermission` 강제 지점을 Spring method security 에 배선한다. +- `@EnableMethodSecurity(prePostEnabled = false)` — method-security 인프라는 켜되 `@PreAuthorize`/ + `@PostAuthorize` 인터셉터는 등록하지 않는다(의도적). 컨텍스트 내 유일한 authorization advice 가 아래 + 커스텀 advisor 가 되게 하기 위함. +- 이 선택이 **애플리케이션 계층을 Spring Security 애너테이션으로부터 자유롭게 유지(D1)**: 유스케이스는 + 프레임워크 독립적 plain 애너테이션 `RequiresPermission` 만 선언하고 Spring-aware 강제는 이 어댑터가 공급. +- advisor 는 `ROLE_INFRASTRUCTURE` static `@Bean` 으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어 + 애플리케이션 빈을 조기 초기화로 끌어들이지 않는다. + +### RequiresPermissionAuthorizationManager +- `RequiresPermission` 의 Spring-aware 강제 메커니즘(커스텀 `AuthorizationManager<MethodInvocation>`). +- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `Authentication` 을 프레임워크 독립적 + `AuthorizationPrincipal` 로 매핑해 결정을 application `AuthorizationPort` 에 위임한다. 따라서 + application/domain 은 어떤 Spring Security 타입도 갖지 않으며, **이 어댑터가 두 세계가 만나는 유일한 지점**이다. +- 포트가 거부 시 application `AuthorizationDeniedException` 을 던지고, 이 매니저가 그것을 거부된 + `AuthorizationDecision` 으로 변환한다. method-security 인터셉터가 이를 `AccessDeniedException` → + `AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 만든다(§4). `null` 반환은 기권(abstain)이라 애너테이션 없는 + 메서드는 영향받지 않는다. +- 매핑은 **fail-closed**: 미인증 요청이거나 우리 `AuthenticatedPrincipal` 이 아닌 principal 은 0개 role 로 + 해석되어 거부된다. + +### AuthorizationAdapter +- application `AuthorizationPort` 의 web-adapter 구현체. +- 결정은 **fail-closed**: principal 의 유효 권한 집합에 요구 권한이 없으면 `AuthorizationDeniedException` 으로 + 거부 → `RequiresPermissionAuthorizationManager` 가 변환 → 최종 `AUTHZ_INSUFFICIENT_PERMISSION` 403. + +### RolePermissionRegistry +- 호출자의 raw role 들을 유효 `Permission` 집합으로 해석한다. +- role 키를 **소문자로 normalize**: Keycloak 이 role 대소문자를 보장하지 않으므로 조회를 대소문자 무관으로. +- 권한은 role 별로 **명시적으로 열거**한 집합이며 와일드카드(예: `worklog:*`)는 의도적으로 미지원 — 미래의 + `worklog:delete` 가 암묵적으로 부여되지 않도록(least-privilege, OWASP-AUTHZ-C4; §3 default B). +- 해석은 fail-closed: 알 수 없는 role / 빈 role 집합 / 빈 registry 모두 0개 권한. + +### RolePermissionPolicy +- app-side role→permission 매핑 소스. +- 키가 raw IdP role 이름인 이유: `ROLE_` 접두사는 Spring `GrantedAuthority` 에만 있고 principal 의 raw role + 집합엔 없으므로 붙이지 않는다. +- startup-bound static config 라 staleness 가 없다. +- **app-side config 를 기본값으로 택한 근거**: resource server 를 IdP 의 permission-claim 발급으로부터 + 디커플링한다. IdP-authoritative 소스(Keycloak Authorization Services / permission claims)는 본 contract 에서 + 의도적으로 out-of-scope 인 대안이다. + +--- + +## error — 에러 → Envelope 변환 + +### GlobalExceptionHandler +스켈레톤 공통 기반 에러 → `Envelope` 변환기. + +- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다. +- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고 + Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조). +- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을 + 제공하도록. +- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서 + 라이브러리에 결합하지 않고 span 에러를 기록하기 위한 이음새. 기본값 `SpanErrorRecorder.NOOP`. `@Autowired` + 생성자가 `ObjectProvider` 로 self-default 하므로 전체 컨텍스트 / `@WebMvcTest` 슬라이스 / 순수 단위 테스트 + 모두 seam 빈 등록을 강제하지 않고 와이어링된다. Micrometer Tracing fork 는 자체 `SpanErrorRecorder` 빈만 + 등록하면 no-op 을 오버라이드한다. + +**예외 → 에러코드 → HTTP 상태 매핑 계약** (매핑 자체는 코드가 SSOT; 아래는 근거): + +| 예외 | 코드 | 상태 | 근거 | +|---|---|---|---| +| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 | +| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 | +| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 | +| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | field/message violation 리스트를 details 로 | +| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 | +| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | | +| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | | +| `AccessDeniedException` | `SecurityErrorClassifier` 결정(예: `AUTHZ_INSUFFICIENT_PERMISSION`) | 분류기 결정 | 메서드-시큐리티 거부가 컨트롤러를 빠져나오면 여기 도달. 필터 계층 `EnvelopeAccessDeniedHandler` 와 **동일한 세분화 코드**를 내도록 무상태 classifier 에 위임 | +| `PreconditionFailedException` | `PRECONDITION_FAILED` | 412 | D15: `If-Match` 불일치 쓰기 = 낙관적 동시성 충돌 → 412 (raw 409/500 금지) | +| `PageValidationException` | `VALIDATION_FAILED` | 400 | D18/D20/D21: 계약 범위 밖 페이지/정렬/필터 파라미터. field + reasonCode 를 details 로 | +| `CursorException` | `VALIDATION_FAILED` | 400 | D22: 변조/만료/손상 커서. 조언 "첫 페이지 재요청", details field="cursor" code="CURSOR_INVALID" | +| `IdempotencyInFlightException` | `IDEMPOTENT_IN_FLIGHT` | 409 (retryable=false) | 대기 후에도 원본 처리 중. 진단정보(scope/principal)는 클라이언트 미도달 | +| `IdempotencyRequestMismatchException` | `IDEMPOTENT_REQUEST_MISMATCH` | 422 | D8: `Idempotency-Key` 를 다른 본문으로 재사용. fingerprint/scope 노출 금지 | +| `IdempotencyScopeMissingException` | `VALIDATION_FAILED` | 400 | §실패모드: 해석 가능한 scope 없는 키(예: 미인증 호출자)는 전역 충돌 대신 400 거부 | +| `PersistenceFailureException` | `ex.errorCode()` (사전분류 `DB_*`) | 코드 결정 | adapter-persistence translator 가 SQLState→`DB_*` 로 이미 분류. 클라이언트 메시지는 category-derived 안전 문자열, **절대 `ex.getMessage()` 아님**(SQLState/제약명 담음, 서버 로그 전용) | +| `DependencyFailureException` | `ex.errorCode()` (사전분류 `DEPENDENCY_*`) | 코드 결정 | adapter-outbound `OutboundHttpErrorMapper` 가 upstream 실패를 분류. 클라이언트 메시지는 per-code 고정 문자열(error-codes.yaml), **절대 `ex.getMessage()` 아님**. retryable + `retry_after_seconds` 있으면 `RetryAfterAdvisor` 로 `Retry-After` 부착 | +| `HttpRequestMethodNotSupportedException` | `METHOD_NOT_ALLOWED` | 405 | D12: 405 는 지원 메서드를 나열한 `Allow` 헤더 필수 | +| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 | +| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 | +| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) | +| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | field/rejectedValue/message 리스트를 details 로 | +| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 | +| `NoHandlerFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | | +| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 | + +### ErrorResponseFactory +- 기반 운영 핸들러와 모든 도메인 핸들러가 **공유**하여 envelope 형식이 정확히 한 곳에서만 만들어지게 + 하는 단일-소스 컴포넌트(`httpStatus()` → Spring `HttpStatus` 매핑, `error.category` 운반, MDC 에서 `meta` 추출). + +--- + +## envelope / filter + +### EnvelopeBodyAdvice +- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상 + `{success, data | error, traceId}` 로 보장한다. +- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지 + 않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다. + +### CacheControlFilter +- 스켈레톤 기본 HTTP 캐시 정책. +- `Cache-Control: no-store` 는 인증된 API 의 안전한 기본값. `Vary: Accept, Accept-Encoding, Authorization` 로 + 공유 프록시/CDN 이 협상이나 주체를 가로질러 콘텐츠를 오염(poison)시키지 못하게 한다. +- 기본값을 체인 **이전**에 설정: 캐시 가능한 엔드포인트가 반환값 처리에서 `Cache-Control`(예: + `private, max-age=60`)을 가진 `ResponseEntity` 를 반환해 기본값을 덮어쓰는 opt-in 이 가능하도록. +- 책임 경계: 이 모듈은 캐시 *헤더 정책*을 소유하고, 캐시 *레이어*(Redis/CDN)는 별도 인프라가 소유한다. +- 단일 소유권: Spring Security 기본 `Cache-Control` 은 `SecurityConfig` 에서 비활성화 → 실행 앱에서 이 필터가 + 헤더 단일 소유자. 독립 MockMvc(보안 체인 없음)에서도 이 필터가 유일 writer. + +### RequestLoggingFilter +- **MDC 키 정책(D11/D19).** `MdcKeys` 의 snake_case 키 사용. +- **인바운드 id 헤더(D14/D15).** `X-Request-Id` / `X-Correlation-Id` 는 사용 전 sanitize(CR/LF + control 제거) + 및 길이 제한. 부재/공백은 서버 생성. +- **W3C `traceparent`(D5/D7/D4).** 유효한 인바운드 traceparent 가 있으면 채택해 그 `traceId`→MDC `trace_id`, + `spanId`→`span_id`. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId, + sampled=false)하여 MDC `trace_id` 가 **항상** 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성 + 상태에서도 `meta.traceId` non-null 보장). 해석된 traceparent 는 응답 헤더에 설정. + - `sampled=false` 근거: tracer seam 이 실제 sampling 결정을 소유하며 스켈레톤엔 exporter 가 없다. + - `freshHex16` 근거: 16-char span id 는 fresh UUID 의 least-significant bits 에서 파생·zero-pad — 64비트 + 전체가 entropy 를 갖도록(UUIDv4 version nibble 은 most-significant bits 라 제외). variant bits 가 값을 + non-zero 로 유지해 W3C non-all-zero 규칙 충족. +- **사용자 주체 가명화.** `user_principal` 은 MDC 에 놓이기 전 + `UserPrincipalPseudonymizerPort` 로 가명화. raw `idpUserId()` 는 절대 MDC/로그에 기록되지 않는다. +- **route template 해석.** 저-cardinality 매칭 라우트 템플릿 반환. `BEST_MATCHING_PATTERN_ATTRIBUTE` + 는 handler mapping 이후 DispatcherServlet 이 설정하므로 `finally` 블록에서 항상 사용 가능. +- **주의 — 생성된 `trace_id` 는 실제 span 의 trace-id 가 아니다.** 무-tracer + 스켈레톤에선 이 필터가(인바운드 traceparent 부재 시) `trace_id` 를 발급(MINT)하고 `ResponseMetaFactory` 가 + 이를 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를 + 발급하고 SLF4J-Micrometer 브리지가 *자신의* id 를 MDC `trace_id` 에 쓴다. 어느 값이 최종 반영될지는 + 필터/observation 의 **ORDER 와 scope** 에 달려 있다 — 이 필터가 이기면 클라이언트의 `meta.traceId` 가 + 실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다. + 실제 tracer 를 연결하는 fork 는 tracer 가 MDC `trace_id` 의 유일 소유자가 되게 해야 한다(이 필터를 tracing + observation *이후*로 정렬하거나, 생성 대신 `Span.current()` 채택). 현 green 테스트 스위트는 이를 잡지 + 못한다 — 무-tracer 메커니즘만 검증한다. + +--- + +## ratelimit + +### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter) +- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다. +- **OCP(개방-폐쇄)**: `RateLimitInterceptor` 는 `RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일 + `switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm` + enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`. +- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill) + `X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다. + - `limit` — 설정 quota + - `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor + - `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음 + refill, sliding-window=가장 오래된 카운트 요청 만료 시점) + - `allowed` — quota 소진 시 false (→ 429) +- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를 + 가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance + 전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node, + distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요. + +### RateLimitKeyResolver +- 키 형태: service-to-service + `apikey:<id>`(api_key_id override), 인증 `user:<id>`, 미인증 `ip:<source-ip>:<METHOD route-template>`(정규화). +- principal 은 로그 `user_principal`(`AuthenticatedPrincipal#idpUserId`)과 동일 표현 재사용. pseudonymization 은 + 이 영역의 책임 seam — 이 브랜치는 표현을 재사용만 하고 변환하지 않는다. +- tenant prefixing 은 아직 구현하지 않은 확장 지점이다. +- **하드 룰:** 키는 raw 토큰이나 요청 본문에서 절대 도출하지 않는다. +- `HandlerInterceptor` 입력으로 resolve 하는 이유: route template(`/v1/worklogs/{id}`)을 쓰기 위함. servlet + filter 는 handler mapping 이전에 실행돼 구체 경로만 보므로 모든 id 가 서로 다른 키가 되어버린다. + +### RateLimitClientIpMode +- 비인증 rate-limit 키의 클라이언트 IP 소스 선택 enum. +- `REMOTE_ADDR_ONLY` — 직접 노출 배포의 안전한 기본값(spoofing 가능한 forwarded 헤더 무시). +- `FORWARDED_HEADERS_TRUSTED` — 신뢰할 수 있는 ingress/LB 가 forwarded 헤더를 덮어쓰는 경우에만 사용. + +### RateLimitWebConfig +- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서 + resolve 되기 때문(RateLimitKeyResolver 참조). +- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest` + 슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고 + 슬라이스에선 `Clock#systemUTC()` 로 fallback. + +### RateLimitInterceptor +- fixed-window rate limit 을 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true). +- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable + 의존성 장애로 오분류하는 것을 막는다. + +--- + +## settings / config / http + +### CorsSettings +- **3계층 검증 전략.** + 1. 단순 제약(범위/필수/정규식)은 JSR-303 + `@Validated` 로 선언해 잘못된 값이 `BindValidationException` 으로 + 기동 실패(`maxAgeSeconds`). + 2. JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast `throw` 로 강제(관대한 기본값 + 폴백 금지). + 3. 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움. +- **교차필드 불변식**: CORS enabled 시 최소 하나의 allowed origin 필수(JSR-303 표현 불가 → fail-fast). 빈 목록 + 관대한 폴백은 모든 브라우저 호출자를 조용히 거부하게 된다. +- **D9 (WHATWG Fetch §3.3, FETCH-CORS-C3)**: wildcard origin + credentials 금지 — `Access-Control-Allow-Origin: *` + 는 `Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동 + 시점에 fail-fast 거부. + +### RateLimitSettings +- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다. +- `enabled` 는 `APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑. +- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스 + 정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값. + `algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`. + +### SecuritySettings +- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가 + 기동 시 실패하므로 여기서 명확한 에러를 먼저 표면화한다. 나머지 knob 은 warn 후 폴백. + +### PresentationSettings +- 검증 정책 "warn-and-default": 부재/잘못된 prefix 값은 앱을 멈추는 대신 빈 prefix 로 폴백 — 모든 엔드포인트가 + (/api 없이) 계속 접근 가능하게 유지. + +### JacksonNullableConfig +- `JsonNullableModule` 을 Spring 관리 `ObjectMapper` 에 등록. 없으면 PATCH 요청 DTO 의 `JsonNullable<T>`(B2)를 + Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다. +- 스켈레톤 전역 web 관심사(공유 `Patch<T>` 타입과 짝)라 도메인 샘플 모듈이 아니라 adapter-web 에 위치. + +### ApiHeaders +- 인바운드 web 어댑터 전역의 HTTP 헤더명 상수 — `docs/registries/headers.yaml`의 + 코드 미러. 리터럴을 중앙집중해 controller/filter/advice 가 casing 으로 drift 하지 않게 하고, 레지스트리 + 일관성 테스트가 단일 출처를 참조하게 한다. +- 소유권: `X-Api-Version`(D2)·`Idempotency-Key`(D3 — 이름만; key shape/scope/replay 정책은 + application-core 소유)는 여기서 생산. conditional-request(D15)·cache(D16)·method/negotiation/ + LRO(D12/D17)·pagination(D18)·rate-limit signaling(generated_if_missing=true; Limit/Remaining 은 numeric, Reset 은 + rfc3339 = fixed-window end)·deep-offset deprecation marker(D18)·always-emitted(D24)는 표준 RFC 9110/9111 이름 참조. + +--- + +## observability + +### MdcKeys +- snake_case MDC 키 이름은 로그/진단 레지스트리(`mdc-keys.yaml`)를 따른다. + 같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며, + 변환 단일 지점은 `ResponseMetaFactory`. + +### HeaderSanitizer +- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을 + 가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를 + 제거 후 길이 제한. +- `프로젝트 선택`: 구체 문자셋 정책(strip vs encode)과 최대 길이는 ca-tmpl 트레이드오프. OWASP 는 + 원칙만 규정하고 정규식/한계는 규정하지 않는다. + +### ResponseMetaFactory +- snake_case MDC 진단 키를 camelCase `ResponseMeta` wire 객체로 projection 하는 D19 단일 변환 지점. adapter-web + 에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다. + +### RetryAfterAdvisor +- **`Retry-After` 노출 지점.** 구체 헤더 값과 429/503 세부는 이 영역의 책임이고, + per-code `retry_after_seconds` 는 error-codes.yaml 에 존재한다. + 이 helper 는 "재시도 가능한 코드가 `Retry-After` 헤더를 받을 자격이 있는가?"만 답해, 호출부가 + 재시도 가능 여부를 재도출하지 않고 헤더를 붙이게 한다. +- **Tracing wiring:** 운영 5xx 는 서버 span 에 `exception` 이벤트 + span status ERROR 를 기록해야 하나, + Micrometer-Tracing/OTel 가 classpath 에 없어 wiring 은 이 영역의 책임 — 의도적 + 미구현. +- 필드 `RETRY_AFTER_SECONDS` 는 error-codes.yaml 의 `retry_after_seconds` 컬럼 미러. + 이 advisor 가 유일한 Retry-After 노출 지점이라 여기 중앙화한다. + `DEPENDENCY_4XX_CLIENT` 는 비재시도(retryable=false)라 `shouldAdvise` 가드로 empty 반환. + +--- + +## pagination + +### PageParams +- 검증된 offset 페이지네이션 파라미터. `page` 0-indexed: Spring `Pageable` + parity(SPRING-PAGE-C1). `size` 기본 20 / min 1 / max 100: 프로젝트 DoS 캡(Spring 자체 `DEFAULT_MAX_PAGE_SIZE` + 는 2000, SPRING-PAGE-C4). +- `프로젝트 선택`: 정확한 size 캡(100)/min(1)/deep-offset 임계값(10000)은 프로젝트 내부 + 트레이드오프 — 표준은 원칙만 고정하고 숫자는 고정하지 않는다. + +### SortParam +- Spring `Pageable` 네이티브 문법 `field,direction` 의 단일 정렬 term(D20). 비-네이티브 문법 거부 근거: + JSON:API prefix(`-foo`)·colon form(`foo:desc`)·AIP-132 space form(`"foo desc"`)은 모두 Spring 자동 바인딩을 + 깨뜨리므로 금지. + +### PageValidationException +- 페이지네이션/정렬 요청 파라미터가 스켈레톤의 요청 경계를 위반할 때 발생. + +--- + +## cursor + +### CursorCodec +- 불투명·서명·시간 제한 페이지네이션 커서 코덱(D22, AIP158-C5). +- **SEAM(producer-only)**: HMAC 키와 회전 정책은 이 영역의 책임. 해당 브랜치가 이 + 저장소에 없어 프로덕션 키 wiring 은 `planned`. 코덱은 주입된 키를 받고 테스트/로컬용 `withDevKey()` 팩토리 + 제공(프로덕션 금지). encode/decode 메커니즘·opacity·무결성 검사·TTL 은 여기 구현. +- `DEFAULT_TTL`: D22 의 24h TTL 은 프로젝트 내부 숫자(AIP-158 은 opacity 만 고정, TTL 미고정). + +### CursorException +- 불투명 페이지네이션 커서 검증 실패 시 발생(D22). + +--- + +## conditional + +### ETags +- HTTP 계층 낙관적 동시성/캐시 검증용 weak-ETag 도출 및 조건부 요청 매칭(D15, RFC9110-C13..C17). + `weakFromVersion` 산출물 `W/"<version>"` 는 스켈레톤의 예시 wire 형태다. +- `프로젝트 선택`: RFC 9110 은 `If-Match` 에 strong 비교를 의무화하나, 이 스켈레톤은 불투명 값을 + leniently 비교(`W/` weak 마커와 둘러싼 따옴표 무시)해 문서화된 weak-ETag 형태로도 낙관적 잠금을 구동한다. + strong ETag 를 발행하는 프로덕션 fork 도 동일 호출 지점을 유지 가능. + +### PreconditionFailedException +- 쓰기 요청의 `If-Match` validator 가 현재 리소스 ETag 와 불일치할 때 발생(D15). 412 로 매핑해 raw 409/500 과 + 구분 — persistence 계층이 serialization failure 로 surface 할 동일한 낙관적 동시성 충돌의 HTTP 계층 표현. + +--- + +## idempotency + +### IdempotencyKeySupport +- HTTP 요청으로부터 application `IdempotencyExecutor` 입력을 조립하는 web 측 helper. +- principal 은 인증된 `AuthenticatedPrincipal#idpUserId()` — rate-limit 키 및 로그 `user_principal` 과 동일 + 표현. 미인증 호출자는 principal 이 없어 `IdempotencyScope.of` + 가 `IdempotencyScopeMissingException`(→ 400)으로 거부 → scope 없는 키의 전역 충돌 방지. +- `프로젝트 선택`: fingerprint 는 raw 전송 바이트가 아니라 직렬화된 command payload 기준으로 + 계산 → JSON 키 순서/공백 차이로 인한 false mismatch 방지. 단, 바이트 동일 body 를 두 번 POST 한 클라이언트는 + 여전히 매칭. 완전한 요청 canonicalization 은 실제 요청 패턴으로 추가 검증이 필요하다. +- tenant 는 null(단일 테넌트); tenant scoping 은 아직 구현하지 않은 확장 지점이다. + +### JsonIdempotentResponseCodec +- Jackson 기반 `IdempotentResponseCodec`(§B): web 어댑터가 application executor 의 저장/replay JSON wire 포맷을 + 소유. (역)직렬화 실패는 `MappingException` 으로 surface 되어 base handler 가 raw 500 이 아닌 `MAPPING_FAILED` + 400 으로 라우팅. diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle new file mode 100644 index 00000000..5053694d --- /dev/null +++ b/src/adapter/inbound/web/build.gradle @@ -0,0 +1,16 @@ +// HTTP / web adapters. Depends on application, domain, and shared operational contracts. +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.openapitools:jackson-databind-nullable:0.2.6' + // feature-api-contract-baseline D10: OpenAPI producer. springdoc exposes the + // running app's machine-readable contract at /v3/api-docs (OAS 3.1, generated — + // never a hand-maintained stale schema). The release-blocking drift gate is + // owned by feature-contract-verification-test-suite (planned). + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6' +} diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile new file mode 100644 index 00000000..77513d86 --- /dev/null +++ b/src/adapter/inbound/web/gradle.lockfile @@ -0,0 +1,181 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.29=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springdoc:springdoc-openapi-starter-common:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthErrorResponseWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthErrorResponseWriter.java new file mode 100644 index 00000000..5e06c2ca --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthErrorResponseWriter.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.response.Envelope; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import tools.jackson.databind.ObjectMapper; + +/** + * Writes a classified security failure to the servlet response as the skeleton-wide {@link + * Envelope} (same shape as every other error), and logs it safely. The response body and log line + * carry only redacted, client-safe metadata. See README for the design rationale. + */ +class AuthErrorResponseWriter { + + private static final Logger log = LoggerFactory.getLogger(AuthErrorResponseWriter.class); + + /** Client-safe messages, aligned with the registry {@code client_safe_message} column. */ + private static final Map<OperationalError, String> CLIENT_MESSAGES = + Map.of( + OperationalError.AUTH_TOKEN_MISSING, "Authentication required", + OperationalError.AUTH_TOKEN_EXPIRED, "Authentication expired", + OperationalError.AUTH_KID_UNKNOWN, "Authentication failed, please retry", + OperationalError.AUTH_JWKS_UNAVAILABLE, "Authentication service temporarily unavailable", + OperationalError.AUTHZ_INSUFFICIENT_PERMISSION, "Permission denied", + OperationalError.AUTHZ_TENANT_MISMATCH, "Permission denied"); + + private final ObjectMapper objectMapper; + + AuthErrorResponseWriter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + void write(HttpServletRequest request, HttpServletResponse response, OperationalError code) + throws IOException { + // Log only safe metadata — never the token or the raw failure message. + log.warn( + "security failure: code={} category={} method={} path={}", + code.code(), + code.category(), + request.getMethod(), + request.getRequestURI()); + + response.setStatus(code.httpStatus()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + // A 401 response carries a minimal WWW-Authenticate header (no issuer / token detail). + if (code.httpStatus() == 401) { + response.setHeader( + HttpHeaders.WWW_AUTHENTICATE, + code == OperationalError.AUTH_TOKEN_MISSING + ? "Bearer" + : "Bearer error=\"invalid_token\""); + } + RetryAfterAdvisor.retryAfterSeconds(code) + .ifPresent( + seconds -> response.setHeader(HttpHeaders.RETRY_AFTER, Integer.toString(seconds))); + + Envelope<Void> body = ErrorResponseFactory.body(code, clientMessage(code), null); + objectMapper.writeValue(response.getWriter(), body); + } + + private String clientMessage(OperationalError code) { + return CLIENT_MESSAGES.getOrDefault(code, "Authentication failed"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipal.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipal.java new file mode 100644 index 00000000..d85d9cae --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipal.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import java.util.Set; + +/** + * Principal exposed to controllers via {@code @AuthenticationPrincipal}. Carries the IdP-side + * identifier ({@code idpUserId}, JWT {@code sub} claim) plus any claims a controller is likely to + * want without reaching into the raw Jwt. + */ +public record AuthenticatedPrincipal(String idpUserId, String email, Set<String> roles) { + + public AuthenticatedPrincipal { + roles = roles == null ? Set.of() : Set.copyOf(roles); + } + + public boolean hasRole(String role) { + return roles.contains(role); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAccessDeniedHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAccessDeniedHandler.java new file mode 100644 index 00000000..7968966a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAccessDeniedHandler.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import tools.jackson.databind.ObjectMapper; + +/** + * Resource-server {@link AccessDeniedHandler} that maps an authorization failure (valid token, + * insufficient permission) to {@code AUTHZ_INSUFFICIENT_PERMISSION} (403) and writes it as the + * skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design + * rationale. + */ +public class EnvelopeAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityErrorClassifier classifier; + private final AuthErrorResponseWriter writer; + + public EnvelopeAccessDeniedHandler( + SecurityErrorClassifier classifier, ObjectMapper objectMapper) { + this.classifier = classifier; + this.writer = new AuthErrorResponseWriter(objectMapper); + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException { + writer.write(request, response, classifier.classifyAccessDenied(accessDeniedException)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAuthenticationEntryPoint.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAuthenticationEntryPoint.java new file mode 100644 index 00000000..bb127448 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import tools.jackson.databind.ObjectMapper; + +/** + * Resource-server {@link AuthenticationEntryPoint} that classifies an authentication failure into a + * fine-grained {@link dev.caskeleton.shared.error.OperationalError} and writes it as the + * skeleton-wide error {@link dev.caskeleton.shared.response.Envelope}. See README for the design + * rationale. + */ +public class EnvelopeAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final SecurityErrorClassifier classifier; + private final AuthErrorResponseWriter writer; + + public EnvelopeAuthenticationEntryPoint( + SecurityErrorClassifier classifier, ObjectMapper objectMapper) { + this.classifier = classifier; + this.writer = new AuthErrorResponseWriter(objectMapper); + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException { + writer.write(request, response, classifier.classifyAuthentication(authException)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java new file mode 100644 index 00000000..e9efb72e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfig.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; + +/** + * Custom {@link JwtDecoder} for the resource server with an explicit validator chain: timestamp + * (60s clock skew) + issuer, plus an optional audience check when configured. JWKS discovery is + * deferred via {@link SupplierJwtDecoder} so startup does not require the IdP to be reachable. See + * README for the design rationale. + */ +@Configuration +public class JwtDecoderConfig { + + @Bean + public JwtDecoder jwtDecoder(SecuritySettings settings) { + // Lazy: JWKS discovery happens on first decode, not at startup. + return new SupplierJwtDecoder( + () -> { + NimbusJwtDecoder decoder = + NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build(); + decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience())); + return decoder; + }); + } + + /** The explicit validator chain: timestamp (60s skew) + issuer + optional audience. */ + static OAuth2TokenValidator<Jwt> jwtValidator(String issuerUri, String audience) { + List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>(); + validators.add(new JwtTimestampValidator(Duration.ofSeconds(60))); + validators.add(new JwtIssuerValidator(issuerUri)); + if (audience != null && !audience.isBlank()) { + validators.add(audienceValidator(audience)); + } + return new DelegatingOAuth2TokenValidator<>(validators); + } + + private static OAuth2TokenValidator<Jwt> audienceValidator(String audience) { + return jwt -> { + if (jwt.getAudience() != null && jwt.getAudience().contains(audience)) { + return OAuth2TokenValidatorResult.success(); + } + OAuth2Error error = new OAuth2Error("invalid_token", "The aud claim is not valid", null); + return OAuth2TokenValidatorResult.failure(error); + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java new file mode 100644 index 00000000..480d2d37 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +/** + * Maps an OIDC JWT to a JwtAuthenticationToken whose principal is our {@link + * AuthenticatedPrincipal}. We pull {@code sub} as the IdP user id and union Keycloak-style {@code + * realm_access.roles} with {@code resource_access[*].roles} into a single role set. Roles also + * become Spring authorities (ROLE_*). + */ +@Component +public class JwtToAuthenticatedPrincipalConverter + implements Converter<Jwt, AbstractAuthenticationToken> { + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + Set<String> roles = extractRoles(jwt); + String email = jwt.getClaimAsString("email"); + AuthenticatedPrincipal principal = new AuthenticatedPrincipal(jwt.getSubject(), email, roles); + Collection<GrantedAuthority> authorities = + roles.stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase())) + .collect(Collectors.toCollection(ArrayList::new)); + return new AuthenticatedJwtToken(jwt, authorities, principal); + } + + private Set<String> extractRoles(Jwt jwt) { + Set<String> roles = new HashSet<>(); + + Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access"); + if (realmAccess != null) { + Object r = realmAccess.get("roles"); + if (r instanceof Collection<?> col) { + col.forEach(x -> roles.add(String.valueOf(x))); + } + } + Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access"); + if (resourceAccess != null) { + for (Object client : resourceAccess.values()) { + if (client instanceof Map<?, ?> clientMap + && clientMap.get("roles") instanceof Collection<?> rolesCol) { + rolesCol.forEach(x -> roles.add(String.valueOf(x))); + } + } + } + // Generic OIDC "roles" claim as a fallback + List<String> flat = jwt.getClaimAsStringList("roles"); + if (flat != null) { + roles.addAll(flat); + } + return roles; + } + + /** + * JwtAuthenticationToken whose {@link #getPrincipal()} is our domain-oriented record instead of + * the raw Jwt. Both are kept available — controllers usually want the record; filters/loggers can + * still pull the Jwt via {@link #getToken()}. + */ + public static final class AuthenticatedJwtToken extends JwtAuthenticationToken { + + private static final long serialVersionUID = 1L; + + // transient: the principal is reconstructed by the converter on each authentication, + // never round-tripped through Java serialization. See README for the design rationale. + private final transient AuthenticatedPrincipal principal; + + AuthenticatedJwtToken( + Jwt jwt, + Collection<? extends GrantedAuthority> authorities, + AuthenticatedPrincipal principal) { + super(jwt, authorities); + this.principal = principal; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return principal; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java new file mode 100644 index 00000000..317719a2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import dev.caskeleton.adapter.inbound.web.settings.CorsSettings; +import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import tools.jackson.databind.ObjectMapper; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final SecuritySettings securitySettings; + private final CorsSettings corsSettings; + private final JwtToAuthenticatedPrincipalConverter jwtConverter; + + public SecurityConfig( + SecuritySettings securitySettings, + CorsSettings corsSettings, + JwtToAuthenticatedPrincipalConverter jwtConverter) { + this.securitySettings = securitySettings; + this.corsSettings = corsSettings; + this.jwtConverter = jwtConverter; + } + + @Bean + public SecurityErrorClassifier securityErrorClassifier() { + return new SecurityErrorClassifier(); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint( + SecurityErrorClassifier classifier, ObjectMapper objectMapper) { + return new EnvelopeAuthenticationEntryPoint(classifier, objectMapper); + } + + @Bean + public AccessDeniedHandler accessDeniedHandler( + SecurityErrorClassifier classifier, ObjectMapper objectMapper) { + return new EnvelopeAccessDeniedHandler(classifier, objectMapper); + } + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + AuthenticationEntryPoint authenticationEntryPoint, + AccessDeniedHandler accessDeniedHandler) + throws Exception { + String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]); + http.csrf(csrf -> csrf.disable()) + .cors(c -> c.configurationSource(corsConfigurationSource())) + // Disable Spring Security's default Cache-Control writer; CacheControlFilter + // owns the cache header policy. See README for the design rationale. + .headers(headers -> headers.cacheControl(cache -> cache.disable())) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + auth -> { + if (publicPaths.length > 0) { + auth.requestMatchers(publicPaths).permitAll(); + } + auth.anyRequest().authenticated(); + }) + // The entry point and access-denied handler are set on both exceptionHandling and + // oauth2ResourceServer so every filter resolves to the same Envelope writer. + // See README for the design rationale. + .exceptionHandling( + ex -> + ex.authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .oauth2ResourceServer( + oauth -> + oauth + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + if (!corsSettings.enabled()) { + return source; // no patterns registered -> Spring uses null config -> CORS inactive + } + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(corsSettings.allowedOrigins()); + cfg.setAllowedMethods(corsSettings.allowedMethods()); + cfg.setAllowedHeaders(corsSettings.allowedHeaders()); + cfg.setAllowCredentials(corsSettings.allowCredentials()); + cfg.setMaxAge(corsSettings.maxAgeSeconds()); + source.registerCorsConfiguration("/**", cfg); + return source; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityErrorClassifier.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityErrorClassifier.java new file mode 100644 index 00000000..5ead47e4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityErrorClassifier.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import dev.caskeleton.shared.error.OperationalError; +import java.util.Locale; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.jwt.JwtValidationException; + +/** + * Classifies a resource-server security failure into a fine-grained {@link OperationalError} by + * inspecting the exception graph and validator/Nimbus message text. Unmapped failures fall back to + * the generic, safe {@code AUTH_TOKEN_MALFORMED} (401) rather than a 500. See README for the design + * rationale. + */ +public class SecurityErrorClassifier { + + /** Classifies an authentication (401-family) failure reaching the AuthenticationEntryPoint. */ + public OperationalError classifyAuthentication(AuthenticationException ex) { + if (ex instanceof OAuth2AuthenticationException oauth) { + OperationalError byCause = classifyByCause(oauth.getCause()); + if (byCause != null) { + return byCause; + } + OperationalError byError = classifyByText(describe(oauth.getError())); + return byError != null ? byError : OperationalError.AUTH_TOKEN_MALFORMED; + } + if (ex instanceof InsufficientAuthenticationException) { + return OperationalError.AUTH_TOKEN_MISSING; + } + // Unmapped authentication failure: a generic, safe 401 — never an unclassified 500. + return OperationalError.AUTH_TOKEN_MALFORMED; + } + + /** Classifies an authorization (403-family) failure reaching the AccessDeniedHandler. */ + public OperationalError classifyAccessDenied(AccessDeniedException ex) { + return OperationalError.AUTHZ_INSUFFICIENT_PERMISSION; + } + + private OperationalError classifyByCause(Throwable cause) { + if (cause == null) { + return null; + } + if (cause instanceof JwtValidationException validation) { + // A JWKS retrieval failure can surface wrapped in validation errors too. + OperationalError fromText = null; + for (OAuth2Error error : validation.getErrors()) { + OperationalError mapped = classifyByText(describe(error)); + fromText = higherPriority(fromText, mapped); + } + return fromText != null ? fromText : OperationalError.AUTH_TOKEN_MALFORMED; + } + // BadJwtException extends JwtException; both carry the decode/signature/kid/JWKS message. + return classifyByText(cause.getMessage()); + } + + /** + * Maps a single validator/decoder message to a code. Ordered, narrow heuristics; returns {@code + * null} when nothing matches so callers can fall back. + */ + private OperationalError classifyByText(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String m = raw.toLowerCase(Locale.ROOT); + // JWKS endpoint outage is a transient dependency failure (check before generic decode text). + if (m.contains("jwk set") || m.contains("jwk source") || m.contains("jwkset")) { + return OperationalError.AUTH_JWKS_UNAVAILABLE; + } + if (m.contains("expired") || m.contains("jwt expired")) { + return OperationalError.AUTH_TOKEN_EXPIRED; + } + if (m.contains("iss claim") || m.contains("issuer")) { + return OperationalError.AUTH_ISSUER_MISMATCH; + } + if (m.contains("aud claim") || m.contains("audience")) { + return OperationalError.AUTH_AUDIENCE_MISMATCH; + } + if (m.contains("signature") || m.contains("signed jwt rejected")) { + return OperationalError.AUTH_TOKEN_INVALID_SIGNATURE; + } + if (m.contains("kid") || m.contains("matching key") || m.contains("key id")) { + return OperationalError.AUTH_KID_UNKNOWN; + } + if (m.contains("malformed") + || m.contains("invalid jwt") + || m.contains("invalid compact") + || m.contains("decode")) { + return OperationalError.AUTH_TOKEN_MALFORMED; + } + return null; + } + + /** + * Precedence among multiple simultaneous validation failures. Expiry is the most common + * operational case and is reported first; then issuer, then audience, then anything else. + */ + private OperationalError higherPriority(OperationalError current, OperationalError candidate) { + if (candidate == null) { + return current; + } + if (current == null) { + return candidate; + } + return rank(candidate) < rank(current) ? candidate : current; + } + + private int rank(OperationalError e) { + return switch (e) { + case AUTH_TOKEN_EXPIRED -> 0; + case AUTH_ISSUER_MISMATCH -> 1; + case AUTH_AUDIENCE_MISMATCH -> 2; + default -> 3; + }; + } + + private String describe(OAuth2Error error) { + if (error == null) { + return null; + } + String description = error.getDescription(); + return description != null ? description : error.getErrorCode(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/AuthorizationAdapter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/AuthorizationAdapter.java new file mode 100644 index 00000000..dcc5991c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/AuthorizationAdapter.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import dev.caskeleton.application.security.AuthorizationDeniedException; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.application.security.AuthorizationPrincipal; +import dev.caskeleton.shared.security.Permission; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** + * Web-adapter implementation of the application {@link AuthorizationPort}. + * + * <p>Resolves the caller's raw roles to an effective permission set via {@link + * RolePermissionRegistry} and denies (with {@link AuthorizationDeniedException}) when the required + * permission is absent. See README for the design rationale. + */ +@Component +public class AuthorizationAdapter implements AuthorizationPort { + + private final RolePermissionRegistry registry; + + public AuthorizationAdapter(RolePermissionRegistry registry) { + this.registry = registry; + } + + @Override + public void requirePermission(AuthorizationPrincipal principal, Permission required) { + Set<Permission> effective = registry.effectivePermissions(principal.roles()); + if (!effective.contains(required)) { + throw new AuthorizationDeniedException(principal.subject(), required); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/MethodSecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/MethodSecurityConfig.java new file mode 100644 index 00000000..26563df7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/MethodSecurityConfig.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.application.security.RequiresPermission; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.Advisor; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.Pointcuts; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +/** + * Wires the {@link RequiresPermission} enforcement point into Spring method security. + * + * <p>{@code @EnableMethodSecurity(prePostEnabled = false)} enables the method-security + * infrastructure without the {@code @PreAuthorize}/{@code @PostAuthorize} interceptors, leaving the + * custom advisor below as the only authorization advice. See README for the design rationale. + */ +@Configuration +@EnableMethodSecurity(prePostEnabled = false) +public class MethodSecurityConfig { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) { + AuthorizationManager<MethodInvocation> manager = + new RequiresPermissionAuthorizationManager(authorizationPort); + + Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class); + Pointcut onClass = AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class); + Pointcut pointcut = Pointcuts.union(onMethod, onClass); + + return new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RequiresPermissionAuthorizationManager.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RequiresPermissionAuthorizationManager.java new file mode 100644 index 00000000..a4ec95b9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RequiresPermissionAuthorizationManager.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.application.security.AuthorizationDeniedException; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.application.security.AuthorizationPrincipal; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.shared.security.Permission; +import java.lang.reflect.Method; +import java.util.Set; +import java.util.function.Supplier; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.core.Authentication; + +/** + * Spring-aware enforcement mechanism for {@link RequiresPermission}. + * + * <p>Reads the {@link RequiresPermission} annotation off the intercepted method (or its declaring + * type), maps the current {@link Authentication} to the framework-free {@link + * AuthorizationPrincipal}, and delegates the decision to the application {@link AuthorizationPort}. + * A denial from the port becomes a denied {@link AuthorizationDecision}; an absent annotation + * returns {@code null} to abstain. See README for the design rationale. + */ +public final class RequiresPermissionAuthorizationManager + implements AuthorizationManager<MethodInvocation> { + + private final AuthorizationPort authorizationPort; + + public RequiresPermissionAuthorizationManager(AuthorizationPort authorizationPort) { + this.authorizationPort = authorizationPort; + } + + @Override + public AuthorizationResult authorize( + Supplier<? extends Authentication> authentication, MethodInvocation invocation) { + RequiresPermission annotation = findAnnotation(invocation); + if (annotation == null) { + return null; // not guarded by this manager — abstain + } + Permission required = Permission.parse(annotation.value()); + + Authentication auth = authentication.get(); + if (auth == null || !auth.isAuthenticated()) { + return new AuthorizationDecision(false); + } + try { + authorizationPort.requirePermission(toPrincipal(auth), required); + return new AuthorizationDecision(true); + } catch (AuthorizationDeniedException denied) { + return new AuthorizationDecision(false); + } + } + + private RequiresPermission findAnnotation(MethodInvocation invocation) { + Method method = invocation.getMethod(); + RequiresPermission onMethod = AnnotationUtils.findAnnotation(method, RequiresPermission.class); + if (onMethod != null) { + return onMethod; + } + Class<?> targetClass = + invocation.getThis() != null + ? AopUtils.getTargetClass(invocation.getThis()) + : method.getDeclaringClass(); + return AnnotationUtils.findAnnotation(targetClass, RequiresPermission.class); + } + + private AuthorizationPrincipal toPrincipal(Authentication auth) { + if (auth.getPrincipal() instanceof AuthenticatedPrincipal user) { + return new AuthorizationPrincipal(user.idpUserId(), user.roles()); + } + // Any other principal type carries no resolvable roles → fail-closed. + return new AuthorizationPrincipal(auth.getName(), Set.of()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicy.java new file mode 100644 index 00000000..09be195e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicy.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * App-side role→permission mapping source. + * + * <p>Bound from {@code ca-skeleton.authz.role-permissions.<role> = [resource:action, ...]}. Keys + * are <em>raw</em> IdP role names (no {@code ROLE_} prefix), e.g.: + * + * <pre> + * ca-skeleton: + * authz: + * role-permissions: + * user: [worklog:read, worklog:write] + * admin: [worklog:read, worklog:write, worklog:close] + * </pre> + * + * <p>See README for the design rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.authz") +public record RolePermissionPolicy(Map<String, List<String>> rolePermissions) { + + public RolePermissionPolicy { + rolePermissions = + rolePermissions == null + ? Map.of() + : rolePermissions.entrySet().stream() + .collect( + Collectors.toUnmodifiableMap( + Map.Entry::getKey, entry -> List.copyOf(entry.getValue()))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionRegistry.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionRegistry.java new file mode 100644 index 00000000..118fd902 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionRegistry.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import dev.caskeleton.shared.security.Permission; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Resolves a caller's raw roles to an effective {@link Permission} set. + * + * <p>Built once from {@link RolePermissionPolicy} at startup. Role keys are normalized to lower + * case so a lookup is case-insensitive. Permissions are the explicitly enumerated set per role; + * wildcards are unsupported. An unknown role, an empty role set, or an empty registry all resolve + * to zero permissions. See README for the design rationale. + */ +@Component +public class RolePermissionRegistry { + + private final Map<String, Set<Permission>> permissionsByRole; + + public RolePermissionRegistry(RolePermissionPolicy properties) { + Map<String, Set<Permission>> resolved = new HashMap<>(); + properties + .rolePermissions() + .forEach( + (role, tokens) -> { + Set<Permission> permissions = + tokens.stream().map(Permission::parse).collect(Collectors.toUnmodifiableSet()); + resolved.put(normalize(role), permissions); + }); + this.permissionsByRole = Map.copyOf(resolved); + } + + /** Union of the permissions granted by each of {@code roles}; empty if none/unknown. */ + public Set<Permission> effectivePermissions(Set<String> roles) { + if (roles == null || roles.isEmpty()) { + return Set.of(); + } + return roles.stream() + .filter(role -> role != null && !role.isBlank()) + .map(role -> permissionsByRole.getOrDefault(normalize(role), Set.of())) + .flatMap(Set::stream) + .collect(Collectors.toUnmodifiableSet()); + } + + private static String normalize(String role) { + return role.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java new file mode 100644 index 00000000..597ff09f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +/** + * Weak-ETag derivation and conditional-request matching for HTTP-layer optimistic concurrency / + * cache validation. See README for the design rationale. + * + * <p>An entity's optimistic-lock version is the ETag source: {@link #weakFromVersion} yields {@code + * W/"<version>"}. Reads emit it as the {@code ETag} header; a write carrying {@code If-Match} is + * accepted only when {@link #matches} is {@code true}, otherwise the controller raises {@link + * PreconditionFailedException} (→ 412); a read carrying {@code If-None-Match} that {@link #matches} + * returns 304 (no body). + */ +public final class ETags { + + private static final String WILDCARD = "*"; + + private ETags() {} + + /** {@code W/"<version>"} weak validator from an optimistic-lock version. */ + public static String weakFromVersion(long version) { + return "W/\"" + version + "\""; + } + + /** + * Lenient conditional match: {@code true} when {@code header} is {@code *} or any comma-separated + * candidate's opaque value equals {@code etag}'s opaque value. Null/blank header → {@code false} + * (no precondition supplied). + */ + public static boolean matches( + String header, String etag) { // e.g. If-None-Match: W/"3" on reads, If-Match: W/"3" on writes + if (header == null || header.isBlank() || etag == null) { + return false; + } + String trimmed = header.trim(); + if (WILDCARD.equals(trimmed)) { + return true; + } + String target = opaque(etag); + for (String candidate : trimmed.split(",")) { + if (opaque(candidate).equals(target)) { + return true; + } + } + return false; + } + + /** Strips the {@code W/} weak marker and surrounding double quotes. */ + private static String opaque(String raw) { + String v = raw.trim(); + if (v.startsWith("W/")) { + v = v.substring(2).trim(); + } + if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) { + v = v.substring(1, v.length() - 1); + } + return v; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/PreconditionFailedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/PreconditionFailedException.java new file mode 100644 index 00000000..5b3648d1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/PreconditionFailedException.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +/** + * Raised when a write request's {@code If-Match} validator does not match the current resource + * ETag. The global handler maps it to {@code OperationalError.PRECONDITION_FAILED} (HTTP 412). See + * README for the design rationale. + */ +public class PreconditionFailedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public PreconditionFailedException(String message) { + super(message); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/JacksonNullableConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/JacksonNullableConfig.java new file mode 100644 index 00000000..0024fa7a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/JacksonNullableConfig.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.inbound.web.config; + +import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.BeanProperty; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.module.SimpleModule; + +/** + * Registers Jackson 3 handlers for {@link JsonNullable}. The upstream jackson-databind-nullable + * module is still Jackson 2 based, so the template keeps a narrow local adapter for PATCH request + * DTOs. + */ +@Configuration +public class JacksonNullableConfig { + + @Bean + public SimpleModule jsonNullableModule() { + SimpleModule module = new SimpleModule("JsonNullableJackson3Module"); + module.addDeserializer(JsonNullable.class, new JsonNullableValueDeserializer()); + addJsonNullableSerializer(module); + return module; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void addJsonNullableSerializer(SimpleModule module) { + module.addSerializer((Class) JsonNullable.class, new JsonNullableValueSerializer()); + } + + static final class JsonNullableValueDeserializer extends ValueDeserializer<JsonNullable<Object>> { + + private final JavaType valueType; + private final ValueDeserializer<Object> valueDeserializer; + + JsonNullableValueDeserializer() { + this(null, null); + } + + private JsonNullableValueDeserializer( + JavaType valueType, ValueDeserializer<Object> valueDeserializer) { + this.valueType = valueType; + this.valueDeserializer = valueDeserializer; + } + + @Override + public ValueDeserializer<?> createContextual( + DeserializationContext ctxt, BeanProperty property) { + JavaType contextualType = + property == null ? ctxt.constructType(Object.class) : property.getType(); + JavaType referencedType = + contextualType.containedTypeCount() == 0 + ? ctxt.constructType(Object.class) + : contextualType.containedTypeOrUnknown(0); + return new JsonNullableValueDeserializer( + referencedType, ctxt.findContextualValueDeserializer(referencedType, property)); + } + + @Override + public JsonNullable<Object> deserialize(JsonParser parser, DeserializationContext ctxt) + throws JacksonException { + if (parser.currentToken() == JsonToken.VALUE_NULL) { + return JsonNullable.of(null); + } + Object value = + valueDeserializer == null + ? ctxt.readValue( + parser, valueType == null ? ctxt.constructType(Object.class) : valueType) + : valueDeserializer.deserialize(parser, ctxt); + return JsonNullable.of(value); + } + + @Override + public Object getNullValue(DeserializationContext ctxt) { + return JsonNullable.of(null); + } + + @Override + public Object getAbsentValue(DeserializationContext ctxt) { + return JsonNullable.undefined(); + } + } + + static final class JsonNullableValueSerializer extends ValueSerializer<JsonNullable<Object>> { + + @Override + public void serialize( + JsonNullable<Object> value, JsonGenerator generator, SerializationContext ctxt) + throws JacksonException { + if (value == null || !value.isPresent()) { + generator.writeNull(); + return; + } + ctxt.writeValue(generator, value.get()); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/PresentationWebConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/PresentationWebConfig.java new file mode 100644 index 00000000..84920aba --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/config/PresentationWebConfig.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.web.config; + +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class PresentationWebConfig implements WebMvcConfigurer { + + private final PresentationSettings settings; + + public PresentationWebConfig(PresentationSettings settings) { + this.settings = settings; + } + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + String prefix = settings.apiBasePath(); + if (prefix == null || prefix.isEmpty()) { + return; + } + configurer.addPathPrefix(prefix, c -> true); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/controller/HealthcheckController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/controller/HealthcheckController.java new file mode 100644 index 00000000..63ef452c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/controller/HealthcheckController.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.inbound.web.controller; + +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import dev.caskeleton.shared.response.Envelope; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/healthcheck") +public class HealthcheckController { + + @GetMapping + public Envelope<Map<String, String>> healthcheck() { + return Envelope.ok(Map.of("status", "UP"), ResponseMetaFactory.fromMdc()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorCodec.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorCodec.java new file mode 100644 index 00000000..c18becee --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorCodec.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.inbound.web.cursor; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Opaque, signed, time-bounded pagination cursor codec. A cursor is {@code base64url(iat + ":" + + * payload)} plus an HMAC-SHA256 signature, so it is URL-safe, tamper-evident, and expires after a + * fixed TTL (24h). Clients MUST treat the token as opaque. See README for the design rationale. + */ +public final class CursorCodec { + + /** Default cursor TTL (24h). */ + public static final Duration DEFAULT_TTL = Duration.ofHours(24); + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final char SEP = '.'; + private static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DEC = Base64.getUrlDecoder(); + + private final byte[] key; + private final Duration ttl; + + public CursorCodec(byte[] key, Duration ttl) { + if (key == null || key.length < 16) { + throw new IllegalArgumentException("cursor HMAC key must be at least 16 bytes"); + } + this.key = key.clone(); + this.ttl = ttl; + } + + /** Dev / test factory — NOT for production. */ + public static CursorCodec withDevKey() { + return new CursorCodec( + "ca-skeleton-dev-cursor-key-0001".getBytes(StandardCharsets.UTF_8), DEFAULT_TTL); + } + + /** Encodes an opaque payload string + issue instant into a signed URL-safe token. */ + public String encode(String payload, Instant issuedAt) { + String body = issuedAt.getEpochSecond() + ":" + payload; + String b64Body = ENC.encodeToString(body.getBytes(StandardCharsets.UTF_8)); + return b64Body + SEP + ENC.encodeToString(sign(b64Body)); + } + + /** + * Verifies signature + TTL and returns the original payload, or throws {@link CursorException}. + */ + public String decode(String token, Instant now) { + if (token == null || token.isBlank()) { + throw new CursorException("cursor token is missing"); + } + int dot = token.lastIndexOf(SEP); + if (dot <= 0 || dot == token.length() - 1) { + throw new CursorException("cursor token is malformed"); + } + String b64Body = token.substring(0, dot); + byte[] presented; + byte[] expected; + String body; + try { + presented = DEC.decode(token.substring(dot + 1)); + expected = sign(b64Body); + body = new String(DEC.decode(b64Body), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new CursorException("cursor token is malformed"); + } + if (!MessageDigest.isEqual(expected, presented)) { + throw new CursorException("cursor token signature is invalid"); + } + int colon = body.indexOf(':'); + if (colon < 0) { + throw new CursorException("cursor token payload is malformed"); + } + long issuedAtEpoch; + try { + issuedAtEpoch = Long.parseLong(body.substring(0, colon)); + } catch (NumberFormatException e) { + throw new CursorException("cursor token payload is malformed"); + } + if (now.getEpochSecond() - issuedAtEpoch > ttl.toSeconds()) { + throw new CursorException("cursor token has expired"); + } + return body.substring(colon + 1); + } + + private byte[] sign(String data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(key, HMAC_ALGORITHM)); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("HMAC computation failed", e); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorException.java new file mode 100644 index 00000000..2d5ccd57 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorException.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.inbound.web.cursor; + +/** + * Raised when an opaque pagination cursor fails verification — tampered HMAC signature, malformed + * encoding, or expired TTL. Controllers map it to 400 VALIDATION_FAILED and advise re-requesting + * the first page. + */ +public class CursorException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public CursorException(String message) { + super(message); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdvice.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdvice.java new file mode 100644 index 00000000..61c06223 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdvice.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.web.envelope; + +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import dev.caskeleton.shared.response.BulkEnvelope; +import dev.caskeleton.shared.response.Envelope; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * Wraps every JSON controller response in {@link Envelope} unless it is already an envelope + * variant, so the wire shape is always {@code {success, data | error, traceId}}. See README for the + * design rationale. + * + * <p>Skipped: already-{@link Envelope}/{@link BulkEnvelope} bodies, null/void (DELETE 204), + * non-JSON content types. + */ +@RestControllerAdvice +public class EnvelopeBodyAdvice implements ResponseBodyAdvice<Object> { + + @Override + public boolean supports( + MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { + return true; + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class<? extends HttpMessageConverter<?>> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + if (body == null) { + return null; + } + if (body instanceof Envelope<?> || body instanceof BulkEnvelope<?>) { + return body; + } + if (selectedContentType != null && !MediaType.APPLICATION_JSON.includes(selectedContentType)) { + return body; + } + return Envelope.ok(body, ResponseMetaFactory.fromMdc()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java new file mode 100644 index 00000000..70ac3140 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +final class ClientSafeErrorMessages { + + private ClientSafeErrorMessages() {} + + static String forPersistence(Category category) { + return switch (category) { + case TRANSIENT_DEPENDENCY -> "Service temporarily unavailable, please retry later"; + case CONFLICT -> "Request conflicted with the current state, please retry"; + case DATA_INTEGRITY -> "Request violates a data constraint"; + default -> "Internal server error"; + }; + } + + static String forDependency(ApiErrorCode code) { + return switch (code.code()) { + case "DEPENDENCY_TIMEOUT" -> "Upstream service did not respond in time, please retry"; + case "DEPENDENCY_CONNECT_FAILED" -> "Upstream service unreachable, please retry"; + case "DEPENDENCY_DNS_FAILED" -> "Upstream service unreachable, please retry"; + case "DEPENDENCY_4XX_CLIENT" -> "Upstream service rejected the request"; + case "DEPENDENCY_5XX_SERVER" -> "Upstream service error, please retry"; + case "DEPENDENCY_CIRCUIT_OPEN" -> + "Upstream service temporarily unavailable, please retry later"; + default -> forPersistence(code.category()); + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ErrorResponseFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ErrorResponseFactory.java new file mode 100644 index 00000000..542bcb7a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ErrorResponseFactory.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.response.ApiError; +import dev.caskeleton.shared.response.Envelope; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Builds a failure {@link Envelope} response from any {@link ApiErrorCode}, mapping the + * framework-neutral {@code int httpStatus()} to Spring {@link HttpStatus}, carrying {@code + * error.category}, and lifting the {@code meta} object (request/trace/correlation ids) from MDC via + * {@link ResponseMetaFactory}. + */ +public final class ErrorResponseFactory { + + private ErrorResponseFactory() {} + + public static ResponseEntity<Envelope<Void>> envelope( + ApiErrorCode code, String message, Object details) { + return ResponseEntity.status(HttpStatus.valueOf(code.httpStatus())) + .body(body(code, message, details)); + } + + /** Body-only variant for ResponseEntityExceptionHandler hooks that set status separately. */ + public static Envelope<Void> body(ApiErrorCode code, String message, Object details) { + ApiError err = + details == null + ? ApiError.of(code.code(), code.category().name(), message, code.retryable()) + : ApiError.withDetails( + code.code(), code.category().name(), message, code.retryable(), details); + return Envelope.failure(err, ResponseMetaFactory.fromMdc()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java new file mode 100644 index 00000000..61ed69a9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java @@ -0,0 +1,401 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import dev.caskeleton.adapter.inbound.web.auth.SecurityErrorClassifier; +import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException; +import dev.caskeleton.adapter.inbound.web.cursor.CursorException; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor; +import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; +import dev.caskeleton.application.idempotency.IdempotencyInFlightException; +import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException; +import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException; +import dev.caskeleton.shared.error.AdapterDisabledException; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.MappingException; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import jakarta.validation.ConstraintViolationException; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** + * Skeleton-wide base error → {@link Envelope} converter. Handles operational, transport, and + * security exceptions only; domain exceptions are handled by a separate + * {@code @RestControllerAdvice} in the consuming module. See README for the design rationale. + */ +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** Stateless classifier shared with the filter-layer access-denied handler. */ + private static final SecurityErrorClassifier ACCESS_DENIED_CLASSIFIER = + new SecurityErrorClassifier(); + + /** Records span errors via a tracer-neutral seam (default {@link SpanErrorRecorder#NOOP}). */ + private final SpanErrorRecorder spanErrorRecorder; + + /** + * Spring entry point. Self-defaults to {@link SpanErrorRecorder#NOOP} when no {@code + * SpanErrorRecorder} bean is present. See README for the design rationale. + */ + @Autowired + public GlobalExceptionHandler(ObjectProvider<SpanErrorRecorder> spanErrorRecorderProvider) { + this(spanErrorRecorderProvider.getIfAvailable(() -> SpanErrorRecorder.NOOP)); + } + + /** Direct constructor for tests and explicit wiring (e.g. a capturing recorder). */ + public GlobalExceptionHandler(SpanErrorRecorder spanErrorRecorder) { + this.spanErrorRecorder = spanErrorRecorder; + } + + @ExceptionHandler(MappingException.class) + public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) { + return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null); + } + + /** + * Runtime fail-fast for a disabled optional adapter that was invoked → 500 {@link + * OperationalError#ADAPTER_DISABLED}. See README for the design rationale. + */ + @ExceptionHandler(AdapterDisabledException.class) + public ResponseEntity<Envelope<Void>> handleAdapterDisabled(AdapterDisabledException ex) { + log.error( + "disabled optional adapter invoked at runtime: adapter={} (Layer 3 fail-fast)", + ex.adapterName(), + ex); + return ErrorResponseFactory.envelope(OperationalError.ADAPTER_DISABLED, ex.getMessage(), null); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity<Envelope<Void>> handleIllegalArgument(IllegalArgumentException ex) { + return ErrorResponseFactory.envelope(OperationalError.BAD_PARAMETER, ex.getMessage(), null); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity<Envelope<Void>> handleConstraintViolation(ConstraintViolationException ex) { + List<Map<String, Object>> violations = + ex.getConstraintViolations().stream() + .map( + v -> + Map.<String, Object>of( + "field", v.getPropertyPath().toString(), + "message", v.getMessage())) + .toList(); + return ErrorResponseFactory.envelope( + OperationalError.VALIDATION_FAILED, "Request validation failed", violations); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + Map<String, Object> details = + ex.getRequiredType() == null + ? null + : Map.of("expectedType", ex.getRequiredType().getSimpleName()); + return ErrorResponseFactory.envelope( + OperationalError.BAD_PARAMETER, + "Parameter '" + ex.getName() + "' has invalid value '" + ex.getValue() + "'", + details); + } + + @ExceptionHandler(InvalidBearerTokenException.class) + public ResponseEntity<Envelope<Void>> handleInvalidToken(InvalidBearerTokenException ex) { + return ErrorResponseFactory.envelope(OperationalError.INVALID_TOKEN, ex.getMessage(), null); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity<Envelope<Void>> handleUnauthenticated(AuthenticationException ex) { + return ErrorResponseFactory.envelope(OperationalError.UNAUTHENTICATED, ex.getMessage(), null); + } + + /** + * Handles a method-security {@link AccessDeniedException} that escaped the controller, delegating + * to {@link SecurityErrorClassifier} for the fine-grained authorization code. See README for the + * design rationale. + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity<Envelope<Void>> handleForbidden(AccessDeniedException ex) { + return ErrorResponseFactory.envelope( + ACCESS_DENIED_CLASSIFIER.classifyAccessDenied(ex), ex.getMessage(), null); + } + + /** Handles a failed {@code If-Match} precondition → 412 PRECONDITION_FAILED. */ + @ExceptionHandler(PreconditionFailedException.class) + public ResponseEntity<Envelope<Void>> handlePreconditionFailed(PreconditionFailedException ex) { + return ErrorResponseFactory.envelope( + OperationalError.PRECONDITION_FAILED, ex.getMessage(), null); + } + + /** + * Handles an out-of-bounds pagination / sort / filter parameter → 400 VALIDATION_FAILED carrying + * the offending field + reason code. + */ + @ExceptionHandler(PageValidationException.class) + public ResponseEntity<Envelope<Void>> handlePageValidation(PageValidationException ex) { + Map<String, Object> details = Map.of("field", ex.field(), "code", ex.reasonCode()); + return ErrorResponseFactory.envelope( + OperationalError.VALIDATION_FAILED, ex.getMessage(), details); + } + + /** + * Handles a tampered / expired / malformed opaque cursor → 400 VALIDATION_FAILED advising the + * client to re-request the first page. + */ + @ExceptionHandler(CursorException.class) + public ResponseEntity<Envelope<Void>> handleCursor(CursorException ex) { + Map<String, Object> details = Map.of("field", "cursor", "code", "CURSOR_INVALID"); + return ErrorResponseFactory.envelope( + OperationalError.VALIDATION_FAILED, + ex.getMessage() + "; re-request the first page", + details); + } + + /** + * Handles a duplicate request whose original is still in flight → 409 IDEMPOTENT_IN_FLIGHT with a + * fixed client-safe message. See README for the design rationale. + */ + @ExceptionHandler(IdempotencyInFlightException.class) + public ResponseEntity<Envelope<Void>> handleIdempotentInFlight(IdempotencyInFlightException ex) { + return ErrorResponseFactory.envelope( + OperationalError.IDEMPOTENT_IN_FLIGHT, + "A previous identical request is still being processed, please poll for result", + null); + } + + /** + * Handles an {@code Idempotency-Key} reused with a different body → 422 + * IDEMPOTENT_REQUEST_MISMATCH with a client-safe message only. + */ + @ExceptionHandler(IdempotencyRequestMismatchException.class) + public ResponseEntity<Envelope<Void>> handleIdempotentMismatch( + IdempotencyRequestMismatchException ex) { + return ErrorResponseFactory.envelope( + OperationalError.IDEMPOTENT_REQUEST_MISMATCH, + "Idempotency key reused with different request body", + null); + } + + /** + * Handles an idempotency key applied without a resolvable scope → 400 VALIDATION_FAILED. See + * README for the design rationale. + */ + @ExceptionHandler(IdempotencyScopeMissingException.class) + public ResponseEntity<Envelope<Void>> handleIdempotencyScopeMissing( + IdempotencyScopeMissingException ex) { + return ErrorResponseFactory.envelope( + OperationalError.VALIDATION_FAILED, + "Idempotency key cannot be applied to this request", + null); + } + + /** + * Handles a pre-classified {@link PersistenceFailureException}: its {@link + * PersistenceFailureException#errorCode()} sets the envelope code/status; the client message is a + * category-derived safe string and the raw cause is logged server-side. See README for the design + * rationale. + */ + @ExceptionHandler(PersistenceFailureException.class) + public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) { + ApiErrorCode code = ex.errorCode(); + log.error( + "persistence failure classified as {} (category={}, retryable={})", + code.code(), + code.category(), + code.retryable(), + ex); + spanErrorRecorder.recordException(ex, code.code()); + return ErrorResponseFactory.envelope( + code, ClientSafeErrorMessages.forPersistence(code.category()), null); + } + + /** + * Handles a pre-classified {@link DependencyFailureException}: its {@link + * DependencyFailureException#errorCode()} sets the envelope code/status; the client message is a + * per-code safe string and a {@code Retry-After} header is attached via {@link RetryAfterAdvisor} + * when applicable. See README for the design rationale. + */ + @ExceptionHandler(DependencyFailureException.class) + public ResponseEntity<Envelope<Void>> handleDependencyFailure(DependencyFailureException ex) { + ApiErrorCode code = ex.errorCode(); + log.error( + "dependency failure classified as {} (category={}, retryable={}, dependency={})", + code.code(), + code.category(), + code.retryable(), + ex.dependencyName(), + ex); + spanErrorRecorder.recordException(ex, code.code()); + String message = ClientSafeErrorMessages.forDependency(code); + Envelope<Void> body = ErrorResponseFactory.body(code, message, null); + ResponseEntity.BodyBuilder builder = ResponseEntity.status(code.httpStatus()); + RetryAfterAdvisor.retryAfterSeconds(code) + .ifPresent(seconds -> builder.header(ApiHeaders.RETRY_AFTER, String.valueOf(seconds))); + return builder.body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity<Envelope<Void>> handleUnknown(Exception ex, WebRequest req) { + log.error("unhandled exception on {}", req.getDescription(false), ex); + spanErrorRecorder.recordException(ex, OperationalError.INTERNAL_ERROR.code()); + return ErrorResponseFactory.envelope( + OperationalError.INTERNAL_ERROR, "Internal server error", null); + } + + @Override + protected ResponseEntity<Object> handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + List<Map<String, Object>> fields = + ex.getBindingResult().getFieldErrors().stream() + .map( + fe -> { + Map<String, Object> m = new LinkedHashMap<>(); + m.put("field", fe.getField()); + m.put("rejectedValue", String.valueOf(fe.getRejectedValue())); + m.put("message", fe.getDefaultMessage()); + return m; + }) + .toList(); + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.VALIDATION_FAILED, "Request body failed validation", fields), + HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus())); + } + + @Override + protected ResponseEntity<Object> handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + Map<String, Object> details = + ex.getCause() != null ? Map.of("cause", ex.getCause().getClass().getSimpleName()) : null; + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.VALIDATION_FAILED, "Request body is malformed or unparsable", details), + HttpStatusCode.valueOf(OperationalError.VALIDATION_FAILED.httpStatus())); + } + + @Override + protected ResponseEntity<Object> handleHttpRequestMethodNotSupported( + HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + // 405 carries the `Allow` header listing the supported methods. + HttpHeaders responseHeaders = new HttpHeaders(); + if (ex.getSupportedHttpMethods() != null) { + responseHeaders.setAllow(new LinkedHashSet<>(ex.getSupportedHttpMethods())); + } + Map<String, Object> details = + ex.getSupportedHttpMethods() == null + ? null + : Map.of( + "supportedMethods", + ex.getSupportedHttpMethods().stream().map(Object::toString).toList()); + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.METHOD_NOT_ALLOWED, + "HTTP method " + ex.getMethod() + " not allowed for this route", + details), + responseHeaders, + HttpStatusCode.valueOf(OperationalError.METHOD_NOT_ALLOWED.httpStatus())); + } + + @Override + protected ResponseEntity<Object> handleHttpMediaTypeNotSupported( + HttpMediaTypeNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + // 415: request body format unsupported. + Map<String, Object> details = + ex.getSupportedMediaTypes() == null + ? null + : Map.of( + "supportedMediaTypes", + ex.getSupportedMediaTypes().stream().map(Object::toString).toList()); + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.UNSUPPORTED_MEDIA_TYPE, + "Content-Type " + ex.getContentType() + " is not supported", + details), + HttpStatusCode.valueOf(OperationalError.UNSUPPORTED_MEDIA_TYPE.httpStatus())); + } + + @Override + protected ResponseEntity<Object> handleMaxUploadSizeExceededException( + MaxUploadSizeExceededException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + // An oversized request body classifies as 413 inside the envelope. + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.PAYLOAD_TOO_LARGE, + "Request payload exceeds the maximum allowed size", + null), + HttpStatusCode.valueOf(OperationalError.PAYLOAD_TOO_LARGE.httpStatus())); + } + + @Override + protected ResponseEntity<Object> handleHttpMediaTypeNotAcceptable( + HttpMediaTypeNotAcceptableException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + // 406: no representation matches the Accept header. + Map<String, Object> details = + ex.getSupportedMediaTypes() == null + ? null + : Map.of( + "supportedMediaTypes", + ex.getSupportedMediaTypes().stream().map(Object::toString).toList()); + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.NOT_ACCEPTABLE, + "No acceptable representation for the requested Accept header", + details), + HttpStatusCode.valueOf(OperationalError.NOT_ACCEPTABLE.httpStatus())); + } + + @Override + protected ResponseEntity<Object> handleNoHandlerFoundException( + NoHandlerFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + return new ResponseEntity<>( + ErrorResponseFactory.body( + OperationalError.ROUTE_NOT_FOUND, + "No handler for " + ex.getHttpMethod() + " " + ex.getRequestURL(), + null), + HttpStatusCode.valueOf(OperationalError.ROUTE_NOT_FOUND.httpStatus())); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilter.java new file mode 100644 index 00000000..841c398a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilter.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.filter; + +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Applies the skeleton's default HTTP cache policy: every response gets {@code Cache-Control: + * no-store} and a {@code Vary: Accept, Accept-Encoding, Authorization} header. Defaults are set + * before the chain so a cacheable endpoint can opt in by returning a {@code ResponseEntity} whose + * {@code Cache-Control} header overwrites the default. See README for the design rationale. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 20) +public class CacheControlFilter extends OncePerRequestFilter { + + static final String DEFAULT_CACHE_CONTROL = "no-store"; + static final String DEFAULT_VARY = "Accept, Accept-Encoding, Authorization"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + response.setHeader(ApiHeaders.CACHE_CONTROL, DEFAULT_CACHE_CONTROL); + response.setHeader(ApiHeaders.VARY, DEFAULT_VARY); + chain.doFilter(request, response); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilter.java new file mode 100644 index 00000000..0032f474 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilter.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.inbound.web.filter; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.observability.HeaderSanitizer; +import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.shared.tracing.TraceParent; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerMapping; + +/** + * Logs one structured line per HTTP request and threads the correlation ids onto MDC using the + * snake_case keys of {@link MdcKeys}. Inbound {@code X-Request-Id} / {@code X-Correlation-Id} are + * sanitized and length-capped before use; absent/blank values are server-generated. A valid inbound + * W3C {@code traceparent} is adopted onto MDC ({@code trace_id} / {@code span_id}), otherwise a + * fresh ROOT traceparent is generated; the resolved value is set on the response header. {@code + * user_principal} is pseudonymized via {@link UserPrincipalPseudonymizerPort} before being placed + * on MDC. + * + * <p>See README for the design rationale, including the fork landmine around the generated {@code + * trace_id} when a real tracer is later wired in. + */ +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + private static final String HEADER_REQUEST_ID = "X-Request-Id"; + private static final String HEADER_CORRELATION_ID = "X-Correlation-Id"; + private static final String HEADER_TRACEPARENT = "traceparent"; + private static final int MAX_ID_LENGTH = 200; + + private final UserPrincipalPseudonymizerPort pseudonymizer; + + public RequestLoggingFilter(UserPrincipalPseudonymizerPort pseudonymizer) { + this.pseudonymizer = pseudonymizer; + } + + @Override + protected void doFilterInternal( + HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + + String requestId = resolveOrGenerate(req.getHeader(HEADER_REQUEST_ID)); + String correlationId = resolveOrGenerate(req.getHeader(HEADER_CORRELATION_ID)); + res.setHeader(HEADER_REQUEST_ID, requestId); + res.setHeader(HEADER_CORRELATION_ID, correlationId); + + MDC.put(MdcKeys.REQUEST_ID, requestId); + MDC.put(MdcKeys.CORRELATION_ID, correlationId); + + // W3C traceparent: adopt inbound if valid, otherwise generate a fresh ROOT. + TraceParent traceParent = resolveOrGenerateTraceParent(req.getHeader(HEADER_TRACEPARENT)); + MDC.put(MdcKeys.TRACE_ID, traceParent.traceId()); + MDC.put(MdcKeys.SPAN_ID, traceParent.spanId()); + res.setHeader(HEADER_TRACEPARENT, traceParent.toHeader()); + + long start = System.nanoTime(); + try { + chain.doFilter(req, res); + } finally { + putUserPrincipalIfAvailable(); + long durationMs = (System.nanoTime() - start) / 1_000_000L; + log.info( + "http_request method={} uri_template={} status={} duration_ms={}", + req.getMethod(), + resolveUriTemplate(req), + res.getStatus(), + durationMs); + MDC.remove(MdcKeys.REQUEST_ID); + MDC.remove(MdcKeys.CORRELATION_ID); + MDC.remove(MdcKeys.TRACE_ID); + MDC.remove(MdcKeys.SPAN_ID); + MDC.remove(MdcKeys.USER_PRINCIPAL); + } + } + + /** Sanitize an inbound id header; generate a fresh one when absent/blank. */ + private static String resolveOrGenerate(String inbound) { + String clean = HeaderSanitizer.sanitize(inbound, MAX_ID_LENGTH); + return (clean == null || clean.isBlank()) ? UUID.randomUUID().toString() : clean; + } + + /** + * Resolves a W3C {@code traceparent} for this request: adopts a present, valid inbound header + * unchanged, otherwise generates a fresh ROOT traceparent (32-hex {@code traceId}, 16-hex {@code + * spanId}, {@code sampled=false}). + */ + private static TraceParent resolveOrGenerateTraceParent(String header) { + return TraceParent.parse(header) + .orElseGet( + () -> { + String traceId = freshHex32(); + String spanId = freshHex16(); + return TraceParent.of(traceId, spanId, false); + }); + } + + /** Returns a 32-char lowercase hex string from a random UUID (dashes stripped). */ + private static String freshHex32() { + return UUID.randomUUID().toString().replace("-", ""); + } + + /** + * Returns a 16-char lowercase hex span id (64 random bits), derived from a fresh UUID's + * least-significant bits and zero-padded. See README for the design rationale. + */ + private static String freshHex16() { + return String.format("%016x", UUID.randomUUID().getLeastSignificantBits()); + } + + /** + * Returns the low-cardinality matched route template ({@code BEST_MATCHING_PATTERN_ATTRIBUTE}), + * falling back to the raw request URI for unmatched requests (e.g. 404s). + */ + private static String resolveUriTemplate(HttpServletRequest req) { + Object pattern = req.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + return pattern instanceof String s ? s : req.getRequestURI(); + } + + /** + * Pseudonymizes the authenticated user's raw principal via {@link UserPrincipalPseudonymizerPort} + * before placing the value on the {@code user_principal} MDC key. The raw {@code idpUserId()} is + * never written to MDC or logs. + */ + private void putUserPrincipalIfAvailable() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof AuthenticatedPrincipal user) { + String pseudo = pseudonymizer.pseudonymize(user.idpUserId()); + if (pseudo != null) { + MDC.put(MdcKeys.USER_PRINCIPAL, pseudo); + } + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ApiHeaders.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ApiHeaders.java new file mode 100644 index 00000000..f88b13a2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ApiHeaders.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.http; + +/** + * HTTP header-name constants used across the inbound web adapter. See README for the design + * rationale. + */ +public final class ApiHeaders { + + // versioning + idempotency name + public static final String X_API_VERSION = "X-Api-Version"; + public static final String IDEMPOTENCY_KEY = "Idempotency-Key"; + + // conditional requests + public static final String ETAG = "ETag"; + public static final String IF_MATCH = "If-Match"; + public static final String IF_NONE_MATCH = "If-None-Match"; + + // cache policy + public static final String CACHE_CONTROL = "Cache-Control"; + public static final String VARY = "Vary"; + + // method / negotiation / LRO + public static final String ALLOW = "Allow"; + public static final String LOCATION = "Location"; + public static final String RETRY_AFTER = "Retry-After"; + + // rate-limit signaling + public static final String X_RATELIMIT_LIMIT = "X-RateLimit-Limit"; + public static final String X_RATELIMIT_REMAINING = "X-RateLimit-Remaining"; + public static final String X_RATELIMIT_RESET = "X-RateLimit-Reset"; + + // deep-offset deprecation marker + public static final String DEPRECATION = "Deprecation"; + + // always-emitted + public static final String DATE = "Date"; + + private ApiHeaders() {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java new file mode 100644 index 00000000..0dc1a346 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotentResponseCodec; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.shared.error.MappingException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** + * Web-side helper that assembles the inputs the application {@code IdempotencyExecutor} needs from + * an HTTP request: the {@code Idempotency-Key} header, the scope (principal + use case name, tenant + * seam), the request body fingerprint, and a JSON response codec. See README for the design + * rationale. + */ +@Component +public class IdempotencyKeySupport { + + private final ObjectMapper objectMapper; + + public IdempotencyKeySupport(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** The trimmed {@code Idempotency-Key} header value, or empty when absent/blank. */ + public Optional<String> idempotencyKey(HttpServletRequest request) { + String value = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY); + return (value == null || value.isBlank()) ? Optional.empty() : Optional.of(value.trim()); + } + + /** Build the scope for the current authenticated caller (tenant left null). */ + public IdempotencyScope scope(String idempotencyKey, String useCaseName) { + return IdempotencyScope.of(currentPrincipal(), idempotencyKey, useCaseName); + } + + /** SHA-256 fingerprint of the serialized request payload. */ + public RequestFingerprint fingerprint(Object requestPayload) { + try { + return RequestFingerprint.ofSha256(objectMapper.writeValueAsBytes(requestPayload)); + } catch (JacksonException e) { + throw new MappingException("failed to fingerprint idempotent request payload", e); + } + } + + /** A JSON codec for replaying a use case result of type {@code R}. */ + public <R> IdempotentResponseCodec<R> codec(Class<R> responseType) { + return new JsonIdempotentResponseCodec<>(objectMapper, responseType); + } + + private static String currentPrincipal() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null + && auth.isAuthenticated() + && auth.getPrincipal() instanceof AuthenticatedPrincipal user) { + return user.idpUserId(); + } + return null; // IdempotencyScope.of → IdempotencyScopeMissingException (400) + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/JsonIdempotentResponseCodec.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/JsonIdempotentResponseCodec.java new file mode 100644 index 00000000..e6563e02 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/JsonIdempotentResponseCodec.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.application.idempotency.IdempotentResponseCodec; +import dev.caskeleton.shared.error.MappingException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; + +/** + * Jackson-backed {@link IdempotentResponseCodec}: the web adapter owns the JSON wire format the + * application executor stores and replays. A (de)serialization failure surfaces as {@link + * MappingException}, which the base handler routes to {@code MAPPING_FAILED} 400. See README for + * the design rationale. + * + * @param <R> the use case result type made idempotent + */ +public final class JsonIdempotentResponseCodec<R> implements IdempotentResponseCodec<R> { + + private final ObjectMapper objectMapper; + private final JavaType type; + + public JsonIdempotentResponseCodec(ObjectMapper objectMapper, Class<R> type) { + this.objectMapper = objectMapper; + this.type = objectMapper.getTypeFactory().constructType(type); + } + + @Override + public String serialize(R result) { + try { + return objectMapper.writeValueAsString(result); + } catch (JacksonException e) { + throw new MappingException("failed to serialize idempotent response for replay", e); + } + } + + @Override + public R deserialize(String payload) { + try { + return objectMapper.readValue(payload, type); + } catch (JacksonException e) { + throw new MappingException("failed to deserialize stored idempotent response", e); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizer.java new file mode 100644 index 00000000..dd75b418 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizer.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +/** + * Neutralizes inbound header values before they reach MDC / logs by stripping every {@code \r}, + * {@code \n} and ASCII control char ({@code < 0x20}) and length-capping the result. See README for + * the design rationale. + */ +public final class HeaderSanitizer { + + private HeaderSanitizer() {} + + public static String sanitize(String raw, int maxLength) { + if (raw == null) { + return null; + } + StringBuilder sb = new StringBuilder(Math.min(raw.length(), maxLength)); + for (int i = 0; i < raw.length() && sb.length() < maxLength; i++) { + char c = raw.charAt(i); + if (c >= 0x20) { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/MdcKeys.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/MdcKeys.java new file mode 100644 index 00000000..5e4cfcea --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/MdcKeys.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +/** snake_case MDC key names. See README for the design rationale. */ +public final class MdcKeys { + + public static final String REQUEST_ID = "request_id"; + public static final String TRACE_ID = "trace_id"; + public static final String SPAN_ID = "span_id"; + public static final String CORRELATION_ID = "correlation_id"; + public static final String USER_PRINCIPAL = "user_principal"; + + private MdcKeys() {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/ResponseMetaFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/ResponseMetaFactory.java new file mode 100644 index 00000000..a790a5ee --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/ResponseMetaFactory.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import dev.caskeleton.shared.response.ResponseMeta; +import org.slf4j.MDC; + +/** + * Projects the snake_case MDC diagnostic keys onto the camelCase {@link ResponseMeta} wire object. + * See README for the design rationale. + */ +public final class ResponseMetaFactory { + + private ResponseMetaFactory() {} + + public static ResponseMeta fromMdc() { + return new ResponseMeta( + MDC.get(MdcKeys.REQUEST_ID), MDC.get(MdcKeys.TRACE_ID), MDC.get(MdcKeys.CORRELATION_ID)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/RetryAfterAdvisor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/RetryAfterAdvisor.java new file mode 100644 index 00000000..e18414ea --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/RetryAfterAdvisor.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import dev.caskeleton.shared.error.ApiErrorCode; +import java.util.Map; +import java.util.OptionalInt; + +/** + * Answers whether a retryable error code warrants a {@code Retry-After} header and supplies its + * per-code value (seconds). See README for the design rationale. + */ +public final class RetryAfterAdvisor { + + /** Per-code {@code Retry-After} values (seconds). */ + private static final Map<String, Integer> RETRY_AFTER_SECONDS = + Map.of( + "RATE_LIMIT_EXCEEDED", 1, + "AUTH_KID_UNKNOWN", 5, + "AUTH_JWKS_UNAVAILABLE", 30, + "DEPENDENCY_TIMEOUT", 2, + "DEPENDENCY_CONNECT_FAILED", 2, + "DEPENDENCY_DNS_FAILED", 5, + "DEPENDENCY_5XX_SERVER", 2, + "DEPENDENCY_CIRCUIT_OPEN", 10); + + private RetryAfterAdvisor() {} + + /** True when a code's response should carry a {@code Retry-After} header. */ + public static boolean shouldAdvise(ApiErrorCode code) { + return code.retryable(); + } + + /** + * The concrete {@code Retry-After} value (seconds) for a code, or empty when the code is + * non-retryable or has no registry value. + */ + public static OptionalInt retryAfterSeconds(ApiErrorCode code) { + if (!shouldAdvise(code)) { + return OptionalInt.empty(); + } + Integer seconds = RETRY_AFTER_SECONDS.get(code.code()); + return seconds == null ? OptionalInt.empty() : OptionalInt.of(seconds); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageParams.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageParams.java new file mode 100644 index 00000000..95e0fbac --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageParams.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import dev.caskeleton.shared.response.PageMeta; + +/** + * Validated offset-pagination parameters. See README for the design rationale. + * + * <ul> + * <li>{@code page} 0-indexed; {@code page < 0} → {@link PageValidationException}. + * <li>{@code size} default 20, min 1, max 100; out of range → {@link PageValidationException} + * (mapped to 400 VALIDATION_FAILED). + * <li>{@link #isDeepOffset()} flags {@code page > 10000} so the controller can emit a {@code + * Deprecation} header and recommend cursor pagination. + * </ul> + */ +public record PageParams(int page, int size) { + + public static final int DEFAULT_SIZE = 20; + public static final int MIN_SIZE = 1; + public static final int MAX_SIZE = 100; + public static final int DEEP_OFFSET_THRESHOLD = 10000; + + public static PageParams of(Integer page, Integer size) { + int p = page == null ? 0 : page; + int s = size == null ? DEFAULT_SIZE : size; + if (p < 0) { + throw new PageValidationException("page", "PAGE_NEGATIVE", "page must be >= 0"); + } + if (s < MIN_SIZE) { + throw new PageValidationException("size", "SIZE_BELOW_MIN", "size must be >= " + MIN_SIZE); + } + if (s > MAX_SIZE) { + throw new PageValidationException("size", "SIZE_EXCEEDS_MAX", "size must be <= " + MAX_SIZE); + } + return new PageParams(p, s); + } + + public boolean isDeepOffset() { + return page > DEEP_OFFSET_THRESHOLD; + } + + /** Builds the response {@code meta.page} object for the given total + applied sort. */ + public PageMeta toPageMeta(long total, String sort) { + return new PageMeta(page, size, total, sort); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageValidationException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageValidationException.java new file mode 100644 index 00000000..a0dad859 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageValidationException.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +/** + * Raised when a pagination / sort request parameter violates its bounds (e.g. {@code size > 100}, + * {@code page < 0}, non-native sort syntax). The global handler maps it to {@code + * OperationalError.VALIDATION_FAILED} (HTTP 400) and surfaces {@code error.details.{field,code}} so + * a client can correct the exact parameter. + */ +public class PageValidationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String field; + private final String reasonCode; + + public PageValidationException(String field, String reasonCode, String message) { + super(message); + this.field = field; + this.reasonCode = reasonCode; + } + + public String field() { + return field; + } + + public String reasonCode() { + return reasonCode; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortParam.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortParam.java new file mode 100644 index 00000000..90149f3d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortParam.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.Locale; + +/** + * A single sort term in Spring {@code Pageable} native syntax {@code field,direction} (e.g. {@code + * createdAt,desc}). Direction is optional and defaults to {@code asc}. Non-native syntaxes are + * rejected with {@link PageValidationException} (→ 400 VALIDATION_FAILED); multi-sort is expressed + * by repeating the {@code sort} query parameter, parsed term-by-term. See README for the design + * rationale. + */ +public record SortParam(String field, boolean ascending) { + + public static SortParam parse(String raw) { + if (raw == null || raw.isBlank()) { + throw new PageValidationException("sort", "SORT_EMPTY", "sort term must not be blank"); + } + String term = raw.trim(); + if (term.contains(" ") || term.contains(":") || term.startsWith("-") || term.startsWith("+")) { + throw new PageValidationException( + "sort", + "SORT_SYNTAX_INVALID", + "sort must be 'field,direction' (Spring native); '" + raw + "' is not allowed"); + } + String[] parts = term.split(",", -1); + if (parts.length > 2 || parts[0].isBlank()) { + throw new PageValidationException( + "sort", + "SORT_SYNTAX_INVALID", + "sort must be 'field' or 'field,direction'; got '" + raw + "'"); + } + String field = parts[0].trim(); + if (!field.matches("[a-zA-Z][a-zA-Z0-9]*")) { + throw new PageValidationException( + "sort", "SORT_FIELD_INVALID", "sort field '" + field + "' is not a valid identifier"); + } + boolean ascending = true; + if (parts.length == 2) { + String dir = parts[1].trim().toLowerCase(Locale.ROOT); + if (dir.equals("desc")) { + ascending = false; + } else if (!dir.equals("asc")) { + throw new PageValidationException( + "sort", + "SORT_DIRECTION_INVALID", + "sort direction must be 'asc' or 'desc'; got '" + parts[1] + "'"); + } + } + return new SortParam(field, ascending); + } + + /** Re-renders this term in canonical Spring native form for {@code meta.page.sort}. */ + public String canonical() { + return field + "," + (ascending ? "asc" : "desc"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ClientIpResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ClientIpResolver.java new file mode 100644 index 00000000..d6d622af --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ClientIpResolver.java @@ -0,0 +1,8 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; + +interface ClientIpResolver { + + String resolve(HttpServletRequest request); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ClientIpResolverFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ClientIpResolverFactory.java new file mode 100644 index 00000000..3998c4a6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ClientIpResolverFactory.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +final class ClientIpResolverFactory { + + private ClientIpResolverFactory() {} + + static ClientIpResolver create(RateLimitClientIpMode mode) { + return switch (mode) { + case REMOTE_ADDR_ONLY -> new RemoteAddrClientIpResolver(); + case FORWARDED_HEADERS_TRUSTED -> new ForwardedHeaderClientIpResolver(); + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java new file mode 100644 index 00000000..81890e43 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiter.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window + * {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the + * design rationale. + */ +public final class FixedWindowRateLimiter implements RateLimiter { + + private final int limit; + private final long windowSeconds; + private final Clock clock; + private final ConcurrentMap<String, Window> windows = new ConcurrentHashMap<>(); + + public FixedWindowRateLimiter(int limit, Duration window, Clock clock) { + this.limit = Math.max(1, limit); + this.windowSeconds = Math.max(1L, window.toSeconds()); + this.clock = clock; + } + + @Override + public RateLimitDecision decide(String key) { + long nowSecond = clock.instant().getEpochSecond(); + long windowId = nowSecond / windowSeconds; + Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds); + + Window window = + windows.compute( + key, + (k, current) -> + (current == null || current.id != windowId) ? new Window(windowId) : current); + + int count = window.count.incrementAndGet(); + boolean allowed = count <= limit; + int remaining = Math.max(0, limit - count); + return new RateLimitDecision(allowed, limit, remaining, resetAt); + } + + private static final class Window { + private final long id; + private final AtomicInteger count = new AtomicInteger(); + + private Window(long id) { + this.id = id; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ForwardedHeaderClientIpResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ForwardedHeaderClientIpResolver.java new file mode 100644 index 00000000..48a302c4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/ForwardedHeaderClientIpResolver.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; + +final class ForwardedHeaderClientIpResolver implements ClientIpResolver { + + private static final String X_FORWARDED_FOR = "X-Forwarded-For"; + + @Override + public String resolve(HttpServletRequest request) { + String forwarded = request.getHeader(X_FORWARDED_FOR); + if (forwarded == null || forwarded.isBlank()) { + return request.getRemoteAddr(); + } + String firstHop = forwarded.split(",", 2)[0].trim(); + return firstHop.isEmpty() ? request.getRemoteAddr() : firstHop; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java new file mode 100644 index 00000000..e21d57e1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitAlgorithm.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +/** + * Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README + * for the design rationale. + */ +public enum RateLimitAlgorithm { + + /** Fixed-window counter — the default single-node implementation. */ + FIXED_WINDOW +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitClientIpMode.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitClientIpMode.java new file mode 100644 index 00000000..3a946d89 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitClientIpMode.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +/** + * Selects the client-IP source for unauthenticated rate-limit keys. See README for the design + * rationale. + */ +public enum RateLimitClientIpMode { + /** Uses {@code remoteAddr} only; ignores forwarded headers. */ + REMOTE_ADDR_ONLY, + + /** Trusts forwarded headers. */ + FORWARDED_HEADERS_TRUSTED +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java new file mode 100644 index 00000000..a1e9b443 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.time.Instant; + +/** + * Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*} + * signaling headers. See README for the design rationale. + * + * @param allowed false when the caller has exceeded the limit this window (→ 429) + * @param limit the window quota ({@code X-RateLimit-Limit}) + * @param remaining requests left in the current window, floored at 0 ({@code + * X-RateLimit-Remaining}) + * @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339) + */ +public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java new file mode 100644 index 00000000..d64d3663 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.response.Envelope; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.format.DateTimeFormatter; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.HandlerInterceptor; +import tools.jackson.databind.ObjectMapper; + +/** + * Applies the rate limit before a mapped handler runs. Every response carries the {@code + * X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429 + * {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers. + * See README for the design rationale. + */ +public final class RateLimitInterceptor implements HandlerInterceptor { + + private static final String CLIENT_SAFE_MESSAGE = + "Too many requests, please retry after the indicated interval"; + + private final boolean enabled; + private final RateLimiter limiter; + private final RateLimitKeyResolver keyResolver; + private final ObjectMapper objectMapper; + private final int retryAfterSeconds; + + public RateLimitInterceptor( + boolean enabled, + RateLimiter limiter, + RateLimitKeyResolver keyResolver, + ObjectMapper objectMapper, + int retryAfterSeconds) { + this.enabled = enabled; + this.limiter = limiter; + this.keyResolver = keyResolver; + this.objectMapper = objectMapper; + this.retryAfterSeconds = retryAfterSeconds; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + if (!enabled) { + return true; + } + RateLimitDecision decision = limiter.decide(keyResolver.resolve(request)); + applySignalingHeaders(response, decision); + if (decision.allowed()) { + return true; + } + rejectWith429(response); + return false; + } + + private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) { + response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit())); + response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining())); + response.setHeader( + ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt())); + } + + private void rejectWith429(HttpServletResponse response) throws Exception { + response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus()); + response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + Envelope<Void> body = + ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null); + objectMapper.writeValue(response.getWriter(), body); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java new file mode 100644 index 00000000..489148ee --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolver.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerMapping; + +/** + * Derives the rate-limit key from a request: + * + * <ul> + * <li>authenticated user → {@code user:<principal>} + * <li>service-to-service (a {@code service}-role principal) → {@code apikey:<id>} + * <li>unauthenticated → {@code ip:<source-ip>:<METHOD route-template>} + * </ul> + * + * <p>See README for the design rationale. + */ +public final class RateLimitKeyResolver { + + private static final String SERVICE_ROLE = "service"; + + private final ClientIpResolver clientIpResolver; + + public RateLimitKeyResolver(ClientIpResolver clientIpResolver) { + this.clientIpResolver = clientIpResolver; + } + + public String resolve(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null + && auth.isAuthenticated() + && auth.getPrincipal() instanceof AuthenticatedPrincipal user) { + return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId(); + } + return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request); + } + + private static String routeTemplate(HttpServletRequest request) { + Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + String route = pattern instanceof String s ? s : request.getRequestURI(); + return request.getMethod() + " " + route; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java new file mode 100644 index 00000000..ba7ec3c8 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor; +import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings; +import dev.caskeleton.shared.error.OperationalError; +import java.time.Clock; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import tools.jackson.databind.ObjectMapper; + +/** + * Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken + * from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With + * no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a + * pass-through. See README for the design rationale. + */ +@Configuration +@EnableConfigurationProperties(RateLimitSettings.class) +public class RateLimitWebConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + public RateLimitWebConfig( + RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider<Clock> clock) { + RateLimiter limiter = + RateLimiterFactory.create( + properties.algorithm(), + properties.limit(), + properties.window(), + clock.getIfAvailable(Clock::systemUTC)); + int retryAfter = + RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1); + ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode()); + this.rateLimitInterceptor = + new RateLimitInterceptor( + properties.enabled(), + limiter, + new RateLimitKeyResolver(clientIpResolver), + objectMapper, + retryAfter); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java new file mode 100644 index 00000000..bf598814 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiter.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +/** + * Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code + * X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design + * rationale and the algorithm-neutral output contract. + */ +public interface RateLimiter { + + /** Register one request for {@code key} and report whether it is within the limit. */ + RateLimitDecision decide(String key); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java new file mode 100644 index 00000000..e383b662 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactory.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.time.Clock; +import java.time.Duration; + +/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */ +public final class RateLimiterFactory { + + private RateLimiterFactory() {} + + public static RateLimiter create( + RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) { + return switch (algorithm) { + case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock); + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RemoteAddrClientIpResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RemoteAddrClientIpResolver.java new file mode 100644 index 00000000..d20cc46d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RemoteAddrClientIpResolver.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; + +final class RemoteAddrClientIpResolver implements ClientIpResolver { + + @Override + public String resolve(HttpServletRequest request) { + return request.getRemoteAddr(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettings.java new file mode 100644 index 00000000..87cb1ad4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettings.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * CORS settings bound from {@code ca-skeleton.cors.*}. See README for the validation-policy + * rationale. + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.cors") +public record CorsSettings( + boolean enabled, + List<String> allowedOrigins, + List<String> allowedMethods, + List<String> allowedHeaders, + boolean allowCredentials, + @PositiveOrZero( + message = "APP_SECURITY_CORS_MAX_AGE (ca-skeleton.cors.max-age-seconds) must be >= 0") + long maxAgeSeconds) { + + public CorsSettings { + // When CORS is enabled at least one allowed origin is required. + if (enabled && (allowedOrigins == null || allowedOrigins.isEmpty())) { + throw new IllegalArgumentException( + "APP_SECURITY_CORS_ENABLED=true requires a non-empty APP_SECURITY_CORS_ORIGINS " + + "(ca-skeleton.cors.allowed-origins)"); + } + // A wildcard origin must not be combined with credentials. + if (allowCredentials && allowedOrigins != null && allowedOrigins.contains("*")) { + throw new IllegalArgumentException( + "APP_SECURITY_CORS_ALLOW_CREDENTIALS=true must not be combined with a wildcard " + + "\"*\" in APP_SECURITY_CORS_ORIGINS (ca-skeleton.cors.allowed-origins); " + + "list explicit origins instead"); + } + if (allowedOrigins == null) { + allowedOrigins = List.of(); + } else { + allowedOrigins = List.copyOf(allowedOrigins); + } + if (allowedMethods == null || allowedMethods.isEmpty()) { + allowedMethods = List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"); + } else { + allowedMethods = List.copyOf(allowedMethods); + } + if (allowedHeaders == null || allowedHeaders.isEmpty()) { + allowedHeaders = List.of("*"); + } else { + allowedHeaders = List.copyOf(allowedHeaders); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/PresentationSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/PresentationSettings.java new file mode 100644 index 00000000..b4becc39 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/PresentationSettings.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Bound from {@code ca-skeleton.presentation.*}. See README for the validation-policy rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.presentation") +public record PresentationSettings(String apiBasePath) { + + private static final Logger log = LoggerFactory.getLogger(PresentationSettings.class); + private static final String DEFAULT_API_BASE_PATH = ""; + + public PresentationSettings { + if (apiBasePath == null) { + log.warn("PRESENTATION_API_BASE_PATH is missing; using '{}'", DEFAULT_API_BASE_PATH); + apiBasePath = DEFAULT_API_BASE_PATH; + } else if (!apiBasePath.isEmpty() && !apiBasePath.startsWith("/")) { + String fixed = "/" + apiBasePath; + log.warn( + "PRESENTATION_API_BASE_PATH '{}' must start with '/'; using '{}'", apiBasePath, fixed); + apiBasePath = fixed; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java new file mode 100644 index 00000000..d54838af --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettings.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design + * rationale. + * + * @param enabled whether the rate-limit interceptor enforces limits + * @param limit max requests allowed per key within one window + * @param window the fixed time window over which {@code limit} is counted + * @param algorithm the rate-limit strategy to use + * @param clientIpMode client-IP source for unauthenticated rate-limit keys + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.rate-limit") +public record RateLimitSettings( + boolean enabled, + Integer limit, + Duration window, + RateLimitAlgorithm algorithm, + RateLimitClientIpMode clientIpMode) { + + public RateLimitSettings { + if (limit == null || limit < 1) { + limit = 100; + } + if (window == null || window.isZero() || window.isNegative()) { + window = Duration.ofSeconds(1); + } + if (algorithm == null) { + algorithm = RateLimitAlgorithm.FIXED_WINDOW; + } + if (clientIpMode == null) { + clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java new file mode 100644 index 00000000..67c32ece --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design + * rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.security") +public record SecuritySettings(String issuerUri, String audience, List<String> publicPaths) { + + private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class); + + public SecuritySettings { + if (issuerUri == null || issuerUri.isBlank()) { + throw new IllegalArgumentException( + "APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required"); + } + if (audience == null) { + log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation"); + audience = ""; + } + if (publicPaths == null) { + publicPaths = List.of(); + } else { + publicPaths = List.copyOf(publicPaths); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipalTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipalTest.java new file mode 100644 index 00000000..6b721fc0 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipalTest.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AuthenticatedPrincipalTest { + + @Test + void rolesAreSnapshotAndImmutable() { + Set<String> roles = new HashSet<>(); + roles.add("user"); + + AuthenticatedPrincipal principal = new AuthenticatedPrincipal("sub-1", "u@example.com", roles); + roles.add("admin"); + + assertThat(principal.roles()).containsExactly("user"); + assertThat(principal.hasRole("admin")).isFalse(); + assertThatThrownBy(() -> principal.roles().add("admin")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void nullRolesDefaultToEmptySet() { + AuthenticatedPrincipal principal = new AuthenticatedPrincipal("sub-1", "u@example.com", null); + + assertThat(principal.roles()).isEmpty(); + assertThat(principal.hasRole("user")).isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAccessDeniedHandlerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAccessDeniedHandlerTest.java new file mode 100644 index 00000000..9fe3e743 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAccessDeniedHandlerTest.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +class EnvelopeAccessDeniedHandlerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final EnvelopeAccessDeniedHandler handler = + new EnvelopeAccessDeniedHandler(new SecurityErrorClassifier(), mapper); + + @Test + void accessDeniedWrites403InsufficientPermissionEnvelope() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.handle( + new MockHttpServletRequest("POST", "/v1/things"), + response, + new AccessDeniedException("Access is denied")); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isNull(); + assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isNull(); + JsonNode body = mapper.readTree(response.getContentAsString()); + assertThat(body.get("success").asBoolean()).isFalse(); + assertThat(body.path("error").path("code").asString()) + .isEqualTo("AUTHZ_INSUFFICIENT_PERMISSION"); + assertThat(body.path("error").path("category").asString()).isEqualTo("AUTHZ"); + assertThat(body.path("error").path("message").asString()).isEqualTo("Permission denied"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAuthenticationEntryPointTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAuthenticationEntryPointTest.java new file mode 100644 index 00000000..9b8b9427 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/EnvelopeAuthenticationEntryPointTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtValidationException; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +@ExtendWith(OutputCaptureExtension.class) +class EnvelopeAuthenticationEntryPointTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final EnvelopeAuthenticationEntryPoint entryPoint = + new EnvelopeAuthenticationEntryPoint(new SecurityErrorClassifier(), mapper); + + private JsonNode bodyOf(MockHttpServletResponse response) throws Exception { + return mapper.readTree(response.getContentAsString()); + } + + @Test + void missingTokenWrites401EnvelopeWithWwwAuthenticate() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest("GET", "/v1/things"), + response, + new InsufficientAuthenticationException("Full authentication is required")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isEqualTo("Bearer"); + assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isNull(); + JsonNode body = bodyOf(response); + assertThat(body.get("success").asBoolean()).isFalse(); + assertThat(body.path("error").path("code").asString()).isEqualTo("AUTH_TOKEN_MISSING"); + assertThat(body.path("error").path("category").asString()).isEqualTo("AUTH"); + assertThat(body.path("error").path("message").asString()).isEqualTo("Authentication required"); + } + + @Test + void expiredTokenWrites401WithInvalidTokenChallenge() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2Error expired = + new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at ...", null); + JwtValidationException cause = new JwtValidationException("expired", List.of(expired)); + + entryPoint.commence( + new MockHttpServletRequest("GET", "/v1/things"), + response, + new InvalidBearerTokenException("invalid", cause)); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)) + .isEqualTo("Bearer error=\"invalid_token\""); + assertThat(bodyOf(response).path("error").path("code").asString()) + .isEqualTo("AUTH_TOKEN_EXPIRED"); + } + + @Test + void unknownKidCarriesRetryAfter5() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest("GET", "/v1/things"), + response, + new InvalidBearerTokenException( + "invalid", new BadJwtException("Unable to find a matching key with kid 'abc'"))); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("5"); + JsonNode body = bodyOf(response); + assertThat(body.path("error").path("code").asString()).isEqualTo("AUTH_KID_UNKNOWN"); + assertThat(body.path("error").path("retryable").asBoolean()).isTrue(); + } + + @Test + void jwksOutageIs503WithRetryAfter30AndNoChallenge() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest("GET", "/v1/things"), + response, + new InvalidBearerTokenException( + "invalid", new JwtException("Couldn't retrieve remote JWK set: connect timed out"))); + + assertThat(response.getStatus()).isEqualTo(503); + assertThat(response.getHeader(HttpHeaders.RETRY_AFTER)).isEqualTo("30"); + assertThat(response.getHeader(HttpHeaders.WWW_AUTHENTICATE)).isNull(); + assertThat(bodyOf(response).path("error").path("code").asString()) + .isEqualTo("AUTH_JWKS_UNAVAILABLE"); + } + + @Test + void tokenValueNeverLeaksIntoResponseBodyOrLogs(CapturedOutput output) throws Exception { + // §테스트 계약: a JWT value must never appear in the response or the log output. + String token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJsZWFrIn0.SECRETSIGNATURE"; + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/things"); + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + MockHttpServletResponse response = new MockHttpServletResponse(); + // The decode failure message embeds the raw token, as Nimbus messages sometimes do. + InvalidBearerTokenException ex = + new InvalidBearerTokenException( + "An error occurred while attempting to decode the Jwt: " + token); + + entryPoint.commence(request, response, ex); + + assertThat(response.getContentAsString()) + .as("response body must not echo the bearer token") + .doesNotContain(token) + .doesNotContain("eyJ"); + assertThat(output.getOut()) + .as("log output must not contain the bearer token") + .doesNotContain(token) + .doesNotContain("eyJ"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfigTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfigTest.java new file mode 100644 index 00000000..6fb2d87d --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtDecoderConfigTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * feature-security-operational-baseline §구현가이드 2 — the explicit JWT validator chain (60s clock skew + * + issuer + audience). Unit-tested directly (no IdP / network), which also answers the branch-note + * Claim "61s expired token is rejected (auto-config path)". + */ +class JwtDecoderConfigTest { + + private static final String ISSUER = "https://issuer.example/realms/ca-skeleton"; + private static final String AUDIENCE = "ca-skeleton-api"; + + private final OAuth2TokenValidator<Jwt> validator = + JwtDecoderConfig.jwtValidator(ISSUER, AUDIENCE); + + private Jwt.Builder validJwt() { + Instant now = Instant.now(); + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("user-1") + .issuer(ISSUER) + .audience(List.of(AUDIENCE)) + .issuedAt(now.minus(5, ChronoUnit.MINUTES)) + .expiresAt(now.plus(5, ChronoUnit.MINUTES)); + } + + @Test + void aFullyValidTokenPasses() { + OAuth2TokenValidatorResult result = validator.validate(validJwt().build()); + assertThat(result.hasErrors()).isFalse(); + } + + @Test + void expiredWithin60sSkewIsAccepted() { + Jwt jwt = validJwt().expiresAt(Instant.now().minus(30, ChronoUnit.SECONDS)).build(); + assertThat(validator.validate(jwt).hasErrors()) + .as("a token expired 30s ago is within the 60s clock-skew tolerance") + .isFalse(); + } + + @Test + void expiredBeyond60sSkewIsRejected() { + Jwt jwt = validJwt().expiresAt(Instant.now().minus(90, ChronoUnit.SECONDS)).build(); + OAuth2TokenValidatorResult result = validator.validate(jwt); + assertThat(result.hasErrors()).as("a token expired 90s ago exceeds the 60s skew").isTrue(); + assertThat(result.getErrors()) + .anyMatch( + e -> + e.getDescription() != null && e.getDescription().toLowerCase().contains("expired")); + } + + @Test + void issuerMismatchIsRejected() { + Jwt jwt = validJwt().issuer("https://evil.example/realms/other").build(); + OAuth2TokenValidatorResult result = validator.validate(jwt); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()) + .anyMatch( + e -> e.getDescription() != null && e.getDescription().toLowerCase().contains("iss")); + } + + @Test + void audienceMismatchIsRejectedWithAudClaimDescription() { + Jwt jwt = validJwt().audience(List.of("some-other-api")).build(); + OAuth2TokenValidatorResult result = validator.validate(jwt); + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()) + .anyMatch(e -> "The aud claim is not valid".equals(e.getDescription())); + } + + @Test + void blankAudienceSkipsTheAudienceCheck() { + OAuth2TokenValidator<Jwt> noAud = JwtDecoderConfig.jwtValidator(ISSUER, ""); + Jwt jwt = validJwt().audience(List.of("anything")).build(); + assertThat(noAud.validate(jwt).hasErrors()) + .as("a blank configured audience disables audience validation") + .isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityErrorClassifierTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityErrorClassifierTest.java new file mode 100644 index 00000000..fe204faa --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityErrorClassifierTest.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.web.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.OperationalError; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtValidationException; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; + +/** + * feature-security-operational-baseline §구현가이드 3 — the resource-server exception → fine-grained + * {@link OperationalError} classifier that resolves the CODE_GRANULARITY_DRIFT. + * + * <p>The classification is best-effort over Spring Security's exception shapes: a missing token + * surfaces as {@link InsufficientAuthenticationException}; a present-but-invalid token surfaces as + * an {@link org.springframework.security.oauth2.core.OAuth2AuthenticationException} whose cause is + * a {@link JwtValidationException} (claim validators: exp / iss / aud) or a {@link BadJwtException} + * (decode / signature / unknown kid). The heuristics key off the validator/Nimbus message text the + * branch note documents — see the per-case comments. + */ +class SecurityErrorClassifierTest { + + private final SecurityErrorClassifier classifier = new SecurityErrorClassifier(); + + private OperationalError classify(AuthenticationException ex) { + return classifier.classifyAuthentication(ex); + } + + @Test + void missingTokenIsTokenMissing() { + // ExceptionTranslationFilter raises InsufficientAuthenticationException when an + // unauthenticated request hits an authenticated() endpoint (no bearer token present). + assertThat(classify(new InsufficientAuthenticationException("Full authentication is required"))) + .isEqualTo(OperationalError.AUTH_TOKEN_MISSING); + } + + @Test + void expiredTokenIsTokenExpired() { + // JwtTimestampValidator description: "Jwt expired at 2024-..." + OAuth2Error expired = + new OAuth2Error( + OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at 2024-01-01T00:00:00Z", null); + JwtValidationException cause = new JwtValidationException("token expired", List.of(expired)); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_TOKEN_EXPIRED); + } + + @Test + void issuerMismatchIsIssuerMismatch() { + // JwtIssuerValidator description: "The iss claim is not valid" + OAuth2Error iss = + new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid", null); + JwtValidationException cause = new JwtValidationException("bad iss", List.of(iss)); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_ISSUER_MISMATCH); + } + + @Test + void audienceMismatchIsAudienceMismatch() { + OAuth2Error aud = + new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The aud claim is not valid", null); + JwtValidationException cause = new JwtValidationException("bad aud", List.of(aud)); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_AUDIENCE_MISMATCH); + } + + @Test + void expiredTakesPrecedenceOverAudienceWhenBothFail() { + OAuth2Error expired = + new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "Jwt expired at ...", null); + OAuth2Error aud = + new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The aud claim is not valid", null); + JwtValidationException cause = new JwtValidationException("multi", List.of(aud, expired)); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_TOKEN_EXPIRED); + } + + @Test + void badSignatureIsInvalidSignature() { + BadJwtException cause = new BadJwtException("Signed JWT rejected: Invalid signature"); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_TOKEN_INVALID_SIGNATURE); + } + + @Test + void unparsableTokenIsMalformed() { + BadJwtException cause = + new BadJwtException( + "An error occurred while attempting to decode the Jwt: Malformed token"); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_TOKEN_MALFORMED); + } + + @Test + void unknownKidIsKidUnknown() { + // Nimbus: no JWK matches the token's kid header after a key rotation. + BadJwtException cause = + new BadJwtException( + "An error occurred while attempting to decode the Jwt: " + + "Unable to find a matching key with kid 'abc123'"); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_KID_UNKNOWN); + } + + @Test + void jwksEndpointOutageIsJwksUnavailable() { + JwtException cause = + new JwtException( + "An error occurred while attempting to decode the Jwt: Couldn't retrieve remote JWK set: connect timed out"); + assertThat(classify(new InvalidBearerTokenException("invalid", cause))) + .isEqualTo(OperationalError.AUTH_JWKS_UNAVAILABLE); + } + + @Test + void unknownAuthenticationFailureFallsBackToMalformed() { + // A novel/unmapped AuthenticationException must never leak as a 500; the safe default + // is a generic 401 AUTH classification rather than an unclassified error. + assertThat(classify(new AuthenticationException("weird") {})) + .isEqualTo(OperationalError.AUTH_TOKEN_MALFORMED); + } + + @Test + void accessDeniedIsInsufficientPermission() { + assertThat(classifier.classifyAccessDenied(new AccessDeniedException("denied"))) + .isEqualTo(OperationalError.AUTHZ_INSUFFICIENT_PERMISSION); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/AuthorizationAdapterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/AuthorizationAdapterTest.java new file mode 100644 index 00000000..51312790 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/AuthorizationAdapterTest.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.security.AuthorizationDeniedException; +import dev.caskeleton.application.security.AuthorizationPrincipal; +import dev.caskeleton.shared.security.Permission; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AuthorizationAdapterTest { + + private final AuthorizationAdapter adapter = + new AuthorizationAdapter( + new RolePermissionRegistry( + new RolePermissionPolicy( + Map.of( + "user", List.of("worklog:read", "worklog:write"), + "admin", List.of("worklog:read", "worklog:write", "worklog:close"))))); + + @Test + void grantsWhenAnEffectivePermissionCoversTheRequirement() { + AuthorizationPrincipal admin = new AuthorizationPrincipal("sub-admin", Set.of("admin")); + + assertThatCode(() -> adapter.requirePermission(admin, Permission.parse("worklog:close"))) + .doesNotThrowAnyException(); + } + + @Test + void deniesWhenRoleLacksTheRequiredPermission() { + AuthorizationPrincipal user = new AuthorizationPrincipal("sub-user", Set.of("user")); + + assertThatThrownBy(() -> adapter.requirePermission(user, Permission.parse("worklog:close"))) + .isInstanceOf(AuthorizationDeniedException.class); + } + + @Test + void deniesAPrincipalWithNoRolesFailClosed() { + AuthorizationPrincipal anon = new AuthorizationPrincipal("sub-anon", Set.of()); + + assertThatThrownBy(() -> adapter.requirePermission(anon, Permission.parse("worklog:read"))) + .isInstanceOf(AuthorizationDeniedException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RequiresPermissionAuthorizationManagerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RequiresPermissionAuthorizationManagerTest.java new file mode 100644 index 00000000..18d1af07 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RequiresPermissionAuthorizationManagerTest.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.application.security.AuthorizationDeniedException; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.application.security.AuthorizationPrincipal; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.shared.security.Permission; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; + +class RequiresPermissionAuthorizationManagerTest { + + private final AuthorizationPort port = + new AuthorizationAdapter( + new RolePermissionRegistry( + new RolePermissionPolicy( + Map.of( + "user", List.of("worklog:read", "worklog:write"), + "admin", List.of("worklog:read", "worklog:write", "worklog:close"))))); + + private final RequiresPermissionAuthorizationManager manager = + new RequiresPermissionAuthorizationManager(port); + + // --- fixtures: a guarded and an unguarded method ---------------------------------- + + static class Guarded { + @RequiresPermission("worklog:close") + public void close() {} + + public void unguarded() {} + } + + private MethodInvocation invocationOf(String methodName) throws Exception { + Method method = Guarded.class.getMethod(methodName); + Guarded target = new Guarded(); + MethodInvocation mi = mock(MethodInvocation.class); + when(mi.getMethod()).thenReturn(method); + when(mi.getThis()).thenReturn(target); + return mi; + } + + private static Supplier<Authentication> principalWithRoles(String... roles) { + AuthenticatedPrincipal user = + new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles)); + TestingAuthenticationToken auth = + new TestingAuthenticationToken(user, "n/a", AuthorityUtils.NO_AUTHORITIES); + auth.setAuthenticated(true); + return () -> auth; + } + + @Test + void grantsWhenPrincipalHoldsRequiredPermission() throws Exception { + AuthorizationResult decision = + manager.authorize(principalWithRoles("admin"), invocationOf("close")); + + assertThat(decision).isNotNull(); + assertThat(decision.isGranted()).isTrue(); + } + + @Test + void deniesWhenPrincipalLacksRequiredPermission() throws Exception { + AuthorizationResult decision = + manager.authorize(principalWithRoles("user"), invocationOf("close")); + + assertThat(decision).isNotNull(); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void abstainsOnUnguardedMethod() throws Exception { + AuthorizationResult decision = + manager.authorize(principalWithRoles("admin"), invocationOf("unguarded")); + + assertThat(decision).isNull(); + } + + @Test + void deniesUnauthenticatedRequestFailClosed() throws Exception { + Supplier<Authentication> anonymous = + () -> { + AnonymousAuthenticationToken token = + new AnonymousAuthenticationToken( + "key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + token.setAuthenticated(false); + return token; + }; + + AuthorizationResult decision = manager.authorize(anonymous, invocationOf("close")); + + assertThat(decision).isNotNull(); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void deniesWhenPrincipalIsNotOurAuthenticatedUser() throws Exception { + Supplier<Authentication> foreignPrincipal = + () -> { + TestingAuthenticationToken auth = + new TestingAuthenticationToken("just-a-string", "n/a", AuthorityUtils.NO_AUTHORITIES); + auth.setAuthenticated(true); + return auth; + }; + + AuthorizationResult decision = manager.authorize(foreignPrincipal, invocationOf("close")); + + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void portDenialCarriesRequiredPermission() { + AuthorizationPrincipal user = new AuthorizationPrincipal("sub-1", Set.of("user")); + + assertThatThrownBy(() -> port.requirePermission(user, Permission.parse("worklog:close"))) + .isInstanceOfSatisfying( + AuthorizationDeniedException.class, + denied -> + assertThat(denied.requiredPermission()) + .isEqualTo(Permission.parse("worklog:close"))); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicyTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicyTest.java new file mode 100644 index 00000000..94e24a09 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicyTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +/** + * Binding test for {@link RolePermissionPolicy} (AGENTS.md §설정과 런타임: every new + * {@code @ConfigurationProperties} class gets a binding test). Verifies the {@code + * ca-skeleton.authz.role-permissions.<role>=<comma list>} YAML/env shape binds to the {@code + * Map<String, List<String>>} the registry consumes, and that an absent block is null-safe. + */ +class RolePermissionPolicyTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + @Test + void bindsRoleToPermissionLists() { + runner + .withPropertyValues( + "ca-skeleton.authz.role-permissions.user=worklog:read,worklog:write", + "ca-skeleton.authz.role-permissions.admin=worklog:read,worklog:write,worklog:close") + .run( + context -> { + assertThat(context).hasNotFailed(); + RolePermissionPolicy props = context.getBean(RolePermissionPolicy.class); + assertThat(props.rolePermissions()).containsKeys("user", "admin"); + assertThat(props.rolePermissions().get("admin")) + .containsExactly("worklog:read", "worklog:write", "worklog:close"); + }); + } + + @Test + void absentBlockBindsToEmptyMap() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(RolePermissionPolicy.class).rolePermissions()).isEmpty(); + }); + } + + @Test + void rolePermissionListsAreDeepCopied() { + List<String> permissions = new ArrayList<>(List.of("worklog:read")); + Map<String, List<String>> rolePermissions = new HashMap<>(); + rolePermissions.put("user", permissions); + + RolePermissionPolicy policy = new RolePermissionPolicy(rolePermissions); + permissions.add("worklog:write"); + rolePermissions.put("admin", List.of("worklog:close")); + + assertThat(policy.rolePermissions()).containsOnlyKeys("user"); + assertThat(policy.rolePermissions().get("user")).containsExactly("worklog:read"); + assertThat(policy.rolePermissions()).isUnmodifiable(); + assertThat(policy.rolePermissions().get("user")).isUnmodifiable(); + } + + @Configuration + @EnableConfigurationProperties(RolePermissionPolicy.class) + static class EnableProperties {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionRegistryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionRegistryTest.java new file mode 100644 index 00000000..e20d02e1 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionRegistryTest.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.authz; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.security.Permission; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RolePermissionRegistryTest { + + private static RolePermissionRegistry registry(Map<String, List<String>> roles) { + return new RolePermissionRegistry(new RolePermissionPolicy(roles)); + } + + @Test + void resolvesASingleRoleToItsDeclaredPermissions() { + RolePermissionRegistry reg = registry(Map.of("user", List.of("worklog:read", "worklog:write"))); + + assertThat(reg.effectivePermissions(Set.of("user"))) + .containsExactlyInAnyOrder( + Permission.parse("worklog:read"), Permission.parse("worklog:write")); + } + + @Test + void unionsPermissionsAcrossMultipleRoles() { + RolePermissionRegistry reg = + registry( + Map.of( + "user", List.of("worklog:read", "worklog:write"), + "admin", List.of("worklog:read", "worklog:write", "worklog:close"))); + + assertThat(reg.effectivePermissions(Set.of("user", "admin"))) + .containsExactlyInAnyOrder( + Permission.parse("worklog:read"), + Permission.parse("worklog:write"), + Permission.parse("worklog:close")); + } + + @Test + void roleLookupIsCaseInsensitive() { + // D3: Keycloak does not guarantee role casing; registry lookup normalises so a + // raw role of "ADMIN" still resolves to the "admin" bundle. + RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close"))); + + assertThat(reg.effectivePermissions(Set.of("ADMIN"))) + .containsExactly(Permission.parse("worklog:close")); + } + + @Test + void unknownRoleResolvesToZeroPermissionsFailClosed() { + RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close"))); + + assertThat(reg.effectivePermissions(Set.of("ghost"))).isEmpty(); + } + + @Test + void emptyOrNullRoleSetResolvesToZeroPermissions() { + RolePermissionRegistry reg = registry(Map.of("admin", List.of("worklog:close"))); + + assertThat(reg.effectivePermissions(Set.of())).isEmpty(); + assertThat(reg.effectivePermissions(null)).isEmpty(); + } + + @Test + void emptyRegistryGrantsNothing() { + assertThat(registry(Map.of()).effectivePermissions(Set.of("admin"))).isEmpty(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ETagsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ETagsTest.java new file mode 100644 index 00000000..7bd63ab0 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ETagsTest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ETagsTest { + + @Test + void weakEtagFromVersionUsesTheBranchNoteForm() { + assertThat(ETags.weakFromVersion(7)).isEqualTo("W/\"7\""); + } + + @Test + void matchesSameVersionLenientlyAcrossWeakMarker() { + String etag = ETags.weakFromVersion(3); + assertThat(ETags.matches("W/\"3\"", etag)).isTrue(); + assertThat(ETags.matches("\"3\"", etag)).as("strong form of same value still matches").isTrue(); + } + + @Test + void wildcardMatchesAnyExistingEtag() { + assertThat(ETags.matches("*", ETags.weakFromVersion(99))).isTrue(); + } + + @Test + void multipleCandidatesMatchWhenAnyMatches() { + assertThat(ETags.matches("W/\"1\", W/\"2\", W/\"3\"", ETags.weakFromVersion(2))).isTrue(); + } + + @Test + void staleVersionDoesNotMatch() { + assertThat(ETags.matches("W/\"1\"", ETags.weakFromVersion(2))).isFalse(); + } + + @Test + void nullOrBlankHeaderDoesNotMatch() { + assertThat(ETags.matches(null, ETags.weakFromVersion(1))).isFalse(); + assertThat(ETags.matches(" ", ETags.weakFromVersion(1))).isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/contract/.gitkeep b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/contract/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/contract/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/cursor/CursorCodecTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/cursor/CursorCodecTest.java new file mode 100644 index 00000000..b867038e --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/cursor/CursorCodecTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.web.cursor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class CursorCodecTest { + + private final CursorCodec codec = CursorCodec.withDevKey(); + private static final Instant T0 = Instant.parse("2026-06-02T00:00:00Z"); + + @Test + void roundTripsThePayload() { + String token = codec.encode("01HZX9-after", T0); + assertThat(codec.decode(token, T0.plusSeconds(60))).isEqualTo("01HZX9-after"); + } + + @Test + void tokenIsUrlSafeAndNotPlainlyThePayload() { + String token = codec.encode("offset=40", T0); + assertThat(token).doesNotContain("offset=40"); // opacity: not the raw payload + assertThat(token).matches("[A-Za-z0-9_.\\-]+"); // URL-safe (base64url + '.') + } + + @Test + void tamperedTokenIsRejected() { + String token = codec.encode("01HZX9", T0); + String tampered = token.substring(0, token.length() - 1) + (token.endsWith("A") ? "B" : "A"); + assertThatExceptionOfType(CursorException.class) + .isThrownBy(() -> codec.decode(tampered, T0.plusSeconds(1))); + } + + @Test + void expiredTokenIsRejectedAfterTtl() { + String token = codec.encode("01HZX9", T0); + assertThatExceptionOfType(CursorException.class) + .isThrownBy(() -> codec.decode(token, T0.plus(Duration.ofHours(24)).plusSeconds(1))); + } + + @Test + void malformedTokenIsRejected() { + assertThatExceptionOfType(CursorException.class) + .isThrownBy(() -> codec.decode("not-a-valid-token", T0)); + } + + @Test + void shortKeyIsRefused() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new CursorCodec("short".getBytes(), CursorCodec.DEFAULT_TTL)); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdviceTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdviceTest.java new file mode 100644 index 00000000..ce2655c0 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdviceTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.inbound.web.envelope; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.shared.response.BulkEnvelope; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.response.ResponseMeta; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@WebMvcTest( + controllers = EnvelopeBodyAdviceTest.Probe.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({EnvelopeBodyAdviceTest.Probe.class, EnvelopeBodyAdvice.class}) +class EnvelopeBodyAdviceTest { + + @Autowired MockMvc mvc; + + @RestController + static class Probe { + @GetMapping("/probe/raw") + public Map<String, String> raw() { + return Map.of("k", "v"); + } + + @GetMapping("/probe/already-enveloped") + public Envelope<Map<String, String>> enveloped() { + return Envelope.ok(Map.of("k", "v"), new ResponseMeta("r", "t", "c")); + } + + @GetMapping("/probe/bulk") + public BulkEnvelope<Map<String, String>> bulk() { + return BulkEnvelope.allOk(List.of(Map.of("k", "v")), new ResponseMeta("r", "t", "c")); + } + } + + @Test + void rawBodyIsWrappedInEnvelope() throws Exception { + mvc.perform(get("/probe/raw")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.k").value("v")) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + void wrappedBodyCarriesAMetaObject() throws Exception { + mvc.perform(get("/probe/raw")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.meta").exists()); + } + + @Test + void alreadyEnvelopedBodyIsNotDoubleWrapped() throws Exception { + mvc.perform(get("/probe/already-enveloped")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.k").value("v")) + .andExpect(jsonPath("$.data.data").doesNotExist()); + } + + @Test + void bulkEnvelopeBodyIsNotDoubleWrapped() throws Exception { + mvc.perform(get("/probe/bulk")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[0].k").value("v")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + static class TestBootstrap {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeMetaIntegrationTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeMetaIntegrationTest.java new file mode 100644 index 00000000..629d85d8 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeMetaIntegrationTest.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.web.envelope; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.filter.RequestLoggingFilter; +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * End-to-end pin of the foundation envelope contract across the adapter-web chain: {@link + * RequestLoggingFilter} sets the SNAKE_CASE MDC ids, {@link EnvelopeBodyAdvice} (success) and + * {@link GlobalExceptionHandler} (5xx) project them onto a camelCase {@code meta} object. + * Standalone MockMvc — no Spring context, so it neither needs the production {@code + * application.yml} placeholders nor a security filter chain. + */ +class EnvelopeMetaIntegrationTest { + + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = + MockMvcBuilders.standaloneSetup(new Probe()) + .addFilter(new RequestLoggingFilter(rawPrincipal -> rawPrincipal)) + .setControllerAdvice( + new EnvelopeBodyAdvice(), new GlobalExceptionHandler(SpanErrorRecorder.NOOP)) + .build(); + } + + @Test + void successResponseCarriesNonEmptyMetaRequestAndTraceId() throws Exception { + mvc.perform(get("/__meta/ok")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.probe").value("ok")) + .andExpect(jsonPath("$.meta.requestId").isNotEmpty()) + .andExpect(jsonPath("$.meta.traceId").isNotEmpty()); + } + + @Test + void thrown5xxCarriesEnvelopeWithMetaAndCategoryAndNoStacktrace() throws Exception { + mvc.perform(get("/__meta/boom")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.category").value("INTERNAL")) + .andExpect(jsonPath("$.error.message").value("Internal server error")) + .andExpect(jsonPath("$.error.retryable").value(true)) + .andExpect(jsonPath("$.meta.traceId").isNotEmpty()); + } + + @RestController + static class Probe { + + @GetMapping("/__meta/ok") + Map<String, String> ok() { + return Map.of("probe", "ok"); + } + + @GetMapping("/__meta/boom") + Map<String, String> boom() { + throw new RuntimeException("kaboom"); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..ba65dad2 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java @@ -0,0 +1,374 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.shared.error.AdapterDisabledException; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.MappingException; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.context.request.ServletWebRequest; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(SpanErrorRecorder.NOOP); + + @Test + void mappingExceptionRoutesToMappingFailedEnvelope() { + ResponseEntity<Envelope<Void>> response = + handler.handleMapping(new MappingException("cannot map field 'role'")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + Envelope<Void> body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.success()).isFalse(); + assertThat(body.error().code()).isEqualTo("MAPPING_FAILED"); + assertThat(body.error().category()).isEqualTo("VALIDATION"); + assertThat(body.error().message()).isEqualTo("cannot map field 'role'"); + assertThat(body.error().retryable()).isFalse(); + } + + @Test + void illegalArgumentMapsToBadParameterEnvelope() { + ResponseEntity<Envelope<Void>> response = + handler.handleIllegalArgument(new IllegalArgumentException("bad offset")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().error().code()).isEqualTo("BAD_PARAMETER"); + } + + @Test + void adapterDisabledMapsToAdapterDisabled500NotRetryable() { + // integration-adapter-templates Layer 3 / §Audit A2 — runtime fail-fast code, + // distinct from the startup REQUIRED_ADAPTER_DISABLED. + ResponseEntity<Envelope<Void>> response = + handler.handleAdapterDisabled(new AdapterDisabledException("kafka")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().error().code()).isEqualTo("ADAPTER_DISABLED"); + assertThat(response.getBody().error().category()).isEqualTo("INTERNAL"); + assertThat(response.getBody().error().retryable()).isFalse(); + } + + @Test + void metaIsLiftedFromSnakeCaseMdc() { + try { + MDC.put("request_id", "req-xyz"); + MDC.put("trace_id", "trace-xyz"); + MDC.put("correlation_id", "corr-xyz"); + ResponseEntity<Envelope<Void>> response = handler.handleMapping(new MappingException("x")); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().requestId()).isEqualTo("req-xyz"); + assertThat(response.getBody().meta().traceId()).isEqualTo("trace-xyz"); + assertThat(response.getBody().meta().correlationId()).isEqualTo("corr-xyz"); + } finally { + MDC.clear(); + } + } + + @Test + void internalErrorIsRetryableAndHidesMessage() { + ResponseEntity<Envelope<Void>> response = + handler.handleUnknown( + new RuntimeException("boom"), + new ServletWebRequest(new MockHttpServletRequest("GET", "/x"))); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().error().retryable()).isTrue(); + assertThat(response.getBody().error().message()).isEqualTo("Internal server error"); + } + + // feature-authentication-authorization-contract D5/§4: an authorization denial that + // escapes the controller (a @RequiresPermission the caller could not satisfy) reaches this + // controller-advice path as an AccessDeniedException. It must emit the same fine-grained + // AUTHZ_INSUFFICIENT_PERMISSION code as the filter-layer handler, not a coarse FORBIDDEN. + @Test + void accessDeniedMapsToFineGrainedInsufficientPermission() { + ResponseEntity<Envelope<Void>> response = + handler.handleForbidden(new AccessDeniedException("Access Denied")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + Envelope<Void> body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.success()).isFalse(); + assertThat(body.error().code()).isEqualTo("AUTHZ_INSUFFICIENT_PERMISSION"); + assertThat(body.error().category()).isEqualTo("AUTHZ"); + assertThat(body.error().retryable()).isFalse(); + } + + // feature-persistence-failure-baseline D1 + business-rule-validation C7/D9: a classified + // persistence carrier sets the envelope from its DB_* code, but the client message is a fixed + // category-derived safe string — the raw diagnostic (SQLState / constraint name) the translator + // stored as the carrier's message must never reach the client. + @Test + void persistenceFailureCarrierSetsEnvelopeFromCodeWithSafeMessage() { + PersistenceFailureException carrier = + new PersistenceFailureException( + OperationalError.DB_UNIQUE_VIOLATION, + "persistence failure classified from SQLState=23505 constraint=\"uq_worklog_title\"", + new SQLException( + "duplicate key value violates unique constraint \"uq_worklog_title\"", "23505")); + + ResponseEntity<Envelope<Void>> response = handler.handlePersistenceFailure(carrier); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(body).isNotNull(); + assertThat(body.success()).isFalse(); + assertThat(body.error().code()).isEqualTo("DB_UNIQUE_VIOLATION"); + assertThat(body.error().category()).isEqualTo("CONFLICT"); + assertThat(body.error().retryable()).isFalse(); + // the safe message must not echo the SQLState, constraint name, or "unique constraint". + assertThat(body.error().message()) + .as("D1/C7: client message is the fixed category-safe string") + .isEqualTo("Request conflicted with the current state, please retry"); + assertThat(body.error().details()).isNull(); + } + + @Test + void persistenceFailureTransientMapsTo503RetryableWithSafeMessage() { + PersistenceFailureException carrier = + new PersistenceFailureException( + OperationalError.DB_UNAVAILABLE, + "persistence failure classified from SQLState=08006", + new SQLException("connection refused", "08006")); + + ResponseEntity<Envelope<Void>> response = handler.handlePersistenceFailure(carrier); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(body).isNotNull(); + assertThat(body.error().code()).isEqualTo("DB_UNAVAILABLE"); + assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY"); + assertThat(body.error().retryable()).isTrue(); + assertThat(body.error().message()) + .isEqualTo("Service temporarily unavailable, please retry later"); + } + + @Test + void persistenceFailureIntegrityMapsTo409DataIntegritySafeMessage() { + PersistenceFailureException carrier = + new PersistenceFailureException( + OperationalError.DB_NULL_VIOLATION, + "persistence failure classified from SQLState=23502", + new SQLException("null value in column \"owner_id\"", "23502")); + + ResponseEntity<Envelope<Void>> response = handler.handlePersistenceFailure(carrier); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(body).isNotNull(); + assertThat(body.error().category()).isEqualTo("DATA_INTEGRITY"); + assertThat(body.error().message()).isEqualTo("Request violates a data constraint"); + } + + // feature-business-rule-validation-contract C7 / D9 + persistence-failure-baseline + // 테스트 계약: a persistence-layer exception whose message embeds the raw SQL / constraint + // name must never reach the client. The base handler's catch-all replaces the message with + // a fixed client-safe string and emits null details, so no constraint name, SQLState, table + // name, or stack frame can leak through the envelope. (The category-correct persistence + // mapping itself — 23505 → CONFLICT/DB_UNIQUE_VIOLATION — is owned by + // feature-persistence-failure-baseline; this test pins only the leak-prevention guarantee.) + @Test + void persistenceExceptionMessageWithRawConstraintNameDoesNotLeakToClient() { + String rawDbMessage = + "ERROR: duplicate key value violates unique constraint " + + "\"uq_worklog_title\"; SQLState: 23505; Detail: Key (title)=(DB tuning) already exists."; + + ResponseEntity<Envelope<Void>> response = + handler.handleUnknown( + new RuntimeException(rawDbMessage), + new ServletWebRequest(new MockHttpServletRequest("POST", "/work-logs"))); + + Envelope<Void> body = response.getBody(); + assertThat(body).isNotNull(); + // The catch-all replaces the message with a fixed safe string, so none of the leaked + // tokens (constraint name "uq_worklog_title", SQLState 23505, "unique constraint", the + // column "title") can survive — isEqualTo pins this exactly, doesNotContain would be + // logically subsumed. + assertThat(body.error().message()) + .as("C7/D9: client message must be the fixed safe string, never the raw DB message") + .isEqualTo("Internal server error"); + assertThat(body.error().details()) + .as("C7/D9: no raw object / body / SQL detail in error.details") + .isNull(); + } + + // ── feature-outbound-http-client-baseline D12: upstream dependency failure ── + + // Each of the 6 dependency codes maps to the correct HTTP status, code name, category, + // and retryable flag (registry SSOT rows 636~711, docs/registries/error-codes.yaml). + + @Test + void dependencyTimeoutMapsTo504TransientRetryable() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_TIMEOUT, + "payment-service", + "connect timed out after 3000 ms", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GATEWAY_TIMEOUT); + assertThat(body).isNotNull(); + assertThat(body.success()).isFalse(); + assertThat(body.error().code()).isEqualTo("DEPENDENCY_TIMEOUT"); + assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY"); + assertThat(body.error().retryable()).isTrue(); + assertThat(body.error().message()) + .isEqualTo("Upstream service did not respond in time, please retry"); + // Retry-After header must be present (registry: 2 s) + assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("2"); + } + + @Test + void dependencyConnectFailedMapsTo503RetryableWithRetryAfter() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_CONNECT_FAILED, + "inventory-api", + "Connection refused to 10.0.0.1:8080", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(body).isNotNull(); + assertThat(body.error().code()).isEqualTo("DEPENDENCY_CONNECT_FAILED"); + assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY"); + assertThat(body.error().retryable()).isTrue(); + assertThat(body.error().message()).isEqualTo("Upstream service unreachable, please retry"); + assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("2"); + } + + @Test + void dependencyDnsFailedMapsTo503RetryableWithRetryAfter5s() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_DNS_FAILED, + "notification-service", + "DNS resolution failed for notification-service.internal", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(body).isNotNull(); + assertThat(body.error().code()).isEqualTo("DEPENDENCY_DNS_FAILED"); + assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY"); + assertThat(body.error().retryable()).isTrue(); + assertThat(body.error().message()).isEqualTo("Upstream service unreachable, please retry"); + assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("5"); + } + + @Test + void dependency4xxClientMapsTo502PermanentNonRetryableNoRetryAfter() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_4XX_CLIENT, + "github", + "upstream returned 422 Unprocessable Entity", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(body).isNotNull(); + assertThat(body.error().code()).isEqualTo("DEPENDENCY_4XX_CLIENT"); + assertThat(body.error().category()).isEqualTo("PERMANENT_DEPENDENCY"); + assertThat(body.error().retryable()).isFalse(); + assertThat(body.error().message()).isEqualTo("Upstream service rejected the request"); + // non-retryable → no Retry-After header + assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isNull(); + } + + @Test + void dependency5xxServerMapsTo502TransientRetryableWithRetryAfter() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_5XX_SERVER, + "github", + "upstream returned HTTP 503 Service Unavailable", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(body).isNotNull(); + assertThat(body.error().code()).isEqualTo("DEPENDENCY_5XX_SERVER"); + assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY"); + assertThat(body.error().retryable()).isTrue(); + assertThat(body.error().message()).isEqualTo("Upstream service error, please retry"); + assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("2"); + } + + @Test + void dependencyCircuitOpenMapsTo503RetryableWithRetryAfter10s() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_CIRCUIT_OPEN, + "payment-service", + "circuit breaker OPEN for payment-service", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(body).isNotNull(); + assertThat(body.error().code()).isEqualTo("DEPENDENCY_CIRCUIT_OPEN"); + assertThat(body.error().category()).isEqualTo("TRANSIENT_DEPENDENCY"); + assertThat(body.error().retryable()).isTrue(); + assertThat(body.error().message()) + .isEqualTo("Upstream service temporarily unavailable, please retry later"); + assertThat(response.getHeaders().getFirst(ApiHeaders.RETRY_AFTER)).isEqualTo("10"); + } + + // feature-outbound-http-client-baseline D12 spec contract: + // "upstream raw error body가 response/log에 노출되면 실패" — web side. + // The diagnosticMessage and dependencyName must never appear in the response body. + @Test + void dependencyFailureDiagnosticAndDependencyNameDoNotLeakToClient() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_5XX_SERVER, + "github", + "status 500 from upstream UPSTREAM_DIAG", + null); + + ResponseEntity<Envelope<Void>> response = handler.handleDependencyFailure(ex); + + Envelope<Void> body = response.getBody(); + assertThat(body).isNotNull(); + String bodyMessage = body.error().message(); + assertThat(bodyMessage) + .as("D12: diagnostic message must not leak to client") + .doesNotContain("UPSTREAM_DIAG"); + assertThat(bodyMessage) + .as("D12: dependency name must not leak to client") + .doesNotContain("github"); + assertThat(body.error().details()) + .as("D12: no raw diagnostic detail in error.details") + .isNull(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/IdempotencyExceptionMappingTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/IdempotencyExceptionMappingTest.java new file mode 100644 index 00000000..d1b1169d --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/IdempotencyExceptionMappingTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.idempotency.IdempotencyInFlightException; +import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +class IdempotencyExceptionMappingTest { + + private static final IdempotencyScope SCOPE = + IdempotencyScope.of("user-1", "key-1", "CreateWorkLogUseCase"); + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(SpanErrorRecorder.NOOP); + + @Test + void inFlightMapsTo409ConflictNotRetryable() { + ResponseEntity<Envelope<Void>> res = + handler.handleIdempotentInFlight(new IdempotencyInFlightException(SCOPE)); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(res.getBody().error().code()).isEqualTo("IDEMPOTENT_IN_FLIGHT"); + assertThat(res.getBody().error().category()).isEqualTo("CONFLICT"); + assertThat(res.getBody().error().retryable()).isFalse(); + // diagnostic (with scope/principal) must not leak into the client message + assertThat(res.getBody().error().message()).doesNotContain("user-1"); + } + + @Test + void mismatchMapsTo422ValidationNotRetryable() { + ResponseEntity<Envelope<Void>> res = + handler.handleIdempotentMismatch(new IdempotencyRequestMismatchException(SCOPE)); + + assertThat(res.getStatusCode().value()).isEqualTo(422); + assertThat(res.getBody().error().code()).isEqualTo("IDEMPOTENT_REQUEST_MISMATCH"); + assertThat(res.getBody().error().category()).isEqualTo("VALIDATION"); + assertThat(res.getBody().error().retryable()).isFalse(); + } + + @Test + void scopeMissingMapsTo400Validation() { + ResponseEntity<Envelope<Void>> res = + handler.handleIdempotencyScopeMissing(new IdempotencyScopeMissingException("principal")); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(res.getBody().error().code()).isEqualTo("VALIDATION_FAILED"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java new file mode 100644 index 00000000..3c64fc83 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/SpanErrorRecorderHookTest.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.ServletWebRequest; + +/** + * feature-distributed-tracing-contract D12: verifies that the {@link GlobalExceptionHandler} + * invokes {@link SpanErrorRecorder#recordException} with the correct error code on each exception + * path where a code is in hand. + */ +class SpanErrorRecorderHookTest { + + /** Capturing fake — records every (throwable, errorCode) pair received. */ + static final class CapturingRecorder implements SpanErrorRecorder { + record Recorded(Throwable error, String errorCode) {} + + final List<Recorded> calls = new ArrayList<>(); + + @Override + public void recordException(Throwable error, String errorCode) { + calls.add(new Recorded(error, errorCode)); + } + } + + @Test + void catchAllHandlerRecordsExceptionWithInternalErrorCode() { + CapturingRecorder recorder = new CapturingRecorder(); + GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder); + + RuntimeException boom = new RuntimeException("kaboom"); + handler.handleUnknown(boom, new ServletWebRequest(new MockHttpServletRequest("GET", "/x"))); + + assertThat(recorder.calls).hasSize(1); + assertThat(recorder.calls.get(0).error()).isSameAs(boom); + assertThat(recorder.calls.get(0).errorCode()).isEqualTo(OperationalError.INTERNAL_ERROR.code()); + } + + @Test + void persistenceFailureHandlerRecordsExceptionWithClassifiedCode() { + CapturingRecorder recorder = new CapturingRecorder(); + GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder); + + PersistenceFailureException ex = + new PersistenceFailureException( + OperationalError.DB_UNIQUE_VIOLATION, + "SQLState=23505", + new SQLException("unique key violation", "23505")); + + handler.handlePersistenceFailure(ex); + + assertThat(recorder.calls).hasSize(1); + assertThat(recorder.calls.get(0).error()).isSameAs(ex); + assertThat(recorder.calls.get(0).errorCode()) + .isEqualTo(OperationalError.DB_UNIQUE_VIOLATION.code()); + } + + @Test + void dependencyFailureHandlerRecordsExceptionWithClassifiedCode() { + CapturingRecorder recorder = new CapturingRecorder(); + GlobalExceptionHandler handler = new GlobalExceptionHandler(recorder); + + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_TIMEOUT, "payment-service", "connect timed out", null); + + handler.handleDependencyFailure(ex); + + assertThat(recorder.calls).hasSize(1); + assertThat(recorder.calls.get(0).error()).isSameAs(ex); + assertThat(recorder.calls.get(0).errorCode()) + .isEqualTo(OperationalError.DEPENDENCY_TIMEOUT.code()); + } + + @Test + void noopRecorderDoesNotThrowAndHandlerStillReturnsResponse() { + // Verify the happy path: NOOP recorder wired — no exceptions thrown, response still built. + GlobalExceptionHandler handler = new GlobalExceptionHandler(SpanErrorRecorder.NOOP); + + var response = + handler.handleUnknown( + new RuntimeException("noop test"), + new ServletWebRequest(new MockHttpServletRequest("GET", "/noop"))); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().error().code()).isEqualTo(OperationalError.INTERNAL_ERROR.code()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/TransportErrorHandlingTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/TransportErrorHandlingTest.java new file mode 100644 index 00000000..415e78ce --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/TransportErrorHandlingTest.java @@ -0,0 +1,143 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException; +import dev.caskeleton.adapter.inbound.web.cursor.CursorException; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +/** + * Pins the transport-shape failure classification owned by feature-api-contract-baseline: 405 + + * {@code Allow} (D12), 406 vs 415 distinct (D9), 413 (D8), 412 (D15), and the pagination/cursor + * 400s (D18/D22). Standalone MockMvc routes the Spring exceptions to {@link + * GlobalExceptionHandler}. + */ +class TransportErrorHandlingTest { + + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = + MockMvcBuilders.standaloneSetup(new Probe()) + .setControllerAdvice( + new GlobalExceptionHandler(SpanErrorRecorder.NOOP), new EnvelopeBodyAdvice()) + .build(); + } + + @Test + void methodNotAllowedIs405WithAllowHeaderAndEnvelope() throws Exception { + mvc.perform(post("/t/get-only").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().string("Allow", containsString("GET"))) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("METHOD_NOT_ALLOWED")); + } + + @Test + void unsupportedMediaTypeIs415() throws Exception { + mvc.perform(post("/t/json").contentType(MediaType.TEXT_PLAIN).content("hello")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.error.code").value("UNSUPPORTED_MEDIA_TYPE")); + } + + @Test + void notAcceptableIs406DistinctFrom415() throws Exception { + // Trigger the exception directly: the produces/Accept-mismatch path would + // also fail to serialize the 406 envelope itself to the rejected media type. + mvc.perform(get("/t/not-acceptable")) + .andExpect(status().isNotAcceptable()) + .andExpect(jsonPath("$.error.code").value("NOT_ACCEPTABLE")); + } + + @Test + void preconditionFailedIs412Conflict() throws Exception { + mvc.perform(get("/t/precondition")) + .andExpect(status().isPreconditionFailed()) + .andExpect(jsonPath("$.error.code").value("PRECONDITION_FAILED")) + .andExpect(jsonPath("$.error.category").value("CONFLICT")); + } + + @Test + void oversizedPayloadIs413NotRaw500() throws Exception { + mvc.perform(get("/t/too-large")) + .andExpect(status().isContentTooLarge()) + .andExpect(jsonPath("$.error.code").value("PAYLOAD_TOO_LARGE")); + } + + @Test + void badPaginationIs400ValidationWithFieldAndCode() throws Exception { + mvc.perform(get("/t/bad-page")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.error.details.field").value("size")) + .andExpect(jsonPath("$.error.details.code").value("SIZE_EXCEEDS_MAX")); + } + + @Test + void badCursorIs400Validation() throws Exception { + mvc.perform(get("/t/bad-cursor")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.error.details.code").value("CURSOR_INVALID")); + } + + @RestController + static class Probe { + + @GetMapping("/t/get-only") + Map<String, String> getOnly() { + return Map.of("ok", "ok"); + } + + @PostMapping(value = "/t/json", consumes = MediaType.APPLICATION_JSON_VALUE) + Map<String, String> json(@RequestBody Map<String, Object> body) { + return Map.of("ok", "ok"); + } + + @GetMapping("/t/not-acceptable") + Map<String, String> notAcceptable() throws HttpMediaTypeNotAcceptableException { + throw new HttpMediaTypeNotAcceptableException(List.of(MediaType.APPLICATION_JSON)); + } + + @GetMapping("/t/precondition") + Map<String, String> precondition() { + throw new PreconditionFailedException("If-Match did not match current ETag"); + } + + @GetMapping("/t/too-large") + Map<String, String> tooLarge() { + throw new MaxUploadSizeExceededException(1024L); + } + + @GetMapping("/t/bad-page") + Map<String, String> badPage() { + throw new PageValidationException("size", "SIZE_EXCEEDS_MAX", "size must be <= 100"); + } + + @GetMapping("/t/bad-cursor") + Map<String, String> badCursor() { + throw new CursorException("cursor token signature is invalid"); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilterTest.java new file mode 100644 index 00000000..c9a604d3 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilterTest.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.web.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class CacheControlFilterTest { + + private final CacheControlFilter filter = new CacheControlFilter(); + + @Test + void setsNoStoreAndVaryByDefault() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + filter.doFilter(req, res, (rq, rs) -> {}); + + assertThat(res.getHeader("Cache-Control")).isEqualTo("no-store"); + assertThat(res.getHeader("Vary")).isEqualTo("Accept, Accept-Encoding, Authorization"); + } + + @Test + void cacheableEndpointCanOptInByOverwritingCacheControl() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + filter.doFilter( + req, + res, + (rq, rs) -> ((HttpServletResponse) rs).setHeader("Cache-Control", "private, max-age=60")); + + assertThat(res.getHeader("Cache-Control")).isEqualTo("private, max-age=60"); + assertThat(res.getHeader("Vary")).isEqualTo("Accept, Accept-Encoding, Authorization"); + } + + @Test + void doesNotStripAContainerSetDateHeader() throws Exception { + // feature-api-contract-baseline D24: the servlet container (Tomcat) emits Date on + // every response; the cache-policy filter must not remove it. (The full + // 200/204/400/404/500 Date matrix is verified by a running container — `planned`.) + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + MockHttpServletResponse res = new MockHttpServletResponse(); + res.setHeader("Date", "Mon, 02 Jun 2026 00:00:00 GMT"); + + filter.doFilter(req, res, (rq, rs) -> {}); + + assertThat(res.getHeader("Date")).isEqualTo("Mon, 02 Jun 2026 00:00:00 GMT"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilterTest.java new file mode 100644 index 00000000..dd6e28c8 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilterTest.java @@ -0,0 +1,296 @@ +package dev.caskeleton.adapter.inbound.web.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import jakarta.servlet.FilterChain; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerMapping; + +class RequestLoggingFilterTest { + + private final RequestLoggingFilter filter = new RequestLoggingFilter(raw -> "pseudo-" + raw); + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + MDC.clear(); + } + + @Test + void generatesSnakeCaseRequestIdAndEchoesHeaderWhenAbsent() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + MockHttpServletResponse res = new MockHttpServletResponse(); + AtomicReference<String> seen = new AtomicReference<>(); + FilterChain chain = (rq, rs) -> seen.set(MDC.get(MdcKeys.REQUEST_ID)); + + filter.doFilter(req, res, chain); + + assertThat(seen.get()).as("request_id present on MDC during the chain").isNotBlank(); + assertThat(res.getHeader("X-Request-Id")).isEqualTo(seen.get()); + assertThat(MDC.get(MdcKeys.REQUEST_ID)).as("MDC cleared after request").isNull(); + } + + @Test + void honoursClientRequestIdAndCorrelationIdHeaders() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("X-Request-Id", "client-req-1"); + req.addHeader("X-Correlation-Id", "client-corr-1"); + MockHttpServletResponse res = new MockHttpServletResponse(); + AtomicReference<String> reqId = new AtomicReference<>(); + AtomicReference<String> corrId = new AtomicReference<>(); + FilterChain chain = + (rq, rs) -> { + reqId.set(MDC.get(MdcKeys.REQUEST_ID)); + corrId.set(MDC.get(MdcKeys.CORRELATION_ID)); + }; + + filter.doFilter(req, res, chain); + + assertThat(reqId.get()).isEqualTo("client-req-1"); + assertThat(corrId.get()).isEqualTo("client-corr-1"); + assertThat(res.getHeader("X-Correlation-Id")).isEqualTo("client-corr-1"); + } + + @Test + void sanitizesCrlfInjectionInInboundRequestId() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("X-Request-Id", "foo\r\nFAKE LOG"); + MockHttpServletResponse res = new MockHttpServletResponse(); + AtomicReference<String> reqId = new AtomicReference<>(); + FilterChain chain = (rq, rs) -> reqId.set(MDC.get(MdcKeys.REQUEST_ID)); + + filter.doFilter(req, res, chain); + + assertThat(reqId.get()).isEqualTo("fooFAKE LOG"); + assertThat(reqId.get()).doesNotContain("\r").doesNotContain("\n"); + } + + // DRIFT-3: logs the route template, not the raw concrete path + @Test + void logsUriTemplateNotRawPathWhenHandlerMappingAttributeSet() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123"); + req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestLoggingFilter.class); + ListAppender<ILoggingEvent> listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + + try { + filter.doFilter(req, res, (rq, rs) -> {}); + + List<ILoggingEvent> events = listAppender.list; + assertThat(events).isNotEmpty(); + ILoggingEvent event = + events.stream() + .filter(e -> e.getFormattedMessage().contains("http_request")) + .findFirst() + .orElseThrow(() -> new AssertionError("No http_request log event found")); + + String msg = event.getFormattedMessage(); + assertThat(msg).contains("uri_template=/v1/worklogs/{id}"); + assertThat(msg).doesNotContain("uri_template=/v1/worklogs/123"); + } finally { + logger.detachAppender(listAppender); + } + } + + // feature-distributed-tracing-contract D5/D7/D4: W3C traceparent handling + + /** + * D5/D7: a valid inbound {@code traceparent} header is adopted — its traceId and spanId are + * placed on MDC and the same traceparent value is echoed on the response. D4: {@link + * ResponseMetaFactory#fromMdc()} returns a non-null traceId. + */ + @Test + void validInboundTraceparentIsAdoptedIntoMdcAndEchoedOnResponse() throws Exception { + String validTraceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("traceparent", validTraceparent); + MockHttpServletResponse res = new MockHttpServletResponse(); + + AtomicReference<String> capturedTraceId = new AtomicReference<>(); + AtomicReference<String> capturedSpanId = new AtomicReference<>(); + AtomicReference<String> capturedMetaTraceId = new AtomicReference<>(); + FilterChain chain = + (rq, rs) -> { + capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID)); + capturedSpanId.set(MDC.get(MdcKeys.SPAN_ID)); + capturedMetaTraceId.set(ResponseMetaFactory.fromMdc().traceId()); + }; + + filter.doFilter(req, res, chain); + + assertThat(capturedTraceId.get()) + .as("MDC trace_id must be adopted from traceparent traceId") + .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(capturedSpanId.get()) + .as("MDC span_id must be adopted from traceparent spanId") + .isEqualTo("00f067aa0ba902b7"); + assertThat(res.getHeader("traceparent")) + .as("response traceparent must echo the resolved value") + .isEqualTo(validTraceparent); + assertThat(capturedMetaTraceId.get()) + .as("D4: ResponseMetaFactory.fromMdc().traceId() must be non-null when traceparent adopted") + .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + // MDC must be cleaned up after filter + assertThat(MDC.get(MdcKeys.TRACE_ID)).isNull(); + assertThat(MDC.get(MdcKeys.SPAN_ID)).isNull(); + } + + /** + * D4 / disabled-tracing fallback: when no inbound {@code traceparent} header is present, the + * filter generates a fresh ROOT traceparent — 32-hex traceId (non-null, non-all-zeros), so {@link + * ResponseMetaFactory#fromMdc()} keeps {@code meta.traceId} non-null even when no tracer/exporter + * is wired. + */ + @Test + void absentTraceparentGeneratesFreshW3cRootAndMetaTraceIdIsNonNull() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/y"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + AtomicReference<String> capturedTraceId = new AtomicReference<>(); + AtomicReference<String> capturedSpanId = new AtomicReference<>(); + AtomicReference<String> capturedMetaTraceId = new AtomicReference<>(); + FilterChain chain = + (rq, rs) -> { + capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID)); + capturedSpanId.set(MDC.get(MdcKeys.SPAN_ID)); + capturedMetaTraceId.set(ResponseMetaFactory.fromMdc().traceId()); + }; + + filter.doFilter(req, res, chain); + + assertThat(capturedTraceId.get()) + .as("generated trace_id must be non-null, 32 hex chars") + .isNotNull() + .hasSize(32) + .matches("[0-9a-f]{32}"); + assertThat(capturedTraceId.get()) + .as("generated trace_id must not be all zeros") + .isNotEqualTo("00000000000000000000000000000000"); + assertThat(capturedSpanId.get()) + .as("generated span_id must be non-null, 16 hex chars") + .isNotNull() + .hasSize(16) + .matches("[0-9a-f]{16}"); + assertThat(res.getHeader("traceparent")) + .as("response traceparent header must be set") + .isNotNull() + .startsWith("00-"); + assertThat(capturedMetaTraceId.get()) + .as("D4: meta.traceId must be non-null even with no tracer (disabled-tracing fallback)") + .isNotNull() + .hasSize(32); + } + + /** + * D4 fallback: an INVALID inbound {@code traceparent} header (malformed) yields the same + * generated-root behavior — traceId is a fresh 32-hex value. + */ + @Test + void invalidTraceparentGeneratesFreshRootTraceId() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/z"); + req.addHeader("traceparent", "NOT-A-VALID-TRACEPARENT"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + AtomicReference<String> capturedTraceId = new AtomicReference<>(); + FilterChain chain = (rq, rs) -> capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID)); + + filter.doFilter(req, res, chain); + + assertThat(capturedTraceId.get()) + .as("invalid traceparent must yield a fresh generated 32-hex traceId") + .isNotNull() + .hasSize(32) + .matches("[0-9a-f]{32}"); + } + + /** + * §테스트계약: request_id and correlation_id MDC keys are still set regardless of traceparent + * handling, keeping existing observability contracts intact. + */ + @Test + void requestIdAndCorrelationIdMdcStillSetAlongsideTraceparent() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/w"); + req.addHeader("X-Request-Id", "my-req-id"); + req.addHeader("X-Correlation-Id", "my-corr-id"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + AtomicReference<String> capturedReqId = new AtomicReference<>(); + AtomicReference<String> capturedCorrId = new AtomicReference<>(); + AtomicReference<String> capturedTraceId = new AtomicReference<>(); + FilterChain chain = + (rq, rs) -> { + capturedReqId.set(MDC.get(MdcKeys.REQUEST_ID)); + capturedCorrId.set(MDC.get(MdcKeys.CORRELATION_ID)); + capturedTraceId.set(MDC.get(MdcKeys.TRACE_ID)); + }; + + filter.doFilter(req, res, chain); + + assertThat(capturedReqId.get()).isEqualTo("my-req-id"); + assertThat(capturedCorrId.get()).isEqualTo("my-corr-id"); + assertThat(capturedTraceId.get()) + .as("trace_id must be set (W3C generated root since no traceparent header)") + .isNotNull() + .hasSize(32) + .matches("[0-9a-f]{32}"); + } + + // DRIFT-6: user_principal on MDC must be pseudonymized, never raw + @Test + void putsPseudonymizedUserPrincipalOnMdcNotRawId() throws Exception { + AuthenticatedPrincipal user = + new AuthenticatedPrincipal("raw-user-1", "user@example.com", Set.of()); + TestingAuthenticationToken auth = new TestingAuthenticationToken(user, null); + auth.setAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(auth); + + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/test"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestLoggingFilter.class); + ListAppender<ILoggingEvent> listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + + try { + // user_principal is set in the filter's finally block (after chain.doFilter) and is + // captured on the http_request log event's MDC snapshot, asserted below. + filter.doFilter(req, res, (rq, rs) -> {}); + + // Capture the MDC value from the http_request log event (which fires after putUserPrincipal) + List<ILoggingEvent> events = listAppender.list; + ILoggingEvent event = + events.stream() + .filter(e -> e.getFormattedMessage().contains("http_request")) + .findFirst() + .orElseThrow(() -> new AssertionError("No http_request log event found")); + + String userPrincipalOnMdc = event.getMDCPropertyMap().get("user_principal"); + assertThat(userPrincipalOnMdc).isEqualTo("pseudo-raw-user-1"); + assertThat(userPrincipalOnMdc).isNotEqualTo("raw-user-1"); + } finally { + logger.detachAppender(listAppender); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupportTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupportTest.java new file mode 100644 index 00000000..b531bd2a --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupportTest.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyScopeMissingException; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import tools.jackson.databind.ObjectMapper; + +class IdempotencyKeySupportTest { + + private final IdempotencyKeySupport support = new IdempotencyKeySupport(new ObjectMapper()); + + record SampleCommand(String title, int amount) {} + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticate(String principal) { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + new AuthenticatedPrincipal(principal, "u@x.io", Set.of("user")), "n/a", Set.of())); + } + + @Test + void readsTheIdempotencyKeyHeader() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.addHeader(ApiHeaders.IDEMPOTENCY_KEY, " key-1 "); + assertThat(support.idempotencyKey(req)).contains("key-1"); + } + + @Test + void absentHeaderIsEmpty() { + assertThat(support.idempotencyKey(new MockHttpServletRequest())).isEmpty(); + } + + @Test + void scopeUsesAuthenticatedPrincipalAndUseCaseName() { + authenticate("user-9"); + IdempotencyScope scope = support.scope("key-1", "CreateWorkLogUseCase"); + assertThat(scope.principal()).isEqualTo("user-9"); + assertThat(scope.useCaseName()).isEqualTo("CreateWorkLogUseCase"); + assertThat(scope.idempotencyKey()).isEqualTo("key-1"); + assertThat(scope.isTenantScoped()).isFalse(); + } + + @Test + void scopeForUnauthenticatedCallerIsRejected() { + assertThatThrownBy(() -> support.scope("key-1", "CreateWorkLogUseCase")) + .isInstanceOf(IdempotencyScopeMissingException.class); + } + + @Test + void fingerprintIsDeterministicForEqualPayloads() { + RequestFingerprint a = support.fingerprint(new SampleCommand("t", 10)); + RequestFingerprint b = support.fingerprint(new SampleCommand("t", 10)); + RequestFingerprint c = support.fingerprint(new SampleCommand("t", 11)); + assertThat(a).isEqualTo(b); + assertThat(a).isNotEqualTo(c); + } + + @Test + void codecRoundTripsAResponse() { + var codec = support.codec(SampleCommand.class); + SampleCommand original = new SampleCommand("done", 5); + assertThat(codec.deserialize(codec.serialize(original))).isEqualTo(original); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/.gitkeep b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizerTest.java new file mode 100644 index 00000000..793dbbbb --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizerTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class HeaderSanitizerTest { + + @Test + void stripsCrLfSoALogForgingPayloadStaysOneLine() { + String sanitized = HeaderSanitizer.sanitize("foo\r\nFAKE LOG ENTRY", 200); + assertThat(sanitized).isEqualTo("fooFAKE LOG ENTRY"); + assertThat(sanitized).doesNotContain("\r").doesNotContain("\n"); + } + + @Test + void stripsAsciiControlCharactersButKeepsNormalSpace() { + // \t (0x09) and  are control chars (< 0x20) and are removed; + // a normal space (0x20) is preserved. + String sanitized = HeaderSanitizer.sanitize("abc\td e", 200); + assertThat(sanitized).isEqualTo("abcd e"); + } + + @Test + void capsLength() { + String sanitized = HeaderSanitizer.sanitize("abcdef", 3); + assertThat(sanitized).isEqualTo("abc"); + } + + @Test + void nullInNullOut() { + assertThat(HeaderSanitizer.sanitize(null, 10)).isNull(); + } + + @Test + void preservesNormalPrintableValue() { + assertThat(HeaderSanitizer.sanitize("01HZX9-ok_value", 64)).isEqualTo("01HZX9-ok_value"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/ResponseMetaFactoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/ResponseMetaFactoryTest.java new file mode 100644 index 00000000..4e01d57f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/ResponseMetaFactoryTest.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.response.ResponseMeta; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +class ResponseMetaFactoryTest { + + @Test + void projectsSnakeMdcKeysToCamelMeta() { + try { + MDC.put(MdcKeys.REQUEST_ID, "req-1"); + MDC.put(MdcKeys.TRACE_ID, "trace-1"); + MDC.put(MdcKeys.CORRELATION_ID, "corr-1"); + ResponseMeta meta = ResponseMetaFactory.fromMdc(); + assertThat(meta.requestId()).isEqualTo("req-1"); + assertThat(meta.traceId()).isEqualTo("trace-1"); + assertThat(meta.correlationId()).isEqualTo("corr-1"); + } finally { + MDC.clear(); + } + } + + @Test + void absentKeysProjectToNullFields() { + MDC.clear(); + ResponseMeta meta = ResponseMetaFactory.fromMdc(); + assertThat(meta.requestId()).isNull(); + assertThat(meta.traceId()).isNull(); + assertThat(meta.correlationId()).isNull(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/RetryAfterAdvisorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/RetryAfterAdvisorTest.java new file mode 100644 index 00000000..9d8bfb0c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/RetryAfterAdvisorTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.OperationalError; +import org.junit.jupiter.api.Test; + +class RetryAfterAdvisorTest { + + @Test + void nonRetryableCodeAdvisesNoRetryAfter() { + assertThat(RetryAfterAdvisor.shouldAdvise(OperationalError.VALIDATION_FAILED)).isFalse(); + } + + @Test + void retryableCodeAdvisesRetryAfter() { + // INTERNAL_ERROR is retryable per-code; the seam keys off retryable(), the + // owner branch decides the concrete seconds + 429/503 specifics. + assertThat(RetryAfterAdvisor.shouldAdvise(OperationalError.INTERNAL_ERROR)).isTrue(); + } + + @Test + void rateLimitExceededAdvisesAOneSecondRetryAfter() { + // feature-rate-limit-idempotency-contract D1: owner-branch concrete value. + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED)) + .hasValue(1); + } + + @Test + void authKidUnknownHasRetryAfter5s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.AUTH_KID_UNKNOWN)).hasValue(5); + } + + @Test + void authJwksUnavailableHasRetryAfter30s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.AUTH_JWKS_UNAVAILABLE)) + .hasValue(30); + } + + @Test + void nonRetryableCodeHasNoRetryAfterValue() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.IDEMPOTENT_IN_FLIGHT)) + .isEmpty(); + } + + // feature-outbound-http-client-baseline D12: dependency codes — rows 636~711 in + // docs/registries/error-codes.yaml, retry_after_seconds column. + + @Test + void dependencyTimeoutHasRetryAfter2s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.DEPENDENCY_TIMEOUT)) + .hasValue(2); + } + + @Test + void dependencyConnectFailedHasRetryAfter2s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.DEPENDENCY_CONNECT_FAILED)) + .hasValue(2); + } + + @Test + void dependencyDnsFailedHasRetryAfter5s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.DEPENDENCY_DNS_FAILED)) + .hasValue(5); + } + + @Test + void dependency4xxClientIsNonRetryableNoRetryAfter() { + // PERMANENT_DEPENDENCY, retryable=false — no Retry-After header should be sent. + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.DEPENDENCY_4XX_CLIENT)) + .isEmpty(); + } + + @Test + void dependency5xxServerHasRetryAfter2s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.DEPENDENCY_5XX_SERVER)) + .hasValue(2); + } + + @Test + void dependencyCircuitOpenHasRetryAfter10s() { + assertThat(RetryAfterAdvisor.retryAfterSeconds(OperationalError.DEPENDENCY_CIRCUIT_OPEN)) + .hasValue(10); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/PageParamsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/PageParamsTest.java new file mode 100644 index 00000000..e35baf04 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/PageParamsTest.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import dev.caskeleton.shared.response.PageMeta; +import org.junit.jupiter.api.Test; + +class PageParamsTest { + + @Test + void defaultsArePageZeroSizeTwenty() { + PageParams p = PageParams.of(null, null); + assertThat(p.page()).isZero(); + assertThat(p.size()).isEqualTo(20); + } + + @Test + void sizeAboveCapIsRejectedWithFieldAndReasonCode() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> PageParams.of(0, 101)) + .satisfies( + ex -> { + assertThat(ex.field()).isEqualTo("size"); + assertThat(ex.reasonCode()).isEqualTo("SIZE_EXCEEDS_MAX"); + }); + } + + @Test + void enormousSizeIsRejected() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> PageParams.of(0, 10_000_000)); + } + + @Test + void sizeBelowMinIsRejected() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> PageParams.of(0, 0)) + .satisfies(ex -> assertThat(ex.reasonCode()).isEqualTo("SIZE_BELOW_MIN")); + } + + @Test + void negativePageIsRejected() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> PageParams.of(-1, 20)) + .satisfies( + ex -> { + assertThat(ex.field()).isEqualTo("page"); + assertThat(ex.reasonCode()).isEqualTo("PAGE_NEGATIVE"); + }); + } + + @Test + void deepOffsetIsFlaggedForDeprecation() { + assertThat(PageParams.of(10_001, 20).isDeepOffset()).isTrue(); + assertThat(PageParams.of(10_000, 20).isDeepOffset()).isFalse(); + } + + @Test + void toPageMetaCarriesTotalAndSort() { + PageMeta meta = PageParams.of(2, 20).toPageMeta(57, "title,asc"); + assertThat(meta.number()).isEqualTo(2); + assertThat(meta.size()).isEqualTo(20); + assertThat(meta.total()).isEqualTo(57); + assertThat(meta.sort()).isEqualTo("title,asc"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/SortParamTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/SortParamTest.java new file mode 100644 index 00000000..355be4b6 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/SortParamTest.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import org.junit.jupiter.api.Test; + +class SortParamTest { + + @Test + void parsesFieldAndExplicitDirection() { + SortParam s = SortParam.parse("createdAt,desc"); + assertThat(s.field()).isEqualTo("createdAt"); + assertThat(s.ascending()).isFalse(); + assertThat(s.canonical()).isEqualTo("createdAt,desc"); + } + + @Test + void directionDefaultsToAscending() { + SortParam s = SortParam.parse("title"); + assertThat(s.field()).isEqualTo("title"); + assertThat(s.ascending()).isTrue(); + assertThat(s.canonical()).isEqualTo("title,asc"); + } + + @Test + void rejectsJsonapiPrefixSyntax() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> SortParam.parse("-createdAt")) + .satisfies(ex -> assertThat(ex.reasonCode()).isEqualTo("SORT_SYNTAX_INVALID")); + } + + @Test + void rejectsColonSyntax() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> SortParam.parse("createdAt:desc")); + } + + @Test + void rejectsAip132SpaceSyntax() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> SortParam.parse("createdAt desc")); + } + + @Test + void rejectsUnknownDirection() { + assertThatExceptionOfType(PageValidationException.class) + .isThrownBy(() -> SortParam.parse("title,sideways")) + .satisfies(ex -> assertThat(ex.reasonCode()).isEqualTo("SORT_DIRECTION_INVALID")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java new file mode 100644 index 00000000..75bfe8d0 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/FixedWindowRateLimiterTest.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +class FixedWindowRateLimiterTest { + + private static final Instant T0 = Instant.parse("2026-06-09T12:00:00Z"); + + @Test + void allowsUpToTheLimitThenRejectsWithinAWindow() { + FixedWindowRateLimiter limiter = + new FixedWindowRateLimiter(2, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC)); + + assertThat(limiter.decide("k").allowed()).isTrue(); + RateLimitDecision second = limiter.decide("k"); + assertThat(second.allowed()).isTrue(); + assertThat(second.remaining()).isZero(); + RateLimitDecision third = limiter.decide("k"); + assertThat(third.allowed()).isFalse(); + assertThat(third.remaining()).isZero(); + } + + @Test + void separateKeysHaveIndependentCounters() { + FixedWindowRateLimiter limiter = + new FixedWindowRateLimiter(1, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC)); + assertThat(limiter.decide("a").allowed()).isTrue(); + assertThat(limiter.decide("b").allowed()).isTrue(); + assertThat(limiter.decide("a").allowed()).isFalse(); + } + + @Test + void counterResetsWhenTheWindowRolls() { + MutableClock clock = new MutableClock(T0); + FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1, Duration.ofSeconds(1), clock); + + assertThat(limiter.decide("k").allowed()).isTrue(); + assertThat(limiter.decide("k").allowed()).isFalse(); + clock.advance(Duration.ofSeconds(1)); // next fixed window + assertThat(limiter.decide("k").allowed()).isTrue(); + } + + @Test + void resetInstantIsTheWindowEnd() { + FixedWindowRateLimiter limiter = + new FixedWindowRateLimiter(5, Duration.ofSeconds(60), Clock.fixed(T0, ZoneOffset.UTC)); + // T0 = 12:00:00 → 60s window starting at 12:00:00 ends at 12:01:00. + assertThat(limiter.decide("k").resetAt()).isEqualTo(Instant.parse("2026-06-09T12:01:00Z")); + } + + static final class MutableClock extends Clock { + private Instant instant; + + MutableClock(Instant start) { + this.instant = start; + } + + void advance(Duration d) { + instant = instant.plus(d); + } + + @Override + public Instant instant() { + return instant; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java new file mode 100644 index 00000000..482921fe --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptorTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import tools.jackson.databind.ObjectMapper; + +class RateLimitInterceptorTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-06-09T12:00:00Z"), ZoneOffset.UTC); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private RateLimitInterceptor interceptor(boolean enabled, int limit) { + FixedWindowRateLimiter limiter = + new FixedWindowRateLimiter(limit, Duration.ofSeconds(1), CLOCK); + return new RateLimitInterceptor( + enabled, + limiter, + new RateLimitKeyResolver(new RemoteAddrClientIpResolver()), + objectMapper, + 1); + } + + private MockHttpServletRequest request() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs"); + req.setRemoteAddr("203.0.113.7"); + return req; + } + + @Test + void allowedRequestPassesAndEmitsSignalingHeaders() throws Exception { + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = interceptor(true, 5).preHandle(request(), res, new Object()); + + assertThat(proceed).isTrue(); + assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5"); + assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4"); + assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z"); + } + + @Test + void exceedingTheLimitRejectsWith429EnvelopeRetryAfterAndRetryableTrue() throws Exception { + RateLimitInterceptor interceptor = interceptor(true, 1); + // first request consumes the only slot + interceptor.preHandle(request(), new MockHttpServletResponse(), new Object()); + + MockHttpServletResponse res = new MockHttpServletResponse(); + boolean proceed = interceptor.preHandle(request(), res, new Object()); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(429); + assertThat(res.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1"); + assertThat(res.getContentAsString()) + .contains("\"RATE_LIMIT_EXCEEDED\"") + .contains("\"RATE_LIMIT\"") + .contains("\"retryable\":true"); + } + + @Test + void disabledLimiterPassesWithoutTouchingHeaders() throws Exception { + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = interceptor(false, 1).preHandle(request(), res, new Object()); + + assertThat(proceed).isTrue(); + assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java new file mode 100644 index 00000000..798da94c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitKeyResolverTest.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerMapping; + +class RateLimitKeyResolverTest { + + private final RateLimitKeyResolver resolver = + new RateLimitKeyResolver(new RemoteAddrClientIpResolver()); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(AuthenticatedPrincipal user) { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(user, "n/a", Set.of())); + } + + @Test + void unauthenticatedKeyIsIpPlusRouteTemplate() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123"); + req.setRemoteAddr("203.0.113.7"); + req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}"); + + assertThat(resolver.resolve(req)).isEqualTo("ip:203.0.113.7:GET /v1/worklogs/{id}"); + } + + @Test + void unauthenticatedKeyFallsBackToUriWhenNoPattern() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/v1/worklogs"); + req.setRemoteAddr("198.51.100.4"); + + assertThat(resolver.resolve(req)).isEqualTo("ip:198.51.100.4:POST /v1/worklogs"); + } + + @Test + void authenticatedUserKeyIsKeyedByPrincipal() { + authenticateAs(new AuthenticatedPrincipal("user-42", "u@x.io", Set.of("user"))); + assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs"))) + .isEqualTo("user:user-42"); + } + + @Test + void servicePrincipalKeyIsKeyedByApiKeyId() { + authenticateAs(new AuthenticatedPrincipal("svc-7", "svc@x.io", Set.of("service"))); + assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs"))) + .isEqualTo("apikey:svc-7"); + } + + @Test + void remoteAddrModeIgnoresForwardedClientIpWhenPresent() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/ping"); + req.setRemoteAddr("10.0.0.1"); + req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); + + assertThat(resolver.resolve(req)).isEqualTo("ip:10.0.0.1:GET /v1/ping"); + } + + @Test + void forwardedHeadersTrustedModeUsesFirstForwardedClientIp() { + RateLimitKeyResolver forwardedResolver = + new RateLimitKeyResolver(new ForwardedHeaderClientIpResolver()); + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/ping"); + req.setRemoteAddr("10.0.0.1"); + req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); + + assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:203.0.113.9:GET /v1/ping"); + } + + @Test + void forwardedHeadersTrustedModeFallsBackToRemoteAddrWhenHeaderIsBlank() { + RateLimitKeyResolver forwardedResolver = + new RateLimitKeyResolver(new ForwardedHeaderClientIpResolver()); + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/ping"); + req.setRemoteAddr("198.51.100.4"); + req.addHeader("X-Forwarded-For", " "); + + assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:198.51.100.4:GET /v1/ping"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java new file mode 100644 index 00000000..12ac0fdf --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimiterFactoryTest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +class RateLimiterFactoryTest { + + private static final Clock CLOCK = + Clock.fixed(Instant.parse("2026-06-09T00:00:00Z"), ZoneOffset.UTC); + + @Test + void fixedWindowAlgorithmBuildsAFixedWindowLimiter() { + RateLimiter limiter = + RateLimiterFactory.create( + RateLimitAlgorithm.FIXED_WINDOW, 10, Duration.ofSeconds(1), CLOCK); + + assertThat(limiter).isInstanceOf(FixedWindowRateLimiter.class); + // returns the interface type so the interceptor never sees the concrete class + RateLimitDecision decision = limiter.decide("k"); + assertThat(decision.allowed()).isTrue(); + assertThat(decision.limit()).isEqualTo(10); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettingsTest.java new file mode 100644 index 00000000..82564f44 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettingsTest.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration; +import org.springframework.context.annotation.Configuration; + +/** + * CORS settings validation contract (feature-env-driven-runtime-configuration D10): simple range + * constraints (maxAge >= 0) are JSR-303 + {@code @Validated} and fail startup; the cross-field rule + * (origins required when enabled) throws fail-fast in the compact constructor. No lenient defaults + * — an invalid value never silently falls back. + */ +class CorsSettingsTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration.class)) + .withUserConfiguration(EnableProperties.class); + + @Test + void allowedMethodsAndHeadersMissingDefaultToApiFriendlyValues() { + runner + .withPropertyValues( + "ca-skeleton.cors.enabled=true", + "ca-skeleton.cors.allowed-origins=http://localhost:3000", + "ca-skeleton.cors.allow-credentials=true", + "ca-skeleton.cors.max-age-seconds=3600") + .run( + context -> { + assertThat(context).hasNotFailed(); + CorsSettings settings = context.getBean(CorsSettings.class); + assertThat(settings.allowedMethods()) + .containsExactly("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"); + assertThat(settings.allowedHeaders()).containsExactly("*"); + }); + } + + @Test + void enabledWithoutOriginsFailsStartup() { + runner + .withPropertyValues( + "ca-skeleton.cors.enabled=true", + "ca-skeleton.cors.allow-credentials=true", + "ca-skeleton.cors.max-age-seconds=3600") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_SECURITY_CORS_ORIGINS"); + }); + } + + @Test + void negativeMaxAgeFailsStartup() { + runner + .withPropertyValues( + "ca-skeleton.cors.enabled=true", + "ca-skeleton.cors.allowed-origins=http://localhost:3000", + "ca-skeleton.cors.allow-credentials=true", + "ca-skeleton.cors.max-age-seconds=-1") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_SECURITY_CORS_MAX_AGE"); + }); + } + + @Test + void wildcardOriginWithCredentialsFailsStartup() { + // D9 / FETCH-CORS-C3: "*" origin + credentials=true is forbidden and must fail fast. + runner + .withPropertyValues( + "ca-skeleton.cors.enabled=true", + "ca-skeleton.cors.allowed-origins=*", + "ca-skeleton.cors.allow-credentials=true", + "ca-skeleton.cors.max-age-seconds=600") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_SECURITY_CORS_ALLOW_CREDENTIALS"); + }); + } + + @Test + void wildcardOriginWithoutCredentialsIsAllowed() { + // A wildcard origin is fine as long as credentials are not also enabled. + runner + .withPropertyValues( + "ca-skeleton.cors.enabled=true", + "ca-skeleton.cors.allowed-origins=*", + "ca-skeleton.cors.allow-credentials=false", + "ca-skeleton.cors.max-age-seconds=600") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(CorsSettings.class).allowedOrigins()).containsExactly("*"); + }); + } + + @Test + void disabledWithoutOriginsIsAllowed() { + runner + .withPropertyValues("ca-skeleton.cors.enabled=false", "ca-skeleton.cors.max-age-seconds=0") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(CorsSettings.class).allowedOrigins()).isEmpty(); + }); + } + + @Test + void collectionSettingsAreSnapshotsAndImmutable() { + List<String> origins = new ArrayList<>(List.of("https://example.test")); + List<String> methods = new ArrayList<>(List.of("GET")); + List<String> headers = new ArrayList<>(List.of("Authorization")); + + CorsSettings settings = new CorsSettings(true, origins, methods, headers, true, 600); + origins.add("https://evil.test"); + methods.add("POST"); + headers.add("X-Evil"); + + assertThat(settings.allowedOrigins()).containsExactly("https://example.test"); + assertThat(settings.allowedMethods()).containsExactly("GET"); + assertThat(settings.allowedHeaders()).containsExactly("Authorization"); + assertThat(settings.allowedOrigins()).isUnmodifiable(); + assertThat(settings.allowedMethods()).isUnmodifiable(); + assertThat(settings.allowedHeaders()).isUnmodifiable(); + } + + @Configuration + @EnableConfigurationProperties(CorsSettings.class) + static class EnableProperties {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/PresentationSettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/PresentationSettingsTest.java new file mode 100644 index 00000000..2d01c284 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/PresentationSettingsTest.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Configuration; + +@ExtendWith(OutputCaptureExtension.class) +class PresentationSettingsTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + @Test + void validApiBasePathBindsAsIs() { + runner + .withPropertyValues("ca-skeleton.presentation.api-base-path=/api") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(PresentationSettings.class).apiBasePath()) + .isEqualTo("/api"); + }); + } + + @Test + void missingApiBasePathWarnsAndDefaultsToEmpty(CapturedOutput output) { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(PresentationSettings.class).apiBasePath()).isEmpty(); + assertThat(output.getOut()).contains("PRESENTATION_API_BASE_PATH is missing"); + }); + } + + @Test + void noLeadingSlashWarnsAndPrepends(CapturedOutput output) { + runner + .withPropertyValues("ca-skeleton.presentation.api-base-path=api") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(PresentationSettings.class).apiBasePath()) + .isEqualTo("/api"); + assertThat(output.getOut()).contains("must start with '/'"); + }); + } + + @Configuration + @EnableConfigurationProperties(PresentationSettings.class) + static class EnableProperties {} +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java new file mode 100644 index 00000000..263247b2 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/RateLimitSettingsTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RateLimitSettingsTest { + + @Test + void bindsSuppliedValues() { + RateLimitSettings props = + new RateLimitSettings( + true, + 250, + Duration.ofSeconds(5), + RateLimitAlgorithm.FIXED_WINDOW, + RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED); + assertThat(props.enabled()).isTrue(); + assertThat(props.limit()).isEqualTo(250); + assertThat(props.window()).isEqualTo(Duration.ofSeconds(5)); + assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW); + assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED); + } + + @Test + void defaultsAbsentOrInvalidLimitWindowAndAlgorithm() { + RateLimitSettings props = new RateLimitSettings(false, null, null, null, null); + assertThat(props.limit()).isEqualTo(100); + assertThat(props.window()).isEqualTo(Duration.ofSeconds(1)); + assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW); + assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY); + } + + @Test + void rejectsNonPositiveLimitAndWindowWithSafeDefaults() { + RateLimitSettings props = new RateLimitSettings(true, 0, Duration.ZERO, null, null); + assertThat(props.limit()).isEqualTo(100); + assertThat(props.window()).isEqualTo(Duration.ofSeconds(1)); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java new file mode 100644 index 00000000..ab2b49c9 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettingsTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Configuration; + +@ExtendWith(OutputCaptureExtension.class) +class SecuritySettingsTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + @Test + void issuerUriMissingFailsStartup() { + runner.run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_SECURITY_JWT_ISSUER"); + }); + } + + @Test + void audienceMissingWarnsAndDefaultsToEmpty(CapturedOutput output) { + runner + .withPropertyValues( + "ca-skeleton.security.issuer-uri=http://localhost:8081/realms/ca-skeleton") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(SecuritySettings.class).audience()).isEmpty(); + assertThat(output.getOut()).contains("APP_SECURITY_JWT_AUDIENCE is missing"); + }); + } + + @Test + void publicPathsBindAsList() { + runner + .withPropertyValues( + "ca-skeleton.security.issuer-uri=http://localhost:8081/realms/ca-skeleton", + "ca-skeleton.security.audience=ca-skeleton-api", + "ca-skeleton.security.public-paths=/api/healthcheck,/api/docs/**") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(SecuritySettings.class).publicPaths()) + .containsExactly("/api/healthcheck", "/api/docs/**"); + }); + } + + @Test + void publicPathsAreSnapshotAndImmutable() { + List<String> paths = new ArrayList<>(List.of("/healthcheck")); + + SecuritySettings settings = + new SecuritySettings("http://localhost:8081/realms/ca-skeleton", "ca-skeleton-api", paths); + paths.add("/admin/**"); + + assertThat(settings.publicPaths()).containsExactly("/healthcheck"); + assertThat(settings.publicPaths()).isUnmodifiable(); + } + + @Configuration + @EnableConfigurationProperties(SecuritySettings.class) + static class EnableProperties {} +} diff --git a/src/adapter/inbound/websocket/CLAUDE.md b/src/adapter/inbound/websocket/CLAUDE.md new file mode 100644 index 00000000..db2db38b --- /dev/null +++ b/src/adapter/inbound/websocket/CLAUDE.md @@ -0,0 +1,74 @@ +# adapter:inbound:websocket — inbound WebSocket adapter (skeleton machinery) + +## Registered identity + +- Module ID: `adapter-inbound-websocket` +- Gradle path: `:adapter:inbound:websocket` +- Focused test: `./gradlew :adapter:inbound:websocket:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.inbound.websocket`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 +규칙 SSOT). + +## Responsibility + +- WebSocket(STOMP over SockJS) 전송 인프라만: STOMP 브로커/엔드포인트 설정(`WebSocketConfig`), + 타입드 설정(`WebSocketProperties`), 그리고 도메인 이벤트를 토픽으로 밀어내는 generic + broadcaster(`LiveEventStompBroadcaster`). +- feature-agnostic: `@DomainEvent` 로 표시된 **모든** 도메인 이벤트를 generic 하게 브로드캐스트한다. + **WorkLog 등 구체 기능을 이름으로 알지 않는다.** broadcaster 는 이벤트를 **소비만** 하고 발행하지 + 않는다 (발행은 sample 모듈 몫). + +## Allowed + +- `:application-core`, `:domain-core`, `:shared-contract`. +- `spring-boot-starter-websocket`, `jackson-databind`, `jackson-datatype-jsr310` + (전부 Spring Boot BOM 관리 — 버전 명시 없음). + +## Forbidden + +- outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드 + 포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit + `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이 + 모듈을 자동 커버 — per-module 규칙 추가 불필요). 이 격리 때문에 outbox tail(W2)이 아니라 in-process + live-bus(W1)를 쓴다. +- 프로덕션 feature `@MessageMapping`/이벤트 발행을 스켈레톤에 두는 것 — STOMP 엔드포인트 + generic + broadcast 표면만 (web 의 `HealthcheckController` 와 동일 원칙). feature 이벤트 발행은 sample 이 + 소유한다. +- 모듈별 `yml` — 설정은 타입드 `@ConfigurationProperties` 로 두고 값은 composition-root + `application.yml` 에 산다. + +## Config knobs (`ca-skeleton.websocket.*`) + +타입드 `@ConfigurationProperties` 만 두고, 값은 composition-root `application.yml` 에 산다 +(모듈별 `yml` 없음). + +| key | default | 의미 | +|---|---|---| +| `endpoint` | `/ws` | STOMP handshake 엔드포인트 경로(SockJS enabled) | +| `allowedOrigins` | `*` | handshake 허용 origin 패턴(콤마 구분) | +| `broadcastDestination` | `/topic/events` | 도메인 이벤트 알림을 밀어내는 STOMP 목적지 | + +## W1 live-bus + +브로커는 in-memory simple broker(`/topic`), app-destination prefix 는 `/app`. `LiveEventStompBroadcaster` +는 in-process Spring `ApplicationEvent` 버스에 실린 `@DomainEvent` 를 STOMP `broadcastDestination` 으로 +민다. 이는 **best-effort live-push** 사이드 채널로, durable transactional outbox 와 **의도적으로 분리**돼 +있다(두 메커니즘이 나란히 존재하는 것이 참조의 교육 포인트). + +## Feature 기여 방법 + +feature 모듈의 use case 가 `ApplicationEventPublisher.publishEvent(domainEvent)` 로 `@DomainEvent` +레코드를 발행하면, 스켈레톤 broadcaster 가 `@EventListener` 로 받아 `{type, payload, occurredAt}` +엔벨로프로 토픽에 민다. 스켈레톤은 어떤 구체 이벤트 타입도 이름으로 알지 않는다. +`sample-portfolio` 를 지워도 스켈레톤은 STOMP 엔드포인트만으로 부팅한다 (disposability). + +## Test + +```bash +cd src +./gradlew :adapter:inbound:websocket:test +``` diff --git a/src/adapter/inbound/websocket/README.md b/src/adapter/inbound/websocket/README.md new file mode 100644 index 00000000..024da5e6 --- /dev/null +++ b/src/adapter/inbound/websocket/README.md @@ -0,0 +1,74 @@ +# adapter-websocket — 설계 결정 참조 + +인바운드 WebSocket 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트: +`dev.caskeleton.adapter.inbound.websocket`. + +허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은 +[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 +모아둔 참조용 기록이다. + +--- + +## 왜 W1 in-process live-bus 인가 (outbox 와 분리) + +ca-tmpl 은 오늘 Spring application event 를 쓰지 않는다 — 도메인/통합 이벤트는 트랜잭셔널 +**outbox** 로만 흐른다. W1 은 Spring `ApplicationEvent` 를 **in-process, best-effort live-push +버스**로만 도입한다. 두 메커니즘은 나란히 존재한다: + +- **outbox** — durable / cross-service. 트랜잭션과 함께 커밋되고 relay 가 신뢰성 있게 전달한다. +- **live-bus(W1)** — best-effort / UI push. 구독 중인 WS 클라이언트에 즉시 미는 용도. + +live-push 를 위해 outbox 를 tail 하는 방식(W2)은 **거부**한다: inbound↔outbound 피어 격리를 깨고 +(ArchUnit `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`) poll 지연을 더한다. 그래서 +broadcaster 는 outbox 가 아니라 in-process 이벤트 버스만 듣는다. + +## 왜 generic broadcaster 인가 — machinery/feature 분리 + +스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** `LiveEventStompBroadcaster` 는 published 된 임의 +객체를 받아 `@DomainEvent` 로 표시된 것만 남기고 STOMP 토픽으로 민다. 그래서 sample 의 +어떤 도메인 이벤트든 스켈레톤을 수정하지 않고 자동으로 push 된다. `sample-portfolio` 를 지우면 +스켈레톤은 여전히 STOMP 엔드포인트만으로 부팅한다(web 과 동일한 disposability 보장). + +## 왜 `Object` 리스닝 + `{type, payload, occurredAt}` 엔벨로프인가 + +참조(ha-tmpl)의 `DomainEvent` 는 `eventType()`/`aggregateId()`/`occurredAt()` accessor 를 가진 +**공통 인터페이스**였다. ca-tmpl 의 `dev.caskeleton.domain.stereotype.DomainEvent` 는 순수 **마커 +annotation** 으로, 임의의 불변 이벤트 레코드(`FeatureAggregateCreated`, `WorkLogReserved` …)에 +붙을 뿐 공통 accessor 가 없다. 그래서: + +- **리스닝 타입**은 공통 상위 타입이 없으므로 `@EventListener void onDomainEvent(Object)` 로 받고, + `event.getClass().isAnnotationPresent(DomainEvent.class)` 로 도메인 이벤트만 남긴다(프레임워크 + lifecycle 이벤트/기타 payload 는 무시). 이렇게 해야 구체 이벤트 타입에 커플링하지 않고 + feature-agnostic 을 유지한다. +- **와이어 페이로드**는 accessor 로 필드를 뽑을 수 없으므로 전송 엔벨로프 + `LiveEvent(String type, Object payload, Instant occurredAt)` 로 감싼다 — `type` 은 이벤트 단순 + 클래스명(클라이언트 discriminator), `payload` 는 이벤트 전체(Jackson 이 JSON 직렬화; + `jackson-datatype-jsr310` 이 시간 타입 담당), `occurredAt` 은 브로드캐스트 시각. 특정 도메인 필드에 + 의존하지 않아 어떤 `@DomainEvent` 든 동일하게 실린다. + +## 왜 plain `@EventListener` 인가 (AFTER_COMMIT 아님) + +이 스켈레톤은 `spring-tx` 에 의존하지 않는다(선언된 의존성은 spring-boot-starter-websocket + +jackson 뿐). 따라서 `@TransactionalEventListener(AFTER_COMMIT)` 는 컴파일 classpath 에 없다. 게다가 +스켈레톤 자체엔 트랜잭셔널 발행자가 없다(발행은 sample 몫). 커밋 후 push 시맨틱(롤백된 write 는 +밀지 않기)이 필요해지면, 트랜잭셔널 발행자가 도입되는 sample 작업에서 `spring-tx` 를 얹고 +`@TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true)` 로 승격하면 된다. + +## STOMP 브로커 형태 + +in-memory **simple broker**(`/topic`) + app-destination prefix `/app`. STOMP 엔드포인트는 +`ca-skeleton.websocket.endpoint`(기본 `/ws`)에서 SockJS + allowed-origin 패턴으로 노출한다. +프로덕션 fork 는 필요 시 외부 STOMP 브로커(RabbitMQ/ActiveMQ relay)로 교체할 수 있다. push 는 +fire-and-forget 이라 per-message 에러 계약이 없다 — STOMP 전송 에러는 Spring 기본 처리를 따른다. + +## 의존성 버전 — strict locking + +spring-websocket / spring-messaging / jackson 은 Spring Boot BOM 이 관리한다. 그래서 이 모듈은 +버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만 +선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다. + +## 설정 — 타입드 `ca-skeleton.websocket.*` + +`WebSocketProperties`(`@ConfigurationProperties`)로 `endpoint`/`allowedOrigins`/ +`broadcastDestination` 을 두고, 값은 composition-root `application.yml` 에 산다(모듈별 `yml` 없음). +`WebSocketConfig` 가 `@EnableConfigurationProperties(WebSocketProperties.class)` 로 바인딩한다. diff --git a/src/adapter/inbound/websocket/build.gradle b/src/adapter/inbound/websocket/build.gradle new file mode 100644 index 00000000..4622b51c --- /dev/null +++ b/src/adapter/inbound/websocket/build.gradle @@ -0,0 +1,23 @@ +// Driving adapter: WebSocket (STOMP over SockJS) live-push channel (skeleton machinery, transport-only). +// +// W1 live-bus: a generic LiveEventStompBroadcaster forwards ANY domain-core @DomainEvent published on +// the in-process Spring ApplicationEvent bus to a STOMP topic. This is the best-effort live-push side +// channel, deliberately separate from the durable transactional outbox (durable/cross-service). The +// skeleton names NO feature — the sample module publishes the events (a later, blocked task). +// +// spring-websocket / spring-messaging / jackson versions are managed by the Spring Boot BOM, so no +// explicit versions or module-scoped platform imports are needed (unlike the grpc adapter, whose +// io.grpc coordinates the BOM does not manage). +description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery)' + +dependencies { + implementation project(':application-core') + implementation project(':domain-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} diff --git a/src/adapter/inbound/websocket/gradle.lockfile b/src/adapter/inbound/websocket/gradle.lockfile new file mode 100644 index 00000000..1089ce25 --- /dev/null +++ b/src/adapter/inbound/websocket/gradle.lockfile @@ -0,0 +1,160 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-websocket:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-websocket:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-websocket:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcaster.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcaster.java new file mode 100644 index 00000000..574dd4ce --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcaster.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.websocket; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +/** + * Bridges the in-process live-event bus onto the WebSocket topic, completing the push chain: + * + * <pre> + * aggregate mutation -> use case -> ApplicationEventPublisher.publishEvent(domainEvent) + * -> @EventListener (here) -> SimpMessagingTemplate -> subscribed WS clients + * </pre> + * + * <p>W1 live-bus: this is the best-effort, in-process live-push side channel, deliberately separate + * from the durable transactional outbox. The domain never imports anything WebSocket-related; this + * adapter is the only thing that knows a STOMP topic exists. + * + * <p><strong>Feature-agnostic.</strong> In ca-tmpl {@link DomainEvent} is a pure marker + * <em>annotation</em> applied to arbitrary immutable event records (unlike a common event interface + * with shared accessors), so this broadcaster listens for any published object, keeps only those + * annotated {@code @DomainEvent}, and forwards a transport envelope carrying the event {@code type} + * (its simple class name, a client-side discriminator), the whole event as {@code payload} + * (JSON-serialised by Jackson), and the broadcast {@code occurredAt} instant. It never names a + * concrete feature — any domain event is broadcast the same way. See the module README. + */ +@Component +public class LiveEventStompBroadcaster { + + private static final Logger log = LoggerFactory.getLogger(LiveEventStompBroadcaster.class); + + private final SimpMessagingTemplate messaging; + private final WebSocketProperties properties; + + public LiveEventStompBroadcaster( + SimpMessagingTemplate messaging, WebSocketProperties properties) { + this.messaging = messaging; + this.properties = properties; + } + + /** + * Forward any {@code @DomainEvent}-annotated application event to the broadcast destination. + * Non-domain events (framework lifecycle events and other payloads) are ignored, so the listener + * stays feature-agnostic without coupling to any concrete event type. + */ + @EventListener + public void onDomainEvent(Object event) { + if (!event.getClass().isAnnotationPresent(DomainEvent.class)) { + return; + } + LiveEvent message = new LiveEvent(event.getClass().getSimpleName(), event, Instant.now()); + String destination = properties.getBroadcastDestination(); + log.debug("broadcasting {} to {}", message.type(), destination); + messaging.convertAndSend(destination, message); + } + + /** + * Wire envelope pushed to subscribed clients: a {@code type} discriminator, the domain {@code + * payload}, and the {@code occurredAt} broadcast instant. + */ + public record LiveEvent(String type, Object payload, Instant occurredAt) {} +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketConfig.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketConfig.java new file mode 100644 index 00000000..ee343288 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketConfig.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.websocket; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * WebSocket (STOMP over SockJS) driving-adapter configuration. + * + * <ul> + * <li>Clients connect to {@code ca-skeleton.websocket.endpoint} (default {@code /ws}). + * <li>They subscribe under {@code /topic/**} to receive server pushes (the simple in-memory + * broker). + * <li>Client-to-server messages are routed to {@code @MessageMapping} methods under the {@code + * /app} prefix (none in the skeleton; features may add them). + * </ul> + * + * <p>Feature-agnostic transport machinery only — see {@link LiveEventStompBroadcaster} for the + * generic domain-event push. Design rationale in the module README. + */ +@Configuration +@EnableWebSocketMessageBroker +@EnableConfigurationProperties(WebSocketProperties.class) +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final WebSocketProperties properties; + + public WebSocketConfig(WebSocketProperties properties) { + this.properties = properties; + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry + .addEndpoint(properties.getEndpoint()) + .setAllowedOriginPatterns(properties.allowedOriginPatterns()) + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketProperties.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketProperties.java new file mode 100644 index 00000000..22db16c6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketProperties.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.websocket; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** WebSocket (STOMP) settings bound from {@code ca-skeleton.websocket.*}. */ +@ConfigurationProperties(prefix = "ca-skeleton.websocket") +public class WebSocketProperties { + + /** STOMP handshake endpoint path (SockJS enabled). */ + private String endpoint = "/ws"; + + /** Comma-separated allowed origin patterns for the handshake. */ + private String allowedOrigins = "*"; + + /** Destination that domain-event notifications are broadcast to. */ + private String broadcastDestination = "/topic/events"; + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(String allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public String getBroadcastDestination() { + return broadcastDestination; + } + + public void setBroadcastDestination(String broadcastDestination) { + this.broadcastDestination = broadcastDestination; + } + + /** Split the comma-separated {@link #allowedOrigins} into patterns for the handshake registry. */ + public String[] allowedOriginPatterns() { + return allowedOrigins.split("\\s*,\\s*"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcasterTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcasterTest.java new file mode 100644 index 00000000..0fc0f81f --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcasterTest.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.caskeleton.adapter.inbound.websocket.LiveEventStompBroadcaster.LiveEvent; +import dev.caskeleton.domain.stereotype.DomainEvent; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +/** + * Pins the generic W1 push contract: any {@code @DomainEvent} is forwarded to the property-driven + * destination as a {@code {type, payload, occurredAt}} envelope, and non-domain objects are + * ignored. A pure unit test — the broadcaster is driven directly with a mocked {@link + * SimpMessagingTemplate}, no STOMP/broker wiring needed. + */ +class LiveEventStompBroadcasterTest { + + private final SimpMessagingTemplate messaging = mock(SimpMessagingTemplate.class); + private final WebSocketProperties properties = new WebSocketProperties(); + private final LiveEventStompBroadcaster broadcaster = + new LiveEventStompBroadcaster(messaging, properties); + + @Test + void broadcastsAnyDomainEventToConfiguredDestinationWithMappedFields() { + properties.setBroadcastDestination("/topic/live"); + SampleReserved event = new SampleReserved("wl-42"); + + broadcaster.onDomainEvent(event); + + ArgumentCaptor<LiveEvent> captor = ArgumentCaptor.forClass(LiveEvent.class); + verify(messaging).convertAndSend(eq("/topic/live"), captor.capture()); + LiveEvent sent = captor.getValue(); + assertThat(sent.type()).isEqualTo("SampleReserved"); + assertThat(sent.payload()).isSameAs(event); + assertThat(sent.occurredAt()).isNotNull(); + } + + @Test + void ignoresObjectsThatAreNotDomainEvents() { + broadcaster.onDomainEvent("not-a-domain-event"); + + verifyNoInteractions(messaging); + } + + /** + * Stand-in domain event: a {@code @DomainEvent}-annotated record with feature-specific fields. + */ + @DomainEvent + private record SampleReserved(String aggregateId) {} +} diff --git a/src/adapter/outbound/cache-redis/CLAUDE.md b/src/adapter/outbound/cache-redis/CLAUDE.md new file mode 100644 index 00000000..95ab2b61 --- /dev/null +++ b/src/adapter/outbound/cache-redis/CLAUDE.md @@ -0,0 +1,28 @@ +# adapter:outbound:cache-redis — cache and Redis adapter + +## Registered identity + +- Module ID: `adapter-outbound-cache-redis` +- Gradle path: `:adapter:outbound:cache-redis` +- Focused test: `./gradlew :adapter:outbound:cache-redis:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.cache`. + +## Responsibility + +- Implement cache stores, routing, Redis capability, and fail-open technical behavior behind ports. +- Own cache binding settings and Redis client adaptation. +- Reuse `adapter:outbound:support` for shared outbound concerns. + +## Boundaries + +- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- No inbound transport, persistence entity/repository, bootstrap, or sample dependency. +- Cache adapters do not decide business freshness, entitlement, or domain fallback rules. + +## Tests + +Use fake Redis clients and contract tests for routing/fail-open behavior. Do not use a real network in +focused tests; configuration changes include binding/validation coverage. diff --git a/src/adapter/outbound/cache-redis/README.md b/src/adapter/outbound/cache-redis/README.md new file mode 100644 index 00000000..8daccd0f --- /dev/null +++ b/src/adapter/outbound/cache-redis/README.md @@ -0,0 +1,37 @@ +# adapter:outbound:cache-redis — 설계 결정 참조 + +캐시 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.cache`(`core` 서브 +패키지에 라우팅/SPI 추상화, `redis` 서브패키지에 Redis 바인딩). `:adapter:outbound:support` 에 +의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다. + +허용/금지 의존 정책은 `src/build.gradle` 의 +`allowedProjectDependencies['adapter:outbound:cache-redis']` 항목이 SSOT 다(이 모듈은 아직 별도 +CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 +기록이다. + +## 모듈 개요 + +application-core 포트 뒤에 두는 **선택형** 캐시 어댑터다. `@ConditionalOnProperty` +(`APP_CACHE_REDIS_ENABLED`)로 게이팅되고 기본 비활성이다. `core` 서브패키지는 라우팅/SPI 추상화만 +갖고, `redis` 서브패키지가 이 모듈이 기본 제공하는 유일한 구체 백엔드(`RedisCacheAdapterConfig` +/ `RedisCacheStore`)다. 다른 벤더 백엔드가 필요하면 `CacheBackend` SPI 를 구현해 빈으로 추가한다. + +## 중앙 fail-open 합성 + +`FailOpenCacheStore` 데코레이터는 `CacheRouterConfig` 가 모든 `CacheBackend` 에 **중앙에서** +적용한다 — 백엔드 설정이 실수로 fail-open 정책을 빠뜨릴 수 없다. 백엔드 실패는 cache-miss 로 +다운그레이드돼 외부 장애가 5xx 로 번지지 않는다. 바인딩되지 않은 논리 이름은 설정 오류이며 +라우터에서 fail-fast 한다(Layer 3). + +## 기여 계약은 `CacheBackend`, SPI 는 `CacheStore` + +기여(contribution) 타입을 `CacheStore` 가 아닌 `CacheBackend` 로 둔 건 의도적이다 — 임의의 +`CacheStore` 빈이 실수로 라우팅되지 않게 하고, 타입이 IDE 탐색 가능하며 중복 id 는 startup 을 +실패시킨다. `CacheStore.get()` 의 `Optional.empty()` 는 miss 를 뜻한다(SDK 타입이 어댑터 밖으로 +새지 않게 — B7). + +## 라우팅 바인딩 + +논리 캐시 이름 → 백엔드는 `app.cache.bindings.<name>=<backendId>` 로 선택하며, `backendId` 는 +`CacheBackend#backendId()` 에서 온다. 백엔드는 `@ConditionalOnProperty` 게이팅 config(예: +`RedisCacheAdapterConfig`)가 `CacheBackend` 빈으로 기여한다. diff --git a/src/adapter/outbound/cache-redis/build.gradle b/src/adapter/outbound/cache-redis/build.gradle new file mode 100644 index 00000000..bf8b1f19 --- /dev/null +++ b/src/adapter/outbound/cache-redis/build.gradle @@ -0,0 +1,14 @@ +plugins { id 'groovy' } +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + implementation project(':adapter:outbound:support') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.slf4j:slf4j-api' + + testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' +} +tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/cache-redis/gradle.lockfile b/src/adapter/outbound/cache-redis/gradle.lockfile new file mode 100644 index 00000000..85eb4c96 --- /dev/null +++ b/src/adapter/outbound/cache-redis/gradle.lockfile @@ -0,0 +1,156 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/CacheBindingSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/CacheBindingSettings.java new file mode 100644 index 00000000..d86baef3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/CacheBindingSettings.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.cache; + +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Logical-cache-name → backendId bindings bound from {@code app.cache.bindings.*} (relaxed binding + * also accepts env keys, e.g. {@code APP_CACHE_BINDINGS_WORKLOG=redis}). + * + * <p>Example: {@code app.cache.bindings.worklog=redis} routes {@code + * CacheStoreRouter.get("worklog", key)} to the backend whose {@link CacheBackend#backendId()} is + * {@code redis}. Absent keys default to an empty map so the cache template stays a non-required + * optional module. Binding consistency (every referenced backendId has an enabled backend) is + * validated fail-fast by {@code CacheStoreRouter} at startup. + * + * @param bindings logical cache name → backendId (default empty) + */ +@ConfigurationProperties(prefix = "app.cache") +public record CacheBindingSettings(Map<String, String> bindings) { + + public CacheBindingSettings { + bindings = (bindings == null) ? Map.of() : Map.copyOf(bindings); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/CacheRouterConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/CacheRouterConfig.java new file mode 100644 index 00000000..917eeab0 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/CacheRouterConfig.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.cache; + +import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; +import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter; +import dev.caskeleton.adapter.outbound.cache.core.FailOpenCacheStore; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import java.util.List; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Assembles the {@link CacheStoreRouter} from every contributed {@link CacheBackend} bean. Adding a + * backend is new files only — this config and the router never change. The fail-open policy is + * applied here, centrally, by wrapping every backend in {@link FailOpenCacheStore}, so a backend + * config cannot forget it. + */ +@Configuration +@EnableConfigurationProperties(CacheBindingSettings.class) +public class CacheRouterConfig { + + @Bean + public CacheStoreRouter cacheStoreRouter( + ObjectProvider<List<CacheBackend>> backends, + CacheBindingSettings settings, + FailOpenDependencyLogger failOpenDependencyLogger) { + List<FailOpenCacheStore> failOpenBackends = + backends.getIfAvailable(List::of).stream() + .map(backend -> new FailOpenCacheStore(backend, failOpenDependencyLogger)) + .toList(); + return new CacheStoreRouter(failOpenBackends, settings.bindings()); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheBackend.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheBackend.java new file mode 100644 index 00000000..b567be80 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheBackend.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +/** + * Cache backend contribution contract. A backend opts into routing by registering a bean of this + * interface; {@link #backendId()} is the identifier referenced by {@code + * app.cache.bindings.<logicalName>} values. + */ +public interface CacheBackend extends CacheStore { + + /** Stable backend identifier referenced by {@code app.cache.bindings.*} values. */ + String backendId(); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheBackendException.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheBackendException.java new file mode 100644 index 00000000..bc92f9c3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheBackendException.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +/** + * Unchecked wrapper a cache backend binding throws when its integration client fails (the seam + * interfaces declare {@code throws Exception}, but {@link CacheStore} does not). The {@link + * FailOpenCacheStore} decorator catches it and applies the fail-open cache-miss contract — backend + * bindings must propagate failures, never swallow them, so a backend outage is observable and + * cannot be mistaken for a miss. + */ +public class CacheBackendException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public CacheBackendException(String backendId, Throwable cause) { + super("cache backend '" + backendId + "' access failed", cause); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheStore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheStore.java new file mode 100644 index 00000000..56ed1c25 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheStore.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +import java.util.Optional; + +/** + * Per-backend cache SPI for the optional adapter template. Consumers do not inject this type + * directly — they call {@link CacheStoreRouter} with a logical cache name. {@link #get(String)} + * returns {@link Optional#empty()} on a miss (so no cache SDK type escapes the adapter — B7). + * Routing and fail-open composition rationale is in the module README. + */ +public interface CacheStore { + + /** + * Reads a cached value. {@link Optional#empty()} == miss (or a degraded backend's fail-open + * downgrade). + */ + Optional<String> get(String key); + + /** + * Writes a value. Backend failures are handled fail-open by the central {@link + * FailOpenCacheStore}. + */ + void put(String key, String value); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheStoreRouter.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheStoreRouter.java new file mode 100644 index 00000000..cfde1d7a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/CacheStoreRouter.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Routes logical cache names to contributed {@link CacheBackend}s ({@code + * app.cache.bindings.<logicalName>=<backendId>}). The Layer 3 fail-fast contract lives here: a + * duplicate backendId or a binding to a backendId with no enabled backend fails construction, and + * {@code get}/{@code put} on an unbound logical name throws {@link AdapterDisabledException} (never + * a silent no-op). With no backends and no bindings it constructs cleanly, so the cache template + * never becomes a required dependency. It does not expose the resolved {@link CacheStore}, so no + * adapter type escapes via a public return (B7). + */ +public final class CacheStoreRouter { + + private static final String ADAPTER_NAME = "cache"; + + private final Map<String, CacheStore> backends; + private final Map<String, String> bindings; + + public CacheStoreRouter( + Collection<? extends CacheBackend> backends, Map<String, String> bindings) { + Map<String, CacheStore> byId = new HashMap<>(); + for (CacheBackend backend : backends) { + CacheStore previous = byId.putIfAbsent(backend.backendId(), backend); + if (previous != null) { + throw new IllegalStateException( + "duplicate cache backendId '" + + backend.backendId() + + "' — every contributed CacheBackend bean must have a unique backendId"); + } + } + this.backends = Map.copyOf(byId); + this.bindings = Map.copyOf(bindings); + for (Map.Entry<String, String> binding : this.bindings.entrySet()) { + if (!this.backends.containsKey(binding.getValue())) { + throw new IllegalStateException( + "app.cache.bindings." + + binding.getKey() + + "=" + + binding.getValue() + + " references cache backend '" + + binding.getValue() + + "' but no enabled backend contributes that id — enable the backend" + + " (e.g. app.cache." + + binding.getValue() + + ".enabled=true)" + + " or fix the binding"); + } + } + } + + /** Reads from the backend bound to {@code logicalName} (empty == miss). */ + public Optional<String> get(String logicalName, String key) { + return resolve(logicalName).get(key); + } + + /** Writes to the backend bound to {@code logicalName}. */ + public void put(String logicalName, String key, String value) { + resolve(logicalName).put(key, value); + } + + private CacheStore resolve(String logicalName) { + String backendId = bindings.get(logicalName); + if (backendId == null) { + throw new AdapterDisabledException( + ADAPTER_NAME, + "no cache backend bound for logical cache '" + + logicalName + + "' — set app.cache.bindings." + + logicalName + + "=<backendId> and enable that backend" + + " (integration-adapter-templates Layer 3)"); + } + return backends.get(backendId); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/FailOpenCacheStore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/FailOpenCacheStore.java new file mode 100644 index 00000000..cf477a49 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/core/FailOpenCacheStore.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import java.util.Optional; + +/** + * Fail-open decorator: a cache backend outage degrades to a miss ({@code get} → empty, {@code put} + * swallowed), never a 5xx. Applied centrally by {@code CacheRouterConfig}. + */ +public final class FailOpenCacheStore implements CacheBackend { + + private static final String DEPENDENCY_TYPE = "cache"; + + private final CacheBackend delegate; + private final FailOpenDependencyLogger dependencyLogger; + + public FailOpenCacheStore(CacheBackend delegate, FailOpenDependencyLogger dependencyLogger) { + this.delegate = delegate; + this.dependencyLogger = dependencyLogger; + } + + @Override + public String backendId() { + return delegate.backendId(); + } + + @Override + public Optional<String> get(String key) { + try { + Optional<String> value = delegate.get(key); + dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "get"); + return value; + } catch (Exception ex) { + // fail-open: an unavailable backend degrades to a cache-miss, not a 5xx. + dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "get", ex); + return Optional.empty(); + } + } + + @Override + public void put(String key, String value) { + try { + delegate.put(key, value); + dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "put"); + } catch (Exception ex) { + // fail-open: a failed cache write is observed, not propagated. + dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "put", ex); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java new file mode 100644 index 00000000..97832990 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheAdapterConfig.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Layer 1 bean-gating for the Redis cache backend. The {@code app.cache.redis.enabled} flag (env + * {@code APP_CACHE_REDIS_ENABLED}, default false) decides whether this backend <em>contributes</em> + * a {@link CacheBackend} bean whose {@link CacheBackend#backendId()} is {@link + * RedisCacheStore#BACKEND_ID} — the id that {@code app.cache.bindings.<logicalName>=redis} routes + * to. Fail-open wrapping and dependency logging are applied centrally by {@code CacheRouterConfig}; + * this config stays a thin contribution. + * + * <p>No disabled-sentinel bean: when disabled this config contributes nothing, and the Layer 3 + * fail-fast contract is enforced by {@code CacheStoreRouter} (unbound logical name → {@code + * AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs + * therefore never need to know about each other — a new backend is new files only. + */ +@Configuration +public class RedisCacheAdapterConfig { + + @Bean + @ConditionalOnProperty( + name = "app.cache.redis.enabled", + havingValue = "true", + matchIfMissing = false) + public CacheBackend redisCacheBackend(RedisClient redisClient) { + return new RedisCacheStore(redisClient); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStore.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStore.java new file mode 100644 index 00000000..9d62ee02 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStore.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; +import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException; +import java.util.Optional; + +/** + * Thin Redis binding of {@link CacheBackend} (active only when {@code + * app.cache.redis.enabled=true}). Delegates to the project-supplied {@link RedisClient} seam and + * wraps its checked failures into {@link CacheBackendException}. The fail-open contract (outage == + * cache-miss, never a 5xx) lives in {@code FailOpenCacheStore}, which {@code CacheRouterConfig} + * composes around every contributed backend centrally — keeping the policy identical across all + * backends. + */ +public class RedisCacheStore implements CacheBackend { + + /** Routing id referenced by {@code app.cache.bindings.*} values. */ + public static final String BACKEND_ID = "redis"; + + private final RedisClient client; + + public RedisCacheStore(RedisClient client) { + this.client = client; + } + + @Override + public String backendId() { + return BACKEND_ID; + } + + @Override + public Optional<String> get(String key) { + try { + return client.read(key); + } catch (Exception ex) { + throw new CacheBackendException(BACKEND_ID, ex); + } + } + + @Override + public void put(String key, String value) { + try { + client.write(key, value); + } catch (Exception ex) { + throw new CacheBackendException(BACKEND_ID, ex); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisClient.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisClient.java new file mode 100644 index 00000000..5802c67f --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisClient.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import java.util.Optional; + +/** + * Integration seam the forking project implements to bind the Redis cache template to a real + * client. The skeleton carries no Redis SDK dependency — the implementation is supplied by the + * project that enables Redis. Implementations may throw on a backend outage; the fail-open handling + * is done by {@code FailOpenCacheStore} (module README). + */ +public interface RedisClient { + + /** + * Reads a value from Redis. + * + * @return the value, or empty if absent + * @throws Exception on a backend/connection failure (handled fail-open by the store) + */ + Optional<String> read(String key) throws Exception; + + /** + * Writes a value to Redis. + * + * @throws Exception on a backend/connection failure (handled fail-open by the store) + */ + void write(String key, String value) throws Exception; +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/CacheBindingSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/CacheBindingSettingsTest.java new file mode 100644 index 00000000..0c6e903e --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/CacheBindingSettingsTest.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CacheBindingSettingsTest { + + @Test + void nullBindingsDefaultToAnEmptyMap() { + // absent app.cache.bindings.* keys must not be a startup dependency (L262). + CacheBindingSettings settings = new CacheBindingSettings(null); + assertThat(settings.bindings()).isEmpty(); + } + + @Test + void bindingsAreDefensivelyCopied() { + CacheBindingSettings settings = new CacheBindingSettings(Map.of("worklog", "redis")); + assertThat(settings.bindings()).containsEntry("worklog", "redis"); + assertThatThrownBy(() -> settings.bindings().put("x", "y")) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/core/CacheStoreRouterTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/core/CacheStoreRouterTest.java new file mode 100644 index 00000000..b92eb51a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/core/CacheStoreRouterTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class CacheStoreRouterTest { + + @Test + void routesEachLogicalCacheToItsBoundBackend() { + CacheStoreRouter router = + new CacheStoreRouter( + List.of(fixedStore("redis", "from-redis"), fixedStore("local", "from-local")), + Map.of("worklog", "redis", "codes", "local")); + + assertThat(router.get("worklog", "k")).contains("from-redis"); + assertThat(router.get("codes", "k")).contains("from-local"); + } + + @Test + void putRoutesToTheBoundBackend() { + AtomicReference<String> written = new AtomicReference<>(); + CacheBackend recording = + new CacheBackend() { + @Override + public String backendId() { + return "redis"; + } + + @Override + public Optional<String> get(String key) { + return Optional.empty(); + } + + @Override + public void put(String key, String value) { + written.set(key + "=" + value); + } + }; + CacheStoreRouter router = new CacheStoreRouter(List.of(recording), Map.of("worklog", "redis")); + + router.put("worklog", "k", "v"); + + assertThat(written.get()).isEqualTo("k=v"); + } + + @Test + void unboundLogicalCacheFailsFastWithRemediationMessage() { + // D4: a cache path that is not wired is a configuration error — fail fast, + // never a silent no-op (DisabledAdapterSentinelTest contract, router edition). + CacheStoreRouter router = new CacheStoreRouter(List.of(), Map.of()); + + assertThatThrownBy(() -> router.get("worklog", "k")) + .isInstanceOf(AdapterDisabledException.class) + .hasMessageContaining("app.cache.bindings.worklog") + .extracting("adapterName") + .isEqualTo("cache"); + assertThatThrownBy(() -> router.put("worklog", "k", "v")) + .isInstanceOf(AdapterDisabledException.class); + } + + @Test + void bindingToAnUnknownBackendFailsConstruction() { + // startup validation: a binding that names a backend with no enabled bean is a + // configuration contradiction — surface it at boot, not on first cache access. + assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of("worklog", "redis"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.cache.bindings.worklog") + .hasMessageContaining("redis"); + } + + @Test + void duplicateBackendIdsFailConstruction() { + // two contributions claiming the same backendId is a wiring bug — routing would + // silently pick one of them; surface it at boot with the offending id. + assertThatThrownBy( + () -> + new CacheStoreRouter( + List.of(fixedStore("redis", "a"), fixedStore("redis", "b")), Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate") + .hasMessageContaining("redis"); + } + + @Test + void emptyRouterConstructsCleanly() { + // L262: zero backends + zero bindings must not block startup. + assertThat(new CacheStoreRouter(List.of(), Map.of())).isNotNull(); + } + + private static CacheBackend fixedStore(String backendId, String cachedValue) { + return new CacheBackend() { + @Override + public String backendId() { + return backendId; + } + + @Override + public Optional<String> get(String key) { + return Optional.of(cachedValue); + } + + @Override + public void put(String key, String value) {} + }; + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/core/FailOpenCacheStoreTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/core/FailOpenCacheStoreTest.java new file mode 100644 index 00000000..0dc55105 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/core/FailOpenCacheStoreTest.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.cache.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +class FailOpenCacheStoreTest { + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private FailOpenDependencyLogger dependencyLogger; + + @BeforeEach + void setUp() { + logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.failopen"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + dependencyLogger = new FailOpenDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + @Test + void getReturnsTheDelegateValueOnAHit() { + FailOpenCacheStore store = new FailOpenCacheStore(fixedDelegate("cached"), dependencyLogger); + + assertThat(store.get("k")).contains("cached"); + } + + @Test + void backendIdDelegatesToTheWrappedBackend() { + FailOpenCacheStore store = new FailOpenCacheStore(fixedDelegate("cached"), dependencyLogger); + + // the decorator must not change routing identity — the router maps by this id. + assertThat(store.backendId()).isEqualTo("redis"); + } + + @Test + void getDegradesToACacheMissWhenTheDelegateThrows() { + FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger); + + // fail-open: unavailable backend == cache-miss (Optional.empty), never an exception. + assertThat(store.get("k")).isEmpty(); + } + + @Test + void getOnFailingDelegateNeverThrowsAndLogsCorrelationId() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-cache-1"); + FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger); + + assertThatCode(() -> store.get("k")).doesNotThrowAnyException(); + + ILoggingEvent warn = + appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().orElseThrow(); + assertThat(warn.getFormattedMessage()) + .contains("dependency_name=\"redis\"") + .contains("dependency_type=\"cache\"") + .contains("correlation_id=\"corr-cache-1\""); + } + + @Test + void putIsFailOpenWhenTheDelegateThrows() { + FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger); + + assertThatCode(() -> store.put("k", "v")).doesNotThrowAnyException(); + } + + private static CacheBackend fixedDelegate(String cachedValue) { + return new CacheBackend() { + @Override + public String backendId() { + return "redis"; + } + + @Override + public Optional<String> get(String key) { + return Optional.of(cachedValue); + } + + @Override + public void put(String key, String value) {} + }; + } + + private static CacheBackend failingDelegate() { + return new CacheBackend() { + @Override + public String backendId() { + return "redis"; + } + + @Override + public Optional<String> get(String key) { + throw new RuntimeException("connection refused"); + } + + @Override + public void put(String key, String value) { + throw new RuntimeException("connection refused"); + } + }; + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStoreTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStoreTest.java new file mode 100644 index 00000000..387f7637 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisCacheStoreTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.cache.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class RedisCacheStoreTest { + + @Test + void backendIdIsRedis() { + // routing identity the router maps app.cache.bindings.* values against. + assertThat(new RedisCacheStore(failingClient()).backendId()).isEqualTo("redis"); + } + + @Test + void getReturnsTheClientValueOnAHit() { + RedisCacheStore store = + new RedisCacheStore( + new RedisClient() { + @Override + public Optional<String> read(String key) { + return Optional.of("cached"); + } + + @Override + public void write(String key, String value) {} + }); + + assertThat(store.get("k")).contains("cached"); + } + + @Test + void putDelegatesToTheClient() { + AtomicReference<String> written = new AtomicReference<>(); + RedisCacheStore store = + new RedisCacheStore( + new RedisClient() { + @Override + public Optional<String> read(String key) { + return Optional.empty(); + } + + @Override + public void write(String key, String value) { + written.set(key + "=" + value); + } + }); + + store.put("k", "v"); + + assertThat(written.get()).isEqualTo("k=v"); + } + + @Test + void getWrapsACheckedClientFailureIntoCacheBackendException() { + RedisCacheStore store = new RedisCacheStore(failingClient()); + + // fail-open is the decorator's job (FailOpenCacheStore) — the binding itself + // must propagate, otherwise a failure could be silently mistaken for a miss. + assertThatThrownBy(() -> store.get("k")) + .isInstanceOf(CacheBackendException.class) + .hasMessageContaining("redis"); + } + + @Test + void putWrapsACheckedClientFailureIntoCacheBackendException() { + RedisCacheStore store = new RedisCacheStore(failingClient()); + + assertThatThrownBy(() -> store.put("k", "v")) + .isInstanceOf(CacheBackendException.class) + .hasMessageContaining("redis"); + } + + @Test + void getPropagatesEmptyOnAMiss() { + RedisCacheStore store = + new RedisCacheStore( + new RedisClient() { + @Override + public Optional<String> read(String key) { + return Optional.empty(); + } + + @Override + public void write(String key, String value) {} + }); + + assertThat(store.get("k")).isEmpty(); + } + + private static RedisClient failingClient() { + return new RedisClient() { + @Override + public Optional<String> read(String key) throws Exception { + throw new Exception("connection refused"); + } + + @Override + public void write(String key, String value) throws Exception { + throw new Exception("connection refused"); + } + }; + } +} diff --git a/src/adapter/outbound/fileserver/CLAUDE.md b/src/adapter/outbound/fileserver/CLAUDE.md new file mode 100644 index 00000000..8129cb8c --- /dev/null +++ b/src/adapter/outbound/fileserver/CLAUDE.md @@ -0,0 +1,49 @@ +# adapter:outbound:fileserver — module rules + +## Registered identity + +- Module ID: `adapter-outbound-fileserver` +- Gradle path: `:adapter:outbound:fileserver` +- Focused test: `./gradlew :adapter:outbound:fileserver:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) adapter implementing +`dev.caskeleton.application.fileexport.FileExportPort` (application-core). Design rationale lives in +[README.md](README.md). + +## Responsibility + +- Export tabular data as CSV files behind `FileExportPort`, written under + `ca-skeleton.fileserver.base-directory` (stand-in for NFS/SFTP). Single implementation + (`FilesystemCsvExportAdapter`); pure JDK filesystem IO, no external service. +- Opt-in: `FileExportConfig` gates the single `FileExportPort` bean with + `@ConditionalOnProperty(ca-skeleton.fileserver.enabled=true)`, default off. The adapter is a plain + class; the config assembles it as a bean. + +## Allowed + +- Project deps: `:application-core`, `:shared-contract` — SSOT is the + `adapter-outbound-fileserver` entry in `.harness/project/modules.yaml`; `src/build.gradle` + enforces it. No + `:domain-core`, no sibling adapters. +- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor` + (annotation processor) only. + +## Forbidden + +- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio` + (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). +- Leaking a framework/domain type across `FileExportPort` — the port takes/returns only `String` / + `List<String>` / `List<List<String>>` / `ExportedFile`. +- Fully-qualified inline type references; more than one public top-level type per file. + +## Tests + +`FilesystemCsvExportAdapterTest` (temp-dir CSV write/verify: header + rows, RFC-4180 escaping, +null-field, overwrite, path-traversal + blank-name rejection). + +```bash +cd src +./gradlew :adapter:outbound:fileserver:check +``` diff --git a/src/adapter/outbound/fileserver/README.md b/src/adapter/outbound/fileserver/README.md new file mode 100644 index 00000000..e79a49b2 --- /dev/null +++ b/src/adapter/outbound/fileserver/README.md @@ -0,0 +1,64 @@ +# adapter:outbound:fileserver — design-decision reference + +File-server export outbound (driven) adapter. Package root: +`dev.caskeleton.adapter.outbound.fileserver`. Implements the `application-core` port +`dev.caskeleton.application.fileexport.FileExportPort` behind an opt-in `@ConditionalOnProperty` +selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient / +objectstorage). + +The allowed/forbidden dependency policy is owned by `src/build.gradle`'s +`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in +[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code +comments. + +## Module overview + +An **opt-in** file-export adapter placed behind an application-core port. A single +`FilesystemCsvExportAdapter` writes CSV files under `ca-skeleton.fileserver.base-directory` — a +stand-in for an NFS mount, shared file server, or SFTP drop. There is no external service and no +external dependency (pure JDK filesystem IO), so the local profile just works and the lockfile only +pins the shared Spring Boot / tooling graph. + +Selector: `ca-skeleton.fileserver.enabled=true` (default `false`). Unlike objectstorage there is a +single implementation, so no backend switch is needed; the `enabled` flag keeps the module from +activating unexpectedly when merely present on the classpath. `FileExportConfig` gates the single +`FileExportPort` bean on that flag. + +## The port contract (framework/domain-neutral) + +`FileExportPort` is a minimal, domain-neutral surface: + +- `ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows)`. + +The caller supplies a bare file name, an optional header row, and the data rows as lists of +already-stringified field values. The adapter owns file placement, RFC-4180 escaping, and byte +encoding, and returns an `ExportedFile` receipt (`fileName`, absolute `path`, `byteSize`, +`rowCount`). No framework or domain type crosses the port — the application layer stays decoupled +from the CSV format and the destination filesystem. A fork that needs a real domain export maps its +rows to `List<List<String>>` at the call site (or adds a typed convenience method in its own layer). + +## CSV escaping + +Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage return, or +line feed is wrapped in double-quotes with embedded quotes doubled. A `null` field is written as an +empty field. Rows are separated by `\n` and the file is UTF-8 encoded. Overwriting an existing file +at the same name replaces it. + +## IO-failure handling + +Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException` +(`dependencyName="fileserver"`) so a fork's web error handler classifies them uniformly with the +other outbound dependencies. A blank file name or one that escapes the base directory (path +traversal) is `IllegalArgumentException` (a caller bug, not a dependency failure) — the adapter +normalises the resolved path and checks it still starts with the base directory. + +## Tests + +- `FilesystemCsvExportAdapterTest` — `@TempDir` write/verify: header + rows, CSV escaping of a field + containing a comma / quote / newline, null-field handling, header-only and headerless exports, + overwrite, and path-traversal + blank-name rejection. + +```bash +cd src +./gradlew :adapter:outbound:fileserver:check +``` diff --git a/src/adapter/outbound/fileserver/build.gradle b/src/adapter/outbound/fileserver/build.gradle new file mode 100644 index 00000000..6fe5159d --- /dev/null +++ b/src/adapter/outbound/fileserver/build.gradle @@ -0,0 +1,15 @@ +// Driven adapter: file server / filesystem exports behind application-core's FileExportPort. Writes +// delimited (CSV) files to a configured base directory — a stand-in for an NFS mount, shared file +// server, or SFTP drop. Pure JDK filesystem IO, so there are NO external dependencies: the lockfile +// only pins the shared Spring Boot / tooling graph. Opt-in via @ConditionalOnProperty +// (ca-skeleton.fileserver.enabled), off by default so the module never activates unexpectedly. +description = 'Outbound adapter: file server exports (filesystem/CSV)' + +dependencies { + implementation project(':application-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} diff --git a/src/adapter/outbound/fileserver/gradle.lockfile b/src/adapter/outbound/fileserver/gradle.lockfile new file mode 100644 index 00000000..ea5fec23 --- /dev/null +++ b/src/adapter/outbound/fileserver/gradle.lockfile @@ -0,0 +1,152 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java new file mode 100644 index 00000000..082530f9 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.fileexport.FileExportPort; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Opt-in wiring for the file-server export adapter. The single {@link FileExportPort} bean is + * contributed only when {@code ca-skeleton.fileserver.enabled=true}, so the module never activates + * unexpectedly when merely present on the classpath (there is a single implementation, so no + * backend selector is needed). The adapter is a plain class; this config assembles it as a bean, + * mirroring the object-storage module. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(FileExportProperties.class) +public class FileExportConfig { + + @Bean + @ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true") + public FileExportPort filesystemCsvExportPort(FileExportProperties properties) { + return new FilesystemCsvExportAdapter(properties.getBaseDirectory()); + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java new file mode 100644 index 00000000..69b99724 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Typed settings for the file-server export adapter, bound from {@code ca-skeleton.fileserver.*}. + * Bound as a mutable JavaBean (not a record) so a fork can leave any subset of fields unset and + * inherit the defaults below. + */ +@ConfigurationProperties(prefix = "ca-skeleton.fileserver") +public class FileExportProperties { + + /** + * Whether to contribute the export adapter. Defaults to {@code false} so the module never + * activates unexpectedly when merely present on the classpath; a fork opts in explicitly. + */ + private boolean enabled = false; + + /** Base directory that export files are written under (stand-in for an NFS/SFTP drop). */ + private String baseDirectory = "./.data/fileserver"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBaseDirectory() { + return baseDirectory; + } + + public void setBaseDirectory(String baseDirectory) { + this.baseDirectory = baseDirectory; + } +} diff --git a/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java new file mode 100644 index 00000000..68202eff --- /dev/null +++ b/src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import dev.caskeleton.application.fileexport.ExportedFile; +import dev.caskeleton.application.fileexport.FileExportPort; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filesystem-backed {@link FileExportPort} — writes a CSV export under a configured base directory + * (the "file server" boundary, a stand-in for NFS/SFTP). Fields are escaped per RFC-4180 and the + * file is UTF-8 encoded; the returned {@link ExportedFile} carries the absolute path, byte size, + * and row count. + */ +public class FilesystemCsvExportAdapter implements FileExportPort { + + private static final String DEPENDENCY_NAME = "fileserver"; + + private static final Logger log = LoggerFactory.getLogger(FilesystemCsvExportAdapter.class); + + private final Path baseDir; + + public FilesystemCsvExportAdapter(String baseDirectory) { + this.baseDir = Path.of(baseDirectory).toAbsolutePath().normalize(); + try { + Files.createDirectories(baseDir); + log.info("filesystem file-export base dir: {}", baseDir); + } catch (IOException e) { + throw new DependencyFailureException( + OperationalError.INTERNAL_ERROR, + DEPENDENCY_NAME, + "cannot create file-export base dir", + e); + } + } + + @Override + public ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows) { + Objects.requireNonNull(header, "header must be non-null"); + Objects.requireNonNull(rows, "rows must be non-null"); + Path target = resolve(fileName); + + StringBuilder csv = new StringBuilder(); + if (!header.isEmpty()) { + appendRow(csv, header); + } + for (List<String> row : rows) { + Objects.requireNonNull(row, "row must be non-null"); + appendRow(csv, row); + } + + byte[] bytes = csv.toString().getBytes(StandardCharsets.UTF_8); + try { + Files.write(target, bytes); + } catch (IOException e) { + throw new DependencyFailureException( + OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to write CSV export", e); + } + log.info("exported {} rows -> {} ({} bytes)", rows.size(), target, bytes.length); + return new ExportedFile(fileName, target.toString(), bytes.length, rows.size()); + } + + /** Appends one CSV record (comma-separated, escaped fields, trailing line feed). */ + private static void appendRow(StringBuilder csv, List<String> fields) { + for (int i = 0; i < fields.size(); i++) { + if (i > 0) { + csv.append(','); + } + csv.append(escape(fields.get(i))); + } + csv.append('\n'); + } + + /** Minimal RFC-4180 escaping: quote fields containing a comma, quote, CR, or LF. */ + private static String escape(String value) { + if (value == null) { + return ""; + } + if (value.contains(",") + || value.contains("\"") + || value.contains("\n") + || value.contains("\r")) { + return '"' + value.replace("\"", "\"\"") + '"'; + } + return value; + } + + /** Resolves a bare file name under {@code baseDir}, rejecting blank names and path traversal. */ + private Path resolve(String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("fileName must be non-null and non-blank"); + } + Path resolved = baseDir.resolve(fileName).normalize(); + if (!resolved.startsWith(baseDir) || resolved.equals(baseDir)) { + throw new IllegalArgumentException("illegal export file name (path traversal): " + fileName); + } + return resolved; + } +} diff --git a/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java new file mode 100644 index 00000000..7066d446 --- /dev/null +++ b/src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.outbound.fileserver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.fileexport.ExportedFile; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Temp-dir CSV write/verify contract for {@link FilesystemCsvExportAdapter}. */ +class FilesystemCsvExportAdapterTest { + + private Path baseDir; + private FilesystemCsvExportAdapter adapter; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + baseDir = tempDir; + adapter = new FilesystemCsvExportAdapter(tempDir.toString()); + } + + @Test + void exportsHeaderAndRowsWithCsvEscaping() throws IOException { + List<String> header = List.of("id", "name", "note"); + List<List<String>> rows = + List.of( + List.of("1", "plain", "ok"), + List.of("2", "has,comma", "quote\"inside"), + List.of("3", "line\nbreak", "trailing")); + + ExportedFile result = adapter.exportCsv("export.csv", header, rows); + + Path written = baseDir.resolve("export.csv"); + assertThat(written).exists(); + assertThat(result.fileName()).isEqualTo("export.csv"); + assertThat(result.path()).isEqualTo(written.toAbsolutePath().normalize().toString()); + assertThat(result.rowCount()).isEqualTo(3); + assertThat(result.byteSize()).isEqualTo(Files.size(written)); + + String content = Files.readString(written, StandardCharsets.UTF_8); + assertThat(content) + .isEqualTo( + "id,name,note\n" + + "1,plain,ok\n" + + "2,\"has,comma\",\"quote\"\"inside\"\n" + + "3,\"line\nbreak\",trailing\n"); + } + + @Test + void writesHeaderOnlyWhenRowsEmpty() throws IOException { + ExportedFile result = adapter.exportCsv("empty.csv", List.of("a", "b"), List.of()); + + assertThat(result.rowCount()).isZero(); + assertThat(Files.readString(baseDir.resolve("empty.csv"), StandardCharsets.UTF_8)) + .isEqualTo("a,b\n"); + } + + @Test + void writesNoHeaderLineWhenHeaderEmpty() throws IOException { + adapter.exportCsv("headerless.csv", List.of(), List.of(List.of("only", "data"))); + + assertThat(Files.readString(baseDir.resolve("headerless.csv"), StandardCharsets.UTF_8)) + .isEqualTo("only,data\n"); + } + + @Test + void nullFieldIsWrittenAsEmpty() throws IOException { + List<String> rowWithNull = Arrays.asList("x", null, "z"); + adapter.exportCsv("nulls.csv", List.of("a", "b", "c"), List.of(rowWithNull)); + + assertThat(Files.readString(baseDir.resolve("nulls.csv"), StandardCharsets.UTF_8)) + .isEqualTo("a,b,c\nx,,z\n"); + } + + @Test + void overwritesExistingFile() throws IOException { + adapter.exportCsv("dup.csv", List.of("a"), List.of(List.of("first"))); + adapter.exportCsv("dup.csv", List.of("a"), List.of(List.of("second"))); + + assertThat(Files.readString(baseDir.resolve("dup.csv"), StandardCharsets.UTF_8)) + .isEqualTo("a\nsecond\n"); + } + + @Test + void pathTraversalFileNameIsRejected() { + assertThatThrownBy(() -> adapter.exportCsv("../escape.csv", List.of("a"), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void blankFileNameIsRejected() { + assertThatThrownBy(() -> adapter.exportCsv(" ", List.of("a"), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/httpclient/CLAUDE.md b/src/adapter/outbound/httpclient/CLAUDE.md new file mode 100644 index 00000000..89986f0f --- /dev/null +++ b/src/adapter/outbound/httpclient/CLAUDE.md @@ -0,0 +1,30 @@ +# adapter:outbound:httpclient — resilient HTTP client adapter + +## Registered identity + +- Module ID: `adapter-outbound-httpclient` +- Gradle path: `:adapter:outbound:httpclient` +- Focused test: `./gradlew :adapter:outbound:httpclient:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.httpclient`. + +## Responsibility + +- Own outbound REST client construction, timeouts, retries, circuit breakers, response-size bounds, + trace propagation, diagnostics, and shutdown safety. +- Adapt external HTTP calls behind application/domain ports. +- Reuse `adapter:outbound:support` for shared outbound concerns. + +## Boundaries + +- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- No inbound controller/DTO, persistence, bootstrap, or sample dependency. +- Retry and circuit-breaker code is technical resilience; business compensation and use-case + sequencing stay in application/domain layers. + +## Tests + +Use fake clients/servers or direct collaborator fakes with no real network. Settings receive +binding/validation tests; retry/error mapping and resource bounds receive focused unit tests. diff --git a/src/adapter/outbound/httpclient/README.md b/src/adapter/outbound/httpclient/README.md new file mode 100644 index 00000000..dbcffc65 --- /dev/null +++ b/src/adapter/outbound/httpclient/README.md @@ -0,0 +1,148 @@ +# adapter:outbound:httpclient — 설계 결정 참조 + +아웃바운드 HTTP client 베이스라인 모듈. 패키지 루트: +`dev.caskeleton.adapter.outbound.httpclient`(`resilience`, `diagnostics` 서브패키지 포함). +`:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다. + +허용/금지 의존 정책은 `src/build.gradle` 의 +`allowedProjectDependencies['adapter:outbound:httpclient']` 항목이 SSOT 다(이 모듈은 아직 별도 +CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 +기록이다. + +## OutboundHttpClient + +단일 명명 의존성(named upstream dependency)용 베이스라인 HTTP 클라이언트. + +### static `baseline(...)` 팩토리인 이유 +`public final class` + `private` 생성자 + `public static baseline(...)` 형태다. ArchUnit B7 은 +non-`@Configuration` 아웃바운드 클래스의 public **non-static** 메서드가 `..adapter.outbound..` +타입을 반환하는 것을 금지한다. static 메서드는 B7 대상에서 제외되므로, 인스턴스 팩토리 대신 +static 팩토리를 쓴다. + +### 템플릿 seam — 기본 client 빈 없음 +일반(generic) `OutboundHttpClient` 빈은 두지 않는다. 의존성마다 고유한 이름·base URL 이 +필요하므로, 포킹 프로젝트가 자신의 `@Configuration` 에서 `baseline(...)` 을 직접 호출해 +의존성별 인스턴스를 만든다 — `OutboundHttpClientConfig` 가 만들어 주지 않는다. + +### 내부 RestClient 두 개 +`buffered` 와 `streaming` 두 인스턴스를 둔다. 둘은 하나의 공유 `JdkClientHttpRequestFactory` +위에 만들어져 TCP 연결 풀/타임아웃 설정이 동일하다. `buffered` 만 +`ResponseSizeBoundingInterceptor`(D7)를 포함하고, `streaming` 은 크기 인터셉터 없이 raw +`InputStream` 을 그대로 전달한다. + +### retry 를 CB **바깥**에 두는 이유 +`exchange()` 의 decoration 순서는 CB(outer) → retry(inner)다. retry 를 CB 바깥에 둬야 각 retry +시도가 CB 슬라이딩 윈도에 **독립적으로** 카운트된다. retry 를 CB 안에 두면 모든 재시도가 CB 호출 +1건으로 합산돼 실제 실패 빈도가 CB 에 가려진다. + +### size 위반은 분류하지 않고 전파 +`OutboundResponseSizeExceededException` 은 의도적으로 `DependencyFailureException` 이 +**아니다**. 이는 업스트림 실패가 아니라 호출자가 잘못된 API 경로를 골랐다는 사용 계약 +(usage-contract) 위반이며, 분류 없이 그대로 전파한다. 큰 응답이 예상되면 호출자는 `stream()` 을 +써야 한다. + +### streaming 경로에 retry 없음 +이미 소비된 스트림은 안전하게 재발행할 수 없다 — reader 에 이미 전달된 바이트는 잃고, 서버가 +처음부터 재전송을 보장하지 않는다. 그래서 `stream()` 은 retry 없이 shutdown 게이팅·분류·로깅만 +적용한다. + +### OutboundHttpShutdownGuard — SmartLifecycle 인 이유 +`SmartLifecycle` + `getPhase() = Integer.MAX_VALUE`(가장 먼저 stop)로 종료 시 아웃바운드 +호출자보다 먼저 멈춘다. `ContextClosedEvent` 를 쓰지 않는 이유: SmartLifecycle phase 순서는 +결정적이고 close 시퀀스가 빈을 파괴하기 전에 동작하지만, `ContextClosedEvent` 는 컨텍스트 종료가 +시작된 뒤 발생하고 다른 lifecycle 빈과의 순서가 정의되지 않는다. 종료 중에는 +`DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast 한다(전용 shutdown 코드를 새로 만들지 않고 +가장 가까운 버킷을 재사용). + +### OutboundHttpTimeoutEnforcer — `static @Bean` BeanPostProcessor +raw `RestClient`/`RestClient.Builder` 빈이 등록되면 startup 을 실패시키는 BeanPostProcessor 다. +`static @Bean` 으로 선언해야 다른 빈보다 먼저 생성된다 — non-static BeanPostProcessor 는 일찍 +생성되는 빈을 놓칠 수 있다. 인라인으로 직접 만든 `RestClient`(빈이 아닌)는 잡지 못하는 잔여 +리스크가 있다. + +## resilience — `OutboundHttpResilienceConfig` / `OutboundHttpResilience` / `OutboundRetryPolicy` + +### 메트릭 없는 resilience 금지 (D3) +retry/CB 중 하나라도 켜지면 `MeterRegistry` 빈이 **반드시** 있어야 한다. low-cardinality 메트릭 +없이 retry/CB 를 돌리는 것은 D3 가 금지한다. enable 상태인데 registry 가 없으면 빈 생성 시점에 +`IllegalStateException` 으로 startup 을 실패시킨다. + +### D4 메트릭 정규화 — `@Bean` 이 아니라 직접 주입 +`MeterFilter` 를 `MeterRegistry.Config#meterFilter` 로 **직접** 등록한다. Spring Boot Actuator 의 +`MeterRegistryCustomizer`(filter 빈을 주워가는 자동설정)는 이 모듈 classpath 에 없으므로, +`@Bean MeterFilter` 로 등록하면 아무 효과가 없다. + +### Micrometer 1.15.x 비호환 — 커스텀 `map(Meter.Id)` 필요 +`MeterFilter.replaceTagValues` / `MeterFilter.renameTag` 는 `TaggedRetryMetrics` / +`TaggedCircuitBreakerMetrics` 가 등록한 `FunctionCounter`·`DefaultGauge` 인스턴스의 태그 +키/값을 Micrometer 1.15.x 에서 안정적으로 변환하지 못한다(이들 ID 가 편의 팩토리의 `map()` +체인을 타지 않음). 명시적 `map(Meter.Id)` 구현을 가진 커스텀 `MeterFilter` 가 필요하다 +(Micrometer 1.15.11 / Resilience4j 2.2.0 에서 확인). + +### MeterFilter 설치 순서 불변식 +필터는 설치 **이후** 등록되는 meter 에만 영향을 준다. `TaggedCircuitBreakerMetrics` 는 state +gauge 를 `bindTo()` 시점에 즉시(eager) 등록하므로, state 태그 대문자화 필터는 `bindTo()` +**전에** 설치해야 한다 — 아니면 그 gauge 들에는 변환이 조용히 누락된다. 정규화 필터(1–3)는 +DENY 필터(4)보다 먼저 설치해 rename 된 태그 키가 필터 4 의 NEUTRAL 판정에 보인다. + +### 승인된 3개 meter 외 전부 DENY +`resilience4j.retry.calls`, `resilience4j.circuitbreaker.calls`, +`resilience4j.circuitbreaker.state` 만 통과시키고, vendor 가 추가로 내보내는 `failure.rate`, +`buffered.calls`, `not.permitted.calls`, `slow.call.rate` 등은 D4 low-cardinality 를 위해 +필터 4 가 DENY 한다. + +### OutboundHttpResilience — `Optional.empty()` 계약 +retry/CB 머신을 담는 홀더. `retryFor()` / `circuitBreakerFor()` 는 해당 기능이 비활성이면 +`Optional.empty()`(= decoration 없음)를 반환한다. 생성자의 registry 인자는 nullable — 기능이 +비활성일 때 `null` 을 넘긴다. + +### OutboundRetryPolicy — POST/PATCH 는 항상 non-retryable +재시도 조건은 ThreadLocal 호출 컨텍스트 기반의 4가지로, 멱등(idempotent) 메서드만 재시도한다. +POST/PATCH 는 Idempotency-Key 계약이 정의되지 않았으므로 보수적으로 항상 재시도하지 않는다. +exponential random backoff(jitter)는 settings 로 구동된다. + +## diagnostics — `OutboundHttpErrorMapper` / `OutboundHttpDependencyLogger` + +### 분류 순서가 타입 계층 때문에 중요 +`HttpConnectTimeoutException extends HttpTimeoutException` 이므로 connect-timeout 을 +read-timeout **보다 먼저** 검사해야 한다 — 순서가 바뀌면 connect 타임아웃이 read 타임아웃으로 +오분류된다. `ConnectException` 이 `UnresolvedAddressException`(JDK HttpClient 래핑)을 감쌀 수 +있어, DNS 검사는 `CONNECT_FAILED` 반환 전에 sub-cause 체인을 훑는다. + +업스트림 4xx 는 408/429 포함 전부 `DEPENDENCY_4XX_CLIENT`(non-retryable, PERMANENT)로 +분류한다. 의미상 408/429 는 재시도 가능하지만 Idempotency-Key 계약이 없는 상태에서의 보수적· +안전한 결정이며, 열린(open) 리스크로 남겨 둔다. + +### 진단 메시지 본문 누출 금지 (D12) +진단 메시지에 `getResponseBodyAsString()`, 응답 헤더, 업스트림 페이로드를 **절대** 포함하지 +않는다 — HTTP status code 와 예외 클래스명만 쓴다. + +### 로그 레벨 규칙 +로그 필드는 MDC SSOT 를 따른다. SUCCESS=DEBUG, CIRCUIT_OPEN/REJECTED=WARN(예상되는 일시적 +상태 — use case 는 성공), 그 외 hard failure=ERROR. 이는 `:adapter:outbound:support` 의 +`FailOpenDependencyLogger`(선택형 어댑터 fail-open, 전부 WARN)와 명확히 구분된다 — 이 client +는 호출자에게 직접 노출되는 hard failure 를 다루므로 ERROR 까지 올린다. 본문/수신자/페이로드를 +받지 않아 PII 가 로그에 닿지 않는다. + +## TraceContextPropagationInterceptor + +> 코드에는 압축된 경고만 남기고, 전체 메커니즘은 여기 둔다. + +sampled 플래그가 `00`(not-sampled)로 하드코딩돼 있다 — 스켈레톤은 exporter/sampler 를 +와이어링하지 않고 foundation `mdc-keys.yaml` 에 `trace_flags`/`sampled` 키가 없어 inbound +sampled 비트를 전파할 수 없기 때문이다. `traceparent` 는 `trace_id`+`span_id` 로만 재구성되고 +sampled 비트는 `00` 으로 강제된다. + +포크가 실제 트레이서(Micrometer Tracing + OTel)를 붙이면 두 가지가 터진다: + +1. **downstream suppression** — downstream `ParentBased` sampler 가 `00` 을 "parent not + sampled" 로 읽고 child span 을 버린다. upstream 이 샘플링한 트레이스라도 이 경계에서 분산 + 트레이스가 끊긴다. +2. **이 인터셉터가 경쟁에서 이긴다** — RestClient 에 **가장 먼저** 등록돼(`OutboundHttpClient` + 참조) 나중 OTel instrumentation 인터셉터보다 앞서 `traceparent` 를 찍고, 멱등 가드가 실제 + instrumentation 을 스킵시킨다. 즉 `00` 은 fallback 이 아니라 실제 결정을 덮어쓴다. + +**포크 체크리스트**: (a) 이 인터셉터를 비활성화/제거하고 `traceparent` 소유를 OTel 에 넘기거나, +(b) `TraceParent.of(.., false)` 의 `false` 를 실제 `Span.getSpanContext().isSampled()` 로 +교체하고 foundation MDC `trace_flags` carrier 를 마련한다. 스켈레톤 테스트는 no-tracer +메커니즘만 검증하며 live SDK 와의 합성은 검증하지 않는다. diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle new file mode 100644 index 00000000..28c58247 --- /dev/null +++ b/src/adapter/outbound/httpclient/build.gradle @@ -0,0 +1,19 @@ +plugins { id 'groovy' } +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + implementation project(':adapter:outbound:support') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework:spring-web' + implementation 'io.micrometer:micrometer-core' + implementation 'io.github.resilience4j:resilience4j-retry:2.2.0' + implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' + implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0' + implementation 'org.slf4j:slf4j-api' + + testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' +} +tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/httpclient/gradle.lockfile b/src/adapter/outbound/httpclient/gradle.lockfile new file mode 100644 index 00000000..a7b86893 --- /dev/null +++ b/src/adapter/outbound/httpclient/gradle.lockfile @@ -0,0 +1,166 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java new file mode 100644 index 00000000..89ea616d --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserver.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; + +/** + * Package-private observer that centralises duration calculation, success/failure logging, error + * classification, and shutdown rejection for outbound HTTP calls. + */ +final class OutboundHttpCallObserver { + + private final String dependencyName; + private final OutboundHttpErrorMapper errorMapper; + private final OutboundHttpDependencyLogger logger; + + OutboundHttpCallObserver( + String dependencyName, + OutboundHttpErrorMapper errorMapper, + OutboundHttpDependencyLogger logger) { + this.dependencyName = dependencyName; + this.errorMapper = errorMapper; + this.logger = logger; + } + + void recordSuccess(long startNs, int retryAttempt) { + long durationMs = (System.nanoTime() - startNs) / 1_000_000; + logger.logSuccess(dependencyName, durationMs, retryAttempt); + } + + /** + * <em>Returns</em> the classified exception so the caller throws via {@code throw + * observer.recordFailure(...)}. + */ + DependencyFailureException recordFailure(Throwable t, long startNs, int retryAttempt) { + long durationMs = (System.nanoTime() - startNs) / 1_000_000; + DependencyFailureException dfe = errorMapper.classify(dependencyName, t); + String outcome = outcomeFor(dfe); + logger.logFailure(dependencyName, outcome, durationMs, retryAttempt, dfe); + return dfe; + } + + /** + * Reuses {@code DEPENDENCY_CIRCUIT_OPEN}/REJECTED semantics since no dedicated shutdown code + * exists. + */ + DependencyFailureException rejectShutdown(String message) { + DependencyFailureException rejected = + new DependencyFailureException( + OperationalError.DEPENDENCY_CIRCUIT_OPEN, dependencyName, message, null); + logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected); + return rejected; + } + + private static String outcomeFor(DependencyFailureException dfe) { + return switch ((OperationalError) dfe.errorCode()) { + case DEPENDENCY_TIMEOUT -> "TIMEOUT"; + case DEPENDENCY_CIRCUIT_OPEN -> "CIRCUIT_OPEN"; + default -> "FAILURE"; + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java new file mode 100644 index 00000000..73919af3 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClient.java @@ -0,0 +1,177 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.retry.Retry; +import java.io.InputStream; +import java.time.Instant; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestClient; + +/** + * Baseline outbound HTTP client for a single named upstream dependency. Created via the {@code + * static baseline(...)} factory; holds two internal RestClients (buffered + streaming). Rationale + * for the static factory, the two-client split, and retry/CB decoration order is in the module + * README. + */ +public final class OutboundHttpClient { + + private final String dependencyName; + private final OutboundHttpSettings settings; + private final OutboundHttpShutdownGuard shutdownGuard; + private final OutboundHttpResilience resilience; + private final OutboundRetryPolicy retryPolicy; + private final OutboundHttpCallObserver observer; + + private final RestClient bufferedClient; + private final RestClient streamingClient; + + private OutboundHttpClient( + String dependencyName, + String baseUrl, + OutboundHttpSettings settings, + OutboundHttpShutdownGuard shutdownGuard, + OutboundHttpResilience resilience, + OutboundRetryPolicy retryPolicy, + OutboundHttpErrorMapper errorMapper, + OutboundHttpDependencyLogger logger) { + this.dependencyName = dependencyName; + this.settings = settings; + this.shutdownGuard = shutdownGuard; + this.resilience = resilience; + this.retryPolicy = retryPolicy; + this.observer = new OutboundHttpCallObserver(dependencyName, errorMapper, logger); + + var clients = OutboundHttpRestClientFactory.create(dependencyName, baseUrl, settings); + this.bufferedClient = clients.buffered(); + this.streamingClient = clients.streaming(); + } + + /** + * Baseline client factory. Why it is static (B7) and the template-seam usage are in the module + * README — forking projects call this per dependency from their own {@code @Configuration}. + */ + public static OutboundHttpClient baseline( + String dependencyName, + String baseUrl, + OutboundHttpSettings settings, + OutboundHttpShutdownGuard shutdownGuard, + OutboundHttpResilience resilience, + OutboundRetryPolicy retryPolicy, + OutboundHttpErrorMapper errorMapper, + OutboundHttpDependencyLogger logger) { + return new OutboundHttpClient( + dependencyName, + baseUrl, + settings, + shutdownGuard, + resilience, + retryPolicy, + errorMapper, + logger); + } + + /** Shortcut: GET with buffered deserialization. */ + public <T> T get(String uri, Class<T> responseType) { + return exchange(HttpMethod.GET, uri, null, responseType); + } + + /** + * If the response exceeds the size limit, {@link OutboundResponseSizeExceededException} + * propagates unclassified (usage-contract violation — large responses must use {@link #stream}). + */ + public <T> T exchange(HttpMethod method, String uri, Object requestBody, Class<T> responseType) { + if (shutdownGuard.isShuttingDown()) { + throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast (D8)"); + } + + Instant deadline = Instant.now().plus(settings.globalCallTimeout()); + retryPolicy.beginCall(method, deadline); + + // Track attempt count for logging — declared outside try so catch can read it. + int[] attemptCount = {0}; + long startNs = System.nanoTime(); + try { + Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType); + + // Retry OUTSIDE the CB so each attempt is independently CB-counted + // (inside the CB, all retries would count as a single CB call). + Optional<CircuitBreaker> cb = resilience.circuitBreakerFor(dependencyName); + Optional<Retry> retry = resilience.retryFor(dependencyName); + + Supplier<T> countingSupplier = + () -> { + attemptCount[0]++; + return supplier.get(); + }; + + Supplier<T> decorated = countingSupplier; + if (retry.isPresent()) { + decorated = Retry.decorateSupplier(retry.get(), decorated); + } + if (cb.isPresent()) { + decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated); + } + + T result = decorated.get(); + + // retryAttempt = attemptCount - 1 (0 means the first attempt succeeded). + observer.recordSuccess(startNs, Math.max(0, attemptCount[0] - 1)); + return result; + + } catch (OutboundResponseSizeExceededException sizeEx) { + // Size violation — propagate unclassified (usage-contract violation, not an upstream + // failure). + throw sizeEx; + + } catch (Throwable t) { + throw observer.recordFailure(t, startNs, Math.max(0, attemptCount[0] - 1)); + + } finally { + retryPolicy.endCall(); + } + } + + /** + * Streaming API for large responses. No retry — a consumed stream cannot be safely re-issued + * (delivered bytes are lost and the server may not support resending). + */ + public <T> T stream(HttpMethod method, String uri, Function<InputStream, T> reader) { + if (shutdownGuard.isShuttingDown()) { + throw observer.rejectShutdown( + "shutdown in progress — outbound stream call rejected fail-fast (D8)"); + } + + long startNs = System.nanoTime(); + try { + T result = + streamingClient + .method(method) + .uri(uri) + .exchange((req, res) -> reader.apply(res.getBody())); + + observer.recordSuccess(startNs, 0); + return result; + + } catch (Throwable t) { + throw observer.recordFailure(t, startNs, 0); + } + } + + private <T> Supplier<T> buildSupplier( + HttpMethod method, String uri, Object requestBody, Class<T> responseType) { + return () -> { + var spec = bufferedClient.method(method).uri(uri); + if (requestBody != null) { + spec = spec.body(requestBody); + } + // Default RestClient status handling throws on 4xx/5xx; the error mapper classifies it. + return spec.retrieve().body(responseType); + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java new file mode 100644 index 00000000..cabdaf22 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientConfig.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers the common outbound HTTP infrastructure beans. No default {@link OutboundHttpClient} + * bean — forking projects call {@link OutboundHttpClient#baseline} per dependency (module README). + * {@code @ConditionalOnMissingBean} on each bean lets a fork substitute its own implementation. + */ +@Configuration +@EnableConfigurationProperties(OutboundHttpSettings.class) +public class OutboundHttpClientConfig { + + @Bean + @ConditionalOnMissingBean + public OutboundHttpShutdownGuard outboundHttpShutdownGuard() { + return new OutboundHttpShutdownGuard(); + } + + /** + * Must be {@code static}: a BeanPostProcessor must be created before other beans to intercept + * their post-init callbacks. A {@code static @Bean} is built directly by the BeanFactory + * infrastructure, bypassing the {@code @Configuration} instance lifecycle, so it is ready early + * enough. + */ + @Bean + @ConditionalOnMissingBean + public static OutboundHttpTimeoutEnforcer outboundHttpTimeoutEnforcer() { + return new OutboundHttpTimeoutEnforcer(); + } + + @Bean + @ConditionalOnMissingBean + public OutboundHttpErrorMapper outboundHttpErrorMapper() { + return new OutboundHttpErrorMapper(); + } + + @Bean + @ConditionalOnMissingBean + public OutboundHttpDependencyLogger outboundHttpDependencyLogger() { + return new OutboundHttpDependencyLogger(); + } + + @Bean + @ConditionalOnMissingBean + public OutboundRetryPolicy outboundRetryPolicy( + OutboundHttpSettings settings, + OutboundHttpShutdownGuard guard, + OutboundHttpErrorMapper mapper) { + return new OutboundRetryPolicy(settings, guard, mapper); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java new file mode 100644 index 00000000..12f38f91 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import java.net.http.HttpClient; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * Package-private factory that builds the shared {@link JdkClientHttpRequestFactory} and the + * buffered/streaming {@link RestClient} pair for a single named dependency. Both share one request + * factory so TCP pooling/settings are identical — only buffered carries the {@link + * ResponseSizeBoundingInterceptor}. + */ +final class OutboundHttpRestClientFactory { + + private OutboundHttpRestClientFactory() {} + + record Clients(RestClient buffered, RestClient streaming) {} + + static Clients create(String dependencyName, String baseUrl, OutboundHttpSettings settings) { + HttpClient httpClient = + HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(settings.readTimeout()); + + // Register TraceContextPropagationInterceptor first so trace headers exist before + // the size interceptor inspects the response. + RestClient buffered = + RestClient.builder() + .requestFactory(requestFactory) + .baseUrl(baseUrl) + .requestInterceptor(new TraceContextPropagationInterceptor()) + .requestInterceptor( + new ResponseSizeBoundingInterceptor( + dependencyName, settings.responseSizeLimit().toBytes())) + .build(); + + RestClient streaming = + RestClient.builder() + .requestFactory(requestFactory) + .baseUrl(baseUrl) + .requestInterceptor(new TraceContextPropagationInterceptor()) + .build(); + + return new Clients(buffered, streaming); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java new file mode 100644 index 00000000..146e3044 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java @@ -0,0 +1,182 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.util.unit.DataSize; + +/** + * Typed settings for the outbound HTTP client baseline, bound from {@code app.outbound.http.*}. The + * compact constructor rejects missing/zero/negative timeouts at binding time (startup failure); the + * rationale is in the module README. + * + * @param connectTimeout TCP connect timeout; must be positive + * @param readTimeout socket read timeout; must be positive + * @param globalCallTimeout end-to-end deadline budget per call including retries; must be positive + * @param retryEnabled whether the Resilience4j retry decorator is active + * @param circuitBreakerEnabled whether the Resilience4j circuit-breaker decorator is active + * @param responseSizeLimit max in-memory response body size; null defaults to 10 MB; zero/negative + * forbidden + * @param retry retry tuning; null applies defaults + * @param circuitBreaker circuit-breaker tuning; null applies defaults + */ +@ConfigurationProperties(prefix = "app.outbound.http") +public record OutboundHttpSettings( + Duration connectTimeout, + Duration readTimeout, + Duration globalCallTimeout, + boolean retryEnabled, + boolean circuitBreakerEnabled, + DataSize responseSizeLimit, + Retry retry, + CircuitBreaker circuitBreaker) { + + /** Registry default for {@code APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT}. */ + private static final DataSize DEFAULT_RESPONSE_SIZE_LIMIT = DataSize.ofMegabytes(10); + + private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3; + private static final Duration DEFAULT_RETRY_INITIAL_BACKOFF = Duration.ofMillis(100); + private static final double DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0; + + private static final float DEFAULT_CB_FAILURE_RATE_THRESHOLD = 50f; + private static final int DEFAULT_CB_SLIDING_WINDOW_SIZE = 100; + private static final int DEFAULT_CB_MINIMUM_NUMBER_OF_CALLS = 100; + private static final Duration DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE = Duration.ofSeconds(60); + private static final int DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN = 10; + + @ConstructorBinding + public OutboundHttpSettings { + if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative()) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_CONNECT_TIMEOUT (app.outbound.http.connect-timeout) must be a " + + "positive duration (spring_duration_shorthand_non_zero, D5)"); + } + if (readTimeout == null || readTimeout.isZero() || readTimeout.isNegative()) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_READ_TIMEOUT (app.outbound.http.read-timeout) must be a " + + "positive duration (spring_duration_shorthand_non_zero, D5)"); + } + if (globalCallTimeout == null || globalCallTimeout.isZero() || globalCallTimeout.isNegative()) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT (app.outbound.http.global-call-timeout) must be a " + + "positive duration (spring_duration_shorthand_non_zero, D5)"); + } + if (responseSizeLimit == null) { + responseSizeLimit = DEFAULT_RESPONSE_SIZE_LIMIT; + } else if (responseSizeLimit.toBytes() <= 0) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT (app.outbound.http.response-size-limit) " + + "must be a positive DataSize (registry default 10MB)"); + } + // Unset nested sections (absent yml / secondary ctor) → substitute defaults-filled records. + if (retry == null) { + retry = new Retry(null, null, null); + } + if (circuitBreaker == null) { + circuitBreaker = new CircuitBreaker(null, null, null, null, null); + } + } + + /** + * Secondary constructor: defaults for resilience tuning; preserves the original 6-arg call sites. + */ + public OutboundHttpSettings( + Duration connectTimeout, + Duration readTimeout, + Duration globalCallTimeout, + boolean retryEnabled, + boolean circuitBreakerEnabled, + DataSize responseSizeLimit) { + this( + connectTimeout, + readTimeout, + globalCallTimeout, + retryEnabled, + circuitBreakerEnabled, + responseSizeLimit, + null, + null); + } + + /** + * Resilience4j retry tuning. Null fields fall back to defaults (maxAttempts=3 / + * initialBackoff=100ms / backoffMultiplier=2.0), preserving the prior hardcoded behavior. + */ + public record Retry(Integer maxAttempts, Duration initialBackoff, Double backoffMultiplier) { + public Retry { + if (maxAttempts == null) { + maxAttempts = DEFAULT_RETRY_MAX_ATTEMPTS; + } else if (maxAttempts < 1) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS (app.outbound.http.retry.max-attempts) " + + "must be >= 1 (positive_int)"); + } + if (initialBackoff == null) { + initialBackoff = DEFAULT_RETRY_INITIAL_BACKOFF; + } else if (initialBackoff.isZero() || initialBackoff.isNegative()) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF (app.outbound.http.retry.initial-backoff) " + + "must be a positive duration (spring_duration_shorthand_non_zero)"); + } + if (backoffMultiplier == null) { + backoffMultiplier = DEFAULT_RETRY_BACKOFF_MULTIPLIER; + } else if (backoffMultiplier < 1.0) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER (app.outbound.http.retry.backoff-multiplier) " + + "must be >= 1.0 (double_ge_1)"); + } + } + } + + /** + * Resilience4j circuit-breaker tuning. Null fields fall back to Resilience4j {@code ofDefaults()} + * (failureRate=50 / slidingWindow=100 / minCalls=100 / waitOpen=60s / permittedHalfOpen=10). + * slidingWindowType is not exposed and stays the library default (COUNT_BASED). + */ + public record CircuitBreaker( + Float failureRateThreshold, + Integer slidingWindowSize, + Integer minimumNumberOfCalls, + Duration waitDurationInOpenState, + Integer permittedCallsInHalfOpen) { + public CircuitBreaker { + if (failureRateThreshold == null) { + failureRateThreshold = DEFAULT_CB_FAILURE_RATE_THRESHOLD; + } else if (failureRateThreshold <= 0f || failureRateThreshold > 100f) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD " + + "(app.outbound.http.circuit-breaker.failure-rate-threshold) " + + "must be in (0, 100] (float_in_0_exclusive_to_100)"); + } + if (slidingWindowSize == null) { + slidingWindowSize = DEFAULT_CB_SLIDING_WINDOW_SIZE; + } else if (slidingWindowSize < 1) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE " + + "(app.outbound.http.circuit-breaker.sliding-window-size) must be >= 1 (positive_int)"); + } + if (minimumNumberOfCalls == null) { + minimumNumberOfCalls = DEFAULT_CB_MINIMUM_NUMBER_OF_CALLS; + } else if (minimumNumberOfCalls < 1) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS " + + "(app.outbound.http.circuit-breaker.minimum-number-of-calls) must be >= 1 (positive_int)"); + } + if (waitDurationInOpenState == null) { + waitDurationInOpenState = DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE; + } else if (waitDurationInOpenState.isZero() || waitDurationInOpenState.isNegative()) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE " + + "(app.outbound.http.circuit-breaker.wait-duration-in-open-state) " + + "must be a positive duration (spring_duration_shorthand_non_zero)"); + } + if (permittedCallsInHalfOpen == null) { + permittedCallsInHalfOpen = DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN; + } else if (permittedCallsInHalfOpen < 1) { + throw new IllegalArgumentException( + "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN " + + "(app.outbound.http.circuit-breaker.permitted-calls-in-half-open) must be >= 1 (positive_int)"); + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java new file mode 100644 index 00000000..d1e8425c --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuard.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.springframework.context.SmartLifecycle; + +/** + * Outbound HTTP client shutdown guard. With {@code getPhase() = }{@link Integer#MAX_VALUE} it is + * stopped first during shutdown, setting the {@link #isShuttingDown()} flag ahead of any outbound + * caller. Why SmartLifecycle instead of {@code ContextClosedEvent}: see the module README. + */ +public final class OutboundHttpShutdownGuard implements SmartLifecycle { + + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + + @Override + public void start() { + running.set(true); + } + + @Override + public void stop() { + shuttingDown.set(true); + running.set(false); + } + + @Override + public boolean isRunning() { + return running.get(); + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public int getPhase() { + return Integer.MAX_VALUE; + } + + /** + * True after {@link #stop()} — callers short-circuit on this flag instead of waiting for + * timeouts. + */ + public boolean isShuttingDown() { + return shuttingDown.get(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpTimeoutEnforcer.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpTimeoutEnforcer.java new file mode 100644 index 00000000..a594bb25 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpTimeoutEnforcer.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.web.client.RestClient; + +/** + * {@link BeanPostProcessor} that blocks raw, timeout-less {@link RestClient} / {@link + * RestClient.Builder} beans at startup. On detection it fails context startup, pointing to {@code + * OutboundHttpClient.baseline(...)} and the required env keys. It cannot catch a non-bean inline + * {@code RestClient.create()} — code review and the import-gate (G4) defend that case. + */ +public final class OutboundHttpTimeoutEnforcer implements BeanPostProcessor { + + private static final String ERROR_MESSAGE = + "A raw RestClient or RestClient.Builder bean was detected. " + + "All outbound HTTP clients must be built via OutboundHttpClient.baseline(...) " + + "so that connect, read, and global-call timeouts are applied. " + + "Set the required env keys: " + + "APP_OUTBOUND_HTTP_CONNECT_TIMEOUT, " + + "APP_OUTBOUND_HTTP_READ_TIMEOUT, " + + "APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT " + + "(feature-outbound-http-client-baseline I2 — timeout | Forbidden row)."; + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof RestClient || bean instanceof RestClient.Builder) { + throw new BeanCreationException(beanName, ERROR_MESSAGE); + } + return bean; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundResponseSizeExceededException.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundResponseSizeExceededException.java new file mode 100644 index 00000000..0067e67d --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundResponseSizeExceededException.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +/** + * Thrown on the BUFFERED client path when a response body exceeds {@link + * OutboundHttpSettings#responseSizeLimit()}. Callers expecting large responses must use {@link + * OutboundHttpClient#stream}. + * + * <p>Intentionally NOT a {@link dev.caskeleton.shared.error.DependencyFailureException} — it is a + * usage-contract violation (wrong API path), not an upstream failure, so it propagates + * unclassified. + */ +public final class OutboundResponseSizeExceededException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String dependencyName; + private final long limitBytes; + + public OutboundResponseSizeExceededException(String dependencyName, long limitBytes) { + super( + "Response from dependency '" + + dependencyName + + "' exceeds the configured limit of " + + limitBytes + + " bytes. Use the streaming API (OutboundHttpClient#stream) " + + "for responses larger than the limit (D7: responses above the limit must be " + + "streamed; buffered in-memory load above the limit is forbidden)."); + this.dependencyName = dependencyName; + this.limitBytes = limitBytes; + } + + /** The upstream dependency name that triggered the size violation. */ + public String dependencyName() { + return dependencyName; + } + + /** The configured limit in bytes that was exceeded. */ + public long limitBytes() { + return limitBytes; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java new file mode 100644 index 00000000..ea6bc40a --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.shared.error.DependencyFailureException; +import java.time.Instant; +import java.util.Set; +import org.springframework.http.HttpMethod; + +/** + * Per-call retry gate for the outbound HTTP client. {@link #shouldRetry(Throwable)} retries only + * when all four conditions hold (not shutting down, idempotent method, retryable classification, + * deadline budget remaining). The call context lives in a ThreadLocal; pair {@link + * #beginCall}/{@link #endCall} in try/finally. + */ +public final class OutboundRetryPolicy { + + /** + * Only RFC 9110 idempotent methods are retried. POST/PATCH are always excluded (no + * Idempotency-Key contract). + */ + private static final Set<HttpMethod> IDEMPOTENT_METHODS = + Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE); + + private final OutboundHttpShutdownGuard guard; + private final OutboundHttpErrorMapper mapper; + + private final ThreadLocal<CallContext> callContextHolder = new ThreadLocal<>(); + + public OutboundRetryPolicy( + OutboundHttpSettings settings, + OutboundHttpShutdownGuard guard, + OutboundHttpErrorMapper mapper) { + // settings kept in the signature for future policy extension — the gate uses guard/mapper only. + this.guard = guard; + this.mapper = mapper; + } + + /** Must be paired with {@link #endCall()} in try/finally. */ + public void beginCall(HttpMethod method, Instant deadline) { + callContextHolder.set(new CallContext(method, deadline)); + } + + public void endCall() { + callContextHolder.remove(); + } + + public boolean shouldRetry(Throwable failure) { + if (guard.isShuttingDown()) { + return false; + } + + CallContext ctx = callContextHolder.get(); + if (ctx == null) { + return false; + } + if (!IDEMPOTENT_METHODS.contains(ctx.method())) { + // POST/PATCH are always false — Idempotency-Key contract undefined. + return false; + } + + boolean retryable; + if (failure instanceof DependencyFailureException dfe) { + // Already classified — use the embedded code to avoid double-classification. + retryable = dfe.errorCode().retryable(); + } else { + retryable = mapper.classify("_retry-check_", failure).errorCode().retryable(); + } + if (!retryable) { + return false; + } + + return Instant.now().isBefore(ctx.deadline()); + } + + private record CallContext(HttpMethod method, Instant deadline) {} +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/ResponseSizeBoundingInterceptor.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/ResponseSizeBoundingInterceptor.java new file mode 100644 index 00000000..814369ce --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/ResponseSizeBoundingInterceptor.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import java.io.IOException; +import java.io.InputStream; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +/** + * Interceptor for the BUFFERED RestClient path that enforces {@link + * OutboundHttpSettings#responseSizeLimit()}. Rejects immediately when Content-Length exceeds the + * limit; since that header may be absent or wrong, it also wraps the body in a counting {@link + * BoundedInputStream} that throws once the limit is crossed. + */ +final class ResponseSizeBoundingInterceptor implements ClientHttpRequestInterceptor { + + private final String dependencyName; + private final long limitBytes; + + ResponseSizeBoundingInterceptor(String dependencyName, long limitBytes) { + this.dependencyName = dependencyName; + this.limitBytes = limitBytes; + } + + @Override + public ClientHttpResponse intercept( + HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + ClientHttpResponse response = execution.execute(request, body); + + // Level 1: Content-Length fast-path — refuse immediately, no body bytes consumed. + long contentLength = response.getHeaders().getContentLength(); + if (contentLength > limitBytes) { + response.close(); + throw new OutboundResponseSizeExceededException(dependencyName, limitBytes); + } + + // Level 2: wrap the body stream with a counting InputStream that throws once + // the limit is crossed (handles missing or lying Content-Length). + return new SizeCapClientHttpResponse(response, dependencyName, limitBytes); + } + + private static final class SizeCapClientHttpResponse implements ClientHttpResponse { + + private final ClientHttpResponse delegate; + private final String dependencyName; + private final long limitBytes; + private InputStream boundedBody; + + SizeCapClientHttpResponse(ClientHttpResponse delegate, String dependencyName, long limitBytes) { + this.delegate = delegate; + this.dependencyName = dependencyName; + this.limitBytes = limitBytes; + } + + @Override + public InputStream getBody() throws IOException { + if (boundedBody == null) { + boundedBody = new BoundedInputStream(delegate.getBody(), dependencyName, limitBytes); + } + return boundedBody; + } + + @Override + public HttpStatusCode getStatusCode() throws IOException { + return delegate.getStatusCode(); + } + + @Override + public String getStatusText() throws IOException { + return delegate.getStatusText(); + } + + @Override + public HttpHeaders getHeaders() { + return delegate.getHeaders(); + } + + @Override + public void close() { + delegate.close(); + } + } + + static final class BoundedInputStream extends InputStream { + + private final InputStream delegate; + private final String dependencyName; + private final long limitBytes; + private long bytesRead = 0; + + BoundedInputStream(InputStream delegate, String dependencyName, long limitBytes) { + this.delegate = delegate; + this.dependencyName = dependencyName; + this.limitBytes = limitBytes; + } + + @Override + public int read() throws IOException { + int b = delegate.read(); + if (b != -1) { + checkLimit(1); + } + return b; + } + + @Override + public int read(byte[] buf, int off, int len) throws IOException { + int n = delegate.read(buf, off, len); + if (n > 0) { + checkLimit(n); + } + return n; + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + private void checkLimit(int n) { + bytesRead += n; + if (bytesRead > limitBytes) { + throw new OutboundResponseSizeExceededException(dependencyName, limitBytes); + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/TraceContextPropagationInterceptor.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/TraceContextPropagationInterceptor.java new file mode 100644 index 00000000..7280501e --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/TraceContextPropagationInterceptor.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import dev.caskeleton.shared.tracing.BaggageAllowlist; +import dev.caskeleton.shared.tracing.TraceParent; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.MDC; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +/** + * {@link ClientHttpRequestInterceptor} that propagates the distributed trace context from the + * current SLF4J {@link MDC} into outbound HTTP headers ({@code traceparent}, {@code X-Request-Id}, + * {@code X-Correlation-Id}, {@code baggage}). Baggage is filtered by {@link BaggageAllowlist} so + * only {@code request_id}/{@code tenant_id} leave and credentials/PII are stripped (D8). + * Already-set headers are not overwritten, and the downstream call always runs. + * + * <p><strong>⚠ FORK LANDMINE:</strong> the sampled flag is hardcoded {@code 00} (not-sampled) and + * this interceptor is registered FIRST on the RestClient — a fork that wires a real tracer + * (Micrometer Tracing + OTel) will have its {@code traceparent} overwritten and its sampling + * decision dropped. A fork MUST disable this interceptor OR replace the hardcoded flag. See the + * module README (FORK LANDMINE section) for the full mechanism and checklist. + */ +public final class TraceContextPropagationInterceptor implements ClientHttpRequestInterceptor { + + // snake_case MDC keys — SSOT: docs/registries/mdc-keys.yaml (foundation D11) + private static final String MDC_TRACE_ID = "trace_id"; + private static final String MDC_SPAN_ID = "span_id"; + private static final String MDC_REQUEST_ID = "request_id"; + private static final String MDC_CORRELATION_ID = "correlation_id"; + private static final String MDC_TENANT_ID = "tenant_id"; + + // Outbound header names (W3C / docs/registries/headers.yaml) + private static final String HEADER_TRACEPARENT = "traceparent"; + private static final String HEADER_REQUEST_ID = "X-Request-Id"; + private static final String HEADER_CORRELATION_ID = "X-Correlation-Id"; + private static final String HEADER_BAGGAGE = "baggage"; + + // Baggage MDC keys to collect (must all be allowlisted — D8 defense in depth) + private static final List<String> BAGGAGE_MDC_KEYS = List.of(MDC_REQUEST_ID, MDC_TENANT_ID); + + @Override + public ClientHttpResponse intercept( + HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + injectHeaders(request); + return execution.execute(request, body); + } + + private void injectHeaders(HttpRequest request) { + injectTraceparent(request); + injectSingleHeader(request, HEADER_REQUEST_ID, MDC.get(MDC_REQUEST_ID)); + injectSingleHeader(request, HEADER_CORRELATION_ID, MDC.get(MDC_CORRELATION_ID)); + injectBaggage(request); + } + + private void injectTraceparent(HttpRequest request) { + if (request.getHeaders().containsHeader(HEADER_TRACEPARENT)) { + return; // already set — do not overwrite + } + String traceId = MDC.get(MDC_TRACE_ID); + String spanId = MDC.get(MDC_SPAN_ID); + if (!TraceParent.isValidTraceId(traceId) || !TraceParent.isValidSpanId(spanId)) { + return; // malformed or absent — skip silently + } + try { + // sampled=false: the skeleton has no exporter/sampler and mdc-keys.yaml has no + // trace-flags key, so the inbound sampled bit cannot be propagated (see FORK LANDMINE). + TraceParent tp = TraceParent.of(traceId, spanId, false); + request.getHeaders().set(HEADER_TRACEPARENT, tp.toHeader()); + } catch (IllegalArgumentException ignored) { + // The guards above should prevent this; if reached (e.g. concurrent MDC mutation), skip + // instead of throwing. + } + } + + private void injectBaggage(HttpRequest request) { + if (request.getHeaders().containsHeader(HEADER_BAGGAGE)) { + return; // already set — do not overwrite + } + Map<String, String> raw = new LinkedHashMap<>(); + for (String key : BAGGAGE_MDC_KEYS) { + String value = MDC.get(key); + if (value != null && !value.isBlank()) { + raw.put(key, value); + } + } + // Defense in depth: even if BAGGAGE_MDC_KEYS drifts, the filter removes forbidden keys. + Map<String, String> safe = BaggageAllowlist.filter(raw); + String rendered = BaggageAllowlist.renderHeader(safe); + if (!rendered.isEmpty()) { + request.getHeaders().set(HEADER_BAGGAGE, rendered); + } + } + + private static void injectSingleHeader(HttpRequest request, String headerName, String mdcValue) { + if (mdcValue == null || mdcValue.isBlank()) { + return; + } + if (request.getHeaders().containsHeader(headerName)) { + return; // do not overwrite existing header + } + request.getHeaders().set(headerName, mdcValue); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpDependencyLogger.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpDependencyLogger.java new file mode 100644 index 00000000..d0903c14 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpDependencyLogger.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.httpclient.diagnostics; + +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Structured log emitter for the outbound HTTP path. Field names follow the registry + * log_field_mapping SSOT. It accepts no body/URI/payload, so PII cannot reach the log (D13). Log + * levels: success=DEBUG, outcome CIRCUIT_OPEN/REJECTED=WARN (expected transient state), other + * failures=ERROR. + */ +public final class OutboundHttpDependencyLogger { + + private final Logger log; + + public OutboundHttpDependencyLogger() { + this(LoggerFactory.getLogger(OutboundHttpDependencyLogger.class)); + } + + /** Test seam — inject a logger bound to a captured appender. */ + public OutboundHttpDependencyLogger(Logger log) { + this.log = log; + } + + public void logSuccess(String dependencyName, long durationMs, int retryAttempt) { + log.debug( + "dependency_name=\"{}\" dependency_type=\"http\" outcome=\"SUCCESS\" " + + "duration_ms={} retry_attempt={} correlation_id=\"{}\"", + dependencyName, + durationMs, + retryAttempt, + OutboundCorrelation.current()); + } + + public void logFailure( + String dependencyName, String outcome, long durationMs, int retryAttempt, Throwable cause) { + String errorField = cause.getClass().getSimpleName() + ": " + cause.getMessage(); + String format = + "dependency_name=\"{}\" dependency_type=\"http\" " + + "outcome=\"{}\" " + + "duration_ms={} " + + "retry_attempt={} " + + "correlation_id=\"{}\" " + + "error=\"{}\""; + + if ("CIRCUIT_OPEN".equals(outcome) || "REJECTED".equals(outcome)) { + log.warn( + format, + dependencyName, + outcome, + durationMs, + retryAttempt, + OutboundCorrelation.current(), + errorField); + } else { + log.error( + format, + dependencyName, + outcome, + durationMs, + retryAttempt, + OutboundCorrelation.current(), + errorField); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java new file mode 100644 index 00000000..02a3b26f --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapper.java @@ -0,0 +1,164 @@ +package dev.caskeleton.adapter.outbound.httpclient.diagnostics; + +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpTimeoutException; +import java.nio.channels.UnresolvedAddressException; +import java.util.concurrent.TimeoutException; +import org.springframework.web.client.RestClientResponseException; + +/** + * Classifies raw network/HTTP exceptions into stable {@link OperationalError}{@code .DEPENDENCY_*} + * codes. Walks the full cause chain once and takes the first match in priority order. Why the + * ordering matters (type hierarchy) and the 4xx/408/429 policy are in the module README. + * + * <p>Body-leakage safety (D12): the returned diagnostic message never includes the response body, + * headers, or payload — only the HTTP status and exception class names. The original throwable is + * attached as the cause only. + */ +public final class OutboundHttpErrorMapper { + + /** Returns the classified exception — never {@code null}; always rethrow or propagate. */ + public DependencyFailureException classify(String dependencyName, Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof CallNotPermittedException) { + return new DependencyFailureException( + OperationalError.DEPENDENCY_CIRCUIT_OPEN, + dependencyName, + "Circuit breaker open for dependency: " + + dependencyName + + " (" + + current.getClass().getSimpleName() + + ")", + failure); + } + if (current instanceof UnknownHostException + || current instanceof UnresolvedAddressException) { + return new DependencyFailureException( + OperationalError.DEPENDENCY_DNS_FAILED, + dependencyName, + "DNS resolution failed for dependency: " + + dependencyName + + " (" + + current.getClass().getSimpleName() + + ")", + failure); + } + // HttpConnectTimeoutException extends HttpTimeoutException — check before the read-timeout + // rule. + if (current instanceof HttpConnectTimeoutException) { + return new DependencyFailureException( + OperationalError.DEPENDENCY_CONNECT_FAILED, + dependencyName, + "Connect failed for dependency: " + + dependencyName + + " (" + + current.getClass().getSimpleName() + + ")", + failure); + } + if (current instanceof ConnectException) { + // A ConnectException may wrap an UnresolvedAddressException, so DNS takes priority. + if (hasDnsCauseInChain(current.getCause())) { + return new DependencyFailureException( + OperationalError.DEPENDENCY_DNS_FAILED, + dependencyName, + "DNS resolution failed for dependency: " + + dependencyName + + " (wrapped in ConnectException; root cause " + + rootCauseClassName(current) + + ")", + failure); + } + return new DependencyFailureException( + OperationalError.DEPENDENCY_CONNECT_FAILED, + dependencyName, + "Connect failed for dependency: " + + dependencyName + + " (" + + current.getClass().getSimpleName() + + ")", + failure); + } + if (current instanceof HttpTimeoutException + || current instanceof SocketTimeoutException + || current instanceof TimeoutException) { + return new DependencyFailureException( + OperationalError.DEPENDENCY_TIMEOUT, + dependencyName, + "Timeout for dependency: " + + dependencyName + + " (" + + current.getClass().getSimpleName() + + ")", + failure); + } + if (current instanceof RestClientResponseException responseEx) { + int status = responseEx.getStatusCode().value(); + if (status >= 400 && status < 500) { + String diagnostic = build4xxDiagnostic(dependencyName, status); + return new DependencyFailureException( + OperationalError.DEPENDENCY_4XX_CLIENT, dependencyName, diagnostic, failure); + } + if (status >= 500) { + // Diagnostic message must NOT include getResponseBodyAsString() (D12). + return new DependencyFailureException( + OperationalError.DEPENDENCY_5XX_SERVER, + dependencyName, + "Upstream 5xx from dependency: " + + dependencyName + + " status=" + + status + + " (" + + current.getClass().getSimpleName() + + ")", + failure); + } + } + current = current.getCause(); + } + + // No recognised cause in the chain — conservative fallback. + String rootCauseClass = rootCauseClassName(failure); + return new DependencyFailureException( + OperationalError.DEPENDENCY_CONNECT_FAILED, + dependencyName, + "Unclassified failure for dependency: " + dependencyName + " root-cause=" + rootCauseClass, + failure); + } + + private static String build4xxDiagnostic(String dependencyName, int status) { + String base = "Upstream 4xx from dependency: " + dependencyName + " status=" + status; + return switch (status) { + case 401 -> base + " — check credential / auth-token configuration"; + case 403 -> base + " — check scope/config for dependency access"; + default -> base; + }; + } + + private static boolean hasDnsCauseInChain(Throwable t) { + Throwable current = t; + while (current != null) { + if (current instanceof UnknownHostException + || current instanceof UnresolvedAddressException) { + return true; + } + current = current.getCause(); + } + return false; + } + + private static String rootCauseClassName(Throwable t) { + Throwable root = t; + while (root.getCause() != null) { + root = root.getCause(); + } + return root.getClass().getSimpleName(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilience.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilience.java new file mode 100644 index 00000000..40ed8440 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilience.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings; +import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import java.util.Optional; + +/** + * Holder for the optional Resilience4j retry / circuit-breaker machinery. Returns {@link + * Optional#empty()} (no decoration) when the corresponding feature is disabled. + */ +public final class OutboundHttpResilience { + + private final OutboundHttpSettings settings; + private final OutboundRetryPolicy retryPolicy; + private final RetryRegistry retryRegistry; + private final CircuitBreakerRegistry circuitBreakerRegistry; + + /** Registry args are nullable — pass {@code null} when the feature is disabled. */ + public OutboundHttpResilience( + OutboundHttpSettings settings, + OutboundRetryPolicy retryPolicy, + RetryRegistry retryRegistry, + CircuitBreakerRegistry circuitBreakerRegistry) { + this.settings = settings; + this.retryPolicy = retryPolicy; + this.retryRegistry = retryRegistry; + this.circuitBreakerRegistry = circuitBreakerRegistry; + } + + /** Returns {@link Optional#empty()} when retry is disabled. */ + public Optional<Retry> retryFor(String dependencyName) { + if (!settings.retryEnabled() || retryRegistry == null) { + return Optional.empty(); + } + OutboundHttpSettings.Retry r = settings.retry(); + RetryConfig config = + RetryConfig.custom() + .maxAttempts(r.maxAttempts()) + .intervalFunction( + IntervalFunction.ofExponentialRandomBackoff( + r.initialBackoff(), r.backoffMultiplier())) + .retryOnException(retryPolicy::shouldRetry) + .build(); + return Optional.of(retryRegistry.retry(dependencyName, config)); + } + + /** Returns {@link Optional#empty()} when the circuit breaker is disabled. */ + public Optional<CircuitBreaker> circuitBreakerFor(String dependencyName) { + if (!settings.circuitBreakerEnabled() || circuitBreakerRegistry == null) { + return Optional.empty(); + } + OutboundHttpSettings.CircuitBreaker c = settings.circuitBreaker(); + CircuitBreakerConfig config = + CircuitBreakerConfig.custom() + .failureRateThreshold(c.failureRateThreshold()) + .slidingWindowSize(c.slidingWindowSize()) + .minimumNumberOfCalls(c.minimumNumberOfCalls()) + .waitDurationInOpenState(c.waitDurationInOpenState()) + .permittedNumberOfCallsInHalfOpenState(c.permittedCallsInHalfOpen()) + .build(); + return Optional.of(circuitBreakerRegistry.circuitBreaker(dependencyName, config)); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java new file mode 100644 index 00000000..aeba57ca --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfig.java @@ -0,0 +1,174 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings; +import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.micrometer.tagged.TaggedCircuitBreakerMetrics; +import io.github.resilience4j.micrometer.tagged.TaggedRetryMetrics; +import io.github.resilience4j.retry.RetryRegistry; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.config.MeterFilterReply; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Builds the {@link OutboundHttpResilience} bean and normalises the resilience4j metrics. + * + * <p>Micrometer 1.15.x incompatibility: {@code MeterFilter.replaceTagValues}/{@code renameTag} do + * not transform the tags of the {@code FunctionCounter}/{@code DefaultGauge} instances registered + * by {@code TaggedRetryMetrics}/{@code TaggedCircuitBreakerMetrics} — an explicit {@code + * map(Meter.Id)} implementation is required (verified on Micrometer 1.15.11 / Resilience4j 2.2.0). + * Full normalisation / DENY-policy rationale is in the module README. + */ +@Configuration +public class OutboundHttpResilienceConfig { + + private static final String RETRY_CALLS_METER = "resilience4j.retry.calls"; + private static final String CB_CALLS_METER = "resilience4j.circuitbreaker.calls"; + private static final String CB_STATE_METER = "resilience4j.circuitbreaker.state"; + private static final String RESILIENCE4J_PREFIX = "resilience4j."; + + @Bean + public OutboundHttpResilience outboundHttpResilience( + OutboundHttpSettings settings, + OutboundRetryPolicy retryPolicy, + ObjectProvider<MeterRegistry> meterRegistryProvider) { + + boolean resilienceEnabled = settings.retryEnabled() || settings.circuitBreakerEnabled(); + MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable(); + + // D3: a MeterRegistry is required when retry/CB is enabled — resilience without metrics is + // forbidden. + if (resilienceEnabled && meterRegistry == null) { + throw new IllegalStateException( + "APP_OUTBOUND_HTTP_RETRY_ENABLED/CIRCUIT_BREAKER_ENABLED=true requires a " + + "MeterRegistry — retry/circuit breaker without low-cardinality metrics is " + + "forbidden (feature-outbound-http-client-baseline D3)"); + } + + RetryRegistry retryRegistry = null; + CircuitBreakerRegistry cbRegistry = null; + + if (resilienceEnabled) { + // Filters affect only meters registered AFTER install. CB state gauges are + // registered eagerly at bindTo(), so filters must be installed before bindTo() + // or the transform is silently skipped for them. + applyMeterFilters(meterRegistry); + + if (settings.retryEnabled()) { + retryRegistry = RetryRegistry.ofDefaults(); + TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry); + } + if (settings.circuitBreakerEnabled()) { + cbRegistry = CircuitBreakerRegistry.ofDefaults(); + TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(cbRegistry).bindTo(meterRegistry); + } + } + + return new OutboundHttpResilience(settings, retryPolicy, retryRegistry, cbRegistry); + } + + private static void applyMeterFilters(MeterRegistry meterRegistry) { + // Rename "kind" → "outcome" and remap vendor values (retry.calls). + meterRegistry + .config() + .meterFilter( + remapKindToOutcome( + RETRY_CALLS_METER, + kind -> + switch (kind) { + case "successful_without_retry", "successful_with_retry" -> "SUCCESS"; + case "failed_without_retry", "failed_with_retry" -> "FAILURE"; + default -> kind; + })); + + // Rename "kind" → "outcome" (circuitbreaker.calls). + meterRegistry + .config() + .meterFilter( + remapKindToOutcome( + CB_CALLS_METER, + kind -> + switch (kind) { + case "successful" -> "SUCCESS"; + case "failed", "ignored" -> "FAILURE"; + default -> kind; + })); + + // Uppercase the "state" tag values (circuitbreaker.state). + meterRegistry.config().meterFilter(uppercaseStateTag(CB_STATE_METER)); + + // DENY vendor resilience4j.* extras outside the approved three meters (D4 low-cardinality). + meterRegistry + .config() + .meterFilter( + new MeterFilter() { + @Override + public MeterFilterReply accept(Meter.Id id) { + String name = id.getName(); + if (!name.startsWith(RESILIENCE4J_PREFIX)) { + return MeterFilterReply.NEUTRAL; + } + if (name.equals(RETRY_CALLS_METER) + || name.equals(CB_CALLS_METER) + || name.equals(CB_STATE_METER)) { + return MeterFilterReply.NEUTRAL; + } + return MeterFilterReply.DENY; + } + + @Override + public Meter.Id map(Meter.Id id) { + return id; + } + }); + } + + private static MeterFilter remapKindToOutcome( + String meterName, Function<String, String> kindMapper) { + return new MeterFilter() { + @Override + public Meter.Id map(Meter.Id id) { + if (!id.getName().equals(meterName)) { + return id; + } + List<Tag> newTags = new ArrayList<>(); + for (Tag t : id.getTags()) { + if ("kind".equals(t.getKey())) { + newTags.add(Tag.of("outcome", kindMapper.apply(t.getValue()))); + } else { + newTags.add(t); + } + } + return id.replaceTags(newTags); + } + }; + } + + private static MeterFilter uppercaseStateTag(String meterName) { + return new MeterFilter() { + @Override + public Meter.Id map(Meter.Id id) { + if (!id.getName().equals(meterName)) { + return id; + } + List<Tag> newTags = new ArrayList<>(); + for (Tag t : id.getTags()) { + if ("state".equals(t.getKey())) { + newTags.add(Tag.of("state", t.getValue().toUpperCase())); + } else { + newTags.add(t); + } + } + return id.replaceTags(newTags); + } + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicySpec.groovy b/src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicySpec.groovy new file mode 100644 index 00000000..76ff96bb --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicySpec.groovy @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.outbound.httpclient + +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper +import dev.caskeleton.shared.error.DependencyFailureException +import dev.caskeleton.shared.error.OperationalError + +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.util.unit.DataSize +import org.springframework.web.client.HttpClientErrorException + +import spock.lang.Specification + +import java.net.http.HttpTimeoutException +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant + +/** + * {@link OutboundRetryPolicy} 도메인 스펙 — C2 테스트 형태(순수 재시도 결정 로직 = Spock). + * + * <p>feature-outbound-http-client-baseline D6/D8, plan I3/I4: shouldRetry 는 네 조건이 모두 참일 + * 때만 true — (1) 셧다운 아님, (2) 컨텍스트 존재 + 멱등 메서드, (3) 분류가 재시도 가능, (4) 마감 이내.</p> + */ +class OutboundRetryPolicySpec extends Specification { + + OutboundHttpShutdownGuard guard + OutboundRetryPolicy policy + + def setup() { + def settings = new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + true, + false, + DataSize.ofMegabytes(10)) + guard = new OutboundHttpShutdownGuard() + guard.start() + policy = new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper()) + } + + def "멱등 메서드 #method 는 재시도 가능 오류 + 마감 이내면 재시도 여부가 #expected 다"() { + given: + policy.beginCall(method, Instant.now().plusSeconds(30)) + + expect: "POST/PATCH 는 멱등키 계약 미정의(I4)라 항상 false" + policy.shouldRetry(new HttpTimeoutException("read timed out")) == expected + + cleanup: + policy.endCall() + + where: + method || expected + HttpMethod.GET || true + HttpMethod.HEAD || true + HttpMethod.PUT || true + HttpMethod.DELETE || true + HttpMethod.POST || false + HttpMethod.PATCH || false + } + + def "셧다운 중이면 GET 도 재시도를 억제한다 (D8)"() { + given: + guard.stop() + policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30)) + + expect: + !policy.shouldRetry(new HttpTimeoutException("timeout")) + + cleanup: + policy.endCall() + } + + def "비재시도 분류(4xx)는 GET 이라도 재시도하지 않는다"() { + given: + def fourXx = HttpClientErrorException.create(HttpStatus.NOT_FOUND, "Not Found", + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8) + policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30)) + + expect: + !policy.shouldRetry(fourXx) + + cleanup: + policy.endCall() + } + + def "마감을 지난 호출은 재시도 가능 오류라도 재시도하지 않는다 (I3)"() { + given: + policy.beginCall(HttpMethod.GET, Instant.now().minusSeconds(1)) + + expect: + !policy.shouldRetry(new HttpTimeoutException("timeout")) + + cleanup: + policy.endCall() + } + + def "호출 컨텍스트가 없으면 재시도하지 않는다"() { + expect: + !policy.shouldRetry(new HttpTimeoutException("timeout")) + } + + def "endCall 이후에는 컨텍스트가 비어 재시도하지 않는다"() { + given: + policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30)) + policy.endCall() + + expect: + !policy.shouldRetry(new HttpTimeoutException("timeout")) + } + + def "이미 분류된 DependencyFailureException 은 내장 코드(#code)로 재시도 여부를 #expected 로 판단한다"() { + given: + policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30)) + + expect: + policy.shouldRetry(new DependencyFailureException(code, "test-api", "x", null)) == expected + + cleanup: + policy.endCall() + + where: + code || expected + OperationalError.DEPENDENCY_5XX_SERVER || true + OperationalError.DEPENDENCY_4XX_CLIENT || false + } +} diff --git a/src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapperSpec.groovy b/src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapperSpec.groovy new file mode 100644 index 00000000..3d79ac9c --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpErrorMapperSpec.groovy @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.httpclient.diagnostics + +import dev.caskeleton.shared.error.OperationalError + +import io.github.resilience4j.circuitbreaker.CallNotPermittedException +import io.github.resilience4j.circuitbreaker.CircuitBreaker + +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.HttpServerErrorException +import org.springframework.web.client.ResourceAccessException + +import spock.lang.Specification + +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.net.http.HttpConnectTimeoutException +import java.net.http.HttpTimeoutException +import java.nio.channels.UnresolvedAddressException +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeoutException + +/** + * {@link OutboundHttpErrorMapper} 도메인 스펙 — C2 테스트 형태(순수 예외→코드 분류 매트릭스 = Spock). + * + * <p>feature-outbound-http-client-baseline D12, plan I5/I10: 원인 체인을 따라 우선순위 표의 첫 매칭이 + * 이긴다. 상류 응답 바디는 분류 예외 메시지에 노출되지 않는다(D12 누출 방지).</p> + */ +class OutboundHttpErrorMapperSpec extends Specification { + + def mapper = new OutboundHttpErrorMapper() + + def "#desc 는 #expectedCode 로 분류된다"() { + expect: + mapper.classify("dep-api", failure).errorCode() == expectedCode + + where: + desc | failure || expectedCode + "서킷 오픈" | CallNotPermittedException.createCallNotPermittedException(CircuitBreaker.ofDefaults("cb")) || OperationalError.DEPENDENCY_CIRCUIT_OPEN + "알 수 없는 호스트" | new UnknownHostException("h") || OperationalError.DEPENDENCY_DNS_FAILED + "미해결 주소" | new UnresolvedAddressException() || OperationalError.DEPENDENCY_DNS_FAILED + "ResourceAccessException 으로 감싼 호스트 실패" | new ResourceAccessException("I/O error", new UnknownHostException("h")) || OperationalError.DEPENDENCY_DNS_FAILED + "커넥트 타임아웃(read 타임아웃보다 우선)" | new HttpConnectTimeoutException("connect timed out") || OperationalError.DEPENDENCY_CONNECT_FAILED + "커넥트 거부" | new ConnectException("Connection refused") || OperationalError.DEPENDENCY_CONNECT_FAILED + "HTTP read 타임아웃" | new HttpTimeoutException("read timed out") || OperationalError.DEPENDENCY_TIMEOUT + "소켓 타임아웃" | new SocketTimeoutException("Read timed out") || OperationalError.DEPENDENCY_TIMEOUT + "concurrent 타임아웃" | new TimeoutException("deadline exceeded") || OperationalError.DEPENDENCY_TIMEOUT + "HTTP 404" | clientError(HttpStatus.NOT_FOUND) || OperationalError.DEPENDENCY_4XX_CLIENT + "HTTP 408 (registry SSOT: 4xx)" | clientError(HttpStatus.REQUEST_TIMEOUT) || OperationalError.DEPENDENCY_4XX_CLIENT + "HTTP 429 (registry SSOT: 4xx)" | clientError(HttpStatus.TOO_MANY_REQUESTS) || OperationalError.DEPENDENCY_4XX_CLIENT + "HTTP 500" | serverError(HttpStatus.INTERNAL_SERVER_ERROR) || OperationalError.DEPENDENCY_5XX_SERVER + "HTTP 503" | serverError(HttpStatus.SERVICE_UNAVAILABLE) || OperationalError.DEPENDENCY_5XX_SERVER + "미인식 예외(보수적 폴백)" | new RuntimeException("something weird") || OperationalError.DEPENDENCY_CONNECT_FAILED + } + + def "401 은 4xx 로 분류되고 credential 진단을 포함한다"() { + when: + def result = mapper.classify("secure-api", clientError(HttpStatus.UNAUTHORIZED)) + + then: + result.errorCode() == OperationalError.DEPENDENCY_4XX_CLIENT + result.message.contains("credential") + } + + def "403 은 4xx 로 분류되고 scope 진단을 포함한다"() { + when: + def result = mapper.classify("secure-api", clientError(HttpStatus.FORBIDDEN)) + + then: + result.errorCode() == OperationalError.DEPENDENCY_4XX_CLIENT + result.message.contains("scope") + } + + def "폴백 진단은 root cause 클래스명을 포함한다"() { + when: + def result = mapper.classify("unknown-api", new RuntimeException("something weird")) + + then: + result.message.contains("RuntimeException") + } + + def "상류 응답 바디는 분류된 예외 메시지에 노출되지 않는다 (D12)"() { + given: + byte[] secretBody = "UPSTREAM_SECRET".getBytes(StandardCharsets.UTF_8) + def ex = HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", + HttpHeaders.EMPTY, secretBody, StandardCharsets.UTF_8) + + when: + def result = mapper.classify("leaky-api", ex) + + then: + !result.message.contains("UPSTREAM_SECRET") + } + + private static HttpClientErrorException clientError(HttpStatus status) { + HttpClientErrorException.create(status, status.reasonPhrase, + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8) + } + + private static HttpServerErrorException serverError(HttpStatus status) { + HttpServerErrorException.create(status, status.reasonPhrase, + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8) + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java new file mode 100644 index 00000000..4e57ae08 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpCallObserverTest.java @@ -0,0 +1,182 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import java.net.SocketTimeoutException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Unit tests for {@link OutboundHttpCallObserver}. + * + * <p>Verifies: + * + * <ul> + * <li>{@code recordSuccess} emits a DEBUG log line with the correct fields. + * <li>{@code recordFailure} classifies, logs at ERROR/WARN, and returns a {@link + * DependencyFailureException} (so the caller can {@code throw observer.recordFailure(...)}). + * <li>{@code rejectShutdown} builds DEPENDENCY_CIRCUIT_OPEN / REJECTED and returns the dfe. + * <li>{@code outcomeFor} mapping: TIMEOUT → "TIMEOUT", CIRCUIT_OPEN → "CIRCUIT_OPEN", other → + * "FAILURE". + * </ul> + */ +class OutboundHttpCallObserverTest { + + private static final String DEP = "test-dep"; + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> logAppender; + private OutboundHttpCallObserver observer; + + @BeforeEach + void setUp() { + logbackLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.observer"); + logAppender = new ListAppender<>(); + logAppender.start(); + logbackLogger.addAppender(logAppender); + logbackLogger.setLevel(Level.DEBUG); + + OutboundHttpDependencyLogger logger = new OutboundHttpDependencyLogger(logbackLogger); + observer = new OutboundHttpCallObserver(DEP, new OutboundHttpErrorMapper(), logger); + } + + // ========================================================================= + // recordSuccess + // ========================================================================= + + @Test + void recordSuccessEmitsDebugLogWithOutcomeSUCCESS() { + long startNs = System.nanoTime(); + observer.recordSuccess(startNs, 0); + + assertThat(logAppender.list).hasSize(1); + ILoggingEvent event = logAppender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.DEBUG); + assertThat(event.getFormattedMessage()).contains("outcome=\"SUCCESS\""); + assertThat(event.getFormattedMessage()).contains("dependency_name=\"" + DEP + "\""); + assertThat(event.getFormattedMessage()).contains("retry_attempt=0"); + } + + @Test + void recordSuccessIncludesNonZeroRetryAttempt() { + long startNs = System.nanoTime(); + observer.recordSuccess(startNs, 2); + + assertThat(logAppender.list).hasSize(1); + assertThat(logAppender.list.get(0).getFormattedMessage()).contains("retry_attempt=2"); + } + + // ========================================================================= + // recordFailure — outcome mapping + // ========================================================================= + + @Test + void recordFailureTimeoutMapsToTIMEOUTOutcomeAndReturnsDfe() { + long startNs = System.nanoTime(); + SocketTimeoutException cause = new SocketTimeoutException("read timed out"); + + DependencyFailureException dfe = observer.recordFailure(cause, startNs, 0); + + assertThat(dfe).isNotNull(); + assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT); + + assertThat(logAppender.list).hasSize(1); + assertThat(logAppender.list.get(0).getFormattedMessage()).contains("outcome=\"TIMEOUT\""); + } + + @Test + void recordFailureCircuitOpenMapsToCIRCUITOPENOutcomeAndLogsWARN() { + long startNs = System.nanoTime(); + // Build a DFE that classify() will return for a CallNotPermittedException + CircuitBreaker cb = CircuitBreaker.ofDefaults("test"); + cb.transitionToOpenState(); + + CallNotPermittedException cnp = CallNotPermittedException.createCallNotPermittedException(cb); + + DependencyFailureException dfe = observer.recordFailure(cnp, startNs, 0); + + assertThat(dfe).isNotNull(); + assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN); + + assertThat(logAppender.list).hasSize(1); + ILoggingEvent event = logAppender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("outcome=\"CIRCUIT_OPEN\""); + } + + @Test + void recordFailureUnclassifiedMapsToFAILUREOutcomeAndLogsERROR() { + long startNs = System.nanoTime(); + RuntimeException cause = new RuntimeException("unexpected"); + + DependencyFailureException dfe = observer.recordFailure(cause, startNs, 1); + + assertThat(dfe).isNotNull(); + // fallback → DEPENDENCY_CONNECT_FAILED in classifier, outcome → "FAILURE" + assertThat(logAppender.list).hasSize(1); + ILoggingEvent event = logAppender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).contains("outcome=\"FAILURE\""); + assertThat(event.getFormattedMessage()).contains("retry_attempt=1"); + } + + @Test + void recordFailureReturnsDfeSoCallerCanThrow() { + long startNs = System.nanoTime(); + DependencyFailureException dfe = + observer.recordFailure(new RuntimeException("boom"), startNs, 0); + // Must be throwable + assertThat(dfe).isInstanceOf(DependencyFailureException.class); + } + + // ========================================================================= + // rejectShutdown + // ========================================================================= + + @Test + void rejectShutdownReturnsDEPENDENCYCIRCUITOPENWithProvidedMessage() { + String msg = "shutdown in progress — outbound call rejected fail-fast (D8)"; + DependencyFailureException dfe = observer.rejectShutdown(msg); + + assertThat(dfe).isNotNull(); + assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN); + assertThat(dfe.getMessage()).contains(msg); + } + + @Test + void rejectShutdownLogsREJECTEDOutcomeAtWARN() { + observer.rejectShutdown("shutdown test"); + + assertThat(logAppender.list).hasSize(1); + ILoggingEvent event = logAppender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("outcome=\"REJECTED\""); + } + + @Test + void rejectShutdownLogsDuration0AndRetryAttempt0() { + observer.rejectShutdown("shutdown test"); + + assertThat(logAppender.list).hasSize(1); + String msg = logAppender.list.get(0).getFormattedMessage(); + assertThat(msg).contains("duration_ms=0"); + assertThat(msg).contains("retry_attempt=0"); + } + + @Test + void rejectShutdownDfeCauseIsNull() { + DependencyFailureException dfe = observer.rejectShutdown("shutdown test"); + assertThat(dfe.getCause()).isNull(); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientTest.java new file mode 100644 index 00000000..de15de03 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpClientTest.java @@ -0,0 +1,777 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.sun.net.httpserver.HttpServer; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilienceConfig; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.time.Duration; +import java.util.Arrays; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpMethod; +import org.springframework.util.unit.DataSize; + +/** + * Integration tests for {@link OutboundHttpClient} against a local JDK {@link HttpServer} (no + * network, no Testcontainers, no Spring context). + * + * <p>Covers the spec 테스트 계약 from the plan §검증 매트릭스: + * + * <ol> + * <li>200 OK → body decoded + structured log fields verified + * <li>Read timeout → {@code DEPENDENCY_TIMEOUT} / retryable + * <li>Connect refused → {@code DEPENDENCY_CONNECT_FAILED} + * <li>Unknown host → {@code DEPENDENCY_DNS_FAILED} + * <li>500 with secret body → {@code DEPENDENCY_5XX_SERVER} + secret NOT in msg/log + * <li>401 / 404 → {@code DEPENDENCY_4XX_CLIENT} non-retryable + * <li>Retry disabled default → exactly 1 hit on always-500 + * <li>Retry enabled → GET 3 hits; no {@code kind} tag; POST 1 hit (I4) + * <li>Circuit breaker open → {@code DEPENDENCY_CIRCUIT_OPEN} + 0 hits + meters present + * <li>Shutdown → {@code DEPENDENCY_CIRCUIT_OPEN} + outcome REJECTED in log + * <li>Response size limit → buffered throws; streaming path succeeds + * </ol> + */ +class OutboundHttpClientTest { + + // ------------------------------------------------------------------------- + // Logger capture + // ------------------------------------------------------------------------- + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> logAppender; + private OutboundHttpDependencyLogger testLogger; + + // ------------------------------------------------------------------------- + // Local JDK HttpServer lifecycle + // ------------------------------------------------------------------------- + + private HttpServer server; + private String baseUrl; + + @BeforeEach + void setUpLoggerAndServer() throws IOException { + // Set up log capture + logbackLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.http.client"); + logAppender = new ListAppender<>(); + logAppender.start(); + logbackLogger.addAppender(logAppender); + logbackLogger.setLevel(Level.DEBUG); + testLogger = new OutboundHttpDependencyLogger(logbackLogger); + + // Bind to localhost:0 (ephemeral port) + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.start(); + int port = server.getAddress().getPort(); + baseUrl = "http://localhost:" + port; + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(logAppender); + server.stop(0); + } + + // ========================================================================= + // Test 1: 200 OK → body decoded + structured log fields + // Spec: "outbound log에 dependency.name/type/duration_ms가 없으면 실패" + // ========================================================================= + + @Test + void t1200okBodyDecodedAndSuccessLogContainsRequiredFields() { + server.createContext( + "/hello", + exchange -> { + byte[] body = "world".getBytes(); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + + OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings())); + String result = client.get("/hello", String.class); + + assertThat(result).isEqualTo("world"); + + assertThat(logAppender.list).hasSize(1); + String logMsg = logAppender.list.get(0).getFormattedMessage(); + assertThat(logMsg).contains("dependency_name=\"test-dep\""); + assertThat(logMsg).contains("dependency_type=\"http\""); + assertThat(logMsg).contains("duration_ms="); + } + + // ========================================================================= + // Test 2: Read timeout → DEPENDENCY_TIMEOUT + retryable + // Spec: "upstream timeout은 retryable dependency failure로 분류" + // ========================================================================= + + @Test + void t2ReadTimeoutThrowsDEPENDENCYTIMEOUTAndIsRetryable() { + server.createContext( + "/slow", + exchange -> { + // Sleep longer than the read timeout (read=300ms) + try { + Thread.sleep(1000); + } catch (InterruptedException ignored) { + } + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + }); + + OutboundHttpSettings settings = + new OutboundHttpSettings( + Duration.ofMillis(500), // connectTimeout + Duration.ofMillis(300), // readTimeout + Duration.ofSeconds(2), // globalCallTimeout + false, + false, + DataSize.ofMegabytes(10)); + + OutboundHttpClient client = client(settings, defaultResilience(settings)); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/slow", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT); + assertThat(ex.errorCode().retryable()).isTrue(); + } + + // ========================================================================= + // Test 3: Connect refused → DEPENDENCY_CONNECT_FAILED + // ========================================================================= + + @Test + void t3ConnectRefusedThrowsDEPENDENCYCONNECTFAILED() throws IOException { + // Bind a ServerSocket to get a port, then close it so the OS knows nothing is + // listening — subsequent connect attempts get immediate "Connection refused" rather + // than a timeout (unlike HttpServer.stop() which may leave the port in TIME_WAIT). + int refusedPort; + try (ServerSocket ss = new ServerSocket(0)) { + refusedPort = ss.getLocalPort(); + } + + OutboundHttpClient client = + OutboundHttpClient.baseline( + "test-dep", + "http://localhost:" + refusedPort, + defaultSettings(), + activeGuard(), + defaultResilience(defaultSettings()), + retryPolicy(defaultSettings()), + new OutboundHttpErrorMapper(), + testLogger); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/any", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CONNECT_FAILED); + } + + // ========================================================================= + // Test 4: Unknown host → DEPENDENCY_DNS_FAILED + // ========================================================================= + + @Test + void t4UnknownHostThrowsDEPENDENCYDNSFAILED() { + OutboundHttpClient client = + OutboundHttpClient.baseline( + "test-dep", + "http://nonexistent-host-zzz.invalid", + defaultSettings(), + activeGuard(), + defaultResilience(defaultSettings()), + retryPolicy(defaultSettings()), + new OutboundHttpErrorMapper(), + testLogger); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/path", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_DNS_FAILED); + } + + // ========================================================================= + // Test 5: 500 with secret body → DEPENDENCY_5XX_SERVER + // AND "UPSTREAM_SECRET" NOT in ex.getMessage() NOR in any log line + // Spec: "upstream raw error body가 response/log에 노출되면 실패" + // ========================================================================= + + @Test + void t5500BodySecretNotLeakedInMessageOrLog() { + final String secret = "UPSTREAM_SECRET"; + server.createContext( + "/fail500", + exchange -> { + byte[] body = secret.getBytes(); + exchange.sendResponseHeaders(500, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + + OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings())); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/fail500", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_5XX_SERVER); + + // The secret must NOT appear in the exception message + assertThat(ex.getMessage()).doesNotContain(secret); + + // The secret must NOT appear in any captured log line + for (ILoggingEvent event : logAppender.list) { + assertThat(event.getFormattedMessage()).doesNotContain(secret); + } + } + + // ========================================================================= + // Test 6: 401 → DEPENDENCY_4XX_CLIENT non-retryable; 404 → same code + // ========================================================================= + + @Test + void t6401ThrowsDEPENDENCY4XXCLIENTNonRetryable() { + server.createContext( + "/auth", + exchange -> { + exchange.sendResponseHeaders(401, -1); + exchange.getResponseBody().close(); + }); + + OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings())); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/auth", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_4XX_CLIENT); + assertThat(ex.errorCode().retryable()).isFalse(); + } + + @Test + void t6404ThrowsDEPENDENCY4XXCLIENTNonRetryable() { + server.createContext( + "/notfound", + exchange -> { + exchange.sendResponseHeaders(404, -1); + exchange.getResponseBody().close(); + }); + + OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings())); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/notfound", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_4XX_CLIENT); + assertThat(ex.errorCode().retryable()).isFalse(); + } + + // ========================================================================= + // Test 7: Retry disabled (default) → exactly 1 hit on always-500 + // ========================================================================= + + @Test + void t7RetryDisabledSingleHitOnAlways500() { + AtomicInteger hitCount = new AtomicInteger(0); + server.createContext( + "/fail", + exchange -> { + hitCount.incrementAndGet(); + exchange.sendResponseHeaders(500, -1); + exchange.getResponseBody().close(); + }); + + // Default settings: retryEnabled=false + OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings())); + + assertThatThrownBy(() -> client.get("/fail", String.class)) + .isInstanceOf(DependencyFailureException.class); + + assertThat(hitCount.get()).isEqualTo(1); + } + + // ========================================================================= + // Test 8: Retry enabled → GET 3 hits; no `kind` tag; POST 1 hit (plan I4) + // Spec: "POST retry fully forbidden"; meter outcome tag, no kind tag + // ========================================================================= + + @Test + void t8RetryEnabledGETAlways500Hits3Times() { + AtomicInteger hitCount = new AtomicInteger(0); + server.createContext( + "/retry", + exchange -> { + hitCount.incrementAndGet(); + exchange.sendResponseHeaders(500, -1); + exchange.getResponseBody().close(); + }); + + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + OutboundHttpSettings retrySettings = + new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), // generous global timeout for 3 retries + true, + false, + DataSize.ofMegabytes(10)); + + // CRITICAL: the retryPolicy instance MUST be shared between the resilience config + // and the client. OutboundHttpClient.exchange() calls retryPolicy.beginCall() to + // set the thread-local call context that retryPolicy.shouldRetry() checks. + // Using two separate instances means shouldRetry() sees no context (ctx == null) + // and always returns false — producing exactly 1 hit instead of 3. + OutboundRetryPolicy sharedPolicy = retryPolicy(retrySettings); + + OutboundHttpResilience resilience = + new OutboundHttpResilienceConfig() + .outboundHttpResilience(retrySettings, sharedPolicy, singletonProvider(meterRegistry)); + + OutboundHttpClient client = + OutboundHttpClient.baseline( + "test-dep", + baseUrl, + retrySettings, + activeGuard(), + resilience, + sharedPolicy, + new OutboundHttpErrorMapper(), + testLogger); + + assertThatThrownBy(() -> client.get("/retry", String.class)) + .isInstanceOf(DependencyFailureException.class); + + assertThat(hitCount.get()).isEqualTo(3); + + // Failure log must record retry_attempt=2 (3 attempts → attemptCount=3 → 3-1=2). + // This assertion catches the previously hardcoded retry_attempt=0 bug on the failure path. + assertThat(logAppender.list).isNotEmpty(); + boolean foundRetryAttempt2 = + logAppender.list.stream() + .anyMatch(e -> e.getFormattedMessage().contains("retry_attempt=2")); + assertThat(foundRetryAttempt2) + .as("Failure log must contain retry_attempt=2 for always-500 GET with 3 hits") + .isTrue(); + + // Spec: resilience4j.retry.calls meters must exist with `outcome` tag and NO `kind` tag. + // TaggedRetryMetrics registers FunctionCounters (not Counter), so use find().meters() + // rather than find().counters() — FunctionCounter does not implement Counter. + var retryMeters = meterRegistry.find("resilience4j.retry.calls").meters(); + assertThat(retryMeters).isNotEmpty(); + retryMeters.forEach( + meter -> { + assertThat(meter.getId().getTag("outcome")) + .as("outcome tag must be present on resilience4j.retry.calls") + .isNotNull(); + assertThat(meter.getId().getTag("kind")) + .as("kind tag must NOT be present (D4 vendor tag remapped to outcome)") + .isNull(); + }); + } + + @Test + void t8RetryEnabledPOSTAlways500HitsExactly1TimePlanI4() { + AtomicInteger hitCount = new AtomicInteger(0); + server.createContext( + "/post-retry", + exchange -> { + hitCount.incrementAndGet(); + exchange.sendResponseHeaders(500, -1); + exchange.getResponseBody().close(); + }); + + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + OutboundHttpSettings retrySettings = + new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + true, + false, + DataSize.ofMegabytes(10)); + + // Same shared-policy pattern as GET test above (plan I4 verification). + OutboundRetryPolicy sharedPolicy = retryPolicy(retrySettings); + + OutboundHttpResilience resilience = + new OutboundHttpResilienceConfig() + .outboundHttpResilience(retrySettings, sharedPolicy, singletonProvider(meterRegistry)); + + OutboundHttpClient client = + OutboundHttpClient.baseline( + "test-dep", + baseUrl, + retrySettings, + activeGuard(), + resilience, + sharedPolicy, + new OutboundHttpErrorMapper(), + testLogger); + + assertThatThrownBy(() -> client.exchange(HttpMethod.POST, "/post-retry", null, String.class)) + .isInstanceOf(DependencyFailureException.class); + + // Plan I4: POST retry is fully forbidden — exactly 1 hit regardless of retry enabled + assertThat(hitCount.get()).isEqualTo(1); + } + + // ========================================================================= + // Test 9: Circuit breaker open → DEPENDENCY_CIRCUIT_OPEN + 0 hits + // + resilience4j.circuitbreaker.state gauge with UPPERCASE state + // + denied vendor meters ABSENT + // ========================================================================= + + @Test + void t9CircuitBreakerOpenShortCircuitsCallAndMetersCorrect() { + AtomicInteger hitCount = new AtomicInteger(0); + server.createContext( + "/cb", + exchange -> { + hitCount.incrementAndGet(); + exchange.sendResponseHeaders(200, -1); + exchange.getResponseBody().close(); + }); + + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + OutboundHttpSettings cbSettings = + new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + false, + true, + DataSize.ofMegabytes(10)); + + OutboundRetryPolicy sharedPolicy = retryPolicy(cbSettings); + + OutboundHttpResilience resilience = + new OutboundHttpResilienceConfig() + .outboundHttpResilience(cbSettings, sharedPolicy, singletonProvider(meterRegistry)); + + // Force the CB into OPEN state before any call + resilience.circuitBreakerFor("test-dep").get().transitionToOpenState(); + + OutboundHttpClient client = + OutboundHttpClient.baseline( + "test-dep", + baseUrl, + cbSettings, + activeGuard(), + resilience, + sharedPolicy, + new OutboundHttpErrorMapper(), + testLogger); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/cb", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN); + + // Hit count must be 0 — CB blocked the call entirely + assertThat(hitCount.get()).isEqualTo(0); + + // resilience4j.circuitbreaker.state gauge must be present with UPPERCASE state tag value + var stateMeter = meterRegistry.find("resilience4j.circuitbreaker.state").gauges(); + assertThat(stateMeter).isNotEmpty(); + stateMeter.forEach( + gauge -> { + String stateTag = gauge.getId().getTag("state"); + assertThat(stateTag).isNotNull(); + // UPPERCASE: CLOSED, OPEN, HALF_OPEN — not lowercase + assertThat(stateTag).isEqualTo(stateTag.toUpperCase()); + }); + + // Denied vendor meter must be ABSENT (D4 low-cardinality filter) + assertThat(meterRegistry.find("resilience4j.circuitbreaker.failure.rate").gauges()).isEmpty(); + } + + // ========================================================================= + // Test 10: Shutdown → DEPENDENCY_CIRCUIT_OPEN + outcome REJECTED in log + // + server hit count unchanged (fail-fast, no network) + // Spec: "shutdown phase에서 outbound HTTP 호출이 retry를 시도하면 실패" + // ========================================================================= + + @Test + void t10ShutdownGuardStoppedThrowsDEPENDENCYCIRCUITOPENAndLogsREJECTED() { + AtomicInteger hitCount = new AtomicInteger(0); + server.createContext( + "/shutdown-test", + exchange -> { + hitCount.incrementAndGet(); + exchange.sendResponseHeaders(200, -1); + exchange.getResponseBody().close(); + }); + + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + // Trigger shutdown + guard.stop(); + + OutboundHttpClient client = + OutboundHttpClient.baseline( + "test-dep", + baseUrl, + defaultSettings(), + guard, + defaultResilience(defaultSettings()), + retryPolicy(defaultSettings()), + new OutboundHttpErrorMapper(), + testLogger); + + DependencyFailureException ex = + catchThrowableOfType( + DependencyFailureException.class, () -> client.get("/shutdown-test", String.class)); + + assertThat(ex).isNotNull(); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN); + + // Server hit count unchanged — no network call was made + assertThat(hitCount.get()).isEqualTo(0); + + // Failure log must contain outcome="REJECTED" + assertThat(logAppender.list).isNotEmpty(); + boolean foundRejected = + logAppender.list.stream() + .anyMatch(e -> e.getFormattedMessage().contains("outcome=\"REJECTED\"")); + assertThat(foundRejected) + .as("Log must contain outcome=\"REJECTED\" for shutdown path") + .isTrue(); + } + + // ========================================================================= + // Test 11a: Response size limit — Content-Length path throws + // OutboundResponseSizeExceededException + // ========================================================================= + + @Test + void t11aBufferedGetThrowsSizeExceededWhenContentLengthExceedsLimit() { + byte[] bigBody = new byte[1024]; // 1KB + Arrays.fill(bigBody, (byte) 'X'); + + server.createContext( + "/big", + exchange -> { + exchange.sendResponseHeaders(200, bigBody.length); // known Content-Length + try (OutputStream os = exchange.getResponseBody()) { + os.write(bigBody); + } + }); + + // 64-byte limit + OutboundHttpSettings tinyLimitSettings = + new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + false, + false, + DataSize.ofBytes(64)); + + OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings)); + + assertThatThrownBy(() -> client.get("/big", byte[].class)) + .isInstanceOf(OutboundResponseSizeExceededException.class); + } + + // ========================================================================= + // Test 11b: Response size limit — chunked/streaming path without Content-Length + // Counting-stream also throws for buffered path + // ========================================================================= + + @Test + void t11bBufferedGetThrowsSizeExceededOnChunkedResponseNoContentLength() { + byte[] bigBody = new byte[1024]; // 1KB + Arrays.fill(bigBody, (byte) 'Y'); + + server.createContext( + "/chunked", + exchange -> { + // sendResponseHeaders(200, 0) = chunked (unknown Content-Length) + exchange.sendResponseHeaders(200, 0); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bigBody); + } + }); + + OutboundHttpSettings tinyLimitSettings = + new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + false, + false, + DataSize.ofBytes(64)); + + OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings)); + + // Counting-stream path in BoundedInputStream should throw too + assertThatThrownBy(() -> client.get("/chunked", byte[].class)) + .isInstanceOf(OutboundResponseSizeExceededException.class); + } + + // ========================================================================= + // Test 11c: stream() API on the same oversized response succeeds (D7) + // Spec: D7 "초과 시 streaming 처리 의무" — stream() path has no size limit + // ========================================================================= + + @Test + void t11cStreamApiSucceedsOnOversizedResponse() throws Exception { + byte[] bigBody = new byte[1024]; // 1KB + Arrays.fill(bigBody, (byte) 'Z'); + + server.createContext( + "/stream-ok", + exchange -> { + exchange.sendResponseHeaders(200, bigBody.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bigBody); + } + }); + + OutboundHttpSettings tinyLimitSettings = + new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + false, + false, + DataSize.ofBytes(64)); + + OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings)); + + // stream() bypasses the ResponseSizeBoundingInterceptor — should succeed and return all bytes + byte[] result = + client.stream( + HttpMethod.GET, + "/stream-ok", + in -> { + try { + return in.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + assertThat(result).hasSize(1024); + assertThat(result[0]).isEqualTo((byte) 'Z'); + } + + // ========================================================================= + // Private factory / helper methods + // ========================================================================= + + /** Standard settings with generous timeouts for most tests. */ + private static OutboundHttpSettings defaultSettings() { + return new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10)); + } + + /** Creates an already-started (not-shutting-down) shutdown guard. */ + private static OutboundHttpShutdownGuard activeGuard() { + OutboundHttpShutdownGuard g = new OutboundHttpShutdownGuard(); + g.start(); + return g; + } + + /** Creates a retry policy wired to a fresh guard and error mapper. */ + private static OutboundRetryPolicy retryPolicy(OutboundHttpSettings settings) { + return new OutboundRetryPolicy(settings, activeGuard(), new OutboundHttpErrorMapper()); + } + + /** + * Creates a {@link OutboundHttpResilience} with both retry and CB disabled — no MeterRegistry + * needed. + */ + private static OutboundHttpResilience defaultResilience(OutboundHttpSettings settings) { + return new OutboundHttpResilience(settings, retryPolicy(settings), null, null); + } + + /** Builds a client against the local {@link #server} with the given settings/resilience. */ + private OutboundHttpClient client( + OutboundHttpSettings settings, OutboundHttpResilience resilience) { + return OutboundHttpClient.baseline( + "test-dep", + baseUrl, + settings, + activeGuard(), + resilience, + retryPolicy(settings), + new OutboundHttpErrorMapper(), + testLogger); + } + + /** + * ObjectProvider returning the given singleton (mirrors OutboundHttpResilienceConfigTest + * pattern). + */ + private static ObjectProvider<MeterRegistry> singletonProvider(MeterRegistry instance) { + return new ObjectProvider<>() { + @Override + public MeterRegistry getObject() { + return instance; + } + + @Override + public MeterRegistry getObject(Object... args) { + return instance; + } + + @Override + public MeterRegistry getIfAvailable() { + return instance; + } + + @Override + public MeterRegistry getIfUnique() { + return instance; + } + + @Override + public Iterator<MeterRegistry> iterator() { + return Stream.of(instance).iterator(); + } + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactoryTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactoryTest.java new file mode 100644 index 00000000..44a31eea --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactoryTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.util.unit.DataSize; +import org.springframework.web.client.RestClient; + +/** + * Unit tests for {@link OutboundHttpRestClientFactory}. + * + * <p>Verifies that {@code create()} returns a non-null {@code Clients} record with distinct + * (different object identity) buffered and streaming {@link RestClient} instances, both backed by + * the SAME shared request factory (behavior-preserving refactor: the original constructor built one + * {@link org.springframework.http.client.JdkClientHttpRequestFactory} and shared it). + * + * <p>We cannot directly assert that the two RestClients share the same factory instance via public + * API, but we can verify: + * + * <ol> + * <li>Both clients are non-null and distinct objects. + * <li>The factory method completes without throwing given valid settings (proxy for "timeout + * wiring did not blow up"). + * <li>The nested {@code Clients} record accessors work correctly. + * </ol> + */ +class OutboundHttpRestClientFactoryTest { + + private static OutboundHttpSettings validSettings() { + return new OutboundHttpSettings( + Duration.ofMillis(500), + Duration.ofMillis(500), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10)); + } + + @Test + void createReturnsNonNullClientsRecord() { + var clients = + OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings()); + assertThat(clients).isNotNull(); + } + + @Test + void createBufferedClientIsNonNull() { + var clients = + OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings()); + assertThat(clients.buffered()).isNotNull(); + } + + @Test + void createStreamingClientIsNonNull() { + var clients = + OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings()); + assertThat(clients.streaming()).isNotNull(); + } + + @Test + void createBufferedAndStreamingAreDistinctObjects() { + var clients = + OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings()); + // They must be distinct RestClient instances (buffered has the size interceptor; streaming does + // not) + assertThat(clients.buffered()).isNotSameAs(clients.streaming()); + } + + @Test + void createIsStableForMultipleCallsWithSameArgs() { + // Each call creates a fresh set of clients — factory is stateless/repeatable + var c1 = + OutboundHttpRestClientFactory.create("dep-a", "http://localhost:8080", validSettings()); + var c2 = + OutboundHttpRestClientFactory.create("dep-a", "http://localhost:8080", validSettings()); + assertThat(c1).isNotNull(); + assertThat(c2).isNotNull(); + // Different invocations produce independent client instances + assertThat(c1.buffered()).isNotSameAs(c2.buffered()); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java new file mode 100644 index 00000000..d34fec37 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettingsTest.java @@ -0,0 +1,369 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.unit.DataSize; + +/** + * Binding and compact-constructor validation tests for {@link OutboundHttpSettings} + * (feature-outbound-http-client-baseline D5 — "timeout 미설정 또는 무한 timeout forbidden"; registry + * validation {@code spring_duration_shorthand_non_zero}). + */ +class OutboundHttpSettingsTest { + + // --- direct construction --- + + @Test + void validSettingsConstructedDirectly() { + OutboundHttpSettings settings = + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10)); + assertThat(settings.connectTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(settings.readTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(settings.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(settings.retryEnabled()).isFalse(); + assertThat(settings.circuitBreakerEnabled()).isFalse(); + assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); + } + + @Test + void nullConnectTimeoutThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + null, + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT") + .hasMessageContaining("app.outbound.http.connect-timeout"); + } + + @Test + void zeroConnectTimeoutThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ZERO, + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT"); + } + + @Test + void negativeConnectTimeoutThrows() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(-1), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT"); + } + + @Test + void nullReadTimeoutThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + null, + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_READ_TIMEOUT") + .hasMessageContaining("app.outbound.http.read-timeout"); + } + + @Test + void zeroReadTimeoutThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ZERO, + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_READ_TIMEOUT"); + } + + @Test + void nullGlobalCallTimeoutThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + null, + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT") + .hasMessageContaining("app.outbound.http.global-call-timeout"); + } + + @Test + void negativeGlobalCallTimeoutThrows() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofMillis(-1), + false, + false, + DataSize.ofMegabytes(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT"); + } + + @Test + void nullResponseSizeLimitDefaultsTo10MB() { + OutboundHttpSettings settings = + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + null); + assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); + } + + @Test + void negativeResponseSizeLimitThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofBytes(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT"); + } + + @Test + void zeroResponseSizeLimitThrowsNamingEnvKey() { + assertThatThrownBy( + () -> + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofBytes(0))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT"); + } + + // --- ApplicationContextRunner binding --- + + @Configuration + @EnableConfigurationProperties(OutboundHttpSettings.class) + @EnableAutoConfiguration + static class BindingConfig {} + + @Test + void settingsBindFromApplicationContextRunner() { + new ApplicationContextRunner() + .withUserConfiguration(BindingConfig.class) + .withPropertyValues( + "app.outbound.http.connect-timeout=2s", + "app.outbound.http.read-timeout=5s", + "app.outbound.http.global-call-timeout=10s", + "app.outbound.http.retry-enabled=true", + "app.outbound.http.circuit-breaker-enabled=false", + "app.outbound.http.response-size-limit=10MB") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class); + assertThat(s.connectTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(s.readTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(s.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(s.retryEnabled()).isTrue(); + assertThat(s.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10)); + }); + } + + @Test + void contextFailsWhenConnectTimeoutIsMissingFromBinding() { + new ApplicationContextRunner() + .withUserConfiguration(BindingConfig.class) + .withPropertyValues( + "app.outbound.http.read-timeout=5s", "app.outbound.http.global-call-timeout=10s") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + // --- nested record 기본값 (직접 생성) --- + + @Test + void nestedRecordsDefaultWhenNullViaAuxConstructor() { + // 보조 6-arg 생성자: retry/circuitBreaker 미지정 → 기본값 채워진 record + OutboundHttpSettings s = + new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10)); + assertThat(s.retry().maxAttempts()).isEqualTo(3); + assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(100)); + assertThat(s.retry().backoffMultiplier()).isEqualTo(2.0); + assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(50f); + assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(100); + assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(100); + assertThat(s.circuitBreaker().waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(60)); + assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(10); + } + + @Test + void retryRecordNullFieldsDefault() { + OutboundHttpSettings.Retry r = new OutboundHttpSettings.Retry(null, null, null); + assertThat(r.maxAttempts()).isEqualTo(3); + assertThat(r.initialBackoff()).isEqualTo(Duration.ofMillis(100)); + assertThat(r.backoffMultiplier()).isEqualTo(2.0); + } + + @Test + void circuitBreakerRecordNullFieldsDefault() { + OutboundHttpSettings.CircuitBreaker c = + new OutboundHttpSettings.CircuitBreaker(null, null, null, null, null); + assertThat(c.failureRateThreshold()).isEqualTo(50f); + assertThat(c.slidingWindowSize()).isEqualTo(100); + assertThat(c.minimumNumberOfCalls()).isEqualTo(100); + assertThat(c.waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(60)); + assertThat(c.permittedCallsInHalfOpen()).isEqualTo(10); + } + + // --- 경계 검증 --- + + @Test + void retryMaxAttemptsBelowOneThrows() { + assertThatThrownBy(() -> new OutboundHttpSettings.Retry(0, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS"); + } + + @Test + void retryInitialBackoffZeroThrows() { + assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, Duration.ZERO, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF"); + } + + @Test + void retryBackoffMultiplierBelowOneThrows() { + assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, null, 0.5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER"); + } + + @Test + void cbFailureRateOutOfRangeThrows() { + assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(0f, null, null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD"); + assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(150f, null, null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD"); + } + + @Test + void cbSlidingWindowBelowOneThrows() { + assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(50f, 0, null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE"); + } + + @Test + void cbMinimumCallsBelowOneThrows() { + assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 0, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS"); + } + + @Test + void cbWaitDurationZeroThrows() { + assertThatThrownBy( + () -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 100, Duration.ZERO, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE"); + } + + @Test + void cbPermittedHalfOpenBelowOneThrows() { + assertThatThrownBy( + () -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 100, Duration.ofSeconds(60), 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN"); + } + + // --- ApplicationContextRunner 바인딩 --- + + @Test + void nestedSettingsBindFromApplicationContextRunner() { + new ApplicationContextRunner() + .withUserConfiguration(BindingConfig.class) + .withPropertyValues( + "app.outbound.http.connect-timeout=2s", + "app.outbound.http.read-timeout=5s", + "app.outbound.http.global-call-timeout=10s", + "app.outbound.http.retry.max-attempts=5", + "app.outbound.http.retry.initial-backoff=250ms", + "app.outbound.http.retry.backoff-multiplier=3.0", + "app.outbound.http.circuit-breaker.failure-rate-threshold=25", + "app.outbound.http.circuit-breaker.sliding-window-size=20", + "app.outbound.http.circuit-breaker.minimum-number-of-calls=7", + "app.outbound.http.circuit-breaker.wait-duration-in-open-state=30s", + "app.outbound.http.circuit-breaker.permitted-calls-in-half-open=4") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class); + assertThat(s.retry().maxAttempts()).isEqualTo(5); + assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(250)); + assertThat(s.retry().backoffMultiplier()).isEqualTo(3.0); + assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(25f); + assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(20); + assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(7); + assertThat(s.circuitBreaker().waitDurationInOpenState()) + .isEqualTo(Duration.ofSeconds(30)); + assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(4); + }); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuardTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuardTest.java new file mode 100644 index 00000000..949fb342 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpShutdownGuardTest.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Contract tests for {@link OutboundHttpShutdownGuard} (feature-outbound-http-client-baseline D8 / + * plan decision I6 — SmartLifecycle phase ordering ensures shutdown guard is stopped first). + */ +class OutboundHttpShutdownGuardTest { + + @Test + void autoStartupIsTrue() { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + assertThat(guard.isAutoStartup()).isTrue(); + } + + @Test + void startsAsRunningAndNotShuttingDown() { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + assertThat(guard.isRunning()).isTrue(); + assertThat(guard.isShuttingDown()).isFalse(); + } + + @Test + void stopFlipsShuttingDownToTrueAndStopsRunning() { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + guard.stop(); + assertThat(guard.isShuttingDown()).isTrue(); + assertThat(guard.isRunning()).isFalse(); + } + + @Test + void phaseIsIntegerMaxValue() { + // Spring stops phases in DESCENDING order — Integer.MAX_VALUE means this + // lifecycle bean is stopped FIRST during shutdown (D8 / plan I6). + assertThat(new OutboundHttpShutdownGuard().getPhase()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + void isNotShuttingDownBeforeStopIsCalled() { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + // guard has never been started or stopped + assertThat(guard.isShuttingDown()).isFalse(); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpTimeoutEnforcerTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpTimeoutEnforcerTest.java new file mode 100644 index 00000000..f4d2b63e --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpTimeoutEnforcerTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +/** + * Contract tests for {@link OutboundHttpTimeoutEnforcer} (plan decision I2 — BeanPostProcessor that + * detects raw {@link RestClient} / {@link RestClient.Builder} beans and fails the + * ApplicationContext startup with a descriptive error message citing the registry env-key names). + */ +class OutboundHttpTimeoutEnforcerTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withBean(OutboundHttpTimeoutEnforcer.class); + + @Configuration + static class RawRestClientConfig { + @Bean + RestClient rawClient() { + return RestClient.create(); + } + } + + @Configuration + static class RawRestClientBuilderConfig { + @Bean + RestClient.Builder rawBuilder() { + return RestClient.builder(); + } + } + + @Configuration + static class SafeConfig { + @Bean + String harmlessBean() { + return "hello"; + } + } + + @Test + void contextFailsWhenARawRestClientBeanIsRegistered() { + runner + .withUserConfiguration(RawRestClientConfig.class) + .run( + ctx -> { + assertThat(ctx).hasFailed(); + assertThat(ctx.getStartupFailure().getMessage()) + .contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT"); + }); + } + + @Test + void failureMessageMentionsAllThreeTimeoutEnvKeys() { + runner + .withUserConfiguration(RawRestClientConfig.class) + .run( + ctx -> { + assertThat(ctx).hasFailed(); + String msg = ctx.getStartupFailure().getMessage(); + assertThat(msg) + .contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT") + .contains("APP_OUTBOUND_HTTP_READ_TIMEOUT") + .contains("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT"); + }); + } + + @Test + void contextFailsWhenARawRestClientBuilderBeanIsRegistered() { + runner + .withUserConfiguration(RawRestClientBuilderConfig.class) + .run( + ctx -> { + assertThat(ctx).hasFailed(); + assertThat(ctx.getStartupFailure().getMessage()) + .contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT"); + }); + } + + @Test + void contextStartsFineWhenNoRawRestClientBeanIsPresent() { + runner.withUserConfiguration(SafeConfig.class).run(ctx -> assertThat(ctx).hasNotFailed()); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/TraceContextPropagationInterceptorTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/TraceContextPropagationInterceptorTest.java new file mode 100644 index 00000000..619e33fc --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/TraceContextPropagationInterceptorTest.java @@ -0,0 +1,235 @@ +package dev.caskeleton.adapter.outbound.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.shared.tracing.BaggageAllowlist; +import java.io.IOException; +import java.net.URI; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; + +/** + * Unit tests for {@link TraceContextPropagationInterceptor} (Slice 3a, + * feature-distributed-tracing-contract §1 / D2 / D8). + * + * <p>Covers: + * + * <ol> + * <li>Full MDC → all four headers set; baggage contains ONLY allowlisted keys. + * <li>Forbidden MDC key does NOT appear in baggage (D2/D8 trust-boundary). + * <li>Empty/invalid MDC → no headers set; call still executed. + * <li>Header already set → not overwritten. + * <li>Only partial MDC (trace_id/span_id present, no request_id/correlation_id/tenant_id) → only + * traceparent set. + * </ol> + */ +class TraceContextPropagationInterceptorTest { + + // Valid W3C values (32-hex trace-id, 16-hex span-id) + private static final String VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; + private static final String VALID_SPAN_ID = "00f067aa0ba902b7"; + private static final String VALID_REQUEST_ID = "req-abc-123"; + private static final String VALID_CORR_ID = "corr-xyz-456"; + private static final String VALID_TENANT_ID = "tenant-42"; + + private TraceContextPropagationInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new TraceContextPropagationInterceptor(); + MDC.clear(); + } + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + // ========================================================================= + // Test 1: Full MDC → all four outbound headers; baggage allowlisted only + // Spec: "outbound HTTP → propagate traceparent, requestId, correlationId" + // "baggage에 금지 정보가 기록되면 실패" + // ========================================================================= + + @Test + void t1FullMdcAllHeadersSetBaggageContainsOnlyAllowlistedKeys() throws IOException { + MDC.put("trace_id", VALID_TRACE_ID); + MDC.put("span_id", VALID_SPAN_ID); + MDC.put("request_id", VALID_REQUEST_ID); + MDC.put("correlation_id", VALID_CORR_ID); + MDC.put("tenant_id", VALID_TENANT_ID); + // Forbidden key that must never appear in baggage + MDC.put("user_principal", "evil-secret"); + MDC.put("jwt_token", "Bearer eyJhbGci..."); + + MockClientHttpRequest request = + new MockClientHttpRequest(HttpMethod.GET, URI.create("/api/resource")); + ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class); + ClientHttpRequestExecution execution = stubExecution(fakeResponse); + + ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution); + + assertThat(result).isSameAs(fakeResponse); + verify(execution, times(1)).execute(any(), any()); + + HttpHeaders headers = request.getHeaders(); + + // traceparent — W3C format 00-<traceId>-<spanId>-00 + assertThat(headers.getFirst("traceparent")) + .isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00"); + + // requestId and correlationId + assertThat(headers.getFirst("X-Request-Id")).isEqualTo(VALID_REQUEST_ID); + assertThat(headers.getFirst("X-Correlation-Id")).isEqualTo(VALID_CORR_ID); + + // baggage: ONLY tenant_id and request_id may appear + String baggage = headers.getFirst("baggage"); + assertThat(baggage).isNotNull().isNotBlank(); + assertThat(baggage).contains("tenant_id=" + VALID_TENANT_ID); + assertThat(baggage).contains("request_id=" + VALID_REQUEST_ID); + + // Forbidden keys must NOT appear in baggage + assertThat(baggage).doesNotContain("user_principal"); + assertThat(baggage).doesNotContain("jwt_token"); + assertThat(baggage).doesNotContain("evil-secret"); + assertThat(baggage).doesNotContain("Bearer"); + + // Cross-check: all baggage keys must be in the ALLOWED set + BaggageAllowlist.parseHeader(baggage) + .forEach( + (key, value) -> + assertThat(BaggageAllowlist.isAllowed(key)) + .as("Baggage key '%s' must be allowlisted", key) + .isTrue()); + } + + // ========================================================================= + // Test 2: Empty/invalid MDC → no headers added; call still executes + // Spec: "invalid MDC value never throws — skip the header instead" + // ========================================================================= + + @Test + void t2EmptyMdcNoHeadersAddedCallStillExecutes() throws IOException { + // MDC is empty (no keys set) + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/ping")); + ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class); + ClientHttpRequestExecution execution = stubExecution(fakeResponse); + + ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution); + + assertThat(result).isSameAs(fakeResponse); + verify(execution, times(1)).execute(any(), any()); + + HttpHeaders headers = request.getHeaders(); + assertThat(headers.containsHeader("traceparent")).isFalse(); + assertThat(headers.containsHeader("X-Request-Id")).isFalse(); + assertThat(headers.containsHeader("X-Correlation-Id")).isFalse(); + assertThat(headers.containsHeader("baggage")).isFalse(); + } + + // ========================================================================= + // Test 3: Invalid trace_id/span_id (wrong format) → no traceparent header + // but call still executes and other valid headers ARE set + // ========================================================================= + + @Test + void t3InvalidTraceIdAndSpanIdTraceparentSkippedOtherHeadersSet() throws IOException { + MDC.put("trace_id", "not-a-valid-trace-id"); // not 32 hex + MDC.put("span_id", "BAD"); // uppercase, not 16 hex + MDC.put("request_id", VALID_REQUEST_ID); + MDC.put("correlation_id", VALID_CORR_ID); + + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/data")); + ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class); + ClientHttpRequestExecution execution = stubExecution(fakeResponse); + + ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution); + + assertThat(result).isSameAs(fakeResponse); + verify(execution, times(1)).execute(any(), any()); + + HttpHeaders headers = request.getHeaders(); + // No traceparent — both IDs are invalid + assertThat(headers.containsHeader("traceparent")).isFalse(); + // Other headers still set + assertThat(headers.getFirst("X-Request-Id")).isEqualTo(VALID_REQUEST_ID); + assertThat(headers.getFirst("X-Correlation-Id")).isEqualTo(VALID_CORR_ID); + } + + // ========================================================================= + // Test 4: Header already set on the request → not overwritten (idempotent) + // ========================================================================= + + @Test + void t4HeaderAlreadySetNotOverwritten() throws IOException { + MDC.put("trace_id", VALID_TRACE_ID); + MDC.put("span_id", VALID_SPAN_ID); + MDC.put("request_id", VALID_REQUEST_ID); + + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/item")); + // Pre-set an existing traceparent + String existingTraceparent = "00-aaaabbbbccccdddd1111222233334444-5555666677778888-01"; + request.getHeaders().set("traceparent", existingTraceparent); + String existingRequestId = "already-set-req-id"; + request.getHeaders().set("X-Request-Id", existingRequestId); + + ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class); + ClientHttpRequestExecution execution = stubExecution(fakeResponse); + + interceptor.intercept(request, new byte[0], execution); + + HttpHeaders headers = request.getHeaders(); + // Must preserve the original values + assertThat(headers.getFirst("traceparent")).isEqualTo(existingTraceparent); + assertThat(headers.getFirst("X-Request-Id")).isEqualTo(existingRequestId); + } + + // ========================================================================= + // Test 5: Only trace_id + span_id in MDC (no request_id/corr/tenant) → + // only traceparent set; no baggage header + // ========================================================================= + + @Test + void t5OnlyTraceContextTraceparentSetNoBaggage() throws IOException { + MDC.put("trace_id", VALID_TRACE_ID); + MDC.put("span_id", VALID_SPAN_ID); + + MockClientHttpRequest request = + new MockClientHttpRequest(HttpMethod.GET, URI.create("/status")); + ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class); + ClientHttpRequestExecution execution = stubExecution(fakeResponse); + + interceptor.intercept(request, new byte[0], execution); + + HttpHeaders headers = request.getHeaders(); + assertThat(headers.getFirst("traceparent")) + .isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00"); + assertThat(headers.containsHeader("X-Request-Id")).isFalse(); + assertThat(headers.containsHeader("X-Correlation-Id")).isFalse(); + // request_id absent → baggage only has tenant_id if present; here neither → no baggage + assertThat(headers.containsHeader("baggage")).isFalse(); + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private static ClientHttpRequestExecution stubExecution(ClientHttpResponse response) + throws IOException { + ClientHttpRequestExecution execution = mock(ClientHttpRequestExecution.class); + when(execution.execute(any(), any())).thenReturn(response); + return execution; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpDependencyLoggerTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpDependencyLoggerTest.java new file mode 100644 index 00000000..d6525b5a --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/diagnostics/OutboundHttpDependencyLoggerTest.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.outbound.httpclient.diagnostics; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * Contract tests for {@link OutboundHttpDependencyLogger} (feature-outbound-http-client-baseline + * §Audit F1 / plan decision I12 — registry log_field_mapping field names: dependency_name, + * dependency_type, outcome, duration_ms, retry_attempt, correlation_id). + * + * <p>Uses the ListAppender pattern from {@code support/FailOpenDependencyLoggerTest} (test seam via + * logger injection). + */ +class OutboundHttpDependencyLoggerTest { + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private OutboundHttpDependencyLogger logger; + + @BeforeEach + void setUp() { + logbackLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.http.dependency"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + logger = new OutboundHttpDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + // --- success --- + + @Test + void successLogIsAtDEBUGLevel() { + logger.logSuccess("github", 123L, 0); + + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.DEBUG); + } + + @Test + void successLogContainsAllRequiredFields() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-42"); + + logger.logSuccess("github", 250L, 1); + + String msg = appender.list.get(0).getFormattedMessage(); + assertThat(msg) + .contains("dependency_name=\"github\"") + .contains("dependency_type=\"http\"") + .contains("outcome=\"SUCCESS\"") + .contains("duration_ms=250") + .contains("retry_attempt=1") + .contains("correlation_id=\"corr-42\""); + } + + @Test + void successLogUsesUnknownCorrelationIdWhenMdcIsEmpty() { + logger.logSuccess("payment-api", 10L, 0); + + assertThat(appender.list.get(0).getFormattedMessage()) + .contains("correlation_id=\"" + OutboundCorrelation.UNKNOWN + "\""); + } + + // --- failure --- + + @Test + void failureLogIsAtERRORLevelForFAILUREOutcome() { + logger.logFailure( + "inventory-api", "FAILURE", 500L, 2, new RuntimeException("connection refused")); + + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.ERROR); + } + + @Test + void failureLogContainsAllRequiredFields() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-99"); + + logger.logFailure("payment-api", "TIMEOUT", 3000L, 3, new RuntimeException("read timed out")); + + String msg = appender.list.get(0).getFormattedMessage(); + assertThat(msg) + .contains("dependency_name=\"payment-api\"") + .contains("dependency_type=\"http\"") + .contains("outcome=\"TIMEOUT\"") + .contains("duration_ms=3000") + .contains("retry_attempt=3") + .contains("correlation_id=\"corr-99\"") + .contains("error=\"RuntimeException: read timed out\""); + } + + @Test + void circuitOpenOutcomeLogsAtWARNLevel() { + logger.logFailure("catalog-api", "CIRCUIT_OPEN", 0L, 0, new RuntimeException("circuit open")); + + assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.WARN); + } + + @Test + void rejectedOutcomeLogsAtWARNLevel() { + logger.logFailure("catalog-api", "REJECTED", 0L, 0, new RuntimeException("shutting down")); + + assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.WARN); + } + + @Test + void failureLogContainsOnlyExceptionClassAndMessageNotBody() { + // By construction: logFailure signature does not accept a request/response body. + // A throwable whose message contains a "BODY_SECRET" string is acceptable to pass + // as cause (the exception message appears in the error= field for server logs), + // but we assert the field format stays within class+message and never exposes + // a body string that was not part of the throwable message. + RuntimeException cause = new RuntimeException("upstream error"); + logger.logFailure("external-api", "FAILURE", 100L, 0, cause); + + String msg = appender.list.get(0).getFormattedMessage(); + // The log must contain the error= field + assertThat(msg).contains("error=\"RuntimeException: upstream error\""); + // The signature has no body parameter — a body string passed ONLY as a separate + // argument cannot appear in the log (by-construction contract). + assertThat(msg).doesNotContain("RESPONSE_BODY_CONTENT"); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java new file mode 100644 index 00000000..fd8c650d --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceConfigTest.java @@ -0,0 +1,241 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings; +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard; +import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.Iterator; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.unit.DataSize; + +/** + * TDD tests for {@link OutboundHttpResilienceConfig} (feature-outbound-http-client-baseline plan + * decisions I7 — D3 activation guard). + * + * <p>Test contract: "retry/CB enabled 인데 metric/retryable classification 부재 시 실패". + */ +class OutboundHttpResilienceConfigTest { + + // --- direct construction tests (no Spring context needed) --- + + @Test + void retryEnabledWithoutMeterRegistryThrowsOnBeanCreation() { + OutboundHttpSettings settings = retrySettings(); + OutboundRetryPolicy policy = policy(settings); + OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig(); + + // No MeterRegistry available → must throw with D3 message + assertThatThrownBy(() -> config.outboundHttpResilience(settings, policy, emptyProvider())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("MeterRegistry") + .hasMessageContaining("D3"); + } + + @Test + void circuitBreakerEnabledWithoutMeterRegistryThrowsOnBeanCreation() { + OutboundHttpSettings settings = cbSettings(); + OutboundRetryPolicy policy = policy(settings); + OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig(); + + assertThatThrownBy(() -> config.outboundHttpResilience(settings, policy, emptyProvider())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("MeterRegistry"); + } + + @Test + void retryEnabledWithSimpleMeterRegistryProducesResilienceWithRetry() { + OutboundHttpSettings settings = retrySettings(); + OutboundRetryPolicy policy = policy(settings); + SimpleMeterRegistry meter = new SimpleMeterRegistry(); + OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig(); + + OutboundHttpResilience resilience = + config.outboundHttpResilience(settings, policy, singletonProvider(meter)); + + assertThat(resilience.retryFor("test-dep")).isPresent(); + assertThat(resilience.circuitBreakerFor("test-dep")).isEmpty(); + } + + @Test + void bothDisabledProducesEmptyOptionalsAndNoExceptionWithoutMeterRegistry() { + OutboundHttpSettings settings = disabledSettings(); + OutboundRetryPolicy policy = policy(settings); + OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig(); + + // Must NOT throw even without a MeterRegistry when both features are disabled + OutboundHttpResilience resilience = + config.outboundHttpResilience(settings, policy, emptyProvider()); + + assertThat(resilience.retryFor("dep")).isEmpty(); + assertThat(resilience.circuitBreakerFor("dep")).isEmpty(); + } + + @Test + void circuitBreakerEnabledWithSimpleMeterRegistryProducesCircuitBreaker() { + OutboundHttpSettings settings = cbSettings(); + OutboundRetryPolicy policy = policy(settings); + SimpleMeterRegistry meter = new SimpleMeterRegistry(); + OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig(); + + OutboundHttpResilience resilience = + config.outboundHttpResilience(settings, policy, singletonProvider(meter)); + + assertThat(resilience.circuitBreakerFor("test-dep")).isPresent(); + assertThat(resilience.retryFor("test-dep")).isEmpty(); + } + + // --- ApplicationContextRunner test for startup failure path --- + + @Configuration + @EnableConfigurationProperties(OutboundHttpSettings.class) + static class RetryEnabledNoMeterConfig { + + @Bean + OutboundHttpShutdownGuard guard() { + return new OutboundHttpShutdownGuard(); + } + + @Bean + OutboundHttpErrorMapper mapper() { + return new OutboundHttpErrorMapper(); + } + + @Bean + OutboundRetryPolicy retryPolicy( + OutboundHttpSettings s, OutboundHttpShutdownGuard g, OutboundHttpErrorMapper m) { + return new OutboundRetryPolicy(s, g, m); + } + } + + @Test + void applicationContextFailsToStartWhenRetryEnabledAndNoMeterRegistryBean() { + // OutboundHttpResilienceConfig must be registered as a configuration class (NOT as a + // @Bean factory product — Spring does not process @Bean methods on factory-method + // beans), so its outboundHttpResilience(...) bean definition participates in startup. + new ApplicationContextRunner() + .withUserConfiguration(RetryEnabledNoMeterConfig.class, OutboundHttpResilienceConfig.class) + .withPropertyValues( + "app.outbound.http.connect-timeout=2s", + "app.outbound.http.read-timeout=5s", + "app.outbound.http.global-call-timeout=10s", + "app.outbound.http.retry-enabled=true" // enabled but no MeterRegistry bean + ) + .run( + ctx -> { + assertThat(ctx).hasFailed(); + assertThat(ctx.getStartupFailure().getMessage()).contains("MeterRegistry"); + }); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static OutboundHttpSettings retrySettings() { + return new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + true, + false, + DataSize.ofMegabytes(10)); + } + + private static OutboundHttpSettings cbSettings() { + return new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + true, + DataSize.ofMegabytes(10)); + } + + private static OutboundHttpSettings disabledSettings() { + return new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + false, + false, + DataSize.ofMegabytes(10)); + } + + private static OutboundRetryPolicy policy(OutboundHttpSettings settings) { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + return new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper()); + } + + /** ObjectProvider that always returns null (simulates absent bean). */ + private static ObjectProvider<MeterRegistry> emptyProvider() { + return new ObjectProvider<>() { + @Override + public MeterRegistry getObject() { + return null; + } + + @Override + public MeterRegistry getObject(Object... args) { + return null; + } + + @Override + public MeterRegistry getIfAvailable() { + return null; + } + + @Override + public MeterRegistry getIfUnique() { + return null; + } + + @Override + public Iterator<MeterRegistry> iterator() { + return Stream.<MeterRegistry>empty().iterator(); + } + }; + } + + /** ObjectProvider that returns the supplied singleton instance. */ + private static ObjectProvider<MeterRegistry> singletonProvider(MeterRegistry instance) { + return new ObjectProvider<>() { + @Override + public MeterRegistry getObject() { + return instance; + } + + @Override + public MeterRegistry getObject(Object... args) { + return instance; + } + + @Override + public MeterRegistry getIfAvailable() { + return instance; + } + + @Override + public MeterRegistry getIfUnique() { + return instance; + } + + @Override + public Iterator<MeterRegistry> iterator() { + return Stream.of(instance).iterator(); + } + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceTest.java new file mode 100644 index 00000000..a8a6f763 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/OutboundHttpResilienceTest.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings; +import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard; +import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy; +import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.core.IntervalBiFunction; +import io.github.resilience4j.core.functions.Either; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.util.unit.DataSize; + +/** + * {@link OutboundHttpResilience}가 {@link OutboundHttpSettings}의 retry/circuit-breaker 튜닝 값을 실제 + * Resilience4j config로 흘려보내는지 검증한다(설정 외부화 계약). + */ +class OutboundHttpResilienceTest { + + private static OutboundHttpSettings settings( + boolean retry, + boolean cb, + OutboundHttpSettings.Retry r, + OutboundHttpSettings.CircuitBreaker c) { + return new OutboundHttpSettings( + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + retry, + cb, + DataSize.ofMegabytes(10), + r, + c); + } + + private static OutboundRetryPolicy policy(OutboundHttpSettings s) { + OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard(); + guard.start(); + return new OutboundRetryPolicy(s, guard, new OutboundHttpErrorMapper()); + } + + @Test + void retryConfigCarriesConfiguredMaxAttemptsAndBackoff() { + OutboundHttpSettings.Retry r = new OutboundHttpSettings.Retry(5, Duration.ofMillis(200), 3.0); + OutboundHttpSettings s = settings(true, false, r, null); + OutboundHttpResilience resilience = + new OutboundHttpResilience(s, policy(s), RetryRegistry.ofDefaults(), null); + + Retry retry = resilience.retryFor("dep").orElseThrow(); + assertThat(retry.getRetryConfig().getMaxAttempts()).isEqualTo(5); + // exponential random backoff: attempt 1 interval ∈ [initial*0.5, initial*1.5] (jitter 0.5). + // RetryConfig.getIntervalFunction() is deprecated in Resilience4j 2.x → read the backoff via + // the non-deprecated getIntervalBiFunction(); the Either result is unused by a plain + // interval-based backoff (IntervalBiFunction wraps the IntervalFunction, ignoring the result). + IntervalBiFunction<Object> backoff = retry.getRetryConfig().getIntervalBiFunction(); + long firstInterval = backoff.apply(1, Either.right(null)); + assertThat(firstInterval).isBetween(100L, 300L); + } + + @Test + void circuitBreakerConfigCarriesConfiguredValues() { + OutboundHttpSettings.CircuitBreaker c = + new OutboundHttpSettings.CircuitBreaker(25f, 20, 7, Duration.ofSeconds(30), 4); + OutboundHttpSettings s = settings(false, true, null, c); + OutboundHttpResilience resilience = + new OutboundHttpResilience(s, policy(s), null, CircuitBreakerRegistry.ofDefaults()); + + CircuitBreaker cb = resilience.circuitBreakerFor("dep").orElseThrow(); + var cfg = cb.getCircuitBreakerConfig(); + assertThat(cfg.getFailureRateThreshold()).isEqualTo(25f); + assertThat(cfg.getSlidingWindowSize()).isEqualTo(20); + assertThat(cfg.getMinimumNumberOfCalls()).isEqualTo(7); + assertThat(cfg.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(4); + // CB는 plain Duration getter가 없음 → interval function으로 검증(javap 확인) + assertThat(cfg.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(30_000L); + } + + @Test + void disabledSettingsReturnEmptyOptionals() { + OutboundHttpSettings s = settings(false, false, null, null); + OutboundHttpResilience resilience = new OutboundHttpResilience(s, policy(s), null, null); + assertThat(resilience.retryFor("dep")).isEmpty(); + assertThat(resilience.circuitBreakerFor("dep")).isEmpty(); + } + + @Test + void defaultTuningPreservesResilience4jDefaults() { + OutboundHttpSettings s = settings(true, true, null, null); // null nested → 기본값 + OutboundHttpResilience resilience = + new OutboundHttpResilience( + s, policy(s), RetryRegistry.ofDefaults(), CircuitBreakerRegistry.ofDefaults()); + assertThat(resilience.retryFor("dep").orElseThrow().getRetryConfig().getMaxAttempts()) + .isEqualTo(3); + var cfg = resilience.circuitBreakerFor("dep").orElseThrow().getCircuitBreakerConfig(); + assertThat(cfg.getFailureRateThreshold()).isEqualTo(50f); + assertThat(cfg.getSlidingWindowSize()).isEqualTo(100); + assertThat(cfg.getMinimumNumberOfCalls()).isEqualTo(100); + assertThat(cfg.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(10); + assertThat(cfg.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(60_000L); + } +} diff --git a/src/adapter/outbound/identifier/CLAUDE.md b/src/adapter/outbound/identifier/CLAUDE.md new file mode 100644 index 00000000..a8cf5b12 --- /dev/null +++ b/src/adapter/outbound/identifier/CLAUDE.md @@ -0,0 +1,51 @@ +# adapter:outbound:identifier — non-IO infrastructure-capability adapter + +## Registered identity + +- Module ID: `adapter-outbound-identifier` +- Gradle path: `:adapter:outbound:identifier` +- Focused test: `./gradlew :adapter:outbound:identifier:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.identifier`. + +Design decisions previously kept as code comments (algorithm SSOT, salt origin, +build choices) live in [README.md](README.md). + +## Responsibility + +- Driven adapters for capabilities with **no external-system integration**: + identifier generation/encoding today, and clock / crypto/random sources by the + same rationale (feature-resource-identifier-contract §4 taxonomy). +- `UuidCodec` — UUID handling on top of the JDK `java.util.UUID` (RFC 9562 UUIDv7): + `normalize(String)` accepts a case-insensitive canonical UUID and returns the + canonical 36-character lowercase form (D3); `toUuid` / `fromUuid` convert between + the UUID string and the 128-bit `UUID` stored in the PostgreSQL `uuid` column (D10). +- Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is + infrastructure, not an outbound integration point, so `adapter-outbound` keeps its + documented meaning (external HTTP / messaging / cache / notifications). + +## Allowed + +- `:application-core`, `:domain-core`, `:shared-contract` (Gradle matrix). Currently + only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in + [build.gradle](build.gradle). + +## Forbidden + +- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit + `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`; + `.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단. +- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`, + `sample-portfolio`. +- External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`. + +## Test + +Pure unit tests, no Spring context (`UuidCodecSpec`). + +```bash +cd src +./gradlew :adapter:outbound:identifier:test --console=plain +``` diff --git a/src/adapter/outbound/identifier/README.md b/src/adapter/outbound/identifier/README.md new file mode 100644 index 00000000..215b177d --- /dev/null +++ b/src/adapter/outbound/identifier/README.md @@ -0,0 +1,72 @@ +# adapter-identifier — 설계 결정 참조 + +비-IO 인프라 능력(capability) 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.identifier`. + +허용/금지 의존과 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. +이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 +"왜 이렇게 했나"가 궁금할 때 본다. + +## 왜 별도 모듈인가 (adapter-outbound 와의 구분) + +`adapter-outbound` 처럼 도메인 포트를 구현하는 driven/secondary 어댑터지만, **외부 시스템 연동이 +없는**(no external-system integration) 능력만 담는다: 식별자 생성/인코딩(UUIDv7), 같은 근거로 clock· +crypto/random 소스. UUID id/코덱 능력은 인프라이지 아웃바운드 연동 지점이 아니므로, 이것을 +`adapter-outbound` 밖에 둬야 그 모듈의 문서화된 의미("외부 HTTP / messaging / cache / notifications")가 +유지된다. + +## UuidCodec + +도메인 무관 UUID 변환 유틸. JDK `java.util.UUID`(RFC 9562 UUIDv7) 위에서 동작한다. + +- `normalize(String)` — **D3**: 대소문자 무관 canonical UUID 입력을 받아 canonical 36자 소문자 + 형태로 반환. 형식 오류 UUID 에는 `IllegalArgumentException`. `null` 입력은 `null` 반환. +- `toUuid(String)` — **D10**: UUID 문자열 → 128-bit `UUID` (PostgreSQL `uuid` 컬럼용). +- `fromUuid(UUID)` — **D10**: 저장된 `UUID` → canonical 36자 소문자 UUID 문자열. + +## HmacUserPrincipalPseudonymizer + +`UserPrincipalPseudonymizerPort`(application-core) 의 HMAC-SHA-256 구현. + +### 알고리즘 SSOT +구체 알고리즘은 90일 회전 salt 로 키잉한 HMAC-SHA-256 이다. 이 클래스가 유일한 구현이며, +비-IO crypto 능력 어댑터로 이 모듈에 있고 `app-bootstrap` +이 싱글톤 빈으로 와이어링한다. + +### 출력 +비어있지 않은 `rawPrincipal` 에 대해 단방향·안정적인 256-bit HMAC 토큰을 64자 소문자 hex 로 반환. +`rawPrincipal` 이 `null` 이거나 blank 면 `null` 반환. + +### Salt 출처 +salt 는 `app-bootstrap` 이 `APP_PRIVACY_PSEUDONYMIZATION_SALT` 환경변수에서 공급한다(분류: secret, +회전 주기: 90일). 이 클래스는 salt 를 스스로 조달하지 않는다. + +### Thread safety +`Mac` 인스턴스는 thread-safe 하지 않다. 매 `pseudonymize(String)` 호출마다 새 `Mac` 을 생성하므로 +공유 싱글톤 빈으로 안전하다. HmacSHA256 은 JDK 필수 알고리즘(JCA spec)이라 `NoSuchAlgorithmException`· +`InvalidKeyException` 은 사실상 도달 불가능하며, 호출부에 checked exception 잡음을 남기지 않으려고 +`IllegalStateException` 으로 감싼다. + +### Spring-free +이 모듈(`adapter-identifier`)은 설계상 Spring-free 다. 어노테이션이 없고, 빈 생성은 +`app-bootstrap` 의 책임이다. + +## 빌드 결정 (build.gradle) + +### Groovy / Spock (C2 테스트 형태) +순수 값-코덱 동작(`UuidCodec`)은 Groovy/Spock 스펙(`src/test/groovy`)으로 명세한다. core `groovy` +플러그인이 컴파일하고, 모든 서브프로젝트에 이미 켜진 JUnit Platform(`useJUnitPlatform()`)에서 실행된다. +가드/계약 테스트(`HmacUserPrincipalPseudonymizerTest` — 생성자 가드, 정확한 예외/포맷 계약)는 설계상 +Java(`src/test/java`)로 둔다. Spock 2.4 / Groovy 4.0 variant 를 쓰며, spock-core 가 groovy.jar 를 +transitive 로 끌어오므로 data-driven `where:` 스펙에 다른 Groovy 모듈이 필요 없다. + +### implementation vs api +`:application-core` 를 `implementation` 으로 선언한다(`api` 아님). adapter-identifier 가 자신의 public +ABI 에 application-core 타입을 노출하지 않기 때문이다. 유일한 와이어링 소비자인 `app-bootstrap` 은 +이미 자기 classpath 에 application-core 를 갖고 있다. 이 의존 edge 는 `src/build.gradle` 의 +`allowedProjectDependencies['adapter-identifier']` 로 허용된다. + +### UTF-8 인코딩 고정 +한국어(비-ASCII) Spock 스펙 메서드명은 소스를 UTF-8 로 읽어야만 컴파일·리포팅이 정상이다. 이 모듈이 +비-ASCII 소스를 처음 갖는 모듈이라 컴파일 인코딩을 명시적으로 고정한다 — UTF-8 호스트에선 no-op 지만, +플랫폼 기본이 다른 fork(예: 한국어 Windows / MS949)에서 mojibake 빌드를 막는다. C2 가 더 많은 모듈로 +퍼지면 root subprojects 블록(`-parameters` 옆)으로 승격한다. diff --git a/src/adapter/outbound/identifier/build.gradle b/src/adapter/outbound/identifier/build.gradle new file mode 100644 index 00000000..8067ed71 --- /dev/null +++ b/src/adapter/outbound/identifier/build.gradle @@ -0,0 +1,22 @@ +// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README. +plugins { + id 'groovy' +} + +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation 'com.github.f4b6a3:uuid-creator:6.1.1' + + testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' +} + +// Pin UTF-8 so non-ASCII (Korean) Spock spec names build on any host. See README. +tasks.withType(GroovyCompile).configureEach { + groovyOptions.encoding = 'UTF-8' + options.encoding = 'UTF-8' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/src/adapter/outbound/identifier/gradle.lockfile b/src/adapter/outbound/identifier/gradle.lockfile new file mode 100644 index 00000000..38ffa6a0 --- /dev/null +++ b/src/adapter/outbound/identifier/gradle.lockfile @@ -0,0 +1,157 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java b/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java new file mode 100644 index 00000000..53bd99d0 --- /dev/null +++ b/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.identifier; + +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * HMAC-SHA-256 implementation of {@link UserPrincipalPseudonymizerPort}: returns a stable + * 64-character lowercase hex token, or {@code null} for null/blank input. Thread-safe as a shared + * singleton. See README for the algorithm SSOT, salt origin, and design rationale. + */ +public final class HmacUserPrincipalPseudonymizer implements UserPrincipalPseudonymizerPort { + + private static final String ALGORITHM = "HmacSHA256"; + + private final SecretKeySpec key; + + public HmacUserPrincipalPseudonymizer(byte[] salt) { + if (salt == null || salt.length == 0) { + throw new IllegalArgumentException( + "HMAC salt must not be null or empty — supplied by APP_PRIVACY_PSEUDONYMIZATION_SALT"); + } + byte[] saltCopy = salt.clone(); // defensive copy; caller's array is not retained + this.key = new SecretKeySpec(saltCopy, ALGORITHM); + } + + @Override + public String pseudonymize(String rawPrincipal) { + if (rawPrincipal == null || rawPrincipal.isBlank()) { + return null; + } + + try { + Mac mac = Mac.getInstance(ALGORITHM); // Mac is not thread-safe — fresh instance per call + mac.init(key); + byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + // Unreachable: HmacSHA256 is a mandatory JDK algorithm and the key spec is valid. + throw new IllegalStateException( + "HmacSHA256 unavailable or key invalid — this should never happen on a compliant JDK", e); + } + } +} diff --git a/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java b/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java new file mode 100644 index 00000000..a050a2c8 --- /dev/null +++ b/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.identifier; + +import java.util.UUID; + +/** Domain-agnostic UUID conversion utility. See README for the design rationale. */ +public final class UuidCodec { + + private UuidCodec() {} + + /** + * Accepts a case-insensitive canonical UUID string and returns the canonical 36-character + * lowercase form; {@code null} input returns {@code null}. + * + * @throws IllegalArgumentException on a malformed UUID + */ + public static String normalize(String input) { + if (input == null) { + return null; + } + return UUID.fromString(input).toString(); + } + + public static UUID toUuid(String uuidString) { + return UUID.fromString(uuidString); + } + + public static String fromUuid(UUID uuid) { + return uuid.toString(); + } +} diff --git a/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/package-info.java b/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/package-info.java new file mode 100644 index 00000000..2b35916b --- /dev/null +++ b/src/adapter/outbound/identifier/src/main/java/dev/caskeleton/adapter/outbound/identifier/package-info.java @@ -0,0 +1,5 @@ +/** + * Non-IO infrastructure-capability adapters (identifier generation/codec, clock, crypto/random). + * See README for why these are separate from {@code adapter-outbound}. + */ +package dev.caskeleton.adapter.outbound.identifier; diff --git a/src/adapter/outbound/identifier/src/test/groovy/dev/caskeleton/adapter/outbound/identifier/UuidCodecSpec.groovy b/src/adapter/outbound/identifier/src/test/groovy/dev/caskeleton/adapter/outbound/identifier/UuidCodecSpec.groovy new file mode 100644 index 00000000..e0483b4b --- /dev/null +++ b/src/adapter/outbound/identifier/src/test/groovy/dev/caskeleton/adapter/outbound/identifier/UuidCodecSpec.groovy @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.identifier + +import spock.lang.Specification + +class UuidCodecSpec extends Specification { + + static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab" + + def "normalize 는 #label 을 36자 소문자 canonical 형태로 변환한다"() { + expect: + UuidCodec.normalize(input) == CANONICAL + + where: + label | input + "이미 canonical 인 입력" | CANONICAL + "대문자 입력" | CANONICAL.toUpperCase() + } + + def "normalize 는 null 입력에 대해 null 을 반환한다"() { + expect: + UuidCodec.normalize(null) == null + } + + def "normalize 는 형식이 잘못된 UUID 를 거부한다"() { + when: + UuidCodec.normalize("not-a-uuid") + + then: + thrown(IllegalArgumentException) + } + + def "UUID -> UUID -> UUID 왕복 변환은 무손실이다"() { + given: + def uuid = UuidCodec.toUuid(CANONICAL) + + expect: + UuidCodec.fromUuid(uuid) == CANONICAL + } +} diff --git a/src/adapter/outbound/identifier/src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java b/src/adapter/outbound/identifier/src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java new file mode 100644 index 00000000..c28c7a23 --- /dev/null +++ b/src/adapter/outbound/identifier/src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.identifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class HmacUserPrincipalPseudonymizerTest { + + private static final byte[] SALT_A = "test-salt-A-32-bytes-padding-xxx".getBytes(); + private static final byte[] SALT_B = "test-salt-B-32-bytes-padding-yyy".getBytes(); + + // ----------------------------------------------------------------------- + // Constructor guard tests + // ----------------------------------------------------------------------- + + @Test + void nullSaltThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void emptySaltThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(new byte[0])) + .isInstanceOf(IllegalArgumentException.class); + } + + // ----------------------------------------------------------------------- + // Null / blank input → null output + // ----------------------------------------------------------------------- + + @Test + void nullInputReturnsNull() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + assertThat(pseudonymizer.pseudonymize(null)).isNull(); + } + + @Test + void blankInputReturnsNull() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + assertThat(pseudonymizer.pseudonymize(" ")).isNull(); + } + + @Test + void emptyStringInputReturnsNull() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + assertThat(pseudonymizer.pseudonymize("")).isNull(); + } + + // ----------------------------------------------------------------------- + // Determinism + // ----------------------------------------------------------------------- + + @Test + void sameInputSameSaltProducesSameOutputOnSameInstance() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + String first = pseudonymizer.pseudonymize("user-123"); + String second = pseudonymizer.pseudonymize("user-123"); + assertThat(first).isEqualTo(second); + } + + @Test + void sameInputSameSaltProducesSameOutputAcrossTwoInstances() { + var p1 = new HmacUserPrincipalPseudonymizer(SALT_A); + var p2 = new HmacUserPrincipalPseudonymizer(SALT_A.clone()); + assertThat(p1.pseudonymize("user-abc")).isEqualTo(p2.pseudonymize("user-abc")); + } + + // ----------------------------------------------------------------------- + // Salt-sensitivity + // ----------------------------------------------------------------------- + + @Test + void differentSaltProducesDifferentOutput() { + var pA = new HmacUserPrincipalPseudonymizer(SALT_A); + var pB = new HmacUserPrincipalPseudonymizer(SALT_B); + assertThat(pA.pseudonymize("user-xyz")).isNotEqualTo(pB.pseudonymize("user-xyz")); + } + + // ----------------------------------------------------------------------- + // One-way property (output != input, output does not contain input) + // ----------------------------------------------------------------------- + + @Test + void outputDoesNotEqualRawInput() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + String raw = "alice@example.com"; + assertThat(pseudonymizer.pseudonymize(raw)).isNotEqualTo(raw); + } + + @Test + void outputDoesNotContainRawInputAsSubstring() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + String raw = "alice"; + assertThat(pseudonymizer.pseudonymize(raw)).doesNotContain(raw); + } + + // ----------------------------------------------------------------------- + // Output format: lowercase hex, exactly 64 characters (256-bit HMAC) + // ----------------------------------------------------------------------- + + @Test + void outputMatchesLowercaseHex64CharPattern() { + var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A); + String token = pseudonymizer.pseudonymize("some-user"); + assertThat(token).matches("^[0-9a-f]{64}$"); + } +} diff --git a/src/adapter/outbound/messaging/CLAUDE.md b/src/adapter/outbound/messaging/CLAUDE.md new file mode 100644 index 00000000..50ad9bc7 --- /dev/null +++ b/src/adapter/outbound/messaging/CLAUDE.md @@ -0,0 +1,29 @@ +# adapter:outbound:messaging — messaging adapter + +## Registered identity + +- Module ID: `adapter-outbound-messaging` +- Gradle path: `:adapter:outbound:messaging` +- Focused test: `./gradlew :adapter:outbound:messaging:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.messaging`. + +## Responsibility + +- Implement outbound message publication and broker integration behind application/domain ports. +- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox + publication adaptation. +- Reuse `adapter:outbound:support` for shared technical concerns. + +## Boundaries + +- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency. +- Do not hide use-case sequencing or business routing policy in broker adapters. + +## Tests + +Use unit/contract tests with fake broker senders. No real network or broker is used in focused tests; +settings records receive binding/validation tests when configuration changes. diff --git a/src/adapter/outbound/messaging/README.md b/src/adapter/outbound/messaging/README.md new file mode 100644 index 00000000..2c91ae84 --- /dev/null +++ b/src/adapter/outbound/messaging/README.md @@ -0,0 +1,44 @@ +# adapter:outbound:messaging — 설계 결정 참조 + +메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트: +`dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유 +correlation / fail-open 의존성 로깅을 재사용한다. + +허용/금지 의존 정책은 `src/build.gradle` 의 +`allowedProjectDependencies['adapter:outbound:messaging']` 항목이 SSOT 다(이 모듈은 아직 별도 +CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 +기록이다. + +## 모듈 개요 + +application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 두는 **선택형** +연동 어댑터다. `@ConditionalOnProperty` 로 게이팅되고 기본 비활성이며, 비활성 바인딩은 +`Disabled*` 구현으로 fail-fast 한다(Layer 3). 무거운 broker SDK 는 의도적으로 classpath 에 +최소화하고, 실제 broker client(`KafkaSender`)는 포킹 프로젝트가 채우는 seam 이다. + +## 두 포트를 하나의 활성 broker 에 조립 + +`MessagingConfig` 는 두 messaging 포트를 단일 활성 `MessageBroker` 위에 조립한다 — broker +추가는 새 broker 구현 파일 추가만으로 끝나고 이 config 는 바뀌지 않는다. + +## broker 선택 검증 + +`app.messaging.broker` 가 설정됐는데 `MessageBroker` 빈이 없으면 startup 을 명시적 메시지로 +실패시킨다(조용한 no-op 아님). settings 와 활성 빈의 `brokerId()` 불일치도 startup 실패다. + +## 비활성 sentinel 두 개를 분리한 이유 + +`DisabledMessagePublisher` 와 `DisabledOutboxMessagePublisher` 는 별도 클래스다 — 한 클래스가 +두 포트를 모두 구현하면 `getBean(MessagePublisher.class)` 가 모호해진다. + +## OutboxEnvelopeJson — 손수 짠 JSON + +이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope +직렬화는 의존성 없는 손수 짠 JSON 이다. + +## MessagePublisher vs OutboxMessagePublishPort + +`MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께 +로깅하고 삼켜(→ `:adapter:outbound:support` 의 `FailOpenDependencyLogger`) outbox/retry 로 +위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort` 를 +쓴다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7). diff --git a/src/adapter/outbound/messaging/build.gradle b/src/adapter/outbound/messaging/build.gradle new file mode 100644 index 00000000..bf8b1f19 --- /dev/null +++ b/src/adapter/outbound/messaging/build.gradle @@ -0,0 +1,14 @@ +plugins { id 'groovy' } +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + implementation project(':adapter:outbound:support') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.slf4j:slf4j-api' + + testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' +} +tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/messaging/gradle.lockfile b/src/adapter/outbound/messaging/gradle.lockfile new file mode 100644 index 00000000..85eb4c96 --- /dev/null +++ b/src/adapter/outbound/messaging/gradle.lockfile @@ -0,0 +1,156 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java new file mode 100644 index 00000000..1531034b --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.messaging; + +import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Assembles both messaging ports onto the single active {@link MessageBroker} (the cache + * central-assembly pattern). A broker is contributed as a + * {@code @ConditionalOnProperty(app.messaging.broker=<id>)}-gated bean, at most one active — adding + * a broker is new files only and this config never changes. With no active broker it binds the + * disabled sentinels (fail-fast); if set but no contributing bean exists, startup fails. + */ +@Configuration +@EnableConfigurationProperties(MessagingSettings.class) +public class MessagingConfig { + + @Bean + public MessagePublisher messagePublisher( + ObjectProvider<MessageBroker> brokerProvider, + MessagingSettings settings, + FailOpenDependencyLogger dependencyLogger) { + MessageBroker active = resolveBroker(brokerProvider, settings); + return (active == null) + ? new DisabledMessagePublisher() + : new OutboundMessagePublisher(active, dependencyLogger); + } + + @Bean + public OutboxMessagePublishPort outboxMessagePublishPort( + ObjectProvider<MessageBroker> brokerProvider, + MessagingSettings settings, + FailOpenDependencyLogger dependencyLogger) { + MessageBroker active = resolveBroker(brokerProvider, settings); + return (active == null) + ? new DisabledOutboxMessagePublisher() + : new OutboxMessagePublishAdapter(active, dependencyLogger); + } + + private static MessageBroker resolveBroker( + ObjectProvider<MessageBroker> brokerProvider, MessagingSettings settings) { + if (settings.broker().isBlank()) { + return null; + } + MessageBroker active = brokerProvider.getIfAvailable(); + if (active == null) { + throw new IllegalStateException( + "app.messaging.broker=" + + settings.broker() + + " but no MessageBroker bean contributes that id — enable the broker template" + + " (supply its client seam) or unset app.messaging.broker"); + } + if (!settings.broker().equals(active.brokerId())) { + throw new IllegalStateException( + "app.messaging.broker=" + + settings.broker() + + " but the active MessageBroker reports brokerId '" + + active.brokerId() + + "'"); + } + return active; + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java new file mode 100644 index 00000000..a80a60cf --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.messaging; + +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Messaging template selection. {@code app.messaging.broker=<brokerId>} chooses the single active + * {@link MessageBroker} (e.g. {@code kafka}); unset/blank = no broker = fail-fast on use (the + * disabled sentinels {@code DisabledMessagePublisher} / {@code DisabledOutboxMessagePublisher}). + * + * @param broker the active broker id, matched against {@link MessageBroker#brokerId()}; blank means + * the messaging template is disabled (the default) + */ +@ConfigurationProperties(prefix = "app.messaging") +public record MessagingSettings(String broker) { + + public MessagingSettings { + broker = (broker == null) ? "" : broker.trim(); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/DisabledMessagePublisher.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/DisabledMessagePublisher.java new file mode 100644 index 00000000..27f9b011 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/DisabledMessagePublisher.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.messaging.core; + +import dev.caskeleton.shared.error.AdapterDisabledException; + +/** + * Fail-fast {@link MessagePublisher} binding when no broker is active ({@code app.messaging.broker} + * unset — the default). Any publish throws {@link AdapterDisabledException} — never a silent no-op + * (mirrors the cache router's unbound fail-fast). Broker-agnostic; the outbox counterpart is {@code + * DisabledOutboxMessagePublisher}. + */ +public class DisabledMessagePublisher implements MessagePublisher { + + @Override + public void publish(OutboundMessage message) { + throw new AdapterDisabledException("messaging"); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessageBroker.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessageBroker.java new file mode 100644 index 00000000..049377a9 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessageBroker.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.messaging.core; + +/** + * SPI a forking project contributes to bind the messaging template to a real broker. The active + * broker is selected by {@code app.messaging.broker=<brokerId>}. The skeleton carries no broker SDK + * — it is supplied by the project that selects the broker. + */ +public interface MessageBroker { + + /** Stable broker identifier matched against {@code app.messaging.broker}. */ + String brokerId(); + + /** + * Sends a message to the broker. May throw on any transport/broker failure; the binding decorator + * applies the fail-open (general) or fail-closed (outbox) policy. + * + * @throws Exception on any send failure (handled by the binding decorator) + */ + void send(OutboundMessage message) throws Exception; +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessagePublisher.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessagePublisher.java new file mode 100644 index 00000000..2d75a791 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessagePublisher.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.messaging.core; + +/** + * Adapter-local fire-and-forget (fail-open) publishing port for the optional adapter template. Not + * use-case-facing ({@code adapter-outbound} type, so application-core cannot hold it) — a use case + * that needs durable delivery uses the application-core {@code OutboxMessagePublishPort}. The + * active binding is selected by {@code app.messaging.broker}. The {@code void} return keeps broker + * SDK types from escaping the adapter (B7). + */ +public interface MessagePublisher { + + void publish(OutboundMessage message); +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessage.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessage.java new file mode 100644 index 00000000..7ed18c3c --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessage.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.messaging.core; + +import java.util.Objects; + +/** + * Transport-neutral message a {@link MessagePublisher} emits. Carries only the routing key family + * ({@code topic}, partition {@code key}) and an already-serialized {@code payload} string — it + * deliberately does NOT depend on any broker SDK type, so the messaging port stays + * template-portable across Kafka and any future broker. + * + * @param topic logical destination / topic + * @param key partition / ordering key (may be empty, never null) + * @param payload serialized message body (the producer is responsible for serialization) + */ +public record OutboundMessage(String topic, String key, String payload) { + + public OutboundMessage { + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(payload, "payload"); + if (topic.isBlank()) { + throw new IllegalArgumentException("topic must not be blank"); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java new file mode 100644 index 00000000..ad874f95 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.messaging.core; + +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; + +/** + * General {@link MessagePublisher} binding (fail-open). Delegates to the active {@link + * MessageBroker}; on failure it logs with the correlation id and swallows — a broker outage must + * never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path). + * The fail-closed counterpart is {@code OutboxMessagePublishAdapter}. + * + * <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never + * touches this class. + */ +public class OutboundMessagePublisher implements MessagePublisher { + + private static final String DEPENDENCY_TYPE = "messaging"; + + private final MessageBroker broker; + private final FailOpenDependencyLogger dependencyLogger; + + public OutboundMessagePublisher(MessageBroker broker, FailOpenDependencyLogger dependencyLogger) { + this.broker = broker; + this.dependencyLogger = dependencyLogger; + } + + @Override + public void publish(OutboundMessage message) { + try { + broker.send(message); + dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish"); + } catch (Exception ex) { + // fail-open: observe with correlationId, delegate durability to outbox/retry, + // do NOT propagate — the core use case must still succeed. + dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterConfig.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterConfig.java new file mode 100644 index 00000000..dc1b351a --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterConfig.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Layer 1 gating for the Kafka broker template: registers the Kafka {@link MessageBroker} only when + * {@code app.messaging.broker=kafka}. The central {@code MessagingConfig} binds both messaging + * ports onto it. + * + * <p>Needs a project-supplied {@link KafkaSender} bean (the integration seam) and a non-empty + * {@code app.messaging.kafka.brokers}. Adding another broker is a new config like this one — this + * file never changes. + */ +@Configuration +@EnableConfigurationProperties(KafkaAdapterSettings.class) +public class KafkaAdapterConfig { + + @Bean + @ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka") + public MessageBroker kafkaMessageBroker(KafkaSender sender, KafkaAdapterSettings settings) { + if (settings.brokers().isEmpty()) { + throw new IllegalStateException( + "app.messaging.broker=kafka requires a non-empty app.messaging.kafka.brokers " + + "(CSV of host:port)"); + } + return new KafkaMessageBroker(sender); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java new file mode 100644 index 00000000..ffdfed36 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import java.util.List; +import java.util.regex.Pattern; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Kafka broker tuning bound from {@code app.messaging.kafka.*}. Validation is format-only ({@code + * host:port} per entry); the "Kafka selected ⇒ brokers required" cross-field rule is enforced in + * {@code KafkaAdapterConfig}, so an empty list is valid at bind time. + * + * @param brokers CSV of {@code host:port} broker endpoints (each entry format-validated) + */ +@ConfigurationProperties(prefix = "app.messaging.kafka") +public record KafkaAdapterSettings(List<String> brokers) { + + private static final Pattern HOST_PORT = Pattern.compile("^[^:\\s]+:\\d{1,5}$"); + + public KafkaAdapterSettings { + brokers = (brokers == null) ? List.of() : List.copyOf(brokers); + for (String broker : brokers) { + if (!HOST_PORT.matcher(broker.trim()).matches()) { + throw new IllegalArgumentException( + "APP_MESSAGING_KAFKA_BROKERS entry '" + broker + "' is not host:port"); + } + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaMessageBroker.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaMessageBroker.java new file mode 100644 index 00000000..0fc70bd3 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaMessageBroker.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; + +/** + * Kafka contribution of the {@link MessageBroker} SPI (brokerId {@code "kafka"}). Delegates the raw + * send to the project-supplied {@link KafkaSender} seam; the fail-open (general) and fail-closed + * (outbox) policies are applied by the binding decorators in the messaging package, not here — so + * this class carries no policy and no Kafka SDK. + */ +public class KafkaMessageBroker implements MessageBroker { + + private static final String BROKER_ID = "kafka"; + + private final KafkaSender sender; + + public KafkaMessageBroker(KafkaSender sender) { + this.sender = sender; + } + + @Override + public String brokerId() { + return BROKER_ID; + } + + @Override + public void send(OutboundMessage message) throws Exception { + sender.send(message); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSender.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSender.java new file mode 100644 index 00000000..dbb031d2 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSender.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; + +/** + * Integration seam the forking project implements to bind the Kafka template to a real producer. + * The skeleton carries no Kafka SDK dependency — it is added by the project that enables Kafka. A + * send failure may throw; the binding decorator applies the failure policy. + */ +@FunctionalInterface +public interface KafkaSender { + + /** + * Sends a message to the broker. May throw on any transport/broker failure. + * + * @throws Exception on any send failure (caught and handled fail-open by the publisher) + */ + void send(OutboundMessage message) throws Exception; +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java new file mode 100644 index 00000000..af521a89 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.shared.error.AdapterDisabledException; + +/** + * Fail-fast {@link OutboxMessagePublishPort} binding when no broker is active ({@code + * app.messaging.broker} unset — the default). Any publish throws {@link AdapterDisabledException} — + * never a silent no-op. Kept separate from {@code DisabledMessagePublisher} so each disabled bean + * implements exactly one port (a single class implementing both makes {@code + * getBean(MessagePublisher.class)} ambiguous). + */ +public class DisabledOutboxMessagePublisher implements OutboxMessagePublishPort { + + @Override + public void publish(OutboxEvent event) { + throw new AdapterDisabledException("messaging"); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java new file mode 100644 index 00000000..3fb1c2c5 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import dev.caskeleton.application.outbox.OutboxEvent; + +/** + * Hand-rolled, dependency-free JSON serialiser for the outbox envelope (no Jackson — the module + * deliberately keeps {@code jackson-databind} off its classpath). + * + * <p>{@link OutboxEvent#payload()} MUST already be a valid serialised JSON value; it is inserted + * verbatim (no escaping) — serialisation policy is owned by the {@code schema-serialization} + * branch, this class only assembles the envelope. All other string fields go through {@link + * #escape(String)}. + */ +public final class OutboxEnvelopeJson { + + private OutboxEnvelopeJson() {} + + public static String toJson(OutboxEvent event) { + return "{" + + "\"eventId\":\"" + + escape(event.eventId()) + + "\"," + + "\"eventType\":\"" + + escape(event.eventType()) + + "\"," + + "\"aggregateId\":\"" + + escape(event.aggregateId()) + + "\"," + + "\"occurredAt\":\"" + + escape(event.occurredAt().toString()) + + "\"," + + "\"correlationId\":\"" + + escape(event.correlationId()) + + "\"," + + "\"idempotencyKey\":\"" + + escape(event.idempotencyKey()) + + "\"," + + "\"payload\":" + + event.payload() + + "}"; + } + + /** Escapes a string for a JSON string literal (RFC 8259 §7). */ + static String escape(String value) { + StringBuilder sb = new StringBuilder(value.length() + 4); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\') { + sb.append("\\\\"); + } else if (c == '"') { + sb.append("\\\""); + } else if (c == '\b') { + sb.append("\\b"); + } else if (c == '\t') { + sb.append("\\t"); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\f') { + sb.append("\\f"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java new file mode 100644 index 00000000..c79144fb --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; + +/** + * Outbox {@link OutboxMessagePublishPort} binding (fail-closed). Maps the claimed {@link + * OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}; on + * failure it logs and re-throws so the relay can drive the FAILED/DEAD transition (the documented + * fail-closed contract — contrast the fail-open general {@code OutboundMessagePublisher}). + * + * <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never + * touches this class. + */ +public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort { + + private static final String DEPENDENCY_TYPE = "messaging"; + + private final MessageBroker broker; + private final FailOpenDependencyLogger dependencyLogger; + + public OutboxMessagePublishAdapter( + MessageBroker broker, FailOpenDependencyLogger dependencyLogger) { + this.broker = broker; + this.dependencyLogger = dependencyLogger; + } + + @Override + public void publish(OutboxEvent event) { + String envelope = OutboxEnvelopeJson.toJson(event); + OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope); + try { + broker.send(message); + dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish"); + } catch (RuntimeException ex) { + // fail-closed: log then propagate — the relay must observe this to drive FAILED/DEAD. + dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex); + throw ex; + } catch (Exception ex) { + // Wrap checked exceptions; preserve cause so the relay can inspect it. + dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex); + throw new RuntimeException( + "outbox publish failed for broker '" + broker.brokerId() + "'", ex); + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java new file mode 100644 index 00000000..fc809025 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisherTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.messaging.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * General {@link OutboundMessagePublisher} = fail-open: delegates to the active {@link + * MessageBroker}; a broker failure is logged with the correlation id and swallowed (never + * propagated). Broker-agnostic — a fake broker stands in for any real broker. + */ +class OutboundMessagePublisherTest { + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private FailOpenDependencyLogger dependencyLogger; + + @BeforeEach + void setUp() { + logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.messaging"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + dependencyLogger = new FailOpenDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + /** Fake broker (brokerId "kafka") capturing sends, optionally failing. */ + private static final class FakeBroker implements MessageBroker { + final List<OutboundMessage> sent = new ArrayList<>(); + private final RuntimeException failure; + + FakeBroker() { + this.failure = null; + } + + FakeBroker(RuntimeException failure) { + this.failure = failure; + } + + @Override + public String brokerId() { + return "kafka"; + } + + @Override + public void send(OutboundMessage message) { + if (failure != null) { + throw failure; + } + sent.add(message); + } + } + + @Test + void publishDelegatesToTheActiveBrokerOnSuccess() { + FakeBroker broker = new FakeBroker(); + OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger); + OutboundMessage message = new OutboundMessage("worklog-events", "wl-1", "{}"); + + publisher.publish(message); + + assertThat(broker.sent).containsExactly(message); + } + + @Test + void publishFailureIsFailOpenAndNeverThrows() { + FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable")); + OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger); + + // fail-open: a broker outage must not propagate to the core use case. + assertThatCode(() -> publisher.publish(new OutboundMessage("t", "k", "p"))) + .doesNotThrowAnyException(); + } + + @Test + void publishFailureLogCarriesCorrelationIdAndBrokerId() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-msg-1"); + FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable")); + OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger); + + publisher.publish(new OutboundMessage("t", "k", "p")); + + ILoggingEvent event = + appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().orElseThrow(); + assertThat(event.getFormattedMessage()) + .contains("correlation_id=\"corr-msg-1\"") + .contains("dependency_name=\"kafka\"") + .contains("dependency_type=\"messaging\"") + .contains("operation=\"publish\""); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettingsTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettingsTest.java new file mode 100644 index 00000000..e563e3e3 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettingsTest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.messaging.kafka; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class KafkaAdapterSettingsTest { + + @Test + void emptyOrNullBrokersIsTolerated() { + // bound globally via @ConfigurationPropertiesScan even when Kafka is not the active + // broker — an empty list must bind cleanly (the "required" guard lives in the config). + assertThat(new KafkaAdapterSettings((List<String>) null).brokers()).isEmpty(); + assertThat(new KafkaAdapterSettings(List.of()).brokers()).isEmpty(); + } + + @Test + void rejectsABrokerThatIsNotHostPort() { + assertThatThrownBy(() -> new KafkaAdapterSettings(List.of("not-a-broker"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("host:port"); + } + + @Test + void acceptsCsvOfHostPortBrokers() { + KafkaAdapterSettings settings = + new KafkaAdapterSettings(List.of("broker-1:9092", "broker-2:9092")); + assertThat(settings.brokers()).containsExactly("broker-1:9092", "broker-2:9092"); + } + + @Test + void brokersListIsDefensivelyCopied() { + List<String> mutable = new ArrayList<>(List.of("broker-1:9092")); + KafkaAdapterSettings settings = new KafkaAdapterSettings(mutable); + mutable.add("broker-2:9092"); + assertThat(settings.brokers()).containsExactly("broker-1:9092"); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java new file mode 100644 index 00000000..fb6f831a --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java @@ -0,0 +1,266 @@ +package dev.caskeleton.adapter.outbound.messaging.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * Broker-agnostic outbox publish adapter (fail-closed). Covers: + * + * <ul> + * <li>Success — correct OutboundMessage (topic=eventType, key=aggregateId, payload=envelope JSON) + * sent to the active broker. + * <li>Fail-closed — broker failure is logged then propagated (never swallowed); checked + * exceptions are wrapped. + * <li>Envelope JSON fields and escaping. + * </ul> + */ +class OutboxMessagePublishAdapterTest { + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private FailOpenDependencyLogger dependencyLogger; + + @BeforeEach + void setUp() { + logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + dependencyLogger = new FailOpenDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + /** Fake broker (brokerId "kafka") capturing sends, optionally failing with a given throwable. */ + private static final class FakeBroker implements MessageBroker { + final List<OutboundMessage> sent = new ArrayList<>(); + private final Exception failure; + + FakeBroker() { + this.failure = null; + } + + FakeBroker(Exception failure) { + this.failure = failure; + } + + @Override + public String brokerId() { + return "kafka"; + } + + @Override + public void send(OutboundMessage message) throws Exception { + if (failure != null) { + throw failure; + } + sent.add(message); + } + } + + private OutboxEvent sampleEvent() { + return new OutboxEvent( + "evt-01", + "WorkLogReserved", + "wl-agg-1", + "{\"workLogId\":\"wl-01\"}", + Instant.parse("2024-01-02T03:04:05Z"), + "corr-abc", + "idem-xyz", + OutboxEventStatus.IN_FLIGHT, + 1); + } + + @Nested + class SuccessPath { + + @Test + void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() { + FakeBroker broker = new FakeBroker(); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + adapter.publish(sampleEvent()); + + assertThat(broker.sent).hasSize(1); + OutboundMessage sent = broker.sent.get(0); + assertThat(sent.topic()).isEqualTo("WorkLogReserved"); + assertThat(sent.key()).isEqualTo("wl-agg-1"); + } + + @Test + void publishEnvelopeContainsAllFields() { + FakeBroker broker = new FakeBroker(); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + adapter.publish(sampleEvent()); + + String payload = broker.sent.get(0).payload(); + assertThat(payload).contains("\"eventId\""); + assertThat(payload).contains("\"eventType\""); + assertThat(payload).contains("\"aggregateId\""); + assertThat(payload).contains("\"occurredAt\""); + assertThat(payload).contains("\"correlationId\""); + assertThat(payload).contains("\"idempotencyKey\""); + assertThat(payload).contains("\"payload\""); + } + + @Test + void publishEnvelopeFieldValuesMatchEvent() { + FakeBroker broker = new FakeBroker(); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + adapter.publish(sampleEvent()); + + String payload = broker.sent.get(0).payload(); + assertThat(payload).contains("\"evt-01\""); + assertThat(payload).contains("\"WorkLogReserved\""); + assertThat(payload).contains("\"wl-agg-1\""); + assertThat(payload).contains("2024-01-02T03:04:05Z"); + assertThat(payload).contains("\"corr-abc\""); + assertThat(payload).contains("\"idem-xyz\""); + assertThat(payload).contains("{\"workLogId\":\"wl-01\"}"); + } + } + + @Nested + class FailClosedPath { + + @Test + void publishFailurePropagatesAsRuntimeException() { + FakeBroker broker = new FakeBroker(new IllegalStateException("broker down")); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + assertThatThrownBy(() -> adapter.publish(sampleEvent())).isInstanceOf(RuntimeException.class); + } + + @Test + void publishFailureIsLoggedBeforePropagation() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-fail-1"); + FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable")); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + try { + adapter.publish(sampleEvent()); + } catch (RuntimeException ignored) { + // expected + } + + boolean warnLogged = + appender.list.stream() + .anyMatch( + e -> + e.getLevel() == Level.WARN + && e.getFormattedMessage().contains("corr-fail-1")); + assertThat(warnLogged).as("Expected a WARN log with the correlationId on failure").isTrue(); + } + + @Test + void publishFailureLogCarriesDependencyAndOperation() { + FakeBroker broker = new FakeBroker(new RuntimeException("connection refused")); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + try { + adapter.publish(sampleEvent()); + } catch (RuntimeException ignored) { + // expected + } + + String msg = + appender.list.stream() + .filter(e -> e.getLevel() == Level.WARN) + .findFirst() + .map(ILoggingEvent::getFormattedMessage) + .orElse(""); + assertThat(msg) + .contains("dependency_name=\"kafka\"") + .contains("dependency_type=\"messaging\"") + .contains("operation=\"publish\""); + } + + @Test + void publishWrapsCheckedExceptionInRuntimeException() { + FakeBroker broker = new FakeBroker(new IOException("network error")); + OutboxMessagePublishAdapter adapter = + new OutboxMessagePublishAdapter(broker, dependencyLogger); + + assertThatThrownBy(() -> adapter.publish(sampleEvent())) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(IOException.class); + } + } + + @Nested + class JsonEscape { + + @Test + void envelopeEscapesDoubleQuotesInStringFields() { + OutboxEvent eventWithQuote = + new OutboxEvent( + "evt-02", + "Has\"Quote", + "agg-1", + "{}", + Instant.parse("2024-01-01T00:00:00Z"), + "corr-1", + "idem-1", + OutboxEventStatus.IN_FLIGHT, + 1); + FakeBroker broker = new FakeBroker(); + new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(eventWithQuote); + + assertThat(broker.sent.get(0).payload()).contains("Has\\\"Quote"); + } + + @Test + void envelopeInsertsPayloadRawWithoutDoubleEncoding() { + OutboxEvent event = + new OutboxEvent( + "evt-05", + "SomeEvent", + "agg-2", + "{\"nested\":{\"a\":1}}", + Instant.parse("2024-01-01T00:00:00Z"), + "corr-2", + "idem-2", + OutboxEventStatus.IN_FLIGHT, + 1); + FakeBroker broker = new FakeBroker(); + new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(event); + + String payload = broker.sent.get(0).payload(); + assertThat(payload).contains("{\"nested\":{\"a\":1}}"); + assertThat(payload).doesNotContain("\"{\\\"nested\\\""); + } + } +} diff --git a/src/adapter/outbound/notification/CLAUDE.md b/src/adapter/outbound/notification/CLAUDE.md new file mode 100644 index 00000000..ab0eb660 --- /dev/null +++ b/src/adapter/outbound/notification/CLAUDE.md @@ -0,0 +1,28 @@ +# adapter:outbound:notification — notification adapter + +## Registered identity + +- Module ID: `adapter-outbound-notification` +- Gradle path: `:adapter:outbound:notification` +- Focused test: `./gradlew :adapter:outbound:notification:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.notification`. + +## Responsibility + +- Implement notification provider routing and provider-specific Slack/email clients behind ports. +- Own provider settings, technical fallback, and provider adaptation. +- Reuse `adapter:outbound:support` for shared outbound concerns. + +## Boundaries + +- Allowed dependency edges come only from `.harness/project/modules.yaml`. +- No inbound DTO/controller, persistence, bootstrap, or sample dependency. +- Provider selection may route configured channels but must not encode business eligibility rules. + +## Tests + +Use provider/client fakes and contract tests; no real webhook or email network calls. Settings changes +include binding/validation tests. diff --git a/src/adapter/outbound/notification/README.md b/src/adapter/outbound/notification/README.md new file mode 100644 index 00000000..df412927 --- /dev/null +++ b/src/adapter/outbound/notification/README.md @@ -0,0 +1,34 @@ +# adapter:outbound:notification — 설계 결정 참조 + +알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트: +`dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유 +correlation / fail-open 의존성 로깅을 재사용한다. + +허용/금지 의존 정책은 `src/build.gradle` 의 +`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직 +별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 +참조용 기록이다. + +## 모듈 개요 + +application-core 포트 뒤에 두는 **선택형** 알림 어댑터다. `@ConditionalOnProperty` 로 게이팅되고 +기본 비활성이다. 이 모듈이 기본 제공하는 프로바이더는 `email/google`(`GoogleEmailProvider` / +`GoogleEmailClient`)과 `slack/webhook`(`SlackWebhookProvider` / `SlackClient`)이며, 실제 연동 +client 는 포킹 프로젝트가 채우는 seam 이다. + +## (channel, providerId) 복합 키 + fan-out + +`RoutingNotifier` 는 `(channel, providerId)` 복합 키로 프로바이더를 등록한다 — 채널 내 중복 +`providerId` 는 생성 시점에 실패한다. 라우트당 providerId 목록을 주면 fan-out(모든 프로바이더 +호출)이 된다. 각 프로바이더는 이미 `FailOpenNotificationProvider` 로 감싸져 있어 한 곳의 실패가 +다른 곳을 막지 않고, 그래서 fan-out 루프에 try/catch 가 필요 없다. `FailOpenNotificationProvider +.send` 가 `throws` 를 선언하지 않는 건 이 루프를 try/catch 없이 예외-free 로 증명하기 위함이다. + +## 중앙 fail-open 합성 + 라우팅 바인딩 + +`NotificationConfig` 가 모든 프로바이더를 `FailOpenNotificationProvider` 로 중앙에서 감싼다(→ +`:adapter:outbound:support` 의 `FailOpenDependencyLogger` 로 WARN 로깅). 라우팅은 +`app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]`. 프로바이더는 +`channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예: +`GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가 +구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다. diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle new file mode 100644 index 00000000..d332c1a7 --- /dev/null +++ b/src/adapter/outbound/notification/build.gradle @@ -0,0 +1,15 @@ +plugins { id 'groovy' } +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + implementation project(':adapter:outbound:support') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework:spring-web' // Slack webhook client (RestClient) + implementation 'org.slf4j:slf4j-api' + + testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' +} +tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/notification/gradle.lockfile b/src/adapter/outbound/notification/gradle.lockfile new file mode 100644 index 00000000..edf2de5a --- /dev/null +++ b/src/adapter/outbound/notification/gradle.lockfile @@ -0,0 +1,156 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationConfig.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationConfig.java new file mode 100644 index 00000000..23a21360 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationConfig.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.notification; + +import dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider; +import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; +import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.application.notification.NotificationPort; +import java.util.List; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Assembles the {@link RoutingNotifier} from every contributed {@link NotificationProvider} bean, + * mirroring {@code CacheRouterConfig}. + * + * <p>Provider discovery is type-explicit: a provider opts in by registering a {@link + * NotificationProvider} bean (its {@link NotificationProvider#providerId()} + {@link + * NotificationProvider#channel()} are the routing keys). Adding a provider is therefore new files + * only — this config and {@link RoutingNotifier} never change. The fail-open policy is applied + * here, centrally, by wrapping every provider in {@link FailOpenNotificationProvider} — a provider + * config cannot forget it. + */ +@Configuration +@EnableConfigurationProperties(NotificationRoutesSettings.class) +public class NotificationConfig { + + @Bean + public NotificationPort notificationPort( + ObjectProvider<List<NotificationProvider>> providers, + NotificationRoutesSettings settings, + FailOpenDependencyLogger failOpenDependencyLogger) { + List<FailOpenNotificationProvider> failOpenProviders = + providers.getIfAvailable(List::of).stream() + .map(p -> new FailOpenNotificationProvider(p, failOpenDependencyLogger)) + .toList(); + return new RoutingNotifier(failOpenProviders, settings.routes()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationRoutesSettings.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationRoutesSettings.java new file mode 100644 index 00000000..416c2cb2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationRoutesSettings.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.notification; + +import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier; +import dev.caskeleton.application.notification.Channel; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Routes binding for the multi-provider notification template: {@code + * app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}. + * + * <p>Mirrors {@code CacheBindingSettings}: relaxed binding maps the channel segment (e.g. {@code + * email} → {@link Channel#EMAIL}) automatically via Spring's {@code ApplicationConversionService}. + * Route values are coerced to {@code List<String>} by the binder (comma-separated or YAML list). + * Default is an empty map so the notification template stays a non-required optional module: + * startup never fails when no routes are configured. Consistency (every referenced providerId has + * an enabled provider) is validated fail-fast by {@link RoutingNotifier} at construction time. + * + * @param routes channel → (route → providerId list), default empty + */ +@ConfigurationProperties(prefix = "app.notification") +public record NotificationRoutesSettings(Map<Channel, Map<String, List<String>>> routes) { + + public NotificationRoutesSettings { + routes = (routes == null) ? Map.of() : Map.copyOf(routes); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java new file mode 100644 index 00000000..60b282fd --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.notification.core; + +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; + +/** + * Fail-open decorator: a provider failure is logged (no payload/PII) and swallowed so a + * notification — a side-effect — never fails the core use case. Applied centrally by + * NotificationConfig. + */ +public final class FailOpenNotificationProvider implements NotificationProvider { + + private static final String DEPENDENCY_TYPE = "notification"; + + private final NotificationProvider delegate; + private final FailOpenDependencyLogger dependencyLogger; + + public FailOpenNotificationProvider( + NotificationProvider delegate, FailOpenDependencyLogger dependencyLogger) { + this.delegate = delegate; + this.dependencyLogger = dependencyLogger; + } + + @Override + public Channel channel() { + return delegate.channel(); + } + + @Override + public String providerId() { + return delegate.providerId(); + } + + @Override + public void send(Notification notification) { + try { + delegate.send(notification); + dependencyLogger.logSuccess(delegate.providerId(), DEPENDENCY_TYPE, "send"); + } catch (Exception ex) { + // fail-open: observe (no payload/PII), do not fail the core use case. + dependencyLogger.logFailure(delegate.providerId(), DEPENDENCY_TYPE, "send", ex); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java new file mode 100644 index 00000000..fae64a05 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.core; + +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; + +/** + * SPI a forking project contributes to bind the notification template to a real provider for a + * given {@link Channel}. Each provider registers a bean of this type. {@link #providerId()} is the + * identifier referenced by {@code app.notification.routes.<channel>.<route>=<id>[,<id>]} values and + * must be unique within a channel. + */ +public interface NotificationProvider { + + /** The channel this provider delivers to (e.g. {@link Channel#EMAIL}). */ + Channel channel(); + + /** + * Stable provider id referenced by {@code app.notification.routes.*}; unique within a channel. + */ + String providerId(); + + /** + * Sends the notification via the provider. May throw on any transport/provider failure; the + * {@link FailOpenNotificationProvider} decorator applies the fail-open policy centrally — this + * method must not swallow its own exceptions. + * + * @param notification the notification to send (contains PII — never log this value) + * @throws Exception on a send failure (caught and handled fail-open by the decorator) + */ + void send(Notification notification) throws Exception; +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java new file mode 100644 index 00000000..fb82e736 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.outbound.notification.core; + +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; +import dev.caskeleton.application.notification.NotificationPort; +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Routes notification calls to contributed {@link NotificationProvider}s ({@code + * app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}). A duplicate {@code + * providerId} within a channel, or a route to a providerId with no enabled provider, fails + * construction; {@code notify} on an unbound channel/route throws {@link AdapterDisabledException} + * (Layer 3, never a silent no-op). A route's providerId list is fanned out to all providers, each + * already wrapped in {@link FailOpenNotificationProvider}, so one failure does not block the others + * — hence no try/catch in the fan-out loop. It does not expose the resolved provider, so no adapter + * type escapes via a public return (B7). + */ +public final class RoutingNotifier implements NotificationPort { + + private static final String ADAPTER_NAME = "notification"; + + private final Map<Channel, Map<String, FailOpenNotificationProvider>> registry; + + private final Map<Channel, Map<String, List<String>>> routes; + + public RoutingNotifier( + Collection<? extends FailOpenNotificationProvider> providers, + Map<Channel, Map<String, List<String>>> routes) { + this.registry = buildRegistry(providers); + validateRoutes(routes, this.registry); + this.routes = immutableRoutesCopy(routes); + } + + /** + * Indexes contributed providers by {@code (channel, providerId)}. A duplicate providerId within a + * channel fails at construction (boot). + */ + private static Map<Channel, Map<String, FailOpenNotificationProvider>> buildRegistry( + Collection<? extends FailOpenNotificationProvider> providers) { + Map<Channel, Map<String, FailOpenNotificationProvider>> registry = new EnumMap<>(Channel.class); + for (FailOpenNotificationProvider provider : providers) { + Map<String, FailOpenNotificationProvider> byId = + registry.computeIfAbsent(provider.channel(), ch -> new HashMap<>()); + FailOpenNotificationProvider previous = byId.putIfAbsent(provider.providerId(), provider); + if (previous != null) { + throw new IllegalStateException( + "duplicate notification providerId '" + + provider.providerId() + + "' for channel " + + provider.channel() + + " — every contributed NotificationProvider bean must have a" + + " unique providerId within its channel"); + } + } + Map<Channel, Map<String, FailOpenNotificationProvider>> immutable = + new EnumMap<>(Channel.class); + registry.forEach((channel, byId) -> immutable.put(channel, Map.copyOf(byId))); + return Map.copyOf(immutable); + } + + /** + * Fails at construction (boot) if any route references a providerId with no enabled provider on + * that channel. The per-channel registry lookup is hoisted out of the inner loops — it depends + * only on the channel. + */ + private static void validateRoutes( + Map<Channel, Map<String, List<String>>> routes, + Map<Channel, Map<String, FailOpenNotificationProvider>> registry) { + for (Map.Entry<Channel, Map<String, List<String>>> channelEntry : routes.entrySet()) { + Channel channel = channelEntry.getKey(); + Map<String, FailOpenNotificationProvider> channelRegistry = + registry.getOrDefault(channel, Map.of()); + for (Map.Entry<String, List<String>> routeEntry : channelEntry.getValue().entrySet()) { + String route = routeEntry.getKey(); + for (String providerId : routeEntry.getValue()) { + if (!channelRegistry.containsKey(providerId)) { + throw new IllegalStateException( + "app.notification.routes." + + channel.name().toLowerCase() + + "." + + route + + " references providerId '" + + providerId + + "' but no enabled provider contributes that id for channel " + + channel + + " — enable the provider or fix the route binding"); + } + } + } + } + } + + /** Deep, immutable copy of the routes map (channel → route → providerId list). */ + private static Map<Channel, Map<String, List<String>>> immutableRoutesCopy( + Map<Channel, Map<String, List<String>>> routes) { + Map<Channel, Map<String, List<String>>> immutable = new EnumMap<>(Channel.class); + routes.forEach( + (channel, routeMap) -> { + Map<String, List<String>> copy = new HashMap<>(); + routeMap.forEach((route, ids) -> copy.put(route, List.copyOf(ids))); + immutable.put(channel, Map.copyOf(copy)); + }); + return Map.copyOf(immutable); + } + + @Override + public void notify(Channel channel, String route, Notification notification) { + List<String> providerIds = resolveRoute(channel, route); + Map<String, FailOpenNotificationProvider> channelRegistry = + registry.getOrDefault(channel, Map.of()); + // FailOpenNotificationProvider.send declares no throws — no try/catch needed. + // Individual provider failures are observed (logged) inside the decorator + // and never propagated, so one failure does not block remaining fan-out sends. + for (String providerId : providerIds) { + channelRegistry.get(providerId).send(notification); + } + } + + private List<String> resolveRoute(Channel channel, String route) { + Map<String, List<String>> channelRoutes = routes.get(channel); + if (channelRoutes == null || !channelRoutes.containsKey(route)) { + throw new AdapterDisabledException( + ADAPTER_NAME, + "no notification route bound for channel=" + + channel + + " route='" + + route + + "' — set app.notification.routes." + + channel.name().toLowerCase() + + "." + + route + + "=<providerId>[,<providerId>] and enable that provider" + + " (integration-adapter-templates Layer 3)"); + } + return channelRoutes.get(route); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.java new file mode 100644 index 00000000..8d8fe37b --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.notification.email.google; + +import dev.caskeleton.application.notification.Notification; + +/** + * Integration seam the forking project implements to bind the Google Email template to a real + * client (Gmail API / SMTP). The skeleton carries no mail SDK dependency — it is supplied by the + * project that enables Google Email. + */ +@FunctionalInterface +public interface GoogleEmailClient { + + /** + * Sends an email notification. May throw on any transport/provider failure. + * + * @throws Exception on a send failure (caught and handled fail-open by the adapter) + */ + void send(Notification notification) throws Exception; +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java new file mode 100644 index 00000000..88514a5f --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.notification.email.google; + +import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Layer 1 gating for the Google Email provider template: registers the Google Email {@link + * NotificationProvider} only when {@code app.notification.google-email.enabled=true}. Needs a + * project-supplied {@link GoogleEmailClient} bean (the integration seam). Adding another email + * provider is a new config like this one — this file never changes. + */ +@Configuration +public class GoogleEmailNotificationAdapterConfig { + + @Bean + @ConditionalOnProperty( + name = "app.notification.google-email.enabled", + havingValue = "true", + matchIfMissing = false) + public NotificationProvider googleEmailProvider(GoogleEmailClient googleEmailClient) { + return new GoogleEmailProvider(googleEmailClient); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailProvider.java new file mode 100644 index 00000000..69e42abe --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailProvider.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.email.google; + +import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; + +/** + * Google Email contribution of the {@link NotificationProvider} SPI (channel {@link Channel#EMAIL}, + * providerId {@code "google-email"}). Delegates the raw send to the project-supplied {@link + * GoogleEmailClient} seam; the fail-open policy and PII-safe logging are applied centrally by + * {@link dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider}, not here + * — so this class carries no policy and no mail SDK. + * + * <p>Adding another email provider (e.g. AWS SES) is a new sibling file with a different {@code + * providerId} — this file never changes. + */ +public class GoogleEmailProvider implements NotificationProvider { + + private static final String PROVIDER_ID = "google-email"; + + private final GoogleEmailClient client; + + public GoogleEmailProvider(GoogleEmailClient client) { + this.client = client; + } + + @Override + public Channel channel() { + return Channel.EMAIL; + } + + @Override + public String providerId() { + return PROVIDER_ID; + } + + @Override + public void send(Notification notification) throws Exception { + client.send(notification); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java new file mode 100644 index 00000000..78e64152 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.notification.slack.webhook; + +import dev.caskeleton.application.notification.Notification; + +/** + * Integration seam the forking project implements to bind the Slack template to a real Slack client + * (incoming-webhook / Web API). The skeleton carries no Slack SDK dependency — it is supplied by + * the project that enables Slack. + */ +@FunctionalInterface +public interface SlackClient { + + /** + * Sends a notification to Slack. May throw on any transport/provider failure. + * + * @throws Exception on a send failure (caught and handled fail-open by the adapter) + */ + void send(Notification notification) throws Exception; +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java new file mode 100644 index 00000000..a09129df --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.notification.slack.webhook; + +import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Layer 1 gating for the Slack webhook provider template: registers the Slack webhook {@link + * NotificationProvider} only when {@code app.notification.slack-webhook.enabled=true}. Needs a + * project-supplied {@link SlackClient} bean (the integration seam). Adding another Slack provider + * is a new config like this one — this file never changes. + */ +@Configuration +public class SlackNotificationAdapterConfig { + + @Bean + @ConditionalOnProperty( + name = "app.notification.slack-webhook.enabled", + havingValue = "true", + matchIfMissing = false) + public NotificationProvider slackWebhookProvider(SlackClient slackClient) { + return new SlackWebhookProvider(slackClient); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java new file mode 100644 index 00000000..b7805947 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.notification.slack.webhook; + +import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; + +/** + * Slack incoming-webhook contribution of the {@link NotificationProvider} SPI (channel {@link + * Channel#SLACK}, providerId {@code "slack-webhook"}). Delegates the raw send to the + * project-supplied {@link SlackClient} seam; the fail-open policy and PII-safe logging are applied + * centrally by {@link + * dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider}, not here — so + * this class carries no policy and no Slack SDK. + * + * <p>A second Slack provider (e.g. a Web-API client) is added as a sibling {@link + * NotificationProvider} with a different {@code providerId}. + */ +public class SlackWebhookProvider implements NotificationProvider { + + private static final String PROVIDER_ID = "slack-webhook"; + + private final SlackClient client; + + public SlackWebhookProvider(SlackClient client) { + this.client = client; + } + + @Override + public Channel channel() { + return Channel.SLACK; + } + + @Override + public String providerId() { + return PROVIDER_ID; + } + + @Override + public void send(Notification notification) throws Exception { + client.send(notification); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java new file mode 100644 index 00000000..450f4afd --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.adapter.outbound.notification.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * Adapter-level behavior contract for the router-fail-fast notification design: {@link + * RoutingNotifier} + {@link FailOpenNotificationProvider}. + * + * <p>Contracts verified here (plan §4 / router-fail-fast shape, aligned with the cache template): + * + * <ul> + * <li>Fail-open: a provider send failure is observed (no PII) and never rethrown. + * <li>Fail-fast: an unbound route throws {@link AdapterDisabledException}. + * <li>PII contract: {@link Notification} (recipient/body) NEVER appears in a log line. + * </ul> + * + * <p>The old per-channel {@code Disabled*Notifier} sentinel tests are removed — the + * router-fail-fast design has no per-channel sentinel; Layer 3 is now enforced by {@link + * RoutingNotifier#notify} for unbound routes (mirror of cache D4). + */ +class NotificationAdapterTest { + + // PII markers placed in the notification to assert they never reach a log line. + private static final Notification PII_NOTIFICATION = + new Notification("secret@gmail.com", "subject", "secret-body-content"); + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private FailOpenDependencyLogger dependencyLogger; + + @BeforeEach + void setUp() { + logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.notification"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + dependencyLogger = new FailOpenDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + private static NotificationProvider failingProvider(Channel channel, String id) { + return new NotificationProvider() { + @Override + public Channel channel() { + return channel; + } + + @Override + public String providerId() { + return id; + } + + @Override + public void send(Notification n) throws Exception { + throw new RuntimeException("provider-failure"); + } + }; + } + + // ------------------------------------------------------------------------- + // Fail-open (FailOpenNotificationProvider) + // ------------------------------------------------------------------------- + + @Test + void emailSendFailureIsFailOpenAndNeverRethrows() { + FailOpenNotificationProvider failOpen = + new FailOpenNotificationProvider( + failingProvider(Channel.EMAIL, "google-email"), dependencyLogger); + + assertThatCode(() -> failOpen.send(PII_NOTIFICATION)).doesNotThrowAnyException(); + } + + @Test + void emailSendFailureLogContainsProviderIdAndNoPii() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-email-1"); + FailOpenNotificationProvider failOpen = + new FailOpenNotificationProvider( + failingProvider(Channel.EMAIL, "google-email"), dependencyLogger); + + failOpen.send(PII_NOTIFICATION); + + String rendered = + appender.list.stream() + .filter(e -> e.getLevel() == Level.WARN) + .findFirst() + .orElseThrow() + .getFormattedMessage(); + assertThat(rendered) + .contains("dependency_name=\"google-email\"") + .contains("dependency_type=\"notification\"") + .contains("outcome=\"FAILURE\"") + .contains("correlation_id=\"corr-email-1\""); + assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content"); + } + + @Test + void slackSendFailureIsFailOpenAndNeverRethrows() { + FailOpenNotificationProvider failOpen = + new FailOpenNotificationProvider( + failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger); + + assertThatCode(() -> failOpen.send(PII_NOTIFICATION)).doesNotThrowAnyException(); + } + + @Test + void slackSendFailureLogContainsProviderIdAndNoPii() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-slack-1"); + FailOpenNotificationProvider failOpen = + new FailOpenNotificationProvider( + failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger); + + failOpen.send(PII_NOTIFICATION); + + String rendered = + appender.list.stream() + .filter(e -> e.getLevel() == Level.WARN) + .findFirst() + .orElseThrow() + .getFormattedMessage(); + assertThat(rendered) + .contains("dependency_name=\"slack-webhook\"") + .contains("dependency_type=\"notification\"") + .contains("correlation_id=\"corr-slack-1\""); + assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content"); + } + + // ------------------------------------------------------------------------- + // Fail-fast (RoutingNotifier — router-fail-fast, no Disabled* sentinel) + // ------------------------------------------------------------------------- + + @Test + void unboundRouteThrowsAdapterDisabledExceptionForEmail() { + RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of()); + + assertThatThrownBy(() -> notifier.notify(Channel.EMAIL, PII_NOTIFICATION)) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("notification"); + } + + @Test + void unboundRouteThrowsAdapterDisabledExceptionForSlack() { + RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of()); + + assertThatThrownBy(() -> notifier.notify(Channel.SLACK, "alerts", PII_NOTIFICATION)) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("notification"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java new file mode 100644 index 00000000..8aa76835 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java @@ -0,0 +1,343 @@ +package dev.caskeleton.adapter.outbound.notification.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger; +import dev.caskeleton.adapter.outbound.support.OutboundCorrelation; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * TDD tests for the multi-provider notification redesign: {@link RoutingNotifier} + {@link + * FailOpenNotificationProvider}. + * + * <p>Behavior contracts (plan §4): + * + * <ul> + * <li>default route → single provider send once. + * <li>route=N providerIds → N sends (fan-out). + * <li>one provider throws → others still send + failure logged (fail-open, no propagation). + * <li>unbound route → {@link AdapterDisabledException}. + * <li>duplicate providerId in channel → startup {@link IllegalStateException}. + * <li>route references non-existent providerId → startup {@link IllegalStateException}. + * <li>zero providers + zero routes → constructs cleanly. + * <li>PII: {@link Notification} is NEVER passed to the dependency logger. + * </ul> + * + * <p>{@link RoutingNotifier} accepts {@link FailOpenNotificationProvider} (typed registry — no + * try/catch in the fan-out loop). Tests that need routing wrap raw stubs in {@link + * FailOpenNotificationProvider} before passing to the constructor. + */ +class RoutingNotifierTest { + + // PII markers placed in the notification to assert they never reach a log line. + private static final Notification PII_NOTIFICATION = + new Notification("secret@gmail.com", "subject", "secret-body-content"); + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private FailOpenDependencyLogger dependencyLogger; + + @BeforeEach + void setUp() { + logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.notification"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + dependencyLogger = new FailOpenDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + // ------------------------------------------------------------------------- + // Stub helpers + // ------------------------------------------------------------------------- + + private static NotificationProvider stubProvider(Channel channel, String providerId) { + return new NotificationProvider() { + @Override + public Channel channel() { + return channel; + } + + @Override + public String providerId() { + return providerId; + } + + @Override + public void send(Notification n) { + /* no-op success */ + } + }; + } + + private static NotificationProvider countingProvider( + Channel channel, String providerId, AtomicInteger counter) { + return new NotificationProvider() { + @Override + public Channel channel() { + return channel; + } + + @Override + public String providerId() { + return providerId; + } + + @Override + public void send(Notification n) { + counter.incrementAndGet(); + } + }; + } + + private static NotificationProvider failingProvider(Channel channel, String providerId) { + return new NotificationProvider() { + @Override + public Channel channel() { + return channel; + } + + @Override + public String providerId() { + return providerId; + } + + @Override + public void send(Notification n) throws Exception { + throw new RuntimeException("provider-failure"); + } + }; + } + + /** Wraps a raw provider in FailOpenNotificationProvider (mirrors NotificationConfig). */ + private FailOpenNotificationProvider failOpen(NotificationProvider provider) { + return new FailOpenNotificationProvider(provider, dependencyLogger); + } + + // ------------------------------------------------------------------------- + // FailOpenNotificationProvider tests + // ------------------------------------------------------------------------- + + @Test + void failOpenProviderSwallowsExceptionAndNeverRethrows() { + FailOpenNotificationProvider failOpenProv = + new FailOpenNotificationProvider( + failingProvider(Channel.EMAIL, "google-email"), dependencyLogger); + + assertThatCode(() -> failOpenProv.send(PII_NOTIFICATION)).doesNotThrowAnyException(); + } + + @Test + void failOpenProviderLogsFailureWithProviderIdAndNoPii() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-1"); + FailOpenNotificationProvider failOpenProv = + new FailOpenNotificationProvider( + failingProvider(Channel.EMAIL, "google-email"), dependencyLogger); + + failOpenProv.send(PII_NOTIFICATION); + + String rendered = + appender.list.stream() + .filter(e -> e.getLevel() == Level.WARN) + .findFirst() + .orElseThrow() + .getFormattedMessage(); + assertThat(rendered) + .contains("dependency_name=\"google-email\"") + .contains("dependency_type=\"notification\"") + .contains("correlation_id=\"corr-1\""); + // PII contract: notification body/recipient must never appear in logs. + assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content"); + } + + @Test + void failOpenProviderDelegatesChannelAndProviderId() { + FailOpenNotificationProvider failOpenProv = + new FailOpenNotificationProvider( + stubProvider(Channel.SLACK, "slack-webhook"), dependencyLogger); + + assertThat(failOpenProv.channel()).isEqualTo(Channel.SLACK); + assertThat(failOpenProv.providerId()).isEqualTo("slack-webhook"); + } + + @Test + void failOpenProviderLogsSuccessOnSendWithNoPii() { + FailOpenNotificationProvider failOpenProv = + new FailOpenNotificationProvider( + stubProvider(Channel.EMAIL, "google-email"), dependencyLogger); + + failOpenProv.send(PII_NOTIFICATION); + + boolean anyPii = + appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .anyMatch(m -> m.contains("secret@gmail.com") || m.contains("secret-body-content")); + assertThat(anyPii).isFalse(); + } + + // ------------------------------------------------------------------------- + // RoutingNotifier tests + // ------------------------------------------------------------------------- + + @Test + void defaultRouteSendsToSingleProviderOnce() { + AtomicInteger sendCount = new AtomicInteger(); + List<FailOpenNotificationProvider> providers = + List.of(failOpen(countingProvider(Channel.EMAIL, "google-email", sendCount))); + Map<Channel, Map<String, List<String>>> routes = + Map.of(Channel.EMAIL, Map.of("default", List.of("google-email"))); + + RoutingNotifier notifier = new RoutingNotifier(providers, routes); + notifier.notify(Channel.EMAIL, PII_NOTIFICATION); + + assertThat(sendCount.get()).isEqualTo(1); + } + + @Test + void fanOutRouteSendsToAllProviders() { + AtomicInteger sendA = new AtomicInteger(); + AtomicInteger sendB = new AtomicInteger(); + List<FailOpenNotificationProvider> providers = + List.of( + failOpen(countingProvider(Channel.EMAIL, "provider-a", sendA)), + failOpen(countingProvider(Channel.EMAIL, "provider-b", sendB))); + Map<Channel, Map<String, List<String>>> routes = + Map.of(Channel.EMAIL, Map.of("alerts", List.of("provider-a", "provider-b"))); + + RoutingNotifier notifier = new RoutingNotifier(providers, routes); + notifier.notify(Channel.EMAIL, "alerts", PII_NOTIFICATION); + + assertThat(sendA.get()).isEqualTo(1); + assertThat(sendB.get()).isEqualTo(1); + } + + @Test + void failingProviderDoesNotBlockRemainingFanOutSends() { + AtomicInteger sendB = new AtomicInteger(); + // provider-a fails (fail-open wraps it), provider-b must still get called + List<FailOpenNotificationProvider> wrapped = + List.of( + new FailOpenNotificationProvider( + failingProvider(Channel.EMAIL, "provider-a"), dependencyLogger), + new FailOpenNotificationProvider( + countingProvider(Channel.EMAIL, "provider-b", sendB), dependencyLogger)); + Map<Channel, Map<String, List<String>>> routes = + Map.of(Channel.EMAIL, Map.of("alerts", List.of("provider-a", "provider-b"))); + + RoutingNotifier notifier = new RoutingNotifier(wrapped, routes); + assertThatCode(() -> notifier.notify(Channel.EMAIL, "alerts", PII_NOTIFICATION)) + .doesNotThrowAnyException(); + + assertThat(sendB.get()).isEqualTo(1); + } + + @Test + void unboundRouteThrowsAdapterDisabledException() { + RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of()); + + assertThatThrownBy(() -> notifier.notify(Channel.EMAIL, PII_NOTIFICATION)) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("notification"); + } + + @Test + void duplicateProviderIdWithinChannelFailsConstruction() { + List<FailOpenNotificationProvider> providers = + List.of( + failOpen(stubProvider(Channel.EMAIL, "google-email")), + failOpen(stubProvider(Channel.EMAIL, "google-email"))); // duplicate + + assertThatThrownBy(() -> new RoutingNotifier(providers, Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate") + .hasMessageContaining("google-email"); + } + + @Test + void routeReferencingUnknownProviderFailsConstruction() { + List<FailOpenNotificationProvider> providers = List.of(); // no providers + Map<Channel, Map<String, List<String>>> routes = + Map.of(Channel.EMAIL, Map.of("default", List.of("google-email"))); + + assertThatThrownBy(() -> new RoutingNotifier(providers, routes)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("google-email"); + } + + @Test + void zeroProvidersAndRoutesConstructsCleanly() { + // L262: optional module must not block startup when unconfigured. + assertThatCode(() -> new RoutingNotifier(List.of(), Map.of())).doesNotThrowAnyException(); + } + + @Test + void piiNeverReachesLogOnSendFailure() { + List<FailOpenNotificationProvider> wrapped = + List.of( + new FailOpenNotificationProvider( + failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger)); + Map<Channel, Map<String, List<String>>> routes = + Map.of(Channel.SLACK, Map.of("default", List.of("slack-webhook"))); + + RoutingNotifier notifier = new RoutingNotifier(wrapped, routes); + notifier.notify(Channel.SLACK, PII_NOTIFICATION); + + boolean anyPii = + appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .anyMatch(m -> m.contains("secret@gmail.com") || m.contains("secret-body-content")); + assertThat(anyPii).isFalse(); + } + + @Test + void notifyWithExplicitRouteUsesNamedRoute() { + List<String> sent = new ArrayList<>(); + NotificationProvider provider = + new NotificationProvider() { + @Override + public Channel channel() { + return Channel.SLACK; + } + + @Override + public String providerId() { + return "slack-webhook"; + } + + @Override + public void send(Notification n) { + sent.add(providerId()); + } + }; + Map<Channel, Map<String, List<String>>> routes = + Map.of(Channel.SLACK, Map.of("team-alerts", List.of("slack-webhook"))); + + RoutingNotifier notifier = new RoutingNotifier(List.of(failOpen(provider)), routes); + notifier.notify(Channel.SLACK, "team-alerts", PII_NOTIFICATION); + + assertThat(sent).containsExactly("slack-webhook"); + } +} diff --git a/src/adapter/outbound/objectstorage/CLAUDE.md b/src/adapter/outbound/objectstorage/CLAUDE.md new file mode 100644 index 00000000..e8d7ff9b --- /dev/null +++ b/src/adapter/outbound/objectstorage/CLAUDE.md @@ -0,0 +1,49 @@ +# adapter:outbound:objectstorage — module rules + +## Registered identity + +- Module ID: `adapter-outbound-objectstorage` +- Gradle path: `:adapter:outbound:objectstorage` +- Focused test: `./gradlew :adapter:outbound:objectstorage:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.objectstorage`. Driven (outbound) adapter +implementing `dev.caskeleton.application.storage.ObjectStoragePort` (application-core). Design +rationale lives in [README.md](README.md). + +## Responsibility + +- Persist/retrieve binary blobs behind `ObjectStoragePort`. Two backends select the same port by + `ca-skeleton.objectstorage.backend`: `filesystem` (default) and `s3` (S3/MinIO, AWS SDK v2). +- Opt-in: `ObjectStorageConfig` gates each backend with `@ConditionalOnProperty`; filesystem is the + `matchIfMissing` default. The adapters are plain classes; the config assembles them as beans. + +## Allowed + +- Project deps: `:application-core`, `:shared-contract` — SSOT is the + `adapter-outbound-objectstorage` entry in `.harness/project/modules.yaml`; `src/build.gradle` + enforces it. No + `:domain-core`, no sibling adapters (shared outbound code would go through `:adapter:outbound:support` + if ever needed). +- External: `software.amazon.awssdk:s3` (version via the module-scoped `software.amazon.awssdk:bom` + platform, pinned by root `ext.awsSdkVersion`), `spring-boot-starter`, + `spring-boot-configuration-processor` (annotation processor). + +## Forbidden + +- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio` + (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). +- Leaking a raw AWS SDK type across `ObjectStoragePort` (B7) — the port returns only `StoredObject` + / `byte[]` / primitives. +- Fully-qualified inline type references; more than one public top-level type per file. + +## Tests + +`FilesystemObjectStorageAdapterTest` (temp-dir round-trip), `S3ObjectStorageAdapterTest` (mocked +`S3Client` mapping), `S3ObjectStorageAdapterIT` (Testcontainers MinIO, `disabledWithoutDocker`). + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:check +``` diff --git a/src/adapter/outbound/objectstorage/README.md b/src/adapter/outbound/objectstorage/README.md new file mode 100644 index 00000000..fc7f3c1d --- /dev/null +++ b/src/adapter/outbound/objectstorage/README.md @@ -0,0 +1,72 @@ +# adapter:outbound:objectstorage — design-decision reference + +Object-storage outbound (driven) adapter. Package root: +`dev.caskeleton.adapter.outbound.objectstorage`. Implements the `application-core` port +`dev.caskeleton.application.storage.ObjectStoragePort` behind an opt-in `@ConditionalOnProperty` +selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient). + +The allowed/forbidden dependency policy is owned by `src/build.gradle`'s +`allowedProjectDependencies['adapter:outbound:objectstorage']` (SSOT). Module rules live in +[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code +comments. + +## Module overview + +An **opt-in** blob-storage adapter placed behind an application-core port. Two backends select the +same `ObjectStoragePort` by configuration: + +- **filesystem** (default, `matchIfMissing`) — `FilesystemObjectStorageAdapter` writes blobs under + `ca-skeleton.objectstorage.base-path`. No external service, so the local profile just works. The + `location` in the `StoredObject` receipt is the `file://` URI. +- **s3** — `S3ObjectStorageAdapter` uses the AWS SDK v2 `S3Client`. The client's endpoint override + + path-style access (wired in `ObjectStorageConfig`) make the same code work against real AWS S3 + (leave `endpoint` unset) and MinIO (`endpoint=http://localhost:9000`). The `location` is an + `s3://bucket/key` URI. + +Selector: `ca-skeleton.objectstorage.backend=filesystem|s3` (filesystem is the default). Exactly one +`ObjectStoragePort` bean is contributed, so a fork injects the port without knowing the active +backend. + +## The port contract (framework-neutral) + +`ObjectStoragePort` is a minimal, framework-neutral surface: + +- `StoredObject put(String key, byte[] content, String contentType)` — store/overwrite. +- `Optional<byte[]> get(String key)` — read, `empty()` when absent. +- `void delete(String key)` — idempotent delete. +- `boolean exists(String key)`. + +Keys are caller-supplied, backend-relative, opaque strings. Implementations reject a blank key or a +key that escapes the backend namespace (path traversal) with `IllegalArgumentException` — the +filesystem adapter normalises the resolved path and checks it still starts with the base directory. +The port intentionally exposes **no** streaming or presigned-URL surface; a fork adds those when a +concrete feature needs them. Raw external SDK types never cross the port (B7) — the adapter returns +only `StoredObject` / `byte[]` / primitives. + +## AWS SDK versioning (why the BOM is imported at module scope) + +`software.amazon.awssdk:*` versions are **not** managed by the Spring Boot BOM and this repo has no +version catalog. The AWS SDK v2 BOM is therefore imported as a `dependencyManagement` platform in +**this module's** `build.gradle` using the root `ext.awsSdkVersion` SSOT (set in `src/build.gradle`), +exactly like the grpc module imports `grpc-bom`. This keeps the strict-locking blast radius to this +module — the shared root `dependencyManagement` block stays awssdk-free. + +## IO-failure handling + +Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException` +(`dependencyName="objectstorage"`) so a fork's web error handler classifies them uniformly with the +other outbound dependencies. Illegal/blank keys are `IllegalArgumentException` (a caller bug, not a +dependency failure). The S3 adapter maps `NoSuchKey` / HTTP 404 to `Optional.empty()` / `false`. + +## Tests + +- `FilesystemObjectStorageAdapterTest` — `@TempDir` put/get/delete/exists round-trip, overwrite, + idempotent delete, path-traversal + blank-key rejection. +- `S3ObjectStorageAdapterTest` — key/metadata/URI mapping against a mocked `S3Client` (no network). +- `S3ObjectStorageAdapterIT` — real S3-protocol round-trip against Testcontainers MinIO; skipped + automatically when Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`). + +```bash +cd src +./gradlew :adapter:outbound:objectstorage:check +``` diff --git a/src/adapter/outbound/objectstorage/build.gradle b/src/adapter/outbound/objectstorage/build.gradle new file mode 100644 index 00000000..fde99d40 --- /dev/null +++ b/src/adapter/outbound/objectstorage/build.gradle @@ -0,0 +1,33 @@ +// Driven adapter: object storage behind application-core's ObjectStoragePort. Two backends — local +// filesystem (default, no external service) and S3/MinIO via the AWS SDK v2 S3 client (endpoint +// override makes the same code work against real AWS S3 and MinIO). Opt-in via +// @ConditionalOnProperty (ca-skeleton.objectstorage.backend); filesystem is the matchIfMissing +// default. +// +// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, and this repo has no +// version catalog, so the AWS SDK v2 BOM platform is imported HERE (module scope) using the root +// `ext.awsSdkVersion` SSOT — this keeps the strict-locking blast radius to this module (the shared +// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import. +description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)' + +dependencyManagement { + imports { + mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}" + } +} + +dependencies { + implementation project(':application-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'software.amazon.awssdk:s3' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // test-only: Testcontainers MinIO integration test for the S3 backend. Uses the core + // GenericContainer (no dedicated module) so the S3 round-trip runs against a real MinIO when + // Docker is available and is skipped (disabledWithoutDocker) otherwise. + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' +} diff --git a/src/adapter/outbound/objectstorage/gradle.lockfile b/src/adapter/outbound/objectstorage/gradle.lockfile new file mode 100644 index 00000000..70aa1437 --- /dev/null +++ b/src/adapter/outbound/objectstorage/gradle.lockfile @@ -0,0 +1,208 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=runtimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle,runtimeClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpcore:4.4.16=checkstyle,runtimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reflections:reflections:0.10.2=checkstyle +org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:annotations:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:apache-client:2.30.0=runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:arns:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:auth:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-query-protocol:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-xml-protocol:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:checksums-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:checksums:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:crt-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:endpoints-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-aws:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:http-client-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:identity-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:json-utils:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:metrics-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:netty-nio-client:2.30.0=runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:profiles:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:protocol-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:regions:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:retries-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:retries:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:s3:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:sdk-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:third-party-jackson-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:utils:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.eventstream:eventstream:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java new file mode 100644 index 00000000..6dcf4300 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.shared.error.DependencyFailureException; +import dev.caskeleton.shared.error.OperationalError; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filesystem-backed {@link ObjectStoragePort} — the default backend so the local profile needs no + * MinIO/S3. Blobs are written under a configured base directory and the {@code location} in the + * returned {@link StoredObject} is the {@code file://} URI. Content type is not persisted (this + * port exposes no content type on read); it is echoed back in the {@link StoredObject} receipt + * only. + */ +public class FilesystemObjectStorageAdapter implements ObjectStoragePort { + + private static final String DEPENDENCY_NAME = "objectstorage"; + + private static final Logger log = LoggerFactory.getLogger(FilesystemObjectStorageAdapter.class); + + private final Path baseDir; + + public FilesystemObjectStorageAdapter(String basePath) { + this.baseDir = Path.of(basePath).toAbsolutePath().normalize(); + try { + Files.createDirectories(baseDir); + log.info("filesystem object storage base dir: {}", baseDir); + } catch (IOException e) { + throw new DependencyFailureException( + OperationalError.INTERNAL_ERROR, + DEPENDENCY_NAME, + "cannot create object storage base dir", + e); + } + } + + @Override + public StoredObject put(String key, byte[] content, String contentType) { + Objects.requireNonNull(content, "content must be non-null"); + requireContentType(contentType); + Path target = resolve(key); + try { + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(target, content); + } catch (IOException e) { + throw new DependencyFailureException( + OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to store object", e); + } + return new StoredObject(key, content.length, contentType, target.toUri()); + } + + @Override + public Optional<byte[]> get(String key) { + Path target = resolve(key); + if (!Files.isRegularFile(target)) { + return Optional.empty(); + } + try { + return Optional.of(Files.readAllBytes(target)); + } catch (IOException e) { + throw new DependencyFailureException( + OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to read object", e); + } + } + + @Override + public void delete(String key) { + Path target = resolve(key); + try { + Files.deleteIfExists(target); + } catch (IOException e) { + throw new DependencyFailureException( + OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to delete object", e); + } + } + + @Override + public boolean exists(String key) { + return Files.isRegularFile(resolve(key)); + } + + /** Resolves a key under {@code baseDir}, rejecting blank keys and path traversal. */ + private Path resolve(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("object key must be non-null and non-blank"); + } + Path resolved = baseDir.resolve(key).normalize(); + if (!resolved.startsWith(baseDir)) { + throw new IllegalArgumentException("illegal object key (path traversal): " + key); + } + return resolved; + } + + private static void requireContentType(String contentType) { + if (contentType == null || contentType.isBlank()) { + throw new IllegalArgumentException("contentType must be non-null and non-blank"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java new file mode 100644 index 00000000..c71b18d2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import java.net.URI; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; + +/** + * Opt-in wiring for the object-storage adapter. The backend is selected by {@code + * ca-skeleton.objectstorage.backend}: {@code filesystem} (the {@code matchIfMissing} default) + * contributes a {@link FilesystemObjectStorageAdapter}; {@code s3} contributes an AWS SDK v2 {@link + * S3Client} plus an {@link S3ObjectStorageAdapter}. Exactly one {@link ObjectStoragePort} bean is + * contributed, so a fork can inject the port without knowing which backend is active. + * + * <p>The S3 client's endpoint override + path-style access make the same adapter work against real + * AWS S3 (leave {@code endpoint} unset) and MinIO (set {@code endpoint=http://localhost:9000}). + * When {@code auto-create-bucket} is enabled the bucket is created at startup if missing. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(ObjectStorageSettings.class) +public class ObjectStorageConfig { + + @Bean + @ConditionalOnProperty( + prefix = "ca-skeleton.objectstorage", + name = "backend", + havingValue = "filesystem", + matchIfMissing = true) + public ObjectStoragePort filesystemObjectStoragePort(ObjectStorageSettings properties) { + return new FilesystemObjectStorageAdapter(properties.getBasePath()); + } + + @Bean(destroyMethod = "close") + @ConditionalOnProperty(prefix = "ca-skeleton.objectstorage", name = "backend", havingValue = "s3") + public S3Client objectStorageS3Client(ObjectStorageSettings properties) { + S3ClientBuilder builder = + S3Client.builder() + .region(Region.of(properties.getRegion())) + .forcePathStyle(properties.isPathStyleAccess()); + if (StringUtils.hasText(properties.getEndpoint())) { + builder.endpointOverride(URI.create(properties.getEndpoint())); + } + if (StringUtils.hasText(properties.getAccessKey())) { + builder.credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))); + } + return builder.build(); + } + + @Bean + @ConditionalOnProperty(prefix = "ca-skeleton.objectstorage", name = "backend", havingValue = "s3") + public ObjectStoragePort s3ObjectStoragePort( + S3Client objectStorageS3Client, ObjectStorageSettings properties) { + S3ObjectStorageAdapter adapter = + new S3ObjectStorageAdapter(objectStorageS3Client, properties.getBucket()); + if (properties.isAutoCreateBucket()) { + adapter.ensureBucketExists(); + } + return adapter; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java new file mode 100644 index 00000000..ac7f8b92 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Typed settings for the object-storage adapter, bound from {@code ca-skeleton.objectstorage.*}. + * Everything both backends need is expressed here, so switching from the local filesystem to + * S3/MinIO is pure configuration. Bound as a mutable JavaBean (not a record) so a fork can leave + * any subset of fields unset and inherit the defaults below. Named {@code *Settings} per the + * code-conventions N6 naming rule for {@code dev.caskeleton} {@code @ConfigurationProperties} + * types. + */ +@ConfigurationProperties(prefix = "ca-skeleton.objectstorage") +public class ObjectStorageSettings { + + /** Which backend to activate: {@code filesystem} (default) or {@code s3}. */ + private String backend = "filesystem"; + + /** Filesystem backend: root directory blobs are written under. */ + private String basePath = "./.data/objectstorage"; + + /** S3/MinIO backend: target bucket. */ + private String bucket = "ca-skeleton"; + + /** + * S3/MinIO backend: endpoint override. Defaults to a local MinIO ({@code http://localhost:9000}) + * so {@code backend=s3} connects to a local S3-compatible store out of the box; set to {@code + * null}/empty to target real AWS S3 (virtual-host style), or override per environment. + */ + private String endpoint = "http://localhost:9000"; + + /** S3/MinIO backend: AWS region (also required by MinIO's signature). */ + private String region = "us-east-1"; + + /** S3/MinIO backend: access key; null falls back to the default AWS credential chain. */ + private String accessKey; + + /** S3/MinIO backend: secret key; null falls back to the default AWS credential chain. */ + private String secretKey; + + /** S3/MinIO backend: MinIO requires path-style access; real S3 uses virtual-host style. */ + private boolean pathStyleAccess = true; + + /** S3/MinIO backend: create the bucket on startup if it is missing. */ + private boolean autoCreateBucket = true; + + public String getBackend() { + return backend; + } + + public void setBackend(String backend) { + this.backend = backend; + } + + public String getBasePath() { + return basePath; + } + + public void setBasePath(String basePath) { + this.basePath = basePath; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public boolean isPathStyleAccess() { + return pathStyleAccess; + } + + public void setPathStyleAccess(boolean pathStyleAccess) { + this.pathStyleAccess = pathStyleAccess; + } + + public boolean isAutoCreateBucket() { + return autoCreateBucket; + } + + public void setAutoCreateBucket(boolean autoCreateBucket) { + this.autoCreateBucket = autoCreateBucket; + } +} diff --git a/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java new file mode 100644 index 00000000..790f5467 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java @@ -0,0 +1,127 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.storage.StoredObject; +import java.net.URI; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * S3/MinIO implementation of {@link ObjectStoragePort} (AWS SDK v2 {@link S3Client}). Selected with + * {@code ca-skeleton.objectstorage.backend=s3}. The client's endpoint override + path-style access + * (wired in {@link ObjectStorageConfig}) make this same code work against real AWS S3 and MinIO. + * The {@code location} in the returned {@link StoredObject} is an {@code s3://bucket/key} URI. + */ +public class S3ObjectStorageAdapter implements ObjectStoragePort { + + private static final Logger log = LoggerFactory.getLogger(S3ObjectStorageAdapter.class); + + private final S3Client s3; + private final String bucket; + + public S3ObjectStorageAdapter(S3Client s3, String bucket) { + this.s3 = Objects.requireNonNull(s3, "s3 client must be non-null"); + if (bucket == null || bucket.isBlank()) { + throw new IllegalArgumentException("bucket must be non-null and non-blank"); + } + this.bucket = bucket; + } + + /** + * Creates the configured bucket if it does not already exist. Invoked at startup by {@link + * ObjectStorageConfig} when {@code auto-create-bucket} is enabled; a no-op when the bucket is + * present. + */ + public void ensureBucketExists() { + try { + s3.headBucket(HeadBucketRequest.builder().bucket(bucket).build()); + } catch (NoSuchBucketException e) { + createBucket(); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + createBucket(); + } else { + throw e; + } + } + } + + private void createBucket() { + log.info("creating object storage bucket '{}'", bucket); + s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + } + + @Override + public StoredObject put(String key, byte[] content, String contentType) { + requireKey(key); + Objects.requireNonNull(content, "content must be non-null"); + if (contentType == null || contentType.isBlank()) { + throw new IllegalArgumentException("contentType must be non-null and non-blank"); + } + s3.putObject( + PutObjectRequest.builder().bucket(bucket).key(key).contentType(contentType).build(), + RequestBody.fromBytes(content)); + log.debug("uploaded s3://{}/{} ({} bytes)", bucket, key, content.length); + return new StoredObject(key, content.length, contentType, location(key)); + } + + @Override + public Optional<byte[]> get(String key) { + requireKey(key); + try { + ResponseBytes<GetObjectResponse> object = + s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key(key).build()); + return Optional.of(object.asByteArray()); + } catch (NoSuchKeyException e) { + return Optional.empty(); + } + } + + @Override + public void delete(String key) { + requireKey(key); + s3.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build()); + } + + @Override + public boolean exists(String key) { + requireKey(key); + try { + s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return false; + } + throw e; + } + } + + /** The {@code s3://bucket/key} locator recorded in a {@link StoredObject} receipt. */ + URI location(String key) { + return URI.create("s3://" + bucket + "/" + key); + } + + private static void requireKey(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("object key must be non-null and non-blank"); + } + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapterTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapterTest.java new file mode 100644 index 00000000..0879e7a2 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapterTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.storage.StoredObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Temp-dir round-trip contract for {@link FilesystemObjectStorageAdapter} (the default backend). + */ +class FilesystemObjectStorageAdapterTest { + + private FilesystemObjectStorageAdapter adapter; + + @BeforeEach + void setUp(@TempDir Path baseDir) { + adapter = new FilesystemObjectStorageAdapter(baseDir.toString()); + } + + @Test + void putThenGetReturnsSameBytes() { + byte[] content = "hello-object-storage".getBytes(StandardCharsets.UTF_8); + + StoredObject stored = adapter.put("docs/greeting.txt", content, "text/plain"); + + assertThat(stored.key()).isEqualTo("docs/greeting.txt"); + assertThat(stored.size()).isEqualTo(content.length); + assertThat(stored.contentType()).isEqualTo("text/plain"); + assertThat(stored.location().getScheme()).isEqualTo("file"); + + assertThat(adapter.get("docs/greeting.txt")) + .map(bytes -> new String(bytes, StandardCharsets.UTF_8)) + .contains(new String(content, StandardCharsets.UTF_8)); + } + + @Test + void existsReflectsPutAndDelete() { + assertThat(adapter.exists("k")).isFalse(); + + adapter.put("k", new byte[] {1, 2, 3}, "application/octet-stream"); + assertThat(adapter.exists("k")).isTrue(); + + adapter.delete("k"); + assertThat(adapter.exists("k")).isFalse(); + assertThat(adapter.get("k")).isEmpty(); + } + + @Test + void getMissingKeyReturnsEmpty() { + assertThat(adapter.get("absent")).isEqualTo(Optional.empty()); + } + + @Test + void deleteIsIdempotentForMissingKey() { + adapter.delete("never-written"); // must not throw + assertThat(adapter.exists("never-written")).isFalse(); + } + + @Test + void overwriteReplacesContent() { + adapter.put("k", "first".getBytes(StandardCharsets.UTF_8), "text/plain"); + adapter.put("k", "second".getBytes(StandardCharsets.UTF_8), "text/plain"); + + assertThat(adapter.get("k")).map(String::new).contains("second"); + } + + @Test + void pathTraversalKeyIsRejected() { + assertThatThrownBy(() -> adapter.put("../escape", new byte[0], "text/plain")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> adapter.exists("../escape")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void blankKeyIsRejected() { + assertThatThrownBy(() -> adapter.get(" ")).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapterIT.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapterIT.java new file mode 100644 index 00000000..328b199f --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapterIT.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.storage.StoredObject; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +/** + * Real S3-protocol round-trip for {@link S3ObjectStorageAdapter} against a Testcontainers MinIO + * (endpoint override + path-style access — the exact wiring {@link ObjectStorageConfig} applies). + * Skipped automatically when Docker is unavailable ({@code disabledWithoutDocker = true}); the pure + * key/metadata/URI mapping is covered separately by {@link S3ObjectStorageAdapterTest}. + */ +@Testcontainers(disabledWithoutDocker = true) +class S3ObjectStorageAdapterIT { + + private static final int MINIO_PORT = 9000; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + private static final String BUCKET = "it-objectstorage"; + + @Container + @SuppressWarnings("resource") + static final GenericContainer<?> MINIO = + new GenericContainer<>(DockerImageName.parse("minio/minio:RELEASE.2024-01-16T16-07-38Z")) + .withEnv("MINIO_ROOT_USER", ACCESS_KEY) + .withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY) + .withCommand("server", "/data") + .withExposedPorts(MINIO_PORT) + .waitingFor(Wait.forHttp("/minio/health/ready").forPort(MINIO_PORT)); + + private S3Client s3; + private S3ObjectStorageAdapter adapter; + + @BeforeEach + void setUp() { + String endpoint = "http://" + MINIO.getHost() + ":" + MINIO.getMappedPort(MINIO_PORT); + s3 = + S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.US_EAST_1) + .forcePathStyle(true) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .build(); + adapter = new S3ObjectStorageAdapter(s3, BUCKET); + adapter.ensureBucketExists(); + } + + @AfterEach + void tearDown() { + if (s3 != null) { + s3.close(); + } + } + + @Test + void putGetExistsDeleteRoundTrip() { + byte[] content = "minio-round-trip".getBytes(StandardCharsets.UTF_8); + + StoredObject stored = adapter.put("reports/q3.csv", content, "text/csv"); + + assertThat(stored.location().toString()).isEqualTo("s3://" + BUCKET + "/reports/q3.csv"); + assertThat(stored.size()).isEqualTo(content.length); + assertThat(adapter.exists("reports/q3.csv")).isTrue(); + assertThat(adapter.get("reports/q3.csv")).map(String::new).contains("minio-round-trip"); + + adapter.delete("reports/q3.csv"); + + assertThat(adapter.exists("reports/q3.csv")).isFalse(); + assertThat(adapter.get("reports/q3.csv")).isEmpty(); + } + + @Test + void getAndExistsForAbsentKey() { + assertThat(adapter.get("nope/missing.bin")).isEmpty(); + assertThat(adapter.exists("nope/missing.bin")).isFalse(); + } + + @Test + void ensureBucketExistsIsIdempotent() { + adapter.ensureBucketExists(); // second call must not fail + adapter.put("k", new byte[] {9}, "application/octet-stream"); + assertThat(adapter.exists("k")).isTrue(); + } +} diff --git a/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapterTest.java b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapterTest.java new file mode 100644 index 00000000..3f4fcdb8 --- /dev/null +++ b/src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapterTest.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.objectstorage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.application.storage.StoredObject; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Key/metadata/URI mapping contract for {@link S3ObjectStorageAdapter}, verified against a mocked + * {@link S3Client} (no network). The real S3 protocol round-trip is exercised by {@link + * S3ObjectStorageAdapterIT} against Testcontainers MinIO. + */ +class S3ObjectStorageAdapterTest { + + private static final String BUCKET = "ca-skeleton-test"; + + @Test + void putMapsBucketKeyContentTypeAndReturnsS3Location() { + S3Client s3 = mock(S3Client.class); + S3ObjectStorageAdapter adapter = new S3ObjectStorageAdapter(s3, BUCKET); + byte[] content = "payload".getBytes(StandardCharsets.UTF_8); + + StoredObject stored = adapter.put("images/cover.png", content, "image/png"); + + ArgumentCaptor<PutObjectRequest> request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3).putObject(request.capture(), any(RequestBody.class)); + assertThat(request.getValue().bucket()).isEqualTo(BUCKET); + assertThat(request.getValue().key()).isEqualTo("images/cover.png"); + assertThat(request.getValue().contentType()).isEqualTo("image/png"); + + assertThat(stored.key()).isEqualTo("images/cover.png"); + assertThat(stored.size()).isEqualTo(content.length); + assertThat(stored.contentType()).isEqualTo("image/png"); + assertThat(stored.location().toString()).isEqualTo("s3://" + BUCKET + "/images/cover.png"); + } + + @Test + void getReturnsBytesForPresentObject() { + S3Client s3 = mock(S3Client.class); + byte[] content = "downloaded".getBytes(StandardCharsets.UTF_8); + when(s3.getObjectAsBytes(any(GetObjectRequest.class))) + .thenReturn(ResponseBytes.fromByteArray(GetObjectResponse.builder().build(), content)); + + S3ObjectStorageAdapter adapter = new S3ObjectStorageAdapter(s3, BUCKET); + + assertThat(adapter.get("k")).map(String::new).contains("downloaded"); + } + + @Test + void getMapsNoSuchKeyToEmpty() { + S3Client s3 = mock(S3Client.class); + when(s3.getObjectAsBytes(any(GetObjectRequest.class))) + .thenThrow(NoSuchKeyException.builder().message("missing").build()); + + S3ObjectStorageAdapter adapter = new S3ObjectStorageAdapter(s3, BUCKET); + + assertThat(adapter.get("absent")).isEmpty(); + } + + @Test + void existsMapsNoSuchKeyAndNotFoundToFalse() { + S3Client noSuchKey = mock(S3Client.class); + when(noSuchKey.headObject(any(HeadObjectRequest.class))) + .thenThrow(NoSuchKeyException.builder().message("missing").build()); + assertThat(new S3ObjectStorageAdapter(noSuchKey, BUCKET).exists("absent")).isFalse(); + + S3Client notFound = mock(S3Client.class); + when(notFound.headObject(any(HeadObjectRequest.class))) + .thenThrow(S3Exception.builder().statusCode(404).build()); + assertThat(new S3ObjectStorageAdapter(notFound, BUCKET).exists("absent")).isFalse(); + } + + @Test + void existsReturnsTrueWhenHeadSucceeds() { + S3Client s3 = mock(S3Client.class); + when(s3.headObject(any(HeadObjectRequest.class))) + .thenReturn(HeadObjectResponse.builder().build()); + + assertThat(new S3ObjectStorageAdapter(s3, BUCKET).exists("present")).isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md new file mode 100644 index 00000000..26d6e292 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -0,0 +1,194 @@ +# adapter:outbound:persistence-jpa — JPA/PostgreSQL persistence adapter + +## Registered identity + +- Module ID: `adapter-outbound-persistence-jpa` +- Gradle path: `:adapter:outbound:persistence-jpa` +- Focused test: `./gradlew :adapter:outbound:persistence-jpa:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.persistence`. + +Design decisions previously kept as code comments (transaction templates, auditing capture, +failure-translation SPI, idempotency/outbox concurrency, distributed-lock TTL) live in +[README.md](README.md). This file stays the SSOT for module rules and contract tables. + +This module is the RDBMS/JPA implementation base. It is not a datastore-neutral +core for MongoDB, Redis, DynamoDB, or other NoSQL stores. Future NoSQL persistence +adapters implement application/domain ports directly and must not depend on this module. + +## Responsibility + +- JPA entities. +- Spring Data repositories. +- Persistence mappers. +- Repository adapter implementations. +- `TransactionPort` implementation (`SpringTransactionPort`) — the bridge between + application transactional intent and Spring's `PlatformTransactionManager`. +- Audit-metadata base + actor seam (`audit/AuditableEntity`, `audit/AuditContextPort`, + `audit/DomainContextAuditContextPort`) — see "Persistence auditing contract" below. +- Vendor SPI extension points shared by all RDBMS vendors: + - `outbox/OutboxClaimRepository` — vendor module implements claim strategy (e.g. FOR UPDATE SKIP LOCKED). + - `failure/SqlStateErrorMapping` — vendor module contributes vendor-specific SQLState rows. + +## Allowed + +- `:application-core` +- `:domain-core` +- `:shared-contract` +- Spring Data JPA and Spring transaction. + +## Forbidden + +- `adapter-web`, `adapter-outbound`, or `app-bootstrap`. +- Presentation DTOs. +- Business policy decisions. +- Use case orchestration hidden inside persistence adapters. +- Repository adapters owning `@Transactional` boundaries — the application use case owns + the transaction via `TransactionPort` (see + [application-core/CLAUDE.md](../application-core/CLAUDE.md)). +- **DB drivers** (`org.postgresql..`) or **`org.flywaydb.database.postgresql..`** — those are + vendor-specific and belong only in this module's `.postgresql` package; NoSQL-specific dependencies + belong only in their own future modules + (persistence-multi-db-extensibility D3). This is enforced by ArchUnit + `persistence_rdbms_stays_vendor_neutral` in `CleanArchitectureTest`. +- NoSQL adapter code. MongoDB/Redis/DynamoDB adapters are sibling modules, not children of this module. +- Any sibling persistence or inbound/outbound adapter not allowed by the registry. + +## TransactionPort implementation contract + +`SpringTransactionPort` pre-builds one `TransactionTemplate` per mode: + +| Mode | Propagation | Isolation | Read-only | +|---|---|---|---| +| `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | +| `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` | +| `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` | + +Pre-built templates are immutable after construction so concurrent callers cannot +observe each other's reconfiguration. + +### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract) + +`REQUIRES_NEW` acquires a NEW physical JDBC connection while pinning the outer +transaction's connection. Provision the pool to satisfy: + +``` +hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1 +``` + +Loop-per-record `inNew` calls are forbidden (pool exhaustion + deadlock risk). +Batch records inside ONE `inNew`, or move the loop outside the transaction. + +## Persistence failure translation contract (feature-persistence-failure-baseline D1) + +A raw Spring `DataAccessException` (and the JPA exception / SQLState / constraint name +inside it) must never reach the presentation layer. The +`failure/PersistenceExceptionTranslator` classifies a `DataAccessException` by its +SQLState against the §SQLState → Error Code Matrix and returns a +framework-neutral `shared.error.PersistenceFailureException` carrying one of the +`DB_*` `OperationalError` codes. + +**Standard rows (core):** + +| SQLState | code | category | http | retryable | +|---|---|---|---|---| +| `08*` | `DB_UNAVAILABLE` | `TRANSIENT_DEPENDENCY` | 503 | true | +| `40001` | `DB_SERIALIZATION_FAILURE` | `CONFLICT` | 409 | true | +| `23502` | `DB_NULL_VIOLATION` | `DATA_INTEGRITY` | 409 | false | +| `23503` | `DB_FK_VIOLATION` | `DATA_INTEGRITY` | 409 | false | +| `23505` | `DB_UNIQUE_VIOLATION` | `CONFLICT` | 409 | false | +| `23514` | `DB_CHECK_VIOLATION` | `DATA_INTEGRITY` | 409 | false | + +**Vendor-specific rows (contributed by vendor module via `SqlStateErrorMapping` SPI):** + +| SQLState | code | vendor | +|---|---|---| +| `40P01` | `DB_DEADLOCK` | PostgreSQL (`.postgresql` package) | +| `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL | +| `57014` | `DB_QUERY_CANCELED` | PostgreSQL | + +- A repository adapter that catches a `DataAccessException` calls + `translator.translate(ex)` and rethrows the carrier (`ifPresent(e -> { throw e; })`); + an empty result means an unknown SQLState — rethrow the original so the web catch-all + answers a generic `INTERNAL` envelope (no leak). +- The category SSOT is the 10-value `Category` enum — there is **no** `PERSISTENCE` + category (branch-note §Audit CATEGORY_DRIFT). + +## Persistence auditing contract (feature-persistence-auditing-contract) + +Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an +infrastructure concern that must never reach `domain-core` (D2). It lives only on the +`audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in +by extending it (D6 — e.g. the sample `WorkLogEntity`). The domain aggregate itself carries +zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus +`domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy` +fields under `..domain..`). + +- **Capture = Manual explicit-set (D1 current default).** The repository adapter + constructor-injects `Clock` (D4) and `AuditContextPort` (D5) and stamps audit on `save`: + INSERT (null version) → `initializeAudit(now, actor)`; UPDATE (non-null version) → + carry the persisted `created_*` forward + `applyModification(now, actor)`. This mirrors + the `IdempotencyStoreAdapter` precedent. `created_*` is `updatable = false`. +- **Actor seam.** `AuditContextPort.currentActor()` reads the runtime-context-propagation + seam and falls back to `"system"` when no principal is bound (scheduler / Flyway / anonymous). + The actor's value semantics are owned by feature-authentication-authorization-contract + (UNSUPPORTED here); the type is fixed to `String`. +- **Excluded (D6).** Infra/immutable entities such as `IdempotencyRecordEntity` (own + `created_at`, no `updated_at`) do NOT extend `AuditableEntity`. `version`/optimistic-lock + is owned by feature-persistence-failure-baseline / feature-transaction-concurrency-contract, + not by this audit base. +- **Growth path (D1, deferred).** Migrate to Spring Data JPA Auditing + (`@EntityListeners(AuditingEntityListener)` + `@CreatedDate`/`@LastModifiedDate`/… on the + base, `@EnableJpaAuditing(dateTimeProviderRef, auditorAwareRef)` in the composition root, + `DateTimeProvider` wrapping the same `Clock`, `AuditorAware<String>` delegating to + `AuditContextPort`) when manual set risks omission. Bulk/native `@Query` UPDATEs bypass + both capture paths — stamp audit explicitly there if added. + +## MapStruct generated mapper exemption (D9 of feature-architecture-enforcement-rules) + +If MapStruct is introduced for persistence mappers, the generated mapper class will +be annotated with `javax.annotation.processing.Generated`. Architecture rules that +forbid mapper boundary violations MUST exempt generated code via ArchUnit predicate: + +```java +import javax.annotation.processing.Generated; + +classes() + .that().resideInAPackage("..adapter.persistence.mapper..") + .and().areNotAnnotatedWith(Generated.class) + .should() /* ... boundary rule ... */; +``` + +> Note the annotation FQN: MapStruct uses +> `javax.annotation.processing.Generated`. Spring AOT uses +> `org.springframework.aot.generate.Generated` — do **not** mix the two. The +> exemption MUST scope to the specific annotation expected for the build step +> being exempted. + +Current ca-tmpl mappers are hand-written so no MapStruct exemption is wired into +ArchUnit yet — when generation is added, follow the predicate above and add a +red/green test using a fixture mapper. + +## NoSQL extension rule + +Do not create `adapter-persistence-nosql-core` preemptively. NoSQL stores have different +models and operational contracts. When a real MongoDB, Redis, or DynamoDB adapter is needed, +create a sibling module: + +```text +adapter-persistence-mongodb +adapter-persistence-redis +adapter-persistence-dynamodb +``` + +Such modules implement application/domain ports directly and must not depend on +`adapter:outbound:persistence-jpa`. + +## Test + +```bash +cd src +./gradlew :adapter:outbound:persistence-jpa:test --console=plain +``` diff --git a/src/adapter/outbound/persistence-jpa/README.md b/src/adapter/outbound/persistence-jpa/README.md new file mode 100644 index 00000000..84182a90 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/README.md @@ -0,0 +1,250 @@ +# adapter-persistence-rdbms — 설계 결정 참조 + +RDBMS/JPA 퍼시스턴스 베이스 모듈. 패키지 루트: `dev.caskeleton.adapter.persistence`. + +허용/금지 의존, 테스트 명령, 그리고 **계약 테이블**(TransactionPort 모드표, SQLState → Error +Code 매트릭스, auditing 계약, 분산 락 provider 선택표)의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다. +이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 +"왜 이렇게 했나"가 궁금할 때 본다. 표가 CLAUDE.md 에 있으면 여기서는 중복하지 않고 그 근거만 +적는다. + +## transaction — `SpringTransactionPort` + +### 왜 모드별 템플릿을 미리 만들어 두나 +`TransactionTemplate` 은 문서상 thread-safe 지만 **mutable** 하다. 매 호출마다 propagation / +readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window 가 생긴다. 모드별로 +(`WRITE` / `READ_ONLY` / `REQUIRES_NEW`) 템플릿을 생성 시점에 하나씩 만들어 두면 그 race 가 +사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED` +로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract). + +## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort` + +### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값) +`AuditableEntity` 의 네 필드(`created_at` / `updated_at` / `created_by` / `updated_by`)는 평범한 +`@Column` 이다 — Spring Data 의 `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy` +도, `@EntityListeners(AuditingEntityListener.class)` 도 **붙이지 않는다**. 퍼시스턴스 어댑터가 +`initializeAudit`(INSERT) 와 `carryCreation` + `applyModification`(UPDATE)로 명시적으로 값을 +세팅한다. 공유 `Clock` 빈(D4)과 `AuditContextPort` actor(D5)를 재사용하는 방식으로, +`IdempotencyStoreAdapter` 가 생성자 주입 `Clock` 으로 row 를 재구성하는 선례와 동일하다. + +- INSERT 는 `created_*` 와 `updated_*` 를 같은 `now`/`actor` 로 찍는다. NOT NULL 인 `updated_*` + 를 신규 row 에서 채우기 위함이며, JPA-auditing 의 `modifyOnCreate` 기본동작에 의존하지 않는다. +- UPDATE 는 두 단계다: `carryCreation` 으로 (Vernon Option A 재구성된) 엔티티가 잃어버린 + `created_*` 를 다시 채워 넣고, `applyModification` 으로 `updated_*` 만 옮긴다. +- `created_*` 는 `updatable = false` 라 INSERT 이후 모든 UPDATE 문에서 제외된다 — 생성 + actor/시각이 덮어써질 수 없다. +- `version`/optimistic-lock 은 여기 두지 않는다. 이 베이스는 audit 전용으로 남기고, + 낙관적 잠금 정책은 개별 영속성 모델이 소유한다. + +### Growth path (D1, deferred — 여기 와이어링 안 됨) +audited 애그리거트 수가 늘어 수동 세팅이 누락 위험을 키우면 Spring Data JPA Auditing 으로 이전한다: +필드에 `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy` + +`@EntityListeners(AuditingEntityListener.class)` 를 붙이고, 컴포지션 루트에 +`@EnableJpaAuditing(dateTimeProviderRef=..., auditorAwareRef=...)` 를 둔다. `DateTimeProvider` 가 +같은 `Clock`(D4)을, `AuditorAware<String>` 가 `AuditContextPort`(D5)를 감싼다. bulk/native `@Query` +UPDATE 는 두 캡처 경로를 모두 우회하므로 거기서는 audit 를 명시적으로 찍어야 한다. + +### actor seam 을 왜 별도 포트로 격리하나 +`AuditContextPort` 는 퍼시스턴스가 "누가 행위하는가"에 대해 의존하는 단 하나의 seam 이다. +actor 의 **값 의미론**(user id vs email vs subject claim)은 application/web 쪽의 사용자 모델 +책임이고 여기서는 의도적으로 다루지 않는다. 그래서 타입을 `String` 으로 못박고, 이 +포트가 보장하는 건 오직 "non-null actor"(프레임워크의 blank-on-absent 가 아니라) 하나 — +principal 이 없으면 `"system"`. + +`DomainContextAuditContextPort` 는 actor id 를 runtime-context-propagation seam +(`DomainContextPropagator`)에서 읽는다. 그 브랜치가 canonical actor key 를 소유하지만 API 가 +확정되기 전까지 이 어댑터가 `ACTOR_KEY` 뒤로 격리해, 키가 바뀌어도 정확히 한 클래스만 손대게 +한다(D5 Open Risk). 빈/blank context 값은 null/blank actor 가 아니라 `"system"` 으로 떨어뜨려 +scheduler / Flyway / anonymous 경로에서도 NOT NULL `created_by`/`updated_by` 를 항상 만족시킨다. + +## config — `PersistenceJpaConfig` + +### 왜 명시적 `@EntityScan` / `@EnableJpaRepositories` 가 필요한가 +Spring Boot 메인 클래스는 `dev.caskeleton.bootstrap` 에 있어서, `@AutoConfigurationPackage` 가 +앵커로 삼는 기본 엔티티/리포지토리 스캔이 `dev.caskeleton.adapter.persistence.*` 를 놓친다. +`@SpringBootApplication` 의 `scanBasePackages` 는 컴포넌트 스캔만 넓힐 뿐 JPA 엔티티/리포지토리 +스캔은 넓히지 않는다. 이 설정이 없으면 production 리포지토리(예: `IdempotencyRecordJpaRepository`) +가 생성되지 않아 소비자(예: `IdempotencyReaper`)가 부팅에서 와이어링 실패한다. 스캔을 엔티티를 +소유한 모듈에 둬서 와이어링을 그 자리에 유지한다. + +### 왜 이름이 `JpaConfig` 가 아닌가 +sample 모듈에 이미 `...sample.portfolio.adapter.persistence.config.JpaConfig` 가 있다. IDE 의 +"Run main class" 가 테스트 스코프 sample 모듈을 클래스패스에 올리면, simple name `JpaConfig` 를 +공유하는 두 `@Configuration` 이 기본 빈 이름 `jpaConfig` 에서 충돌한다 +(`ConflictingBeanDefinitionException`). 다른 simple name 으로 이를 피한다. + +## failure — 퍼시스턴스 실패 변환 SPI + +매트릭스(SQLState → `DB_*` 코드, category, http, retryable)의 SSOT 는 CLAUDE.md §Persistence +failure translation contract 다. 여기서는 SPI 구조와 fallback 근거만 적는다. + +### SPI-pluggable 설계 (vendor 추출) +`PersistenceExceptionTranslator` 는 exact-state → code 맵을 등록된 모든 `SqlStateErrorMapping` +빈을 생성 시점에 merge 해서 만든다. core 모듈(`StandardSqlStateErrorMapping`)은 모든 RDBMS 가 +공통으로 반환하는 portable/vendor-neutral 5개 row(`40001`, `23502`, `23503`, `23505`, `23514`)만 +기여한다. vendor 별 row(`40P01`, `25P03`, `57014` 등 PostgreSQL)는 `adapter-persistence-postgresql` +가 추가 `SqlStateErrorMapping` 빈으로 기여한다. + +- `08*` connection-class prefix → `DB_UNAVAILABLE` 규칙은 맵 엔트리가 아니라 translator 가 직접 + 처리한다. 따라서 core 매핑 맵에는 `08*` 가 없다. +- **Fallback:** 기여된 어떤 row 에도 없는 SQLState — 또는 cause chain 에 + `SQLException` 자체가 없는 경우 — 는 `Optional.empty()` 를 반환한다. 호출부는 원본 예외를 web + catch-all 까지 전파시켜 detail 누설 없는 generic `INTERNAL` 엔벨로프로 답하게 한다. translator + 는 unknown state 에 대해 `DB_*` 코드를 절대 지어내지 않는다. +- 변환된 carrier 의 진단 메시지에 SQLState 를 넣는 건 server-log 전용이다(web 어댑터가 절대 + surface 하지 않음) — triage 를 돕되 클라이언트로 새지 않는다. + +## idempotency — `IdempotencyStoreAdapter` 외 + +스키마는 Flyway(`V1__idempotency_record.sql`)가 소유하고 엔티티는 그것을 매핑만 한다. + +### 동시성 중재 (D7 insert-or-read) +`tryBegin` 은 `uq_idempotency_scope` unique 제약을 동시성 중재자로 쓴다 — 동시 중복은 insert 를 +잃고 `false` 를 받는다. lookup 과 flush 사이에 다른 호출자가 끼어들면 +`DataIntegrityViolationException` 으로 잡아 `false` 를 반환한다(`saveAndFlush` 의 flush 가 unique +제약 검사를 그 자리에서 강제한다). 같은 scope 의 만료 row 는 insert 전에 reclaim(delete)해, stale +record 가 새 요청을 영구히 막지 못하게 한다. 이 delete + insert 는 호출자 트랜잭션(use case 가 +`TransactionPort` 로 소유) 안에서 도는 것을 전제로 atomic 하다. + +### 엔티티 불변/스코프 (D3 / §B / §F) +- `tenant` 은 절대 `null` 이 아니다(single-tenant 는 빈 문자열). PostgreSQL 은 NULL 을 서로 + distinct 로 취급하므로, null 을 허용하면 single-tenant row 의 unique scope dedup 이 깨진다. + 매퍼가 `null` 애플리케이션 tenant ↔ row 의 `""` 를 왕복시킨다. +- `status = COMPLETED` 가 되면 `responsePayload` / `responseRef` 중 정확히 하나만 채워진다(§F + ≤8KB inline / >8KB ref). +- 엔티티에 setter 가 없는 건 의도다: 상태 전이 때 row 를 재구성·재저장한다(Vernon Option A) — + 애플리케이션 관점에서 엔티티를 immutable 로 유지한다. + +### §F responseRef split & 만료 +- `complete` 의 §F 분기: payload ≤ 8KB 는 row 에 inline 저장, 더 크면 + `IdempotencyResponseObjectStore`(와이어링된 경우)로 offload 하고 reference 만 보관한다. object + store 가 없으면 큰 payload 도 경고와 함께 inline 으로 안전 degrade 한다(D9 프로젝트 선택). +- 만료는 세 곳에서 강제된다: read(`find` 가 만료 row 를 부재로 취급), reclaim(`tryBegin` 이 만료 + row 를 재claim 전에 delete), 그리고 `IdempotencyReaper`. reaper 는 유일한 만료 수단이 아니라 + 테이블 성장을 묶는 backstop 이다. reaper 의 고정 interval 과 clock-skew 미처리는 source-mandated + 가 아니다(프로젝트 선택 — 부하 하 cadence 측정 필요). + +### `IdempotencyResponseObjectStore` 는 왜 optional 인가 (D9 프로젝트 선택) +8KB 임계와 object-store 분리는 cited normative basis 가 없고 production 빈도도 미측정이다. +S3 호환 클라이언트가 템플릿에 없으므로 어댑터는 이 포트를 optional 로 취급한다 — 빈이 없으면 inline +DB 저장으로 fallback. 실제 구현(S3 / GCS / MinIO)을 와이어링하면 offload 가 활성화된다. + +## outbox — `OutboxStoreAdapter` 외 + +스키마는 Flyway(`V3__outbox_event.sql`)가 소유한다. + +### 트랜잭션 경계 계약 +- **append**: 호출자의 `TransactionPort.inWrite()` 경계 안에서 호출되어야 하며, 내부에서 새 + 트랜잭션을 열지 않는다. `@Repository` 가 빈만 등록하고 TX 는 use case 가 소유한다. +- **No `@Transactional`**: 이 코드베이스의 퍼시스턴스 어댑터는 `@Transactional` 을 선언하지 + 않는다. 유일한 트랜잭션 경계는 use case 가 소유한 `TransactionPort` 다(CLAUDE.md "Forbidden"). + +### claim 계약 — vendor SPI (`OutboxClaimRepository`) +row claim 은 vendor 별 락 전략이 필요해 `OutboxClaimRepository` SPI 로 추출했다. canonical +PostgreSQL 구현은 native query 의 `FOR UPDATE SKIP LOCKED` 를 쓰고, 다른 벤더는 등가물을 +공급한다(예: SQL Server `WITH (UPDLOCK, READPAST)`). eligibility predicate(I3 — concurrent-relay +안전을 위한 `SKIP LOCKED`)와 per-aggregate FIFO gate(I4 — `NOT EXISTS` correlated subquery)를 +전부 SQL 에서 강제한다. 한 배치에 aggregate 당 최대 한 row(head)만 나타난다. 어댑터는 추가 +in-memory 필터링을 하지 않는다 — 리포지토리가 반환한 모든 row 를 IN_FLIGHT 로 전이시켜 호출자에 +돌려준다. 전체 SQL gate 동작은 PG contract 테스트(Testcontainers)가 검증한다. + +eligible row(I3/I4/I6): +- `PENDING` — `next_attempt_at <= now`(insert 시 즉시 eligible) +- `FAILED` — `next_attempt_at <= now`(backoff 경과) +- `IN_FLIGHT` — `next_attempt_at <= now`(orphaned row) + +### `markPublished` / `markFailed` / `markDead` 는 왜 row 부재 시 throw 하나 +row 가 없으면 `IllegalStateException` 을 던진다. relay 가 방금 같은 서비스 인스턴스에서 claim 한 +row 이므로, 부재는 프로그래밍/동시성 버그다. 조용히 no-op 하면 row 가 영원히 `IN_FLIGHT` 로 남아 +그 aggregate 의 FIFO 큐를 막고, 호출자나 로그에 아무 신호도 남지 않는다(markFailed 는 relay 가 +재시도 예약을 믿게, markDead 는 runbook 가시성·수동 DEAD 해결을 막게 된다). + +### `OutboxEventEntity` 의 결정 +- **`AuditableEntity` 미상속(D6 — infra 엔티티).** `IdempotencyRecordEntity` 처럼 outbox row 는 + 도메인 애그리거트가 아니라 인프라 record 다. 자체 temporal 필드(`occurred_at`, + `next_attempt_at`)가 도메인 의미를 갖고, generic `created_at`/`updated_at` audit 컬럼과 섞이면 + 안 된다. +- **mutating setter 노출은 의도.** relay 어댑터가 managed 엔티티 위에서 상태(status / + attempt_count / next_attempt_at)를 전이시키되 full reload-and-replace 없이 한다. outbox 어댑터만 + 이 필드를 건드리고, 모든 mutation 이 use case 소유 `TransactionPort.inWrite()` 경계 안에서 + 돌기에 안전하다. +- **`next_attempt_at` dual-purpose(I6 — 추가 컬럼 없음):** + - `PENDING`: insert 때 `occurred_at` 으로 세팅 → 최초 claim 체크(`next_attempt_at <= now`)가 + 즉시 만족. + - `IN_FLIGHT`: `claim_time + in_flight_timeout` → orphaned row 가 visibility window 만료 후 + 재claim 가능. + - `FAILED`: `now + backoff` → backoff window 경과 후에만 재시도. +- `status` 는 `PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD` 문자열이다. + +### metric 쿼리 반환 형태 +`OutboxEventJpaRepository.countGroupedByStatus()` 는 `[status(String), count(Long)]`, +`findOldestUnpublishedOccurredAtByEventType()` 는 `[eventType(String), oldestOccurredAt(Instant)]` +2-요소 배열 리스트를 돌려준다(각각 outbox.pending.size, outbox.publisher.lag gauge 용). +`OutboxStoreAdapter.oldestUnpublishedAgeSecondsByEventType` 가 `HashMap` 을 쓰는 건 키가 +enum 이 아니라 String(event-type 이름)이기 때문이다 — `countByStatus()` 는 키가 +`OutboxEventStatus` enum 이라 `EnumMap` 을 쓴다(두 반환 타입이 의도적으로 다름). + +### `OutboxReaper` +PUBLISHED row 는 이미 전달된 terminal-success record 라 무한 보관할 필요가 없다. reaper 가 +retention 보다 오래된 PUBLISHED row 를 주기적으로 비워 테이블 성장과 metric gauge 를 묶는다. +스케줄링은 컴포지션 루트의 `@EnableScheduling` 으로 켜지고, `@Transactional` bulk delete 가 purge +를 한 문장으로 유지한다. 고정 interval / clock-skew 미처리는 프로젝트 선택(부하 하 +cadence 측정 필요). retention 한 값은 reaper-local 이라 `@Value` 로 받지만, canonical 6-property +문서는 app-bootstrap `OutboxSettings` / `application.yml` 에 있다. + +## lock — 분산 락 + +provider 선택표(flag → bean → registry)의 SSOT 는 CLAUDE.md(또는 app-bootstrap 와이어링)다. +여기서는 어댑터/설정 결정 근거만 적는다. + +### `LockRegistryDistributedLockAdapter` 계약 +- **D4 — transaction-commit ordering invariant.** 이 어댑터는 트랜잭션 경계를 관리하지 않는다. + 호출자는 보호된 트랜잭션이 **커밋된 뒤에만** 핸들(`DistributedLock.close()`)을 release 해야 + 한다. 커밋 전(트랜잭션 안)에 release 하면 lost-update race 가 생긴다. +- **D5 — finite waitTime + lease TTL.** `tryAcquire` 는 Spring Integration `DistributedLock` + 이면 `tryLock(waitTime, leaseTtl)` 로, 일반 `Lock` 이면 `Lock.tryLock(long, TimeUnit)` 으로 최대 + `waitTime` 만 블록하고, 잡으면 핸들을, 못 잡으면 `LockAcquisitionTimeoutException` 을 던진다. + 무한 블로킹은 쓰지 않는다. +- `leaseTtl > configuredTtl` 은 `IllegalArgumentException` 으로 거부한다. provider 기본 TTL 보다 + 긴 lease 를 약속하는 건 false contract 다. shipped 와이어링에선 `leaseTtl == configuredTtl` + (둘 다 `LockSettings.leaseTtl()` 바인딩)이라 이 가드는 mis-wired 호출자/테스트에서만 fail-fast + 로 발동한다. +- 반환 핸들은 `lock::unlock` 람다(SAM 인터페이스 충족). `InterruptedException` 은 interrupt flag + 를 복원하고 `LockAcquisitionTimeoutException` 으로 변환한다. + +### TTL 주의 (SI 7.0) +- `JdbcLockRegistry`: 기본 TTL 은 `JdbcLockRegistry(LockRepository, Duration)` 생성자로 설정하고, + acquisition 별 TTL 은 `DistributedLock.tryLock(Duration waitTime, Duration ttl)` 로 전달한다. 크래시한 + JVM 의 row 는 lock TTL 만료 후 다음 acquire 시도에서 회수된다. +- `DefaultLockRegistry`: TTL 은 advisory 이고 무의미하다 — JVM 크래시가 in-JVM 락을 프로세스와 + 함께 자동으로 떨군다. + +### SI-LOCK-C5 — lease 만료 후 release +반환 핸들은 `lock::unlock` 이다. lease TTL 이 `close()` 전에 만료된 `JdbcLockRegistry` +의 경우, 내부 `JdbcLock.unlock()` 이 `ConcurrentModificationException` 을 던진다(row 가 이미 +회수됨). 이 어댑터는 여기서 일부러 잡지 않는다 — metered `distributedLockProvider` 데코레이터 +(컴포지션 루트)가 SI-LOCK-C5 계약을 소유한다: 로그 + `lock.lease.expired` metric 후 `close()` 에서 +정상 return 해, 만료가 호출자의 보호작업 예외를 가리지 않게 한다. in-process `DefaultLockRegistry` +경로는 만료가 없어 그 `close()` 가 이 예외를 던질 수 없다. + +### `DistributedLockPersistenceConfig` 와이어링 결정 +- `jdbcDistributedLock` 빈은 일부러 `@Primary` 가 아니고 이름도 `distributedLockProvider` 가 + 아니다. app-bootstrap 이 이를 metrics 데코레이터(`MeteredDistributedLockPort`)로 감싸 + `@Primary`/`distributedLockProvider` 빈을 노출한다. 이렇게 해서 SI 타입이 컴파일 타임에 + adapter-persistence 위 레이어에 보이지 않게 유지된다(SI 는 `implementation` 의존). +- `DefaultLockRepository` 는 `InitializingBean`/`SmartLifecycle` 을 구현해 Spring 이 lifecycle 을 + 자동 관리하고, `ApplicationContextAware` 로 `PlatformTransactionManager` 를 auto-discover 한다. + app-bootstrap Testcontainers 테스트가 와이어링 갭을 드러내면 부트스트랩이 `setTransactionManager` + 로 명시 전달할 수 있다. +- `setCheckDatabaseOnStart(false)`: `INT_LOCK` 테이블은 첫 lock acquire 전에 Flyway V4/V5 가 + provision 하므로 DDL 체크를 건너뛴다. +- `JdbcLockRegistry(lockRepository, settings.leaseTtl())`: Spring Integration 7.0 이후 기본 TTL 은 + repository setter 가 아니라 registry 생성자에서 설정한다. 어댑터는 호출별 `leaseTtl` 도 + `DistributedLock.tryLock(waitTime, leaseTtl)` 로 전달한다. + +### `LockSettings` +`ca-skeleton.lock.*` 에서 바인딩되는 yaml-only 기본값이다. 새 `APP_*` env 키가 아니므로 +`env-keys.yaml` 엔트리가 필요 없다. bootstrap 의 `@ConfigurationPropertiesScan` 으로 잡혀 +`@EnableConfigurationProperties` 명시가 필요 없다. cross-field invariant: `leaseTtl >= waitTime` +이어야 한다 — TTL 이 waitTime 보다 먼저 만료되면 첫 holder 의 보호작업이 끝나기 전에 두 번째 +holder 가 락을 잡을 수 있다. diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle new file mode 100644 index 00000000..56bbf45f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -0,0 +1,24 @@ +// JPA persistence adapter — merged RDBMS base + PostgreSQL vendor module. +// Owns JPA entities, Spring Data repositories, mappers, transaction/audit/lock/outbox port +// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository / +// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor +// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral). +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the + // multi-instance distributedLockProvider. Version managed by Spring Boot BOM. + implementation 'org.springframework.integration:spring-integration-jdbc' + + // Vendor (PostgreSQL): Flyway migration API + PostgreSQL driver/dialect. Used only by the + // .postgresql subpackage; the RDBMS base stays vendor-neutral (PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL). + implementation 'org.springframework.boot:spring-boot-starter-flyway' + implementation 'org.flywaydb:flyway-core' + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'org.flywaydb:flyway-database-postgresql' +} + +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile new file mode 100644 index 00000000..d361d284 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -0,0 +1,195 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=runtimeClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=runtimeClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.aspectj:aspectjweaver:1.9.25=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=runtimeClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.6=runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.6=runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.6=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=runtimeClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jboss.logging:jboss-logging:3.6.1.Final=runtimeClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.postgresql:postgresql:42.7.8=runtimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java new file mode 100644 index 00000000..d6b53635 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.persistence.audit; + +/** + * The single seam the persistence layer depends on for "who is acting": supplies the actor id + * stamped into the {@code created_by} / {@code updated_by} audit columns. Fixed to {@code String} + * and guarantees a non-null actor. See README "audit" for why the value semantics are isolated + * here. + */ +public interface AuditContextPort { + + /** + * The current actor id for audit columns. Returns {@code "system"} when no principal is bound + * (scheduler / Flyway migration / anonymous) — never {@code null} and never blank. + */ + String currentActor(); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java new file mode 100644 index 00000000..f69674ff --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.persistence.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; + +/** + * Audit-metadata {@code @MappedSuperclass} for domain aggregate JPA entities: keeps the {@code + * created_*}/{@code updated_*} infrastructure concern out of domain-core. The adapter stamps the + * fields manually (see README "audit" for the capture mechanism, growth path, and why this base + * stays audit-only). + */ +@MappedSuperclass +public abstract class AuditableEntity { + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "created_by", nullable = false, updatable = false, length = 256) + private String createdBy; + + @Column(name = "updated_by", nullable = false, length = 256) + private String updatedBy; + + protected AuditableEntity() {} + + /** INSERT path: stamp both creation and modification with the same {@code now}/{@code actor}. */ + public void initializeAudit(Instant now, String actor) { + this.createdAt = now; + this.updatedAt = now; + this.createdBy = actor; + this.updatedBy = actor; + } + + /** UPDATE path, step 1: carry persisted creation metadata forward on a reconstructed entity. */ + public void carryCreation(Instant createdAt, String createdBy) { + this.createdAt = createdAt; + this.createdBy = createdBy; + } + + /** UPDATE path, step 2: move {@code updated_*} forward, leaving {@code created_*} untouched. */ + public void applyModification(Instant now, String actor) { + this.updatedAt = now; + this.updatedBy = actor; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public String getCreatedBy() { + return createdBy; + } + + public String getUpdatedBy() { + return updatedBy; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/DomainContextAuditContextPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/DomainContextAuditContextPort.java new file mode 100644 index 00000000..bc4fd04f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/DomainContextAuditContextPort.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.persistence.audit; + +import dev.caskeleton.shared.concurrency.DomainContextKey; +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import org.springframework.stereotype.Component; + +/** + * Default {@link AuditContextPort}: reads the audit actor from the runtime-context-propagation seam + * ({@link DomainContextPropagator}) and falls back to {@code "system"}. The canonical actor key is + * isolated behind {@link #ACTOR_KEY} so a future key change touches one class (see README "audit"). + */ +@Component +public class DomainContextAuditContextPort implements AuditContextPort { + + static final String SYSTEM_ACTOR = "system"; + + static final DomainContextKey<String> ACTOR_KEY = DomainContextKey.of("actor", String.class); + + private final DomainContextPropagator propagator; + + public DomainContextAuditContextPort(DomainContextPropagator propagator) { + this.propagator = propagator; + } + + @Override + public String currentActor() { + return propagator.get(ACTOR_KEY).filter(actor -> !actor.isBlank()).orElse(SYSTEM_ACTOR); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java new file mode 100644 index 00000000..ee07770b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.persistence.config; + +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Registers this module's JPA entities and Spring Data repositories. The explicit scans are + * required because the Boot main class lives in another package; the simple name avoids {@code + * JpaConfig} to dodge a bean-name collision with the sample module. See README "config". + */ +@Configuration +@EntityScan(basePackages = "dev.caskeleton.adapter.outbound.persistence") +@EnableJpaRepositories(basePackages = "dev.caskeleton.adapter.outbound.persistence") +public class PersistenceJpaConfig {} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java new file mode 100644 index 00000000..8f11644e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.failure; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import java.sql.SQLException; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Component; + +/** + * Classifies a Spring {@link DataAccessException} into a framework-neutral {@link OperationalError} + * {@code DB_*} code by its SQLState, returning a {@link PersistenceFailureException} carrier (or + * {@link Optional#empty()} for an unknown state). Mappings are merged from all {@link + * SqlStateErrorMapping} SPI beans. See README "failure" for the SPI/fallback rationale and + * CLAUDE.md for the SQLState → Error Code matrix. + */ +@Component +public class PersistenceExceptionTranslator { + + /** SQLState class prefix for connection failures → {@code DB_UNAVAILABLE}. */ + private static final String CONNECTION_CLASS_PREFIX = "08"; + + private final Map<String, OperationalError> byExactSqlState; + + public PersistenceExceptionTranslator(Collection<SqlStateErrorMapping> mappings) { + Map<String, OperationalError> merged = new HashMap<>(); + for (SqlStateErrorMapping m : mappings) { + merged.putAll(m.exactMappings()); + } + this.byExactSqlState = Map.copyOf(merged); + } + + /** Classify {@code ex}, or {@link Optional#empty()} when its SQLState is unmapped or absent. */ + public Optional<PersistenceFailureException> translate(DataAccessException ex) { + String sqlState = extractSqlState(ex); + if (sqlState == null) { + return Optional.empty(); + } + ApiErrorCode code = classify(sqlState); + if (code == null) { + return Optional.empty(); + } + // The diagnostic is server-log-only (the web adapter never surfaces it); naming the + // SQLState here aids triage without leaking it to the client. + return Optional.of( + new PersistenceFailureException( + code, "persistence failure classified from SQLState=" + sqlState, ex)); + } + + /** The matrix lookup: exact codes first, then the {@code 08*} connection-class prefix. */ + private ApiErrorCode classify(String sqlState) { + OperationalError exact = byExactSqlState.get(sqlState); + if (exact != null) { + return exact; + } + if (sqlState.startsWith(CONNECTION_CLASS_PREFIX)) { + return OperationalError.DB_UNAVAILABLE; + } + return null; + } + + /** Walk the cause chain for the first {@link SQLException} and return its SQLState. */ + private static String extractSqlState(Throwable ex) { + for (Throwable t = ex; t != null; t = t.getCause()) { + if (t instanceof SQLException sqlException) { + String state = sqlException.getSQLState(); + if (state != null && !state.isBlank()) { + return state; + } + } + if (t.getCause() == t) { + break; // self-referential cause guard + } + } + return null; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/SqlStateErrorMapping.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/SqlStateErrorMapping.java new file mode 100644 index 00000000..1dd45e08 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/SqlStateErrorMapping.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.persistence.failure; + +import dev.caskeleton.shared.error.OperationalError; +import java.util.Map; + +/** + * SPI for contributing exact SQLState → {@link OperationalError} mappings to the {@link + * PersistenceExceptionTranslator}. The core module contributes vendor-neutral rows; vendor modules + * contribute their own. See README "failure" and CLAUDE.md for the matrix. + */ +public interface SqlStateErrorMapping { + + /** + * Immutable exact SQLState → code map; never {@code null}. The {@code 08*} prefix is the + * translator's responsibility, not a map entry. + */ + Map<String, OperationalError> exactMappings(); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/StandardSqlStateErrorMapping.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/StandardSqlStateErrorMapping.java new file mode 100644 index 00000000..7808b7f2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/StandardSqlStateErrorMapping.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.persistence.failure; + +import dev.caskeleton.shared.error.OperationalError; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Core {@link SqlStateErrorMapping}: the portable, vendor-neutral SQLState rows every RDBMS + * returns. Vendor-specific states are contributed by the vendor module. See CLAUDE.md for the + * SQLState → Error Code matrix. + */ +@Component +public class StandardSqlStateErrorMapping implements SqlStateErrorMapping { + + private static final Map<String, OperationalError> MAPPINGS = + Map.of( + "40001", OperationalError.DB_SERIALIZATION_FAILURE, + "23502", OperationalError.DB_NULL_VIOLATION, + "23503", OperationalError.DB_FK_VIOLATION, + "23505", OperationalError.DB_UNIQUE_VIOLATION, + "23514", OperationalError.DB_CHECK_VIOLATION); + + @Override + public Map<String, OperationalError> exactMappings() { + return MAPPINGS; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java new file mode 100644 index 00000000..dd2ab941 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaper.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +import java.time.Clock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Periodically deletes expired idempotency records. A backstop that bounds table growth — expiry is + * also enforced lazily on read and on reclaim (see README "idempotency"). + */ +@Component +public class IdempotencyReaper { + + private static final Logger log = LoggerFactory.getLogger(IdempotencyReaper.class); + + private final IdempotencyRecordJpaRepository repository; + private final Clock clock; + + public IdempotencyReaper(IdempotencyRecordJpaRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Scheduled(fixedDelayString = "${ca-skeleton.idempotency.reaper-interval:PT10M}") + @Transactional + public int reap() { + int purged = repository.deleteExpired(clock.instant()); + if (purged > 0) { + log.debug("idempotency reaper purged {} expired record(s)", purged); + } + return purged; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyRecordJpaRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyRecordJpaRepository.java new file mode 100644 index 00000000..ad4277cf --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyRecordJpaRepository.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * Spring Data repository for {@link IdempotencyRecordEntity}: lookup by scope, delete by scope, and + * the reaper's expiry purge. + */ +public interface IdempotencyRecordJpaRepository + extends JpaRepository<IdempotencyRecordEntity, UUID> { + + Optional<IdempotencyRecordEntity> findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName( + String tenant, String principal, String idempotencyKey, String useCaseName); + + @Modifying + @Query( + "delete from IdempotencyRecordEntity e where e.tenant = :tenant and e.principal = :principal " + + "and e.idempotencyKey = :idempotencyKey and e.useCaseName = :useCaseName") + int deleteByScope( + @Param("tenant") String tenant, + @Param("principal") String principal, + @Param("idempotencyKey") String idempotencyKey, + @Param("useCaseName") String useCaseName); + + /** Reaper / TTL boundary (§E): purge every record whose expiry is at/before {@code now}. */ + @Modifying + @Query("delete from IdempotencyRecordEntity e where e.expiresAt <= :now") + int deleteExpired(@Param("now") Instant now); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyResponseObjectStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyResponseObjectStore.java new file mode 100644 index 00000000..4f435c03 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyResponseObjectStore.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +/** + * Optional seam for offloading a large idempotent response body to an object store. With no bean + * wired, {@link IdempotencyStoreAdapter} falls back to inline DB storage (D9 is UNSUPPORTED — see + * README "idempotency"). Wire a real implementation (S3 / GCS / MinIO) to activate the offload. + */ +public interface IdempotencyResponseObjectStore { + + /** Persist {@code payload}, returning the storage reference kept in the DB row. */ + String put(String payload); + + /** Resolve a previously stored reference back to the full payload for replay. */ + String get(String reference); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java new file mode 100644 index 00000000..a425899c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java @@ -0,0 +1,164 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +/** + * DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope} + * unique constraint as the concurrency arbiter; {@link #complete} applies the §F inline/ref payload + * split. See README "idempotency" for the concurrency and §F rationale. + */ +@Repository +public class IdempotencyStoreAdapter implements IdempotencyStorePort { + + /** §F threshold: payloads up to this size are stored inline in the DB row. */ + static final int INLINE_MAX_BYTES = 8 * 1024; + + private static final Logger log = LoggerFactory.getLogger(IdempotencyStoreAdapter.class); + + private final IdempotencyRecordJpaRepository repository; + private final @Nullable IdempotencyResponseObjectStore objectStore; + private final Clock clock; + + public IdempotencyStoreAdapter( + IdempotencyRecordJpaRepository repository, + @Nullable IdempotencyResponseObjectStore objectStore, + Clock clock) { + this.repository = repository; + this.objectStore = objectStore; + this.clock = clock; + } + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + // Runs in the caller's transaction (use case owns the boundary via TransactionPort). + Optional<IdempotencyRecordEntity> existing = lookup(scope); + if (existing.isPresent()) { + IdempotencyRecordEntity row = existing.get(); + if (!clock.instant().isBefore(row.getExpiresAt())) { + repository.delete(row); // reclaim an expired row so the new claim can win + } else { + return false; // a live record already owns the scope + } + } + IdempotencyRecordEntity claim = + new IdempotencyRecordEntity( + UUID.randomUUID(), + IdempotencyRecordEntityMapper.tenantColumn(scope), + scope.principal(), + scope.idempotencyKey(), + scope.useCaseName(), + fingerprint.hex(), + "IN_FLIGHT", + null, + null, + clock.instant(), + expiresAt); + try { + repository.saveAndFlush(claim); // flush forces the unique-constraint check now + return true; + } catch (DataIntegrityViolationException raceLost) { + // Another caller inserted between the lookup and the flush — they own it. + return false; + } + } + + @Override + public Optional<IdempotencyRecord> find(IdempotencyScope scope, Instant now) { + Optional<IdempotencyRecordEntity> row = lookup(scope); + if (row.isEmpty()) { + return Optional.empty(); + } + IdempotencyRecordEntity entity = row.get(); + if (!now.isBefore(entity.getExpiresAt())) { + return Optional.empty(); // expired → treated as absent (§E); reaper / tryBegin purge it + } + return Optional.of(IdempotencyRecordEntityMapper.toRecord(entity, resolvePayload(entity))); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + IdempotencyRecordEntity row = + lookup(scope) + .orElseThrow( + () -> + new IllegalStateException( + "no in-flight idempotency record to complete for " + scope.storageKey())); + + String payload = response.payload(); + String inline = null; + String ref = null; + if (exceedsInlineThreshold(payload) && objectStore != null) { + ref = objectStore.put(payload); + } else { + if (exceedsInlineThreshold(payload)) { + log.warn( + "idempotency response exceeds {}B but no object store is configured; " + + "storing inline (feature-rate-limit-idempotency-contract §F seam)", + INLINE_MAX_BYTES); + } + inline = payload; + } + + repository.save( + new IdempotencyRecordEntity( + row.getId(), + row.getTenant(), + row.getPrincipal(), + row.getIdempotencyKey(), + row.getUseCaseName(), + row.getRequestHash(), + "COMPLETED", + inline, + ref, + row.getCreatedAt(), + row.getExpiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + repository.deleteByScope( + IdempotencyRecordEntityMapper.tenantColumn(scope), + scope.principal(), + scope.idempotencyKey(), + scope.useCaseName()); + } + + private Optional<IdempotencyRecordEntity> lookup(IdempotencyScope scope) { + return repository.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName( + IdempotencyRecordEntityMapper.tenantColumn(scope), + scope.principal(), + scope.idempotencyKey(), + scope.useCaseName()); + } + + private String resolvePayload(IdempotencyRecordEntity entity) { + if (entity.getResponsePayload() != null) { + return entity.getResponsePayload(); + } + if (entity.getResponseRef() != null && objectStore != null) { + return objectStore.get(entity.getResponseRef()); + } + return null; // IN_FLIGHT row, or a ref with no object store to resolve it + } + + private static boolean exceedsInlineThreshold(String payload) { + return payload.getBytes(StandardCharsets.UTF_8).length > INLINE_MAX_BYTES; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java new file mode 100644 index 00000000..8ef66aaa --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * JPA row for the {@code idempotency_record} table; schema owned by Flyway ({@code + * V1__idempotency_record.sql}). {@code tenant} is never {@code null} (empty string for + * single-tenant) and setters are intentionally absent (row rebuilt on state transitions). See + * README "idempotency" for the invariants behind both. + */ +@Entity +@Table( + name = "idempotency_record", + uniqueConstraints = + @UniqueConstraint( + name = "uq_idempotency_scope", + columnNames = {"tenant", "principal", "idempotency_key", "use_case_name"})) +public class IdempotencyRecordEntity { + + @Id + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "tenant", nullable = false, length = 128) + private String tenant; + + @Column(name = "principal", nullable = false, length = 256) + private String principal; + + @Column(name = "idempotency_key", nullable = false, length = 256) + private String idempotencyKey; + + @Column(name = "use_case_name", nullable = false, length = 256) + private String useCaseName; + + @Column(name = "request_hash", nullable = false, length = 64) + private String requestHash; + + @Column(name = "status", nullable = false, length = 16) + private String status; + + @Column(name = "response_payload") + private String responsePayload; + + @Column(name = "response_ref", length = 512) + private String responseRef; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + protected IdempotencyRecordEntity() {} + + public IdempotencyRecordEntity( + UUID id, + String tenant, + String principal, + String idempotencyKey, + String useCaseName, + String requestHash, + String status, + String responsePayload, + String responseRef, + Instant createdAt, + Instant expiresAt) { + this.id = id; + this.tenant = tenant; + this.principal = principal; + this.idempotencyKey = idempotencyKey; + this.useCaseName = useCaseName; + this.requestHash = requestHash; + this.status = status; + this.responsePayload = responsePayload; + this.responseRef = responseRef; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + } + + public UUID getId() { + return id; + } + + public String getTenant() { + return tenant; + } + + public String getPrincipal() { + return principal; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public String getUseCaseName() { + return useCaseName; + } + + public String getRequestHash() { + return requestHash; + } + + public String getStatus() { + return status; + } + + public String getResponsePayload() { + return responsePayload; + } + + public String getResponseRef() { + return responseRef; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/mapper/IdempotencyRecordEntityMapper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/mapper/IdempotencyRecordEntityMapper.java new file mode 100644 index 00000000..934fa5be --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/mapper/IdempotencyRecordEntityMapper.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency.mapper; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; + +/** + * Pure translation between the {@link IdempotencyRecordEntity} row and the application {@link + * IdempotencyRecord} — no business policy. The {@code tenant} dimension round-trips through an + * empty string ({@code null} ↔ {@code ""}); see README "idempotency" for why. + */ +public final class IdempotencyRecordEntityMapper { + + private IdempotencyRecordEntityMapper() {} + + /** Empty string on the row represents "no tenant" (single-tenant triple scope). */ + public static String tenantColumn(IdempotencyScope scope) { + return scope.tenant() == null ? "" : scope.tenant(); + } + + public static IdempotencyScope toScope(IdempotencyRecordEntity e) { + String tenant = (e.getTenant() == null || e.getTenant().isEmpty()) ? null : e.getTenant(); + return IdempotencyScope.of(tenant, e.getPrincipal(), e.getIdempotencyKey(), e.getUseCaseName()); + } + + /** + * Build the application record from a row. {@code resolvedPayload} is the fully materialized + * response (already read back from the object store when the row only held a reference). + */ + public static IdempotencyRecord toRecord(IdempotencyRecordEntity e, String resolvedPayload) { + IdempotencyStatus status = IdempotencyStatus.valueOf(e.getStatus()); + StoredResponse response = + (status == IdempotencyStatus.COMPLETED && resolvedPayload != null) + ? new StoredResponse(resolvedPayload) + : null; + return new IdempotencyRecord( + toScope(e), + new RequestFingerprint(e.getRequestHash()), + status, + response, + e.getCreatedAt(), + e.getExpiresAt()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java new file mode 100644 index 00000000..744e1249 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.persistence.lock; + +import dev.caskeleton.application.lock.DistributedLockPort; +import javax.sql.DataSource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.integration.jdbc.lock.DefaultLockRepository; +import org.springframework.integration.jdbc.lock.JdbcLockRegistry; +import org.springframework.integration.support.locks.DefaultLockRegistry; + +/** + * Spring wiring for the distributed-lock infrastructure: in-process {@link DefaultLockRegistry} by + * default, JDBC {@link JdbcLockRegistry} when {@code multi-instance-enabled=true}. The JDBC bean is + * deliberately not {@code @Primary} so app-bootstrap can wrap it in a metrics decorator. See README + * "lock" for the provider-selection and wiring rationale. + */ +@Configuration(proxyBeanMethods = false) +public class DistributedLockPersistenceConfig { + + /** In-process adapter; active when {@code multi-instance-enabled} is {@code false} or absent. */ + @Bean + @Primary + @ConditionalOnProperty( + prefix = "ca-skeleton.runtime", + name = "multi-instance-enabled", + havingValue = "false", + matchIfMissing = true) + DistributedLockPort inProcessDistributedLock(LockSettings settings) { + return new LockRegistryDistributedLockAdapter(new DefaultLockRegistry(), settings.leaseTtl()); + } + + /** + * {@link DefaultLockRepository} backing the {@link JdbcLockRegistry}; owns the {@code INT_LOCK} + * table access while the registry constructor owns the default TTL. + */ + @Bean + @ConditionalOnProperty( + prefix = "ca-skeleton.runtime", + name = "multi-instance-enabled", + havingValue = "true") + DefaultLockRepository lockRepository(DataSource dataSource) { + DefaultLockRepository repo = new DefaultLockRepository(dataSource); + // INT_LOCK is provisioned by Flyway V4 before first use — skip the DDL check. + repo.setCheckDatabaseOnStart(false); + return repo; + } + + @Bean + @ConditionalOnProperty( + prefix = "ca-skeleton.runtime", + name = "multi-instance-enabled", + havingValue = "true") + JdbcLockRegistry jdbcLockRegistry(DefaultLockRepository lockRepository, LockSettings settings) { + return new JdbcLockRegistry(lockRepository, settings.leaseTtl()); + } + + /** + * JDBC-backed adapter; named {@code jdbcDistributedLock} for app-bootstrap to wrap (not + * {@code @Primary}). + */ + @Bean("jdbcDistributedLock") + @ConditionalOnProperty( + prefix = "ca-skeleton.runtime", + name = "multi-instance-enabled", + havingValue = "true") + DistributedLockPort jdbcDistributedLock( + JdbcLockRegistry jdbcLockRegistry, LockSettings settings) { + return new LockRegistryDistributedLockAdapter(jdbcLockRegistry, settings.leaseTtl()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockRegistryDistributedLockAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockRegistryDistributedLockAdapter.java new file mode 100644 index 00000000..1f5ee101 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockRegistryDistributedLockAdapter.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.persistence.lock; + +import dev.caskeleton.application.lock.DistributedLockPort; +import dev.caskeleton.application.lock.LockAcquisitionTimeoutException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import org.springframework.integration.support.locks.DistributedLock; +import org.springframework.integration.support.locks.LockRegistry; + +/** + * Implements {@link DistributedLockPort} by delegating to any Spring Integration {@link + * LockRegistry} (provider selection lives in {@link DistributedLockPersistenceConfig}). Does not + * manage the transaction boundary, uses a finite-wait try-lock, and leaves lease-expiry handling to + * the metered decorator. See README "lock" for the D4/D5/TTL/SI-LOCK-C5 contracts. + */ +public class LockRegistryDistributedLockAdapter implements DistributedLockPort { + + private final LockRegistry<? extends Lock> registry; + private final Duration configuredTtl; + + public LockRegistryDistributedLockAdapter( + LockRegistry<? extends Lock> registry, Duration configuredTtl) { + this.registry = registry; + this.configuredTtl = configuredTtl; + } + + @Override + public dev.caskeleton.application.lock.DistributedLock tryAcquire( + String key, Duration waitTime, Duration leaseTtl) { + if (leaseTtl.compareTo(configuredTtl) > 0) { + throw new IllegalArgumentException( + "leaseTtl (" + + leaseTtl + + ") exceeds the registry's configuredTtl (" + + configuredTtl + + "). The provider is configured with a shorter default TTL; " + + "promising a longer lease than configured would be a false promise."); + } + + Lock l = registry.obtain(key); + boolean acquired; + try { + acquired = + l instanceof DistributedLock distributedLock + ? distributedLock.tryLock(waitTime, leaseTtl) + : l.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LockAcquisitionTimeoutException(key, waitTime); + } + if (!acquired) { + throw new LockAcquisitionTimeoutException(key, waitTime); + } + return l::unlock; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java new file mode 100644 index 00000000..b32c34af --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.lock; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Distributed-lock tuning knobs bound from {@code ca-skeleton.lock.*} (yaml-only defaults, not + * {@code APP_*} env keys). {@code waitTime} = max wait before timeout (default 3s); {@code + * leaseTtl} = max lock hold before auto-expiry (default 30s, must be ≥ {@code waitTime}). See + * README "lock". + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.lock") +public record LockSettings(Duration waitTime, Duration leaseTtl) { + + private static final Duration DEFAULT_WAIT_TIME = Duration.ofSeconds(3); + private static final Duration DEFAULT_LEASE_TTL = Duration.ofSeconds(30); + + public LockSettings { + if (waitTime == null) { + waitTime = DEFAULT_WAIT_TIME; + } else if (waitTime.isNegative() || waitTime.isZero()) { + throw new IllegalArgumentException( + "ca-skeleton.lock.wait-time must be positive (> 0), was " + waitTime); + } + if (leaseTtl == null) { + leaseTtl = DEFAULT_LEASE_TTL; + } else if (leaseTtl.isNegative() || leaseTtl.isZero()) { + throw new IllegalArgumentException( + "ca-skeleton.lock.lease-ttl must be positive (> 0), was " + leaseTtl); + } + // Cross-field invariant: a lease shorter than the max wait is nonsensical. + // If the TTL fires before the waitTime elapses, a second holder could acquire + // the lock before the first holder's protected work is complete. + if (leaseTtl.compareTo(waitTime) < 0) { + throw new IllegalArgumentException( + "ca-skeleton.lock.lease-ttl (" + + leaseTtl + + ") must be >= ca-skeleton.lock.wait-time (" + + waitTime + + ") — a lease shorter than the max wait is nonsensical"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java new file mode 100644 index 00000000..f23515ac --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import java.time.Instant; +import java.util.List; + +/** + * SPI for vendor-specific outbox row claim. Implementations select eligible rows with a + * vendor-appropriate locking strategy (PG uses {@code FOR UPDATE SKIP LOCKED}) and own the + * eligibility predicate + per-aggregate FIFO gate in SQL. See README "outbox" for the eligible-row + * rules and the SKIP LOCKED / FIFO rationale. + */ +public interface OutboxClaimRepository { + + /** + * Claims up to {@code limit} eligible outbox rows as of {@code now} (rows with {@code + * next_attempt_at > now} are not yet eligible). Returned list may be empty. + */ + List<OutboxEventEntity> claimEligible(Instant now, int limit); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java new file mode 100644 index 00000000..a2fd792a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import java.time.Instant; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * Spring Data repository for {@link OutboxEventEntity} — only the vendor-neutral JPQL operations. + * Vendor-specific row claiming lives in the {@link OutboxClaimRepository} SPI (see README + * "outbox"). + */ +public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntity, String> { + + /** + * Deletes PUBLISHED rows whose {@code occurred_at} is before {@code cutoff}; returns the count. + */ + @Modifying + @Query("delete from OutboxEventEntity e where e.status = 'PUBLISHED' and e.occurredAt < :cutoff") + int deletePublishedBefore(@Param("cutoff") Instant cutoff); + + /** {@code [status (String), count (Long)]} pairs for the outbox.pending.size gauge. */ + @Query("select e.status, count(e) from OutboxEventEntity e group by e.status") + List<Object[]> countGroupedByStatus(); + + /** + * {@code [eventType (String), oldestOccurredAt (Instant)]} per unpublished type for the lag + * gauge. + */ + @Query( + "select e.eventType, min(e.occurredAt) from OutboxEventEntity e " + + "where e.status <> 'PUBLISHED' group by e.eventType") + List<Object[]> findOldestUnpublishedOccurredAtByEventType(); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java new file mode 100644 index 00000000..e21db4a2 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import java.time.Clock; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Periodically purges PUBLISHED outbox rows older than the configured retention period, bounding + * table growth. See README "outbox" for retention/scheduling rationale. + */ +@Component +public class OutboxReaper { + + private static final Logger log = LoggerFactory.getLogger(OutboxReaper.class); + + private final OutboxEventJpaRepository repository; + private final Clock clock; + private final Duration retention; + + // @Value is acceptable for this single reaper-local value; canonical settings live in + // app-bootstrap OutboxSettings / application.yml. + public OutboxReaper( + OutboxEventJpaRepository repository, + Clock clock, + @Value("${ca-skeleton.outbox.published-retention:P7D}") Duration retention) { + this.repository = repository; + this.clock = clock; + this.retention = retention; + } + + /** Deletes PUBLISHED rows older than {@code clock.instant() - retention}; returns the count. */ + @Scheduled(fixedDelayString = "${ca-skeleton.outbox.reaper-interval:PT10M}") + @Transactional + public int reap() { + int purged = repository.deletePublishedBefore(clock.instant().minus(retention)); + if (purged > 0) { + log.debug("outbox reaper purged {} published row(s)", purged); + } + return purged; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java new file mode 100644 index 00000000..e5d7d1d5 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import dev.caskeleton.application.outbox.OutboxStorePort; +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed {@link OutboxAppendPort} + {@link OutboxStorePort}. {@link #append} and the claim/mark + * operations run inside the caller's {@code TransactionPort.inWrite()} boundary and declare no + * {@code @Transactional} of their own; {@link #claimBatch} delegates the vendor claim to {@link + * OutboxClaimRepository}. See README "outbox" for the append/claim/no-@Transactional contracts. + */ +@Repository +public class OutboxStoreAdapter implements OutboxAppendPort, OutboxStorePort { + + private final OutboxEventJpaRepository repository; + private final OutboxClaimRepository claimRepository; + + public OutboxStoreAdapter( + OutboxEventJpaRepository repository, OutboxClaimRepository claimRepository) { + this.repository = repository; + this.claimRepository = claimRepository; + } + + @Override + public void append(NewOutboxEvent event) { + OutboxEventEntity entity = new OutboxEventEntity(); + entity.setEventId(event.eventId()); + entity.setAggregateId(event.aggregateId()); + entity.setEventType(event.eventType()); + entity.setPayload(event.payload()); + entity.setOccurredAt(event.occurredAt()); + entity.setStatus(OutboxEventStatus.PENDING.name()); + entity.setAttemptCount(0); + // PENDING: nextAttemptAt = occurredAt so the relay can claim immediately. + entity.setNextAttemptAt(event.occurredAt()); + entity.setCorrelationId(event.correlationId()); + entity.setIdempotencyKey(event.idempotencyKey()); + repository.save(entity); + } + + @Override + public List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { + List<OutboxEventEntity> eligible = claimRepository.claimEligible(now, batchSize); + return eligible.stream() + .map( + entity -> { + entity.setStatus(OutboxEventStatus.IN_FLIGHT.name()); + entity.setAttemptCount(entity.getAttemptCount() + 1); + entity.setNextAttemptAt(now.plus(inFlightTimeout)); + return toOutboxEvent(entity); + }) + .toList(); + } + + /** + * Marks the event as published. Throws {@link IllegalStateException} if the row is missing — a + * missing just-claimed row is a bug, and a silent no-op would wedge the aggregate's FIFO queue + * (see README "outbox"). + */ + @Override + public void markPublished(String eventId) { + repository + .findById(eventId) + .orElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId)) + .setStatus(OutboxEventStatus.PUBLISHED.name()); + } + + /** + * Marks the event as failed and schedules the next retry. Throws if the row is missing (see + * {@link #markPublished}). + */ + @Override + public void markFailed(String eventId, Instant nextAttemptAt) { + OutboxEventEntity entity = + repository + .findById(eventId) + .orElseThrow( + () -> new IllegalStateException("outbox row not found for eventId=" + eventId)); + entity.setStatus(OutboxEventStatus.FAILED.name()); + entity.setNextAttemptAt(nextAttemptAt); + } + + /** + * Marks the event as dead-lettered after retries are exhausted. Throws if the row is missing (see + * {@link #markPublished}). + */ + @Override + public void markDead(String eventId) { + repository + .findById(eventId) + .orElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId)) + .setStatus(OutboxEventStatus.DEAD.name()); + } + + @Override + public Map<OutboxEventStatus, Long> countByStatus() { + List<Object[]> rows = repository.countGroupedByStatus(); + Map<OutboxEventStatus, Long> result = new EnumMap<>(OutboxEventStatus.class); + for (Object[] row : rows) { + OutboxEventStatus status = OutboxEventStatus.valueOf((String) row[0]); + result.put(status, (Long) row[1]); + } + return result; + } + + @Override + public Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now) { + List<Object[]> rows = repository.findOldestUnpublishedOccurredAtByEventType(); + // HashMap (not EnumMap): the key is a String event-type name, not an enum. + Map<String, Long> result = new HashMap<>(); + for (Object[] row : rows) { + String eventType = (String) row[0]; + Instant oldest = (Instant) row[1]; + result.put(eventType, Duration.between(oldest, now).toSeconds()); + } + return result; + } + + private static OutboxEvent toOutboxEvent(OutboxEventEntity e) { + return new OutboxEvent( + e.getEventId(), + e.getEventType(), + e.getAggregateId(), + e.getPayload(), + e.getOccurredAt(), + e.getCorrelationId(), + e.getIdempotencyKey(), + OutboxEventStatus.valueOf(e.getStatus()), + e.getAttemptCount()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java new file mode 100644 index 00000000..7a7bc80e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * JPA row for the {@code outbox_event} table; schema owned by Flyway ({@code + * V3__outbox_event.sql}). Does not extend {@code AuditableEntity} (infra record, not a domain + * aggregate) and exposes mutating setters on purpose (relay transitions state in-place). See README + * "outbox" for both. + */ +@Entity +@Table(name = "outbox_event") +public class OutboxEventEntity { + + @Id + @Column(name = "event_id", nullable = false, length = 64, updatable = false) + private String eventId; + + @Column(name = "aggregate_id", nullable = false, length = 256, updatable = false) + private String aggregateId; + + @Column(name = "event_type", nullable = false, length = 256, updatable = false) + private String eventType; + + @JdbcTypeCode(SqlTypes.LONGVARCHAR) + @Column(name = "payload", nullable = false, updatable = false) + private String payload; + + @Column(name = "occurred_at", nullable = false, updatable = false) + private Instant occurredAt; + + /** Lifecycle status string: PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD. */ + @Column(name = "status", nullable = false, length = 16) + private String status; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + /** Dual-purpose per status (PENDING / IN_FLIGHT / FAILED) — see README "outbox". */ + @Column(name = "next_attempt_at", nullable = false) + private Instant nextAttemptAt; + + @Column(name = "correlation_id", nullable = false, length = 64, updatable = false) + private String correlationId; + + @Column(name = "idempotency_key", nullable = false, length = 256, updatable = false) + private String idempotencyKey; + + /** JPA no-arg constructor. */ + public OutboxEventEntity() {} + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getAggregateId() { + return aggregateId; + } + + public void setAggregateId(String aggregateId) { + this.aggregateId = aggregateId; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + + public Instant getOccurredAt() { + return occurredAt; + } + + public void setOccurredAt(Instant occurredAt) { + this.occurredAt = occurredAt; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getAttemptCount() { + return attemptCount; + } + + public void setAttemptCount(int attemptCount) { + this.attemptCount = attemptCount; + } + + public Instant getNextAttemptAt() { + return nextAttemptAt; + } + + public void setNextAttemptAt(Instant nextAttemptAt) { + this.nextAttemptAt = nextAttemptAt; + } + + public String getCorrelationId() { + return correlationId; + } + + public void setCorrelationId(String correlationId) { + this.correlationId = correlationId; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java new file mode 100644 index 00000000..f65b2e6a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java @@ -0,0 +1,2 @@ +/** Persistence adapter anchor for project-owned storage integrations. */ +package dev.caskeleton.adapter.outbound.persistence; diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java new file mode 100644 index 00000000..7ccf5807 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.List; + +/** + * PostgreSQL {@link OutboxClaimRepository}: claims eligible outbox rows with {@code FOR UPDATE SKIP + * LOCKED}. Design notes in the module README. + */ +public class PostgreSqlOutboxClaimRepository implements OutboxClaimRepository { + + private static final String CLAIM_SQL = + """ + SELECT * FROM outbox_event o + WHERE o.next_attempt_at <= :now + AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT') + AND NOT EXISTS ( + SELECT 1 FROM outbox_event p + WHERE p.aggregate_id = o.aggregate_id + AND p.occurred_at < o.occurred_at + AND p.status <> 'PUBLISHED' + ) + ORDER BY o.occurred_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """; + + private final EntityManager entityManager; + + public PostgreSqlOutboxClaimRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @SuppressWarnings("unchecked") + public List<OutboxEventEntity> claimEligible(Instant now, int limit) { + return entityManager + .createNativeQuery(CLAIM_SQL, OutboxEventEntity.class) + .setParameter("now", now) + .setParameter("limit", limit) + .getResultList(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java new file mode 100644 index 00000000..787fa620 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; +import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import jakarta.persistence.EntityManager; +import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor + * {@code @Bean}s. See the module README. + */ +@Configuration(proxyBeanMethods = false) +@Import(PersistenceJpaConfig.class) +public class PostgreSqlPersistenceConfig { + + @Bean + public OutboxClaimRepository outboxClaimRepository(EntityManager entityManager) { + return new PostgreSqlOutboxClaimRepository(entityManager); + } + + @Bean + public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() { + return new PostgreSqlSqlStateErrorMapping(); + } + + @Bean + public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() { + return configuration -> configuration.locations("classpath:db/migration/postgresql"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlSqlStateErrorMapping.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlSqlStateErrorMapping.java new file mode 100644 index 00000000..f44cea4a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlSqlStateErrorMapping.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.shared.error.OperationalError; +import java.util.Map; + +/** + * PostgreSQL-specific {@link SqlStateErrorMapping} contributing vendor-only SQLState codes. See the + * module README for the code/category/retryable table. + */ +public class PostgreSqlSqlStateErrorMapping implements SqlStateErrorMapping { + + private static final Map<String, OperationalError> MAPPINGS = + Map.of( + "40P01", OperationalError.DB_DEADLOCK, + "25P03", OperationalError.DB_IDLE_IN_TX_TIMEOUT, + "57014", OperationalError.DB_QUERY_CANCELED); + + @Override + public Map<String, OperationalError> exactMappings() { + return MAPPINGS; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java new file mode 100644 index 00000000..cea0b805 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import dev.caskeleton.application.transaction.Isolation; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import java.util.function.Supplier; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Spring-backed {@link TransactionPort}: one pre-built {@link TransactionTemplate} per mode, all + * pinned to {@link Isolation#READ_COMMITTED}. See README "transaction" for why the templates are + * pre-built (mutable-template race) and CLAUDE.md for the mode table. + */ +@Component +public class SpringTransactionPort implements TransactionPort { + + private final TransactionTemplate writeTemplate; + private final TransactionTemplate readTemplate; + private final TransactionTemplate requiresNewTemplate; + + public SpringTransactionPort(PlatformTransactionManager transactionManager) { + this.writeTemplate = + template( + transactionManager, + TransactionMode.WRITE, + TransactionDefinition.PROPAGATION_REQUIRED, + false); + this.readTemplate = + template( + transactionManager, + TransactionMode.READ_ONLY, + TransactionDefinition.PROPAGATION_REQUIRED, + true); + this.requiresNewTemplate = + template( + transactionManager, + TransactionMode.REQUIRES_NEW, + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + false); + } + + @Override + public <T> T inWrite(Supplier<T> action) { + return writeTemplate.execute(status -> action.get()); + } + + @Override + public <T> T inRead(Supplier<T> action) { + return readTemplate.execute(status -> action.get()); + } + + @Override + public <T> T inNew(Supplier<T> action) { + return requiresNewTemplate.execute(status -> action.get()); + } + + private static TransactionTemplate template( + PlatformTransactionManager transactionManager, + TransactionMode mode, + int propagation, + boolean readOnly) { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setName("application-" + mode.name().toLowerCase()); + template.setPropagationBehavior(propagation); + template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + template.setReadOnly(readOnly); + return template; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V1__idempotency_record.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V1__idempotency_record.sql new file mode 100644 index 00000000..5976eeae --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V1__idempotency_record.sql @@ -0,0 +1,21 @@ +-- feature-rate-limit-idempotency-contract — idempotency record store. Schema decisions: see module README. + +CREATE TABLE idempotency_record ( + id uuid NOT NULL, + tenant varchar(128) NOT NULL DEFAULT '', + principal varchar(256) NOT NULL, + idempotency_key varchar(256) NOT NULL, + use_case_name varchar(256) NOT NULL, + request_hash char(64) NOT NULL, + status varchar(16) NOT NULL, + response_payload text NULL, + response_ref varchar(512) NULL, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + CONSTRAINT pk_idempotency_record PRIMARY KEY (id), + CONSTRAINT uq_idempotency_scope + UNIQUE (tenant, principal, idempotency_key, use_case_name) +); + +-- Reaper / TTL-boundary expiry scans. +CREATE INDEX ix_idempotency_record_expires_at ON idempotency_record (expires_at); diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V3__outbox_event.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V3__outbox_event.sql new file mode 100644 index 00000000..c35fd5b6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V3__outbox_event.sql @@ -0,0 +1,29 @@ +-- feature-domain-event-outbox-contract — transactional outbox event table. Schema decisions: see module README. + +CREATE TABLE outbox_event ( + event_id varchar(64) NOT NULL, + aggregate_id varchar(256) NOT NULL, + event_type varchar(256) NOT NULL, + payload text NOT NULL, + occurred_at timestamptz NOT NULL, + status varchar(16) NOT NULL, + attempt_count integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL, + correlation_id varchar(64) NOT NULL, + idempotency_key varchar(256) NOT NULL, + CONSTRAINT pk_outbox_event PRIMARY KEY (event_id) +); + +-- Relay-poll lookup of eligible rows. +CREATE INDEX ix_outbox_event_eligible ON outbox_event (next_attempt_at) + WHERE status IN ('PENDING', 'FAILED', 'IN_FLIGHT'); + +-- FIFO gate (I4): per-aggregate ordering / earlier-unpublished lookup. +CREATE INDEX ix_outbox_event_aggregate_occurred ON outbox_event (aggregate_id, occurred_at); + +-- Reaper: PUBLISHED rows older than retention cutoff. +CREATE INDEX ix_outbox_event_published_occurred ON outbox_event (occurred_at) + WHERE status = 'PUBLISHED'; + +-- Metrics: countByStatus / oldest-unpublished-lag. +CREATE INDEX ix_outbox_event_status_occurred ON outbox_event (status, occurred_at); diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V4__int_lock.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V4__int_lock.sql new file mode 100644 index 00000000..c24dea51 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V4__int_lock.sql @@ -0,0 +1,10 @@ +-- feature-distributed-lock-contract — Spring Integration JDBC LockRegistry backing table. +-- DDL is the SI 6.5 PostgreSQL schema verbatim; provenance and TTL notes in module README. + +CREATE TABLE INT_LOCK ( + LOCK_KEY CHAR(36) NOT NULL, + REGION VARCHAR(100) NOT NULL, + CLIENT_ID CHAR(36), + CREATED_DATE TIMESTAMP NOT NULL, + constraint INT_LOCK_PK primary key (LOCK_KEY, REGION) +); diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V5__int_lock_expired_after.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V5__int_lock_expired_after.sql new file mode 100644 index 00000000..0901128b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V5__int_lock_expired_after.sql @@ -0,0 +1,12 @@ +-- Spring Integration 7 adds per-lock expiry state to the JDBC LockRegistry table. +-- Keep V4 immutable for databases that already applied it, and evolve the table forward here. + +ALTER TABLE INT_LOCK + ADD COLUMN IF NOT EXISTS EXPIRED_AFTER TIMESTAMP; + +UPDATE INT_LOCK +SET EXPIRED_AFTER = CREATED_DATE +WHERE EXPIRED_AFTER IS NULL; + +ALTER TABLE INT_LOCK + ALTER COLUMN EXPIRED_AFTER SET NOT NULL; diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java new file mode 100644 index 00000000..df2a7c9a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.persistence.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Unit contract for the manual-capture {@link AuditableEntity} base + * (feature-persistence-auditing-contract D1/D3/D4/D5). + * + * <p>Exercised through a tiny concrete subclass because {@link AuditableEntity} is a + * {@code @MappedSuperclass} (JPA cannot instantiate it standalone, and the production subclass + * {@code WorkLogEntity} lives in the sample module). + */ +class AuditableEntityTest { + + private static final Instant CREATED = Instant.parse("2026-06-10T10:00:00Z"); + private static final Instant MODIFIED = Instant.parse("2026-06-10T11:30:00Z"); + + @MappedSuperclass + static final class Probe extends AuditableEntity {} + + @Test + void initializeAuditSetsCreatedAndUpdatedToTheSameNowAndActor() { + // D3 §1 / D1 Manual path: on INSERT the adapter sets updated_* = created_* so the + // NOT NULL updated_* columns are satisfied without the JPA-auditing modifyOnCreate. + Probe probe = new Probe(); + + probe.initializeAudit(CREATED, "alice"); + + assertThat(probe.getCreatedAt()).isEqualTo(CREATED); + assertThat(probe.getUpdatedAt()).isEqualTo(CREATED); + assertThat(probe.getCreatedBy()).isEqualTo("alice"); + assertThat(probe.getUpdatedBy()).isEqualTo("alice"); + } + + @Test + void carryCreationThenApplyModificationPreservesCreatedAndBumpsUpdated() { + // D2/D3: created_* is immutable (updatable=false). On UPDATE the adapter carries the + // persisted created_* forward and only updated_* moves to the modification now/actor. + Probe probe = new Probe(); + + probe.carryCreation(CREATED, "alice"); + probe.applyModification(MODIFIED, "bob"); + + assertThat(probe.getCreatedAt()).isEqualTo(CREATED); + assertThat(probe.getCreatedBy()).isEqualTo("alice"); + assertThat(probe.getUpdatedAt()).isEqualTo(MODIFIED); + assertThat(probe.getUpdatedBy()).isEqualTo("bob"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/DomainContextAuditContextPortTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/DomainContextAuditContextPortTest.java new file mode 100644 index 00000000..0bcb3e70 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/DomainContextAuditContextPortTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.ThreadLocalDomainContextPropagator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Contract for the runtime-context-backed audit actor seam (feature-persistence-auditing-contract + * D5). Verifies the {@code "system"} fallback is explicit (Spring leaves the field blank on {@code + * Optional.empty()} — the adapter, not the framework, supplies the fallback) and that a bound actor + * is read through the runtime-context-propagation seam. + */ +class DomainContextAuditContextPortTest { + + private final DomainContextPropagator propagator = new ThreadLocalDomainContextPropagator(); + private final AuditContextPort port = new DomainContextAuditContextPort(propagator); + + @AfterEach + void clear() { + propagator.clear(); + } + + @Test + void currentActorReturnsSystemWhenNoPrincipalIsBound() { + // scheduler / Flyway migration / anonymous: no actor in the seam -> "system" (D5). + assertThat(port.currentActor()).isEqualTo("system"); + } + + @Test + void currentActorReturnsTheBoundActorFromTheRuntimeContextSeam() { + propagator.set(DomainContextAuditContextPort.ACTOR_KEY, "user-42"); + + assertThat(port.currentActor()).isEqualTo("user-42"); + } + + @Test + void currentActorFallsBackToSystemWhenTheBoundActorIsBlank() { + propagator.set(DomainContextAuditContextPort.ACTOR_KEY, " "); + + assertThat(port.currentActor()).isEqualTo("system"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java new file mode 100644 index 00000000..1a47af7b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslatorTest.java @@ -0,0 +1,184 @@ +package dev.caskeleton.adapter.outbound.persistence.failure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * feature-persistence-failure-baseline §SQLState → Error Code Matrix (standard rows) + §Edge + * "standard-matrix 밖 미지의 SQLState: fallback". + * + * <p>Tests the SPI-based {@link PersistenceExceptionTranslator} wired with only the core {@link + * StandardSqlStateErrorMapping}. The 5 standard exact-match codes plus the {@code 08*} + * connection-class prefix are verified here. + * + * <p>PostgreSQL-specific rows ({@code 40P01}, {@code 25P03}, {@code 57014}) are tested in {@code + * adapter-persistence-postgresql} module tests. + */ +class PersistenceExceptionTranslatorTest { + + /** + * Translator wired with only the standard mapping — mirrors the real production wiring where + * {@code StandardSqlStateErrorMapping} is always present and vendor mappings are additive. + */ + private final PersistenceExceptionTranslator translator = + new PersistenceExceptionTranslator(List.of(new StandardSqlStateErrorMapping())); + + /** Build a Spring DataAccessException whose root SQLException carries {@code sqlState}. */ + private static DataAccessException daoWithSqlState(String sqlState) { + return new DataIntegrityViolationException( + "wrapper message", new SQLException("driver detail", sqlState)); + } + + // ---- standard matrix: 08* prefix + 5 exact-match codes ---- + + @Test + void connectionClass08MapsToDbUnavailableTransient503Retryable() { + // 08* is a class prefix (08000/08001/08003/08006…) — all connection failures. + for (String sqlState : new String[] {"08000", "08001", "08003", "08006"}) { + Optional<PersistenceFailureException> result = + translator.translate(daoWithSqlState(sqlState)); + assertThat(result).as("SQLState %s must classify", sqlState).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_UNAVAILABLE); + } + OperationalError code = OperationalError.DB_UNAVAILABLE; + assertThat(code.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(code.httpStatus()).isEqualTo(503); + assertThat(code.retryable()).isTrue(); + } + + @Test + void serialization40001MapsToDbSerializationFailureConflictRetryable() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("40001")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_SERIALIZATION_FAILURE); + assertThat(OperationalError.DB_SERIALIZATION_FAILURE.category()).isEqualTo(Category.CONFLICT); + assertThat(OperationalError.DB_SERIALIZATION_FAILURE.retryable()).isTrue(); + } + + @Test + void nullViolation23502MapsToDbNullViolationDataIntegrityNonRetryable() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("23502")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_NULL_VIOLATION); + assertThat(OperationalError.DB_NULL_VIOLATION.category()).isEqualTo(Category.DATA_INTEGRITY); + assertThat(OperationalError.DB_NULL_VIOLATION.retryable()).isFalse(); + } + + @Test + void fkViolation23503MapsToDbFkViolationDataIntegrity() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("23503")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_FK_VIOLATION); + assertThat(OperationalError.DB_FK_VIOLATION.category()).isEqualTo(Category.DATA_INTEGRITY); + } + + @Test + void uniqueViolation23505MapsToDbUniqueViolationConflictNotDataIntegrity() { + // 23505 is the one 23-class code that is a CONFLICT (business duplicate), not DATA_INTEGRITY. + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("23505")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); + assertThat(OperationalError.DB_UNIQUE_VIOLATION.category()).isEqualTo(Category.CONFLICT); + assertThat(OperationalError.DB_UNIQUE_VIOLATION.retryable()).isFalse(); + } + + @Test + void checkViolation23514MapsToDbCheckViolationDataIntegrity() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("23514")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_CHECK_VIOLATION); + assertThat(OperationalError.DB_CHECK_VIOLATION.category()).isEqualTo(Category.DATA_INTEGRITY); + } + + // ---- distinctness: transient lock vs integrity must never collapse onto one code ---- + + @Test + void transientLockAndIntegrityViolationClassifyToDistinctCodes() { + // §테스트 계약: "integrity violation과 transient lock failure가 같은 code로 뭉개지면 실패". + var lock = translator.translate(daoWithSqlState("40001")).orElseThrow().errorCode(); + var integrity = translator.translate(daoWithSqlState("23502")).orElseThrow().errorCode(); + assertThat(lock).isNotEqualTo(integrity); + assertThat(lock.category()).isNotEqualTo(integrity.category()); + // retryable polarity differs: transient lock retryable, integrity not. + assertThat(lock.retryable()).isTrue(); + assertThat(integrity.retryable()).isFalse(); + } + + // ---- fallback: unknown SQLState is NOT classified (caller lets catch-all handle it) ---- + + @Test + void unknownSqlstateIsNotClassified() { + // standard-matrix 밖 미지의 SQLState → empty: the raw exception propagates to the web + // catch-all, which returns a generic INTERNAL envelope. The translator never invents a + // DB_* code. + assertThat(translator.translate(daoWithSqlState("42601"))).isEmpty(); // syntax error + assertThat(translator.translate(daoWithSqlState("99999"))).isEmpty(); + } + + @Test + void pgSpecificStatesNotClassifiedWithoutPgMapping() { + // 40P01 / 25P03 / 57014 are PostgreSQL-specific; they are NOT in the standard + // mapping and must return empty when only StandardSqlStateErrorMapping is wired. + assertThat(translator.translate(daoWithSqlState("40P01"))).isEmpty(); + assertThat(translator.translate(daoWithSqlState("25P03"))).isEmpty(); + assertThat(translator.translate(daoWithSqlState("57014"))).isEmpty(); + } + + @Test + void exceptionWithoutSqlexceptionCauseIsNotClassified() { + DataAccessException noSqlState = + new DataAccessResourceFailureException( + "pool acquire failed", new RuntimeException("plain")); + assertThat(translator.translate(noSqlState)).isEmpty(); + } + + @Test + void nullSqlstateIsNotClassified() { + DataAccessException nullState = + new DataIntegrityViolationException("x", new SQLException("no state", (String) null)); + assertThat(translator.translate(nullState)).isEmpty(); + } + + // ---- carrier hygiene: the raw cause is preserved for the server log, code is set ---- + + @Test + void carrierPreservesRawCauseForServerLog() { + DataAccessException raw = daoWithSqlState("23505"); + PersistenceFailureException carrier = translator.translate(raw).orElseThrow(); + assertThat(carrier.getCause()).isSameAs(raw); + assertThat(carrier.errorCode()).isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); + } + + // ---- SPI merge: additional mapping contributes extra codes ---- + + @Test + void additionalMappingExtendsTranslationWithoutReplacingStandardCodes() { + // Simulate a vendor mapping contributing one extra code + SqlStateErrorMapping vendorMapping = + () -> Map.of("XX001", OperationalError.DB_DEADLOCK); // synthetic vendor-only state + PersistenceExceptionTranslator withVendor = + new PersistenceExceptionTranslator( + List.of(new StandardSqlStateErrorMapping(), vendorMapping)); + + // Vendor-specific code classified + assertThat(withVendor.translate(daoWithSqlState("XX001"))).isPresent(); + assertThat(withVendor.translate(daoWithSqlState("XX001")).orElseThrow().errorCode()) + .isEqualTo(OperationalError.DB_DEADLOCK); + + // Standard codes still work + assertThat(withVendor.translate(daoWithSqlState("23505"))).isPresent(); + assertThat(withVendor.translate(daoWithSqlState("23505")).orElseThrow().errorCode()) + .isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaperTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaperTest.java new file mode 100644 index 00000000..a6dfcaa8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyReaperTest.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +class IdempotencyReaperTest { + + private static final Instant NOW = Instant.parse("2026-06-09T12:00:00Z"); + + @Test + void reapDeletesRecordsExpiredAsOfNow() { + IdempotencyRecordJpaRepository repo = mock(IdempotencyRecordJpaRepository.class); + when(repo.deleteExpired(NOW)).thenReturn(3); + IdempotencyReaper reaper = new IdempotencyReaper(repo, Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThat(reaper.reap()).isEqualTo(3); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java new file mode 100644 index 00000000..ef1e484b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapterTest.java @@ -0,0 +1,180 @@ +package dev.caskeleton.adapter.outbound.persistence.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataIntegrityViolationException; + +class IdempotencyStoreAdapterTest { + + private static final Instant NOW = Instant.parse("2026-06-09T12:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final IdempotencyScope SCOPE = + IdempotencyScope.of("user-1", "key-1", "CreateWorkLogUseCase"); + private static final RequestFingerprint FP = RequestFingerprint.ofSha256("body".getBytes()); + private static final Instant EXPIRES = NOW.plus(Duration.ofHours(24)); + + private final IdempotencyRecordJpaRepository repo = mock(IdempotencyRecordJpaRepository.class); + + private IdempotencyStoreAdapter adapter(IdempotencyResponseObjectStore objectStore) { + return new IdempotencyStoreAdapter(repo, objectStore, CLOCK); + } + + private IdempotencyRecordEntity entity( + String status, Instant expiresAt, String payload, String ref) { + return new IdempotencyRecordEntity( + UUID.randomUUID(), + "", + "user-1", + "key-1", + "CreateWorkLogUseCase", + FP.hex(), + status, + payload, + ref, + NOW, + expiresAt); + } + + @Test + void tryBeginWinsWhenNoRecordExists() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName( + "", "user-1", "key-1", "CreateWorkLogUseCase")) + .thenReturn(Optional.empty()); + + boolean won = adapter(null).tryBegin(SCOPE, FP, EXPIRES); + + assertThat(won).isTrue(); + ArgumentCaptor<IdempotencyRecordEntity> saved = + ArgumentCaptor.forClass(IdempotencyRecordEntity.class); + verify(repo).saveAndFlush(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo("IN_FLIGHT"); + assertThat(saved.getValue().getRequestHash()).isEqualTo(FP.hex()); + assertThat(saved.getValue().getTenant()).isEqualTo(""); + } + + @Test + void tryBeginLosesWhenALiveRecordExists() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(entity("IN_FLIGHT", NOW.plus(Duration.ofHours(1)), null, null))); + + assertThat(adapter(null).tryBegin(SCOPE, FP, EXPIRES)).isFalse(); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void tryBeginReclaimsAnExpiredRecordThenWins() { + IdempotencyRecordEntity expired = entity("COMPLETED", NOW.minusSeconds(1), "stale", null); + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(expired)); + + assertThat(adapter(null).tryBegin(SCOPE, FP, EXPIRES)).isTrue(); + verify(repo).delete(expired); + verify(repo).saveAndFlush(any()); + } + + @Test + void tryBeginLosesOnUniqueConstraintRace() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.empty()); + when(repo.saveAndFlush(any())) + .thenThrow(new DataIntegrityViolationException("uq_idempotency_scope")); + + assertThat(adapter(null).tryBegin(SCOPE, FP, EXPIRES)).isFalse(); + } + + @Test + void findTreatsExpiredRowAsAbsent() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(entity("COMPLETED", NOW.minusSeconds(1), "x", null))); + + assertThat(adapter(null).find(SCOPE, NOW)).isEmpty(); + } + + @Test + void findMapsALiveCompletedRowWithInlinePayload() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(entity("COMPLETED", EXPIRES, "{\"ok\":true}", null))); + + Optional<IdempotencyRecord> found = adapter(null).find(SCOPE, NOW); + + assertThat(found) + .hasValueSatisfying( + r -> { + assertThat(r.status()).isEqualTo(IdempotencyStatus.COMPLETED); + assertThat(r.response().payload()).isEqualTo("{\"ok\":true}"); + assertThat(r.fingerprint()).isEqualTo(FP); + }); + } + + @Test + void completeStoresSmallPayloadInline() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(entity("IN_FLIGHT", EXPIRES, null, null))); + + adapter(null).complete(SCOPE, new StoredResponse("small-body")); + + ArgumentCaptor<IdempotencyRecordEntity> saved = + ArgumentCaptor.forClass(IdempotencyRecordEntity.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo("COMPLETED"); + assertThat(saved.getValue().getResponsePayload()).isEqualTo("small-body"); + assertThat(saved.getValue().getResponseRef()).isNull(); + } + + @Test + void completeOffloadsLargePayloadToObjectStore() { + IdempotencyResponseObjectStore objectStore = mock(IdempotencyResponseObjectStore.class); + when(objectStore.put(any())).thenReturn("s3://bucket/key-1"); + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(entity("IN_FLIGHT", EXPIRES, null, null))); + String large = "x".repeat(IdempotencyStoreAdapter.INLINE_MAX_BYTES + 1); + + adapter(objectStore).complete(SCOPE, new StoredResponse(large)); + + ArgumentCaptor<IdempotencyRecordEntity> saved = + ArgumentCaptor.forClass(IdempotencyRecordEntity.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getResponseRef()).isEqualTo("s3://bucket/key-1"); + assertThat(saved.getValue().getResponsePayload()).isNull(); + } + + @Test + void completeFallsBackToInlineWhenLargeButNoObjectStore() { + when(repo.findByTenantAndPrincipalAndIdempotencyKeyAndUseCaseName(any(), any(), any(), any())) + .thenReturn(Optional.of(entity("IN_FLIGHT", EXPIRES, null, null))); + String large = "x".repeat(IdempotencyStoreAdapter.INLINE_MAX_BYTES + 1); + + adapter(null).complete(SCOPE, new StoredResponse(large)); + + ArgumentCaptor<IdempotencyRecordEntity> saved = + ArgumentCaptor.forClass(IdempotencyRecordEntity.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getResponsePayload()).isEqualTo(large); + assertThat(saved.getValue().getResponseRef()).isNull(); + } + + @Test + void discardDeletesByScope() { + adapter(null).discard(SCOPE); + verify(repo).deleteByScope("", "user-1", "key-1", "CreateWorkLogUseCase"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/integration/.gitkeep b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/integration/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/integration/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/lock/LockRegistryDistributedLockAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/lock/LockRegistryDistributedLockAdapterTest.java new file mode 100644 index 00000000..e8b06f8b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/lock/LockRegistryDistributedLockAdapterTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.outbound.persistence.lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.lock.DistributedLock; +import dev.caskeleton.application.lock.LockAcquisitionTimeoutException; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.integration.support.locks.DefaultLockRegistry; + +/** + * Pure unit tests for {@link LockRegistryDistributedLockAdapter} over an in-process {@link + * DefaultLockRegistry} (no database, no Spring context). + * + * <h2>Determinism strategy</h2> + * + * <p>Timeout tests hold the lock on a background thread using {@link CountDownLatch} coordination. + * The background thread acquires the lock and signals readiness before the main thread makes a + * contending attempt. The background thread releases the lock only after the main thread's + * assertion completes. This avoids timing-based sleeps. + */ +class LockRegistryDistributedLockAdapterTest { + + private static final Duration CONFIGURED_TTL = Duration.ofSeconds(30); + private static final Duration SHORT_WAIT = Duration.ofMillis(50); + + private LockRegistryDistributedLockAdapter adapter() { + return new LockRegistryDistributedLockAdapter(new DefaultLockRegistry(), CONFIGURED_TTL); + } + + // ------------------------------------------------------------------ + // acquireReturnsNonNullHandle + // ------------------------------------------------------------------ + + @Test + void acquireReturnsNonNullHandle() { + LockRegistryDistributedLockAdapter sut = adapter(); + + DistributedLock lock = sut.tryAcquire("key-1", SHORT_WAIT, CONFIGURED_TTL); + + assertThat(lock).isNotNull(); + lock.close(); + } + + // ------------------------------------------------------------------ + // closeReleasesLockSoReAcquireOnSameKeySucceeds + // ------------------------------------------------------------------ + + @Test + void closeReleasesLockSoReAcquireOnSameKeySucceeds() { + LockRegistryDistributedLockAdapter sut = adapter(); + + DistributedLock first = sut.tryAcquire("key-2", SHORT_WAIT, CONFIGURED_TTL); + first.close(); + + // After close(), the same key must be acquirable again. + DistributedLock second = sut.tryAcquire("key-2", SHORT_WAIT, CONFIGURED_TTL); + assertThat(second).isNotNull(); + second.close(); + } + + // ------------------------------------------------------------------ + // timeout_when_key_is_held_by_another_thread + // ------------------------------------------------------------------ + + /** + * Holds the lock on a background thread using latches, then asserts that a contending main-thread + * attempt times out and throws {@link LockAcquisitionTimeoutException} carrying the expected key. + */ + @Test + void timeoutWhenKeyIsHeldByAnotherThreadThrowsWithCorrectKey() throws InterruptedException { + + LockRegistryDistributedLockAdapter sut = adapter(); + String key = "key-3"; + + CountDownLatch lockHeld = new CountDownLatch(1); + CountDownLatch releaseGate = new CountDownLatch(1); + AtomicReference<Throwable> holderError = new AtomicReference<>(); + + Thread holder = + new Thread( + () -> { + DistributedLock lock = sut.tryAcquire(key, SHORT_WAIT, CONFIGURED_TTL); + lockHeld.countDown(); // signal: lock is now held + try { + releaseGate.await(); // wait until main thread is done asserting + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + lock.close(); + } + }, + "lock-holder"); + holder.setUncaughtExceptionHandler((t, e) -> holderError.set(e)); + holder.start(); + + lockHeld.await(); // ensure holder has the lock before we try + + try { + assertThatThrownBy(() -> sut.tryAcquire(key, SHORT_WAIT, CONFIGURED_TTL)) + .isInstanceOf(LockAcquisitionTimeoutException.class) + .satisfies( + ex -> { + LockAcquisitionTimeoutException timeout = (LockAcquisitionTimeoutException) ex; + assertThat(timeout.key()).isEqualTo(key); + }); + } finally { + releaseGate.countDown(); // unblock holder regardless + holder.join(2_000); + } + + assertThat(holderError.get()).isNull(); // holder thread must not have errored + } + + // ------------------------------------------------------------------ + // leaseTtl > configuredTtl must throw IllegalArgumentException + // ------------------------------------------------------------------ + + @Test + void tryAcquireWithLeaseTtlExceedingConfiguredTtlThrowsIllegalArgumentException() { + LockRegistryDistributedLockAdapter sut = adapter(); // configuredTtl = 30s + + Duration excessiveTtl = CONFIGURED_TTL.plusSeconds(1); // 31s > 30s + + assertThatThrownBy(() -> sut.tryAcquire("key-4", SHORT_WAIT, excessiveTtl)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ------------------------------------------------------------------ + // try-with-resources compiles and releases + // ------------------------------------------------------------------ + + @Test + void handleIsUsableInTryWithResources() { + LockRegistryDistributedLockAdapter sut = adapter(); + + try (DistributedLock ignored = sut.tryAcquire("key-5", SHORT_WAIT, CONFIGURED_TTL)) { + // work + } + + // After the try-with-resources block, the lock must have been released. + DistributedLock second = sut.tryAcquire("key-5", SHORT_WAIT, CONFIGURED_TTL); + assertThat(second).isNotNull(); + second.close(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java new file mode 100644 index 00000000..7e27d412 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link OutboxReaper} — verifies that the bulk delete is driven by {@code + * clock.instant() - retention} and that the return value is propagated. + * + * <p>Real TTL / SQL behaviour is covered by the Task E PG contract tests. + */ +class OutboxReaperTest { + + private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z"); + private static final Duration RETENTION = Duration.ofDays(7); + + @Test + void reapDeletesPublishedRowsOlderThanRetentionCutoff() { + OutboxEventJpaRepository repo = mock(OutboxEventJpaRepository.class); + Instant cutoff = NOW.minus(RETENTION); + when(repo.deletePublishedBefore(cutoff)).thenReturn(5); + + OutboxReaper reaper = new OutboxReaper(repo, Clock.fixed(NOW, ZoneOffset.UTC), RETENTION); + + assertThat(reaper.reap()).isEqualTo(5); + } + + @Test + void reapReturnsZeroWhenNothingToDelete() { + OutboxEventJpaRepository repo = mock(OutboxEventJpaRepository.class); + Instant cutoff = NOW.minus(RETENTION); + when(repo.deletePublishedBefore(cutoff)).thenReturn(0); + + OutboxReaper reaper = new OutboxReaper(repo, Clock.fixed(NOW, ZoneOffset.UTC), RETENTION); + + assertThat(reaper.reap()).isEqualTo(0); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperWiringTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperWiringTest.java new file mode 100644 index 00000000..64db826b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperWiringTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.time.Clock; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wiring tests for {@link OutboxReaper} (feature-domain-event-outbox-contract — FIX dispatch: + * OutboxReaper wiring defect). + * + * <p>Two concerns verified here: + * + * <ol> + * <li><strong>Context starts</strong>: {@link ApplicationContextRunner} wires {@link + * OutboxReaper} successfully when a mocked {@link OutboxEventJpaRepository} bean and a {@link + * Clock} bean are present. This catches the "Spring cannot auto-wire" defect described in the + * spec-reviewer finding. + * <li><strong>Drift guard</strong>: the {@code Duration retention} constructor parameter carries + * {@code @Value("${ca-skeleton.outbox.published-retention:P7D}")} with the exact expression. + * Checked via reflection so that a future rename of the property key or default fails the + * build immediately. + * </ol> + */ +class OutboxReaperWiringTest { + + // ----------------------------------------------------------------------- + // Inline configuration that satisfies OutboxReaper's two non-@Value deps + // ----------------------------------------------------------------------- + @Configuration + static class MinimalConfig { + + @Bean + OutboxEventJpaRepository outboxEventJpaRepository() { + return mock(OutboxEventJpaRepository.class); + } + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + } + + /** + * Shared runner pre-wired with: + * + * <ul> + * <li>{@link ApplicationConversionService} — registers Spring Boot's ISO-8601 Duration + * converter so that {@code @Value("P7D")} can be resolved to {@link Duration}. A plain + * {@code GenericApplicationContext} (which {@code ApplicationContextRunner} creates) does + * not include this converter by default; Boot adds it via {@code + * SpringApplication.configureContext}. + * <li>{@link MinimalConfig} — mock {@link OutboxEventJpaRepository} + system {@link Clock} + * beans. + * <li>{@link OutboxReaper} bean class. + * </ul> + */ + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withInitializer( + ctx -> + ctx.getBeanFactory() + .setConversionService(ApplicationConversionService.getSharedInstance())) + .withUserConfiguration(MinimalConfig.class) + .withBean(OutboxReaper.class); + + // ----------------------------------------------------------------------- + // 1. Spring wiring — context must start with default P7D retention + // ----------------------------------------------------------------------- + + @Test + void outboxReaperBeanIsCreatedWhenRequiredBeansArePresent() { + contextRunner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxReaper.class); + }); + } + + @Test + void outboxReaperUsesDefaultP7dRetentionWhenNoPropertyIsSet() { + contextRunner.run( + context -> { + assertThat(context).hasNotFailed(); + OutboxReaper reaper = context.getBean(OutboxReaper.class); + // Verify the reaper was created; retention P7D is injected via @Value default + assertThat(reaper).isNotNull(); + }); + } + + @Test + void outboxReaperUsesConfiguredRetentionWhenPropertyIsSet() { + contextRunner + .withPropertyValues("ca-skeleton.outbox.published-retention=P30D") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxReaper.class); + }); + } + + // ----------------------------------------------------------------------- + // 2. Reflection drift guard — @Value expression must stay exact + // (catches property-key rename or default value drift without a full Spring boot) + // ----------------------------------------------------------------------- + + private static final String EXPECTED_VALUE_EXPRESSION = + "${ca-skeleton.outbox.published-retention:P7D}"; + + @Test + void durationRetentionConstructorParameterCarriesValueAnnotationWithExactExpression() { + Constructor<?>[] ctors = OutboxReaper.class.getDeclaredConstructors(); + assertThat(ctors).as("OutboxReaper must have exactly one constructor").hasSize(1); + + Constructor<?> ctor = ctors[0]; + Parameter[] params = ctor.getParameters(); + + // Find the Duration parameter + Parameter retentionParam = null; + for (Parameter p : params) { + if (p.getType().equals(Duration.class)) { + retentionParam = p; + break; + } + } + + assertThat(retentionParam) + .as("OutboxReaper constructor must have a Duration parameter for retention") + .isNotNull(); + + Value valueAnnotation = retentionParam.getAnnotation(Value.class); + assertThat(valueAnnotation) + .as("Duration retention parameter must be annotated with @Value") + .isNotNull(); + + assertThat(valueAnnotation.value()) + .as("@Value expression must be exactly: " + EXPECTED_VALUE_EXPRESSION) + .isEqualTo(EXPECTED_VALUE_EXPRESSION); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java new file mode 100644 index 00000000..60b77bed --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java @@ -0,0 +1,285 @@ +package dev.caskeleton.adapter.outbound.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for {@link OutboxStoreAdapter} — covers append mapping, claim transition, mark + * operations, countByStatus, and oldestUnpublishedAgeSecondsByEventType. + * + * <h2>FIFO gate location contract</h2> + * + * <p>The per-aggregate FIFO gate (I4) lives entirely in the {@link + * OutboxClaimRepository#claimEligible} native SQL query via a {@code NOT EXISTS} correlated + * subquery. The adapter itself performs NO in-memory filtering: every row returned by the claim + * repository passes through to the caller. Verifying that the SQL gate enforces FIFO is the + * responsibility of the Testcontainers contract test in {@code app-bootstrap}. + * + * <p>Real SKIP LOCKED / FIFO gate behaviour is covered by the Task E PG contract tests. + */ +class OutboxStoreAdapterTest { + + private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z"); + private static final Duration IN_FLIGHT_TIMEOUT = Duration.ofMinutes(5); + + private final OutboxEventJpaRepository repo = mock(OutboxEventJpaRepository.class); + private final OutboxClaimRepository claimRepo = mock(OutboxClaimRepository.class); + private final OutboxStoreAdapter adapter = new OutboxStoreAdapter(repo, claimRepo); + + // ---- helper ---- + + private static NewOutboxEvent newEvent(String eventId) { + return new NewOutboxEvent(eventId, "UserCreated", "agg-1", "{}", NOW, "corr-1", "idem-1"); + } + + private static OutboxEventEntity pendingEntity(String eventId) { + OutboxEventEntity e = new OutboxEventEntity(); + e.setEventId(eventId); + e.setAggregateId("agg-1"); + e.setEventType("UserCreated"); + e.setPayload("{}"); + e.setOccurredAt(NOW.minusSeconds(60)); + e.setStatus("PENDING"); + e.setAttemptCount(0); + e.setNextAttemptAt(NOW.minusSeconds(60)); + e.setCorrelationId("corr-1"); + e.setIdempotencyKey("idem-1"); + return e; + } + + // ---- append ---- + + @Test + void appendPersistsEntityWithCorrectFieldMapping() { + NewOutboxEvent event = newEvent("evt-001"); + + adapter.append(event); + + ArgumentCaptor<OutboxEventEntity> saved = forClass(OutboxEventEntity.class); + verify(repo).save(saved.capture()); + OutboxEventEntity entity = saved.getValue(); + + assertThat(entity.getEventId()).isEqualTo("evt-001"); + assertThat(entity.getEventType()).isEqualTo("UserCreated"); + assertThat(entity.getAggregateId()).isEqualTo("agg-1"); + assertThat(entity.getPayload()).isEqualTo("{}"); + assertThat(entity.getOccurredAt()).isEqualTo(NOW); + assertThat(entity.getStatus()).isEqualTo("PENDING"); + assertThat(entity.getAttemptCount()).isEqualTo(0); + assertThat(entity.getNextAttemptAt()).isEqualTo(NOW); // nextAttemptAt = occurredAt on PENDING + assertThat(entity.getCorrelationId()).isEqualTo("corr-1"); + assertThat(entity.getIdempotencyKey()).isEqualTo("idem-1"); + } + + // ---- claimBatch ---- + + @Test + void claimBatchTransitionsEntityToInFlightAndReturnsMappedEvent() { + OutboxEventEntity entity = pendingEntity("evt-002"); + when(claimRepo.claimEligible(eq(NOW), eq(2))).thenReturn(List.of(entity)); + + List<OutboxEvent> claimed = adapter.claimBatch(2, NOW, IN_FLIGHT_TIMEOUT); + + // status and attemptCount updated on entity + assertThat(entity.getStatus()).isEqualTo("IN_FLIGHT"); + assertThat(entity.getAttemptCount()).isEqualTo(1); + assertThat(entity.getNextAttemptAt()).isEqualTo(NOW.plus(IN_FLIGHT_TIMEOUT)); + + // returned OutboxEvent reflects post-transition state + assertThat(claimed).hasSize(1); + OutboxEvent result = claimed.get(0); + assertThat(result.eventId()).isEqualTo("evt-002"); + assertThat(result.status()).isEqualTo(OutboxEventStatus.IN_FLIGHT); + assertThat(result.attemptCount()).isEqualTo(1); + } + + @Test + void claimBatchReturnsEmptyWhenRepoReturnsNothing() { + when(claimRepo.claimEligible(any(), anyInt())).thenReturn(List.of()); + + assertThat(adapter.claimBatch(5, NOW, IN_FLIGHT_TIMEOUT)).isEmpty(); + } + + /** + * Regression guard: the per-aggregate FIFO gate lives in the SQL query (I4). The adapter must NOT + * apply any in-memory filtering — all rows returned by the repository must appear in the result, + * including multiple rows for the same aggregate. + * + * <p>If this test fails it means in-memory filtering was re-introduced in the adapter; the fix is + * to move that logic back to the {@code claimEligible} SQL query. + */ + @Test + void claimBatchPassesAllRepoResultsThroughWithoutInMemoryFifoFiltering() { + // Two rows for the same aggregate — repo returns both (SQL gate already enforces FIFO) + OutboxEventEntity head = pendingEntity("evt-head"); + OutboxEventEntity tail = new OutboxEventEntity(); + tail.setEventId("evt-tail"); + tail.setAggregateId("agg-1"); // same aggregate as head + tail.setEventType("UserCreated"); + tail.setPayload("{}"); + tail.setOccurredAt(NOW.minusSeconds(30)); // later than head (head is at -60s) + tail.setStatus("PENDING"); + tail.setAttemptCount(0); + tail.setNextAttemptAt(NOW.minusSeconds(30)); + tail.setCorrelationId("corr-2"); + tail.setIdempotencyKey("idem-2"); + + when(claimRepo.claimEligible(eq(NOW), eq(10))).thenReturn(List.of(head, tail)); + + List<OutboxEvent> claimed = adapter.claimBatch(10, NOW, IN_FLIGHT_TIMEOUT); + + // Adapter must return both rows — no in-memory FIFO filtering + assertThat(claimed) + .hasSize(2) + .extracting(OutboxEvent::eventId) + .containsExactly("evt-head", "evt-tail"); + } + + // ---- markPublished ---- + + @Test + void markPublishedUpdatesStatusToPublished() { + OutboxEventEntity entity = pendingEntity("evt-003"); + entity.setStatus("IN_FLIGHT"); + when(repo.findById("evt-003")).thenReturn(Optional.of(entity)); + + adapter.markPublished("evt-003"); + + assertThat(entity.getStatus()).isEqualTo("PUBLISHED"); + } + + // ---- markFailed ---- + + @Test + void markFailedSetsFailedStatusAndNextAttemptAt() { + OutboxEventEntity entity = pendingEntity("evt-004"); + entity.setStatus("IN_FLIGHT"); + when(repo.findById("evt-004")).thenReturn(Optional.of(entity)); + Instant retryAt = NOW.plus(Duration.ofSeconds(30)); + + adapter.markFailed("evt-004", retryAt); + + assertThat(entity.getStatus()).isEqualTo("FAILED"); + assertThat(entity.getNextAttemptAt()).isEqualTo(retryAt); + } + + // ---- markDead ---- + + @Test + void markDeadSetsDeadStatus() { + OutboxEventEntity entity = pendingEntity("evt-005"); + entity.setStatus("IN_FLIGHT"); + when(repo.findById("evt-005")).thenReturn(Optional.of(entity)); + + adapter.markDead("evt-005"); + + assertThat(entity.getStatus()).isEqualTo("DEAD"); + } + + // ---- countByStatus ---- + + @Test + void countByStatusMapsProjectionResultsToStatusEnumMap() { + List<Object[]> rows = + List.of( + new Object[] {"PENDING", 5L}, + new Object[] {"IN_FLIGHT", 2L}, + new Object[] {"PUBLISHED", 10L}); + when(repo.countGroupedByStatus()).thenReturn(rows); + + Map<OutboxEventStatus, Long> counts = adapter.countByStatus(); + + assertThat(counts).containsEntry(OutboxEventStatus.PENDING, 5L); + assertThat(counts).containsEntry(OutboxEventStatus.IN_FLIGHT, 2L); + assertThat(counts).containsEntry(OutboxEventStatus.PUBLISHED, 10L); + assertThat(counts).doesNotContainKey(OutboxEventStatus.FAILED); + assertThat(counts).doesNotContainKey(OutboxEventStatus.DEAD); + } + + // ---- oldestUnpublishedAgeSecondsByEventType ---- + + @Test + void oldestUnpublishedAgeComputesSecondsFromOldestRowOccurredAt() { + // oldest row for "UserCreated" occurred 120 seconds before NOW + Instant oldestAt = NOW.minusSeconds(120); + List<Object[]> rows = List.<Object[]>of(new Object[] {"UserCreated", oldestAt}); + when(repo.findOldestUnpublishedOccurredAtByEventType()).thenReturn(rows); + + Map<String, Long> ages = adapter.oldestUnpublishedAgeSecondsByEventType(NOW); + + assertThat(ages).containsEntry("UserCreated", 120L); + } + + @Test + void oldestUnpublishedAgeReturnsEmptyMapWhenNoUnpublishedRows() { + when(repo.findOldestUnpublishedOccurredAtByEventType()).thenReturn(List.of()); + + assertThat(adapter.oldestUnpublishedAgeSecondsByEventType(NOW)).isEmpty(); + } + + // ---- missing-entity guard (fail-closed consistency) ---- + + /** + * When the outbox row is not found during markPublished, the adapter must throw {@link + * IllegalStateException} with the eventId in the message instead of silently doing nothing. A + * silent no-op leaves the row IN_FLIGHT forever, blocking the aggregate's FIFO queue with no + * error observable by the relay. + */ + @Test + void markPublishedThrowsWhenEventIdNotFound() { + when(repo.findById("missing-evt")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adapter.markPublished("missing-evt")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing-evt"); + } + + /** + * When the outbox row is not found during markFailed, the adapter must throw {@link + * IllegalStateException} with the eventId in the message. A silent no-op would leave the relay + * believing a retry is scheduled while the row remains IN_FLIGHT, blocking the aggregate queue + * and producing no error log. + */ + @Test + void markFailedThrowsWhenEventIdNotFound() { + when(repo.findById("missing-evt")).thenReturn(Optional.empty()); + Instant retryAt = NOW.plus(Duration.ofSeconds(30)); + + assertThatThrownBy(() -> adapter.markFailed("missing-evt", retryAt)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing-evt"); + } + + /** + * When the outbox row is not found during markDead, the adapter must throw {@link + * IllegalStateException} with the eventId in the message. A silent no-op would leave the row + * IN_FLIGHT instead of DEAD, preventing runbook-level visibility and manual resolution. + */ + @Test + void markDeadThrowsWhenEventIdNotFound() { + when(repo.findById("missing-evt")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adapter.markDead("missing-evt")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing-evt"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlSqlStateErrorMappingTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlSqlStateErrorMappingTest.java new file mode 100644 index 00000000..d81b2f3a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlSqlStateErrorMappingTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.persistence.postgresql; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator; +import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping; +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; + +/** Unit tests for {@link PostgreSqlSqlStateErrorMapping}. */ +class PostgreSqlSqlStateErrorMappingTest { + + private final SqlStateErrorMapping pgMapping = new PostgreSqlSqlStateErrorMapping(); + + private final PersistenceExceptionTranslator translator = + new PersistenceExceptionTranslator(List.of(new StandardSqlStateErrorMapping(), pgMapping)); + + private static DataAccessException daoWithSqlState(String sqlState) { + return new DataIntegrityViolationException( + "wrapper message", new SQLException("driver detail", sqlState)); + } + + @Test + void pgMappingContributesExactlyThreeCodes() { + Map<String, OperationalError> codes = pgMapping.exactMappings(); + assertThat(codes).containsOnlyKeys("40P01", "25P03", "57014"); + } + + @Test + void deadlock40P01MapsToDbDeadlockConflictRetryable() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("40P01")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_DEADLOCK); + assertThat(OperationalError.DB_DEADLOCK.category()).isEqualTo(Category.CONFLICT); + assertThat(OperationalError.DB_DEADLOCK.retryable()).isTrue(); + } + + @Test + void idleInTxTimeout25P03MapsToTransientRetryable() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("25P03")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_IDLE_IN_TX_TIMEOUT); + assertThat(OperationalError.DB_IDLE_IN_TX_TIMEOUT.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DB_IDLE_IN_TX_TIMEOUT.retryable()).isTrue(); + } + + @Test + void queryCanceled57014MapsToTransientNonRetryable() { + Optional<PersistenceFailureException> result = translator.translate(daoWithSqlState("57014")); + assertThat(result).isPresent(); + assertThat(result.get().errorCode()).isEqualTo(OperationalError.DB_QUERY_CANCELED); + assertThat(OperationalError.DB_QUERY_CANCELED.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DB_QUERY_CANCELED.retryable()).isFalse(); + } + + @Test + void standardCodesStillClassifyWhenPgMappingAlsoPresent() { + assertThat(translator.translate(daoWithSqlState("23505"))).isPresent(); + assertThat(translator.translate(daoWithSqlState("23505")).orElseThrow().errorCode()) + .isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); + assertThat(translator.translate(daoWithSqlState("40001"))).isPresent(); + assertThat(translator.translate(daoWithSqlState("40001")).orElseThrow().errorCode()) + .isEqualTo(OperationalError.DB_SERIALIZATION_FAILURE); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java new file mode 100644 index 00000000..dd18f74f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.outbound.persistence.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; + +class SpringTransactionPortTest { + + @Test + void inWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + String result = port.inWrite(() -> "ok"); + + assertThat(result).isEqualTo("ok"); + assertThat(tm.definitions).hasSize(1); + TransactionDefinition definition = tm.definitions.get(0); + assertThat(definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED); + assertThat(definition.getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + assertThat(definition.isReadOnly()).isFalse(); + assertThat(tm.commits).isOne(); + assertThat(tm.rollbacks).isZero(); + } + + @Test + void inReadUsesRequiredPropagationReadCommittedIsolationAndReadOnly() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + String result = port.inRead(() -> "ok"); + + assertThat(result).isEqualTo("ok"); + TransactionDefinition definition = tm.definitions.get(0); + assertThat(definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED); + assertThat(definition.getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + assertThat(definition.isReadOnly()).isTrue(); + } + + @Test + void inNewUsesRequiresNewPropagationReadCommittedIsolationAndNotReadOnly() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + port.inNew(() -> null); + + TransactionDefinition definition = tm.definitions.get(0); + assertThat(definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + assertThat(definition.getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + assertThat(definition.isReadOnly()).isFalse(); + } + + // ---- feature-transaction-concurrency-contract D3: vendor default isolation is forbidden ---- + + @Test + void everyModePinsAnExplicitIsolationNeverTheVendorDefault() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + port.inWrite(() -> null); + port.inRead(() -> null); + port.inNew(() -> null); + + assertThat(tm.definitions).hasSize(3); + assertThat(tm.definitions) + .as( + "delegating to the vendor default is forbidden — engine defaults differ " + + "(PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ)") + .allSatisfy( + definition -> + assertThat(definition.getIsolationLevel()) + .isNotEqualTo(TransactionDefinition.ISOLATION_DEFAULT) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED)); + } + + @Test + void exceptionInsideActionTriggersRollback() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + IllegalStateException thrown = + catchAndReturn( + () -> + port.inWrite( + () -> { + throw new IllegalStateException("boom"); + })); + + assertThat(thrown).hasMessage("boom"); + assertThat(tm.commits).isZero(); + assertThat(tm.rollbacks).isOne(); + } + + private static IllegalStateException catchAndReturn(Runnable action) { + try { + action.run(); + return null; + } catch (IllegalStateException e) { + return e; + } + } + + private static final class RecordingTransactionManager implements PlatformTransactionManager { + + private final List<TransactionDefinition> definitions = new ArrayList<>(); + private int commits; + private int rollbacks; + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) + throws TransactionException { + definitions.add(definition); + return new SimpleTransactionStatus(true); + } + + @Override + public void commit(TransactionStatus status) throws TransactionException { + commits++; + } + + @Override + public void rollback(TransactionStatus status) throws TransactionException { + rollbacks++; + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/CLAUDE.md b/src/adapter/outbound/persistence-mongo/CLAUDE.md new file mode 100644 index 00000000..84ed1174 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/CLAUDE.md @@ -0,0 +1,52 @@ +# adapter:outbound:persistence-mongo — module rules + +## Registered identity + +- Module ID: `adapter-outbound-persistence-mongo` +- Gradle path: `:adapter:outbound:persistence-mongo` +- Focused test: `./gradlew :adapter:outbound:persistence-mongo:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — **lightweight +Spring Data MongoDB scaffolding**. Design rationale lives in [README.md](README.md). + +## Responsibility + +- Demonstrate MongoDB-backed persistence: opt-in Mongo config + a self-contained example document / + repository / adapter showing the document↔domain mapping boundary. It does **not** reimplement + idempotency / outbox / lock on Mongo (those stay JPA-only). +- Opt-in: `MongoPersistenceConfig` re-imports the Mongo auto-configuration (`@ImportAutoConfiguration`) + and enables repositories (`@EnableMongoRepositories`, scoped to this package via + `basePackageClasses`) only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The + connection URI comes from Spring's standard `spring.data.mongodb.uri`. + +## Allowed + +- Project deps: `:application-core`, `:shared-contract` — SSOT is the + `adapter-outbound-persistence-mongo` entry in `.harness/project/modules.yaml`; `src/build.gradle` + enforces it. No + `:domain-core`, no sibling adapters. +- External: `org.springframework.boot:spring-boot-starter-data-mongodb` (version via the shared + Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor). Test-only: + Testcontainers (`testcontainers`, `testcontainers-junit-jupiter`), BOM-managed. + +## Forbidden + +- Inbound adapters, sibling outbound adapters, `app-bootstrap`, `sample-portfolio` (ArchUnit + `OUTBOUND_ADAPTERS_*` family rules). +- Leaking the `ExampleMongoDocument` type outside the adapter — the adapter maps documents to the + module-local `ExampleRecord` at the edge. +- Inventing an `application-core` port for the example (scaffolding stays self-contained); adding + idempotency/outbox/lock on Mongo. +- Fully-qualified inline type references; more than one public top-level type per file. + +## Tests + +`ExampleMongoMapperTest` (pure mapping, no container), `ExampleMongoRepositoryIT` (Testcontainers +MongoDB save/find/derived-query, `disabledWithoutDocker`). + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:check +``` diff --git a/src/adapter/outbound/persistence-mongo/README.md b/src/adapter/outbound/persistence-mongo/README.md new file mode 100644 index 00000000..203e038c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/README.md @@ -0,0 +1,68 @@ +# adapter:outbound:persistence-mongo — design-decision reference + +MongoDB persistence outbound (driven) adapter — **lightweight scaffolding**. Package root: +`dev.caskeleton.adapter.outbound.mongo`. Wires Spring Data MongoDB behind an opt-in +`@ConditionalOnProperty` selector and ships a demonstrative document / repository / adapter that +shows the document↔domain mapping boundary a fork follows. Mirrors the existing outbound adapters +(notification / cache-redis / httpclient / objectstorage / fileserver). + +The allowed/forbidden dependency policy is owned by `src/build.gradle`'s +`allowedProjectDependencies['adapter:outbound:persistence-mongo']` (SSOT). Module rules live in +[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code +comments. + +## Scope — deliberately lightweight + +This module is **scaffolding, not a full persistence implementation**. It demonstrates *how* a fork +adds MongoDB-backed storage; it does **not** reimplement idempotency, outbox, or distributed lock on +Mongo (those stay JPA-only in `adapter:outbound:persistence-jpa`). There is no `application-core` +port here on purpose — the demonstrative example is entirely self-contained inside the adapter +package so the skeleton stays decoupled and copy-paste-forkable. + +## Module overview + +An **opt-in** MongoDB module placed behind Spring Data MongoDB: + +- `MongoPersistenceConfig` re-imports the Mongo auto-configuration with `@ImportAutoConfiguration` + (`MongoAutoConfiguration`, `DataMongoAutoConfiguration`, `DataMongoRepositoriesAutoConfiguration`) + and enables the repositories with `@EnableMongoRepositories(basePackageClasses = …)` scoped to this + package — but only when `ca-skeleton.persistence-mongo.enabled=true`. `@ImportAutoConfiguration` is + an explicit import unaffected by `spring.autoconfigure.exclude`, so the driver never connects when + the module is merely on the classpath. This mirrors ha-tmpl's `MongoPersistenceConfig`. +- The connection URI is read from Spring's standard `spring.data.mongodb.uri` (owned by Spring + Boot's `MongoProperties`). The module's own `MongoPersistenceProperties` + (`ca-skeleton.persistence-mongo.*`) owns only the `enabled` opt-in switch and a demonstrative + `database` name. + +Selector: `ca-skeleton.persistence-mongo.enabled=true` (default `false`). + +## The demonstrative example (document↔domain boundary) + +- `ExampleRecord` — a small, self-contained "domain" value (NOT a real domain type, NOT an + `application-core` type). +- `ExampleMongoDocument` — the `@Document` persistence shape (`@Id`, `@Field` BSON names), kept + separate from the domain value exactly like a JPA entity is kept separate from its aggregate. +- `ExampleMongoRepository extends MongoRepository<ExampleMongoDocument, String>` — CRUD plus a + derived-query method (`findByNameIgnoreCase`) demonstrating Spring Data query derivation. +- `ExampleMongoMapper` — a pure, package-private static translator (`toDomain` / `toDocument`), + trivially unit-testable without a running MongoDB. +- `ExampleMongoRepositoryAdapter` — maps at the edge so the document type never leaks to callers; + the exact pattern a fork follows for a real aggregate/port. + +**How a fork replaces this:** swap `ExampleMongoDocument`/`ExampleMongoRepository` for a real +document + repository (renaming the collection and fields), map to the fork's real aggregate in +`ExampleMongoMapper`, and — if the fork wants a framework-neutral seam — implement an +`application-core` port from the adapter. `MongoPersistenceConfig` keeps working unchanged. + +## Tests + +- `ExampleMongoMapperTest` — pure document↔domain mapping round-trip; no MongoDB needed. +- `ExampleMongoRepositoryIT` — real save/findById/derived-query round-trip against Testcontainers + MongoDB (a core `GenericContainer` running `mongo:7.0`); the repository proxy is built directly + with `MongoRepositoryFactory` so no full Spring context is required. Skipped automatically when + Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`). + +```bash +cd src +./gradlew :adapter:outbound:persistence-mongo:check +``` diff --git a/src/adapter/outbound/persistence-mongo/build.gradle b/src/adapter/outbound/persistence-mongo/build.gradle new file mode 100644 index 00000000..b18168c0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/build.gradle @@ -0,0 +1,27 @@ +// Driven adapter: NoSQL persistence scaffolding via Spring Data MongoDB. This is a LIGHTWEIGHT, +// opt-in skeleton — it wires the Mongo driver + a demonstrative document/repository/adapter that +// shows the document<->domain mapping boundary a fork would follow. It does NOT reimplement +// idempotency/outbox/lock on Mongo (those stay JPA-only). Mongo auto-configuration is imported and +// the repositories enabled ONLY when ca-skeleton.persistence-mongo.enabled=true +// (MongoPersistenceConfig), so the driver never connects when the module is merely on the classpath. +// +// spring-boot-starter-data-mongodb's version is managed by the Spring Boot BOM (applied to every +// module in src/build.gradle), so no module-scoped platform is needed. The Testcontainers MongoDB +// integration test uses the BOM-managed Testcontainers, and is skipped when Docker is unavailable. +description = 'Outbound adapter: NoSQL persistence scaffolding (Spring Data MongoDB)' + +dependencies { + implementation project(':application-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // test-only: Testcontainers MongoDB integration test for the repository round-trip. Uses the core + // GenericContainer (no dedicated module) so the repository save/find runs against a real MongoDB + // when Docker is available and is skipped (disabledWithoutDocker) otherwise — mirroring the + // object-storage module's MinIO integration test. + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' +} diff --git a/src/adapter/outbound/persistence-mongo/gradle.lockfile b/src/adapter/outbound/persistence-mongo/gradle.lockfile new file mode 100644 index 00000000..0adb6490 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/gradle.lockfile @@ -0,0 +1,177 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mongodb:bson-record-codec:5.6.1=runtimeClasspath,testRuntimeClasspath +org.mongodb:bson:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mongodb:mongodb-driver-core:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mongodb:mongodb-driver-sync:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-mongodb:5.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java new file mode 100644 index 00000000..2f6c89ac --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * MongoDB document model for the demonstrative {@link ExampleRecord}. Kept separate from the + * "domain" value exactly like a JPA entity is kept separate from its aggregate — this is the + * persistence shape (BSON field names, {@code @Id}), not the domain shape. + * + * <p>Modelled as a mutable JavaBean because that is the least-surprising shape for Spring Data + * MongoDB's mapping (no-arg construct + field population). A fork renames the collection and fields + * to match its real document. + */ +@Document(collection = "ca_skeleton_examples") +public class ExampleMongoDocument { + + @Id private String id; + + private String name; + + @Field("qty") + private int quantity; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java new file mode 100644 index 00000000..cbb95585 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.mongo; + +/** + * Translation between the demonstrative {@link ExampleRecord} "domain" value and its {@link + * ExampleMongoDocument} persistence shape. Package-private and static: mapping is a pure function + * with no framework dependency, so it is trivially unit-testable without a running MongoDB (see + * {@code ExampleMongoMapperTest}). + * + * <p>This is the boundary a fork keeps: the repository/adapter never leak the document type + * outward; callers receive only the domain value. + */ +final class ExampleMongoMapper { + + private ExampleMongoMapper() {} + + static ExampleRecord toDomain(ExampleMongoDocument document) { + return new ExampleRecord(document.getId(), document.getName(), document.getQuantity()); + } + + static ExampleMongoDocument toDocument(ExampleRecord record) { + ExampleMongoDocument document = new ExampleMongoDocument(); + document.setId(record.id()); + document.setName(record.name()); + document.setQuantity(record.quantity()); + return document; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java new file mode 100644 index 00000000..75961121 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import java.util.List; +import org.springframework.data.mongodb.repository.MongoRepository; + +/** + * Spring Data MongoDB repository for {@link ExampleMongoDocument}. Extending {@link + * MongoRepository} supplies the CRUD surface (save / findById / delete / count …); the + * derived-query method below demonstrates Spring Data's query derivation. A fork replaces the + * document type parameter and the derived queries with its own. + */ +public interface ExampleMongoRepository extends MongoRepository<ExampleMongoDocument, String> { + + /** Derived query — case-insensitive lookup by the {@code name} field. */ + List<ExampleMongoDocument> findByNameIgnoreCase(String name); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java new file mode 100644 index 00000000..94eed901 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import java.util.List; +import java.util.Optional; + +/** + * Demonstrative repository adapter: the boundary between the Spring Data {@link + * ExampleMongoRepository} (documents) and the module-local {@link ExampleRecord} "domain" value. + * Every method maps at the edge via {@link ExampleMongoMapper}, so the document type never leaks to + * callers — the exact pattern a fork follows for a real aggregate/port. + * + * <p>Deliberately a plain class (no {@code @Component}); {@link MongoPersistenceConfig} assembles + * it as a bean only when the module is opted in, mirroring the object-storage / file-server + * adapters. + */ +public class ExampleMongoRepositoryAdapter { + + private final ExampleMongoRepository repository; + + public ExampleMongoRepositoryAdapter(ExampleMongoRepository repository) { + this.repository = repository; + } + + /** Inserts or updates the document for {@code record} and returns the persisted value. */ + public ExampleRecord save(ExampleRecord record) { + ExampleMongoDocument saved = repository.save(ExampleMongoMapper.toDocument(record)); + return ExampleMongoMapper.toDomain(saved); + } + + /** Reads by id, mapping the document back to the domain value; {@code empty()} when absent. */ + public Optional<ExampleRecord> findById(String id) { + return repository.findById(id).map(ExampleMongoMapper::toDomain); + } + + /** Derived-query lookup by name, mapped to domain values. */ + public List<ExampleRecord> findByName(String name) { + return repository.findByNameIgnoreCase(name).stream() + .map(ExampleMongoMapper::toDomain) + .toList(); + } + + /** Idempotent delete by id. */ + public void deleteById(String id) { + repository.deleteById(id); + } + + /** Total document count in the collection. */ + public long count() { + return repository.count(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java new file mode 100644 index 00000000..2010e0ca --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.mongo; + +/** + * A small, self-contained "domain" value used purely to demonstrate the document<->domain + * mapping boundary. It is intentionally NOT a real domain type and NOT an {@code application-core} + * type — the persistence-mongo module is lightweight scaffolding, so the example lives entirely + * inside the adapter package. + * + * <p>A fork replaces this with its real aggregate (typically owned by {@code domain-core}) and maps + * to it in {@link ExampleMongoMapper} exactly as shown here. + */ +public record ExampleRecord(String id, String name, int quantity) {} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java new file mode 100644 index 00000000..5f035291 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration; +import org.springframework.boot.data.mongodb.autoconfigure.DataMongoRepositoriesAutoConfiguration; +import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +/** + * Opt-in wiring for the MongoDB persistence scaffolding, mirroring ha-tmpl's {@code + * MongoPersistenceConfig}. The whole configuration — and therefore the Mongo driver connection, + * repositories, and the demonstrative adapter bean — activates ONLY when {@code + * ca-skeleton.persistence-mongo.enabled=true}. + * + * <p>{@link ImportAutoConfiguration} is an <em>explicit</em> import that is not affected by {@code + * spring.autoconfigure.exclude}, so re-importing the Mongo auto-configuration here cleanly turns + * MongoDB on for the opted-in profile without the driver ever connecting when the module is merely + * on the classpath. {@link EnableMongoRepositories} is scoped to this package via {@code + * basePackageClasses} so repository scanning never reaches beyond the skeleton. + * + * <p>A fork replaces {@link ExampleMongoRepository}/{@link ExampleMongoDocument} with its real + * document + repository and this config keeps working unchanged. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "ca-skeleton.persistence-mongo", + name = "enabled", + havingValue = "true") +@EnableConfigurationProperties(MongoPersistenceProperties.class) +@ImportAutoConfiguration({ + MongoAutoConfiguration.class, + DataMongoAutoConfiguration.class, + DataMongoRepositoriesAutoConfiguration.class +}) +@EnableMongoRepositories(basePackageClasses = ExampleMongoRepository.class) +public class MongoPersistenceConfig { + + /** + * Assembles the demonstrative adapter as a bean (the adapter is a plain class), mirroring how the + * object-storage / file-server modules assemble their adapters. A fork swaps this for its real + * repository adapter. + */ + @Bean + ExampleMongoRepositoryAdapter exampleMongoRepositoryAdapter(ExampleMongoRepository repository) { + return new ExampleMongoRepositoryAdapter(repository); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java new file mode 100644 index 00000000..2b4804fa --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Typed settings for the MongoDB persistence scaffolding, bound from {@code + * ca-skeleton.persistence-mongo.*}. Bound as a mutable JavaBean (not a record) so a fork can leave + * any subset of fields unset and inherit the defaults below. + * + * <p>The Mongo <b>connection URI</b> is intentionally NOT modelled here — it is read from Spring's + * own standard {@code spring.data.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}), + * which keeps credentials/host wiring in the one place operators already expect. This class only + * owns the module's own opt-in switch and a demonstrative logical-database name. + */ +@ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo") +public class MongoPersistenceProperties { + + /** + * Whether to activate the MongoDB scaffolding. Defaults to {@code false} so the driver never + * connects when the module is merely present on the classpath; a fork opts in explicitly, and + * {@link MongoPersistenceConfig} re-imports the Mongo auto-configuration only then. + */ + private boolean enabled = false; + + /** + * Demonstrative logical database name. This is scaffolding metadata a fork may surface in + * diagnostics; the effective database is whatever {@code spring.data.mongodb.uri} (or {@code + * spring.data.mongodb.database}) resolves to. + */ + private String database = "ca_skeleton"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java new file mode 100644 index 00000000..512c0b44 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pure document<->domain mapping contract for {@link ExampleMongoMapper} — no MongoDB needed. + */ +class ExampleMongoMapperTest { + + @Test + void toDocumentCopiesEveryField() { + ExampleRecord record = new ExampleRecord("id-1", "widget", 7); + + ExampleMongoDocument document = ExampleMongoMapper.toDocument(record); + + assertThat(document.getId()).isEqualTo("id-1"); + assertThat(document.getName()).isEqualTo("widget"); + assertThat(document.getQuantity()).isEqualTo(7); + } + + @Test + void toDomainCopiesEveryField() { + ExampleMongoDocument document = new ExampleMongoDocument(); + document.setId("id-2"); + document.setName("gadget"); + document.setQuantity(3); + + ExampleRecord record = ExampleMongoMapper.toDomain(document); + + assertThat(record).isEqualTo(new ExampleRecord("id-2", "gadget", 3)); + } + + @Test + void roundTripThroughDocumentPreservesTheDomainValue() { + ExampleRecord original = new ExampleRecord("id-3", "sprocket", 42); + + ExampleRecord roundTripped = + ExampleMongoMapper.toDomain(ExampleMongoMapper.toDocument(original)); + + assertThat(roundTripped).isEqualTo(original); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java new file mode 100644 index 00000000..aaaa2e01 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.mongo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Real MongoDB round-trip for {@link ExampleMongoRepositoryAdapter} against a Testcontainers + * MongoDB. The Spring Data repository proxy is built directly with {@link MongoRepositoryFactory} + * over a {@link MongoTemplate} — no full Spring context needed, mirroring how the object-storage + * MinIO IT constructs its adapter by hand. Skipped automatically when Docker is unavailable ({@code + * disabledWithoutDocker = true}); the pure mapping is covered separately by {@link + * ExampleMongoMapperTest}. + */ +@Testcontainers(disabledWithoutDocker = true) +class ExampleMongoRepositoryIT { + + private static final int MONGO_PORT = 27017; + private static final String DATABASE = "ca_skeleton_it"; + + @Container + @SuppressWarnings("resource") + static final GenericContainer<?> MONGO = + new GenericContainer<>(DockerImageName.parse("mongo:7.0")) + .withExposedPorts(MONGO_PORT) + .waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 1)); + + private MongoClient client; + private ExampleMongoRepositoryAdapter adapter; + + @BeforeEach + void setUp() { + String uri = "mongodb://" + MONGO.getHost() + ":" + MONGO.getMappedPort(MONGO_PORT); + client = MongoClients.create(uri); + MongoTemplate template = + new MongoTemplate(new SimpleMongoClientDatabaseFactory(client, DATABASE)); + ExampleMongoRepository repository = + new MongoRepositoryFactory(template).getRepository(ExampleMongoRepository.class); + repository.deleteAll(); + adapter = new ExampleMongoRepositoryAdapter(repository); + } + + @AfterEach + void tearDown() { + if (client != null) { + client.close(); + } + } + + @Test + void savesAndReadsBackThroughTheDomainBoundary() { + ExampleRecord saved = adapter.save(new ExampleRecord("it-1", "widget", 5)); + assertThat(saved).isEqualTo(new ExampleRecord("it-1", "widget", 5)); + + Optional<ExampleRecord> found = adapter.findById("it-1"); + assertThat(found).contains(new ExampleRecord("it-1", "widget", 5)); + } + + @Test + void findByNameUsesTheDerivedQueryCaseInsensitively() { + adapter.save(new ExampleRecord("it-2", "Gadget", 1)); + adapter.save(new ExampleRecord("it-3", "gadget", 2)); + + assertThat(adapter.findByName("GADGET")) + .extracting(ExampleRecord::id) + .containsExactlyInAnyOrder("it-2", "it-3"); + } + + @Test + void findByIdIsEmptyForAnAbsentId() { + assertThat(adapter.findById("absent")).isEmpty(); + assertThat(adapter.count()).isZero(); + } +} diff --git a/src/adapter/outbound/support/CLAUDE.md b/src/adapter/outbound/support/CLAUDE.md new file mode 100644 index 00000000..d07e5481 --- /dev/null +++ b/src/adapter/outbound/support/CLAUDE.md @@ -0,0 +1,29 @@ +# adapter:outbound:support — shared outbound support + +## Registered identity + +- Module ID: `adapter-outbound-support` +- Gradle path: `:adapter:outbound:support` +- Focused test: `./gradlew :adapter:outbound:support:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package roots: `dev.caskeleton.adapter.outbound` and +`dev.caskeleton.adapter.outbound.support`. + +## Responsibility + +- Own reusable outbound correlation, fail-open dependency logging, and auto-configuration seams. +- Provide technical support used by registered messaging/cache/notification/httpclient leaves. +- Keep feature-specific clients and routing policies in their owning leaf. + +## Boundaries + +- Allowed project dependencies are registry-owned: application, domain, and shared contracts. +- No inbound transport, persistence, bootstrap, or sample dependencies. +- No business rules or external-system-specific protocol implementation. + +## Tests + +Use focused unit tests for support behavior. Do not start a Spring application context unless the +contract being tested is configuration wiring itself. diff --git a/src/adapter/outbound/support/README.md b/src/adapter/outbound/support/README.md new file mode 100644 index 00000000..4ec68146 --- /dev/null +++ b/src/adapter/outbound/support/README.md @@ -0,0 +1,38 @@ +# adapter:outbound:support — 설계 결정 참조 + +아웃바운드 기술 어댑터(`:adapter:outbound:messaging`, `:adapter:outbound:cache-redis`, +`:adapter:outbound:notification`, `:adapter:outbound:httpclient`)가 공유하는 베이스 모듈. 패키지 +루트: `dev.caskeleton.adapter.outbound.support`. + +허용/금지 의존 정책은 `src/build.gradle` 의 +`allowedProjectDependencies['adapter:outbound:support']` 항목이 SSOT 다(이 모듈은 아직 별도 +CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 +기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. + +## 왜 별도 모듈인가 + +옛 `adapter-outbound` 모듈을 기술별(`messaging`/`cache-redis`/`notification`/`httpclient`)로 +쪼개면서, 네 모듈이 공통으로 필요로 하는 조각 — correlation 조회, fail-open 의존성 로깅, +공유 `@Configuration` 등록 지점 — 을 이 모듈로 뽑아냈다. 네 기술 모듈 모두 `implementation +project(':adapter:outbound:support')` 로 이 모듈에 의존한다. + +## FailOpenDependencyLogger — WARN, ERROR 아님 + +실패를 ERROR 가 아니라 WARN 으로 로깅한다: cache/messaging/notification 선택형 어댑터는 +fail-open 이라 의존성 실패가 나도 use case 는 성공한 것이다 — 관측은 하되 escalate 하지 않는다. +이는 호출자에게 직접 노출되는 hard failure 를 ERROR 로 올리는 `httpclient` 모듈의 +`OutboundHttpDependencyLogger` 와 명확히 구분된다. 메서드 시그니처가 본문·수신자·페이로드를 +받지 않아 PII 가 로그에 닿지 않는다. + +## OutboundCorrelation — `"unknown"` 센티넬 + +MDC 에 correlation id 가 없으면 `null` 이 아니라 `"unknown"` 문자열을 반환해 로그 라인에 필드가 +비지 않게 한다. MDC 키는 SSOT(`mdc-keys.yaml`)를 따른다. + +## OutboundSupportConfig — 공유 `@Configuration` seam + +`FailOpenDependencyLogger` 빈을 `@ConditionalOnMissingBean` 으로 무조건 등록하는 단일 +`@Configuration` 이다. 네 기술 모듈이 각자 로거 빈을 중복 등록하지 않고 이 모듈에만 의존하면 +와이어링되며, 포크는 같은 타입의 빈을 직접 등록해 오버라이드할 수 있다. 이 모듈 자체는 어떤 +`@ConditionalOnProperty` 게이팅도 갖지 않는다 — 게이팅은 각 기술 모듈이 자신의 연동 client +단위로 소유한다. diff --git a/src/adapter/outbound/support/build.gradle b/src/adapter/outbound/support/build.gradle new file mode 100644 index 00000000..0048fd90 --- /dev/null +++ b/src/adapter/outbound/support/build.gradle @@ -0,0 +1,14 @@ +// Shared base for outbound integration adapters: correlation, fail-open dependency +// logging, and the @Configuration seam. Depended on by messaging/cache/notification/httpclient. +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':shared-contract') + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.slf4j:slf4j-api' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} diff --git a/src/adapter/outbound/support/gradle.lockfile b/src/adapter/outbound/support/gradle.lockfile new file mode 100644 index 00000000..f12183a3 --- /dev/null +++ b/src/adapter/outbound/support/gradle.lockfile @@ -0,0 +1,151 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/package-info.java b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/package-info.java new file mode 100644 index 00000000..8278bc5f --- /dev/null +++ b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/package-info.java @@ -0,0 +1,2 @@ +/** Outbound adapters for external HTTP clients, messaging, cache, and notifications. */ +package dev.caskeleton.adapter.outbound; diff --git a/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java new file mode 100644 index 00000000..e23fb59b --- /dev/null +++ b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.support; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared fail-open dependency log contract for the optional adapters (cache/messaging/ + * notification). Failures are always logged at WARN — these adapters are fail-open, so a dependency + * failure still means the use case succeeded; it is observed, not escalated (distinct from the + * HTTP-path logger that raises hard failures to ERROR). It accepts no body, recipient, or payload, + * so PII cannot reach the log. Field names follow the MDC/log-field SSOT. + */ +public final class FailOpenDependencyLogger { + + private final Logger log; + + public FailOpenDependencyLogger() { + this(LoggerFactory.getLogger(FailOpenDependencyLogger.class)); + } + + /** Test seam — inject a logger bound to a captured appender. */ + public FailOpenDependencyLogger(Logger log) { + this.log = log; + } + + public void logSuccess(String dependencyName, String dependencyType, String operation) { + log.debug( + "dependency_name=\"{}\" dependency_type=\"{}\" operation=\"{}\" " + + "outcome=\"SUCCESS\" correlation_id=\"{}\"", + dependencyName, + dependencyType, + operation, + OutboundCorrelation.current()); + } + + /** WARN: fail-open adapter — the use case still succeeded, so this is not an ERROR. */ + public void logFailure( + String dependencyName, String dependencyType, String operation, Throwable cause) { + log.warn( + "dependency_name=\"{}\" dependency_type=\"{}\" operation=\"{}\" " + + "outcome=\"FAILURE\" correlation_id=\"{}\" error=\"{}: {}\"", + dependencyName, + dependencyType, + operation, + OutboundCorrelation.current(), + cause.getClass().getSimpleName(), + cause.getMessage()); + } +} diff --git a/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java new file mode 100644 index 00000000..7f9bb17e --- /dev/null +++ b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.support; + +import org.slf4j.MDC; + +/** + * Reads the {@code correlation_id} from the SLF4J MDC for outbound adapter logging. The MDC key + * follows the {@code docs/registries/mdc-keys.yaml} SSOT and is populated by the inbound web + * filter. When no id was propagated (e.g. a background thread with no context) it returns {@link + * #UNKNOWN} so the log field is never left empty. + */ +public final class OutboundCorrelation { + + /** MDC key SSOT (docs/registries/mdc-keys.yaml — key: correlation_id). */ + public static final String MDC_KEY = "correlation_id"; + + /** Sentinel used when no correlation id was propagated to the current thread. */ + public static final String UNKNOWN = "unknown"; + + private OutboundCorrelation() {} + + /** The current thread's correlation id, or {@link #UNKNOWN} if none is in the MDC. */ + public static String current() { + String value = MDC.get(MDC_KEY); + return (value == null || value.isBlank()) ? UNKNOWN : value; + } +} diff --git a/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java new file mode 100644 index 00000000..39da8d15 --- /dev/null +++ b/src/adapter/outbound/support/src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.support; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Unconditionally registers the shared log/error contract bean ({@link FailOpenDependencyLogger}) + * used by every optional adapter — required regardless of which adapter is enabled. + * {@code @ConditionalOnMissingBean} lets a fork override it. + */ +@Configuration +public class OutboundSupportConfig { + + @Bean + @ConditionalOnMissingBean + public FailOpenDependencyLogger failOpenDependencyLogger() { + return new FailOpenDependencyLogger(); + } +} diff --git a/src/adapter/outbound/support/src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java b/src/adapter/outbound/support/src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java new file mode 100644 index 00000000..8730089f --- /dev/null +++ b/src/adapter/outbound/support/src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +class FailOpenDependencyLoggerTest { + + private ch.qos.logback.classic.Logger logbackLogger; + private ListAppender<ILoggingEvent> appender; + private FailOpenDependencyLogger dependencyLogger; + + @BeforeEach + void setUp() { + logbackLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.dependency"); + appender = new ListAppender<>(); + appender.start(); + logbackLogger.addAppender(appender); + logbackLogger.setLevel(Level.DEBUG); + dependencyLogger = new FailOpenDependencyLogger(logbackLogger); + } + + @AfterEach + void tearDown() { + logbackLogger.detachAppender(appender); + MDC.clear(); + } + + @Test + void failureLogCarriesTheCorrelationIdFromMdc() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-123"); + + dependencyLogger.logFailure( + "kafka", "messaging", "publish", new IllegalStateException("broker down")); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()) + .contains("dependency_name=\"kafka\"") + .contains("dependency_type=\"messaging\"") + .contains("operation=\"publish\"") + .contains("outcome=\"FAILURE\"") + .contains("correlation_id=\"corr-123\"") + .contains("IllegalStateException"); + } + + @Test + void failureLogUsesUnknownCorrelationIdWhenMdcIsEmpty() { + dependencyLogger.logFailure("redis", "cache", "get", new RuntimeException("timeout")); + + assertThat(appender.list.get(0).getFormattedMessage()) + .contains("correlation_id=\"" + OutboundCorrelation.UNKNOWN + "\""); + } + + @Test + void failureLogNeverContainsThePayloadOrPii() { + // The logger signature has no payload parameter, so a provider body / PII can never + // be logged. This guards the contract even if the exception message leaks nothing. + dependencyLogger.logFailure( + "google-email", "notification", "send", new RuntimeException("connection refused")); + + String rendered = appender.list.get(0).getFormattedMessage(); + assertThat(rendered).doesNotContain("@gmail.com").doesNotContain("secret-body"); + } + + @Test + void successLogIsDebugWithCorrelationId() { + MDC.put(OutboundCorrelation.MDC_KEY, "corr-9"); + + dependencyLogger.logSuccess("slack", "notification", "send"); + + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.DEBUG); + assertThat(event.getFormattedMessage()) + .contains("dependency_name=\"slack\"") + .contains("dependency_type=\"notification\"") + .contains("outcome=\"SUCCESS\"") + .contains("correlation_id=\"corr-9\""); + } +} diff --git a/src/app-bootstrap/CLAUDE.md b/src/app-bootstrap/CLAUDE.md new file mode 100644 index 00000000..b622b622 --- /dev/null +++ b/src/app-bootstrap/CLAUDE.md @@ -0,0 +1,39 @@ +# app-bootstrap — application entry point & composition root + +## Registered identity + +- Module ID: `app-bootstrap` +- Gradle path: `:app-bootstrap` +- Focused test: `./gradlew :app-bootstrap:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.bootstrap`. + +## Responsibility + +- Spring Boot entrypoint (`CaSkeletonApplication`). +- Runtime settings and logging bootstrap. +- Final cross-module wiring. +- Architecture tests that inspect all modules. + +## Allowed + +- Runtime leaves explicitly allowed by `.harness/project/modules.yaml`; do not duplicate the + 19-leaf dependency list here. +- Spring Boot startup/runtime dependencies. +- ArchUnit in tests. + +## Forbidden + +- Concrete business policy. +- Controllers, use cases, repository adapters, or mappers authored directly under bootstrap. +- Settings classes that implement business logic. + +## Test + +```bash +cd src +./gradlew :app-bootstrap:test +./gradlew verifyCleanArchitectureDependencies +``` diff --git a/src/app-bootstrap/README.md b/src/app-bootstrap/README.md new file mode 100644 index 00000000..402c5c54 --- /dev/null +++ b/src/app-bootstrap/README.md @@ -0,0 +1,831 @@ +# app-bootstrap — 설계 결정 참조 + +애플리케이션 진입점이자 합성 루트(composition root) 모듈. 패키지 루트: `dev.caskeleton.bootstrap`. + +이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 모듈 간 최종 +와이어링, 그리고 모든 모듈을 검사하는 아키텍처 테스트만 둔다. 허용/금지 의존, 책임 범위, 테스트 +명령 같은 **모듈 규칙**의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다. + +이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 +"왜 이렇게 했나"가 궁금할 때 본다. 본문은 한국어로 쓰고, 클래스·Spring API·메트릭 이름처럼 +꼭 필요한 기술 용어만 영어로 남긴다. 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 +트레이드오프를 문장으로 풀어 둔다. + +## 목차 + +- [runtime/startup — Fail-fast 시작 파이프라인](#runtimestartup--fail-fast-시작-파이프라인과-시작-실패-분류) +- [runtime — 시크릿 소스 선택과 런타임 안전 가드](#runtime--시크릿-소스-선택과-런타임-안전-가드) +- [concurrency — 도메인 컨텍스트 전파](#concurrency--도메인-컨텍스트-전파-전략-조립) +- [async — 비동기 executor 컨텍스트 전파와 포화 처리](#async--비동기-executor-컨텍스트-전파와-포화-처리) +- [idempotency — 멱등성 런타임 조립과 TTL 상한](#idempotency--멱등성-런타임-조립과-ttl-상한) +- [lock — 분산 락 메트릭 데코레이터](#lock--분산-락-메트릭-데코레이터-배선) +- [outbox — 트랜잭셔널 아웃박스 릴레이](#outbox--트랜잭셔널-아웃박스-릴레이-와이어링) +- [logging — 로그 시크릿 마스킹·샘플링·가명화](#logging--로그-시크릿-마스킹샘플링가명화) +- [metrics — 메트릭 계약 MeterFilter](#metrics--메트릭-계약-meterfilter-설치) +- [management/security — 액추에이터 엔드포인트 보안](#managementsecurity--액추에이터-엔드포인트-보안-체인) +- [tracing — 분산 트레이싱 wiring과 샘플링 정책](#tracing--분산-트레이싱-wiring과-샘플링-정책) +- [settings — @ConfigurationProperties 검증 정책](#settings--configurationproperties-검증-정책) +- [build.gradle — 의존성 구성 근거](#buildgradle--의존성-구성-근거) + +--- + +## runtime/startup — Fail-fast 시작 파이프라인과 시작 실패 분류 + +잘못 설정된 채로 기동돼 트래픽을 받는 것보다, 기동 시점에 명확한 이유와 함께 멈추는 편이 안전하다. +이 패키지는 그 "빨리·명확하게 실패시키기(fail-fast)"를 담당한다. + +### FlywayProdSafetyValidator +- **`prod` 프로파일에서 Flyway 안전장치가 꺼지지 못하도록 런타임에서 강제한다.** Flyway 옵션은 + `application.yml`에 안전값으로 고정돼 있지만(`baseline-on-migrate=false`, `out-of-order=false`, + `clean-disabled=true`), 환경별 override 가 프로덕션에서 이 값을 조용히 되돌릴 수 있다. 이 검증기는 + 그런 override 를 시작 시점에 잡아 `PROFILE_MISMATCH`(exit 71)로 부팅을 막는다. +- **금지하는 옵션은 각각 "복구 불가능한 위험" 때문이다.** `baseline-on-migrate=true`는 누락된 + 마이그레이션을 감지하는 안전망을 없애고, `out-of-order=true`는 개발자 간 마이그레이션 적용 순서 + 일관성을 깨며, `clean-disabled=false`는 스키마 전체를 드롭하는 파괴적 `clean` 명령을 다시 + 활성화한다. +- **프로파일 비교는 대소문자를 무시한다.** `SPRING_PROFILES_ACTIVE=PROD`처럼 대문자 오타가 나도 + 가드가 우회되지 않게 하기 위함이다. +- **Flyway repair 는 가드 대상이 아니다.** repair 는 property 가 아니라 Flyway 명령이고 스켈레톤은 + repair 호출 경로 자체를 배선하지 않으므로, "prod 에서 repair 금지"는 런타임 트리거가 없는 + 문서화된 정책으로 남는다. 부분 스키마 복구는 in-prod Flyway repair 가 아니라 + runbook(`runbook://migration/manual-recovery`)을 통한 수동 복구로 가도록 실패 메시지에 명시한다. + (프로젝트가 repair 경로를 추가한다면 동일하게 prod 게이트 + non-prod 감사 로그를 걸어야 한다.) + +### RequiredEnvironmentValidator +- **datasource 연결 환경변수를 마이그레이션 실행 전에 검증해 부팅을 빨리 실패시킨다.** Flyway + forward-only 마이그레이션은 context refresh 동안 애플리케이션 datasource 에 대해 실행된다. 연결 + 환경변수가 빠져 있으면 마이그레이션은 시작 단계 구분자 없이 불투명한 driver/connection 에러로 + 한참 뒤에야 실패한다. 이 검증기는 그것을 명시적인 env 검증 실패(`STARTUP_VALIDATION_FAILED`, + exit 78)로 바꿔, 누락된 운영자용 env 키를 전부 이름으로 짚어준다. +- **검증 범위를 datasource 3종(url/username/driver)으로 의도적으로 좁혔다.** 이것들이 이 브랜치가 + 소유하는 마이그레이션 전제조건이기 때문이다. app-name(`BootstrapSettings`), issuer-uri(security + settings) 등 다른 필수값은 각 모듈의 가드가 책임지므로 여기서 중복 검증하지 않는다. +- **값이 없거나(blank) 공백이면 모두 "누락"으로 본다.** spring-dotenv 에서 미설정 placeholder + `${...}`가 빈 문자열로 해석되기 때문에, null 뿐 아니라 blank 도 누락으로 처리해야 빈틈이 없다. +- **메시지는 env 키 이름순으로 정렬해 출력한다.** Map 순회 순서와 무관하게 결정적이고 안정적인 실패 + 메시지를 보장하기 위함이다. + +### MigrationStartupConfig +- **시작 마이그레이션 가드와 Flyway 마이그레이션 전략을 한곳에 모아 소유권을 명확히 한다.** prod + 토글 / 멀티 인스턴스 가드는 env-driven `RuntimeSafetyConfig`가 소유하고, 이 config 는 마이그레이션 + 관련 가드만 배선한다. +- **`migrationStartupRunner` 빈 이름은 반드시 유지해야 한다.** 멀티 인스턴스 모드 + (`APP_MULTI_INSTANCE_ENABLED=true`)에서 `StartupSafetyValidator`가 이 정확한 이름의 빈 존재를 + 단언하기 때문이다. 동시에 이 빈은 `FlywayMigrationStrategy`로서 Spring Boot Flyway + 오토컨피규레이션이 `migrate()`를 위임하는 전략이 된다. + +### MigrationStartupRunner +- **`FlywayMigrationStrategy`로 등록해 context refresh 중에 마이그레이션을 직접 실행한다(오토컨피그 + 기본값을 그대로 두지 않는다).** refresh 안에서 실행한다는 것은 마이그레이션이 완료되거나 부팅을 + 실패시키는 일이 애플리케이션이 readiness 를 보고하기 *전에* 일어난다는 뜻이다. 따라서 절반만 + 마이그레이션된 스키마가 트래픽을 받는 일이 구조적으로 없다(actuator readiness probe 의 형태 + 자체는 health-lifecycle 브랜치가 소유하고, 여기서는 "마이그레이션 → ready" 순서만 책임진다). +- **`FlywayException`을 `MigrationFailedException`(exit 70)으로 번역한다.** 일반적이고 구분 안 되는 + 스택트레이스 대신 구조화된 `MIGRATION_FAILED` 로그와 표준 exit 코드를 남기기 위함이다. +- **멀티 인스턴스에서는 in-app 마이그레이션 + Flyway 자체 schema-history 락에 의존하는 것이 기본 + 전략이다.** 여러 레플리카의 동시 `migrate()`를 Flyway 의 락이 직렬화한다. 대안인 "in-app + 마이그레이션을 끄고 플랫폼 one-shot Job 으로 돌리기"는 "migration=Job"을 강제하는 공개 표준이 + 없는 배포 선택이라 스켈레톤 기본값이 아니다. Job 으로 돌리는 프로젝트는 이 빈의 `migrate()`를 + no-op 으로 만들면 된다. + +### 시작 실패 타입(StartupErrorCode / StartupPhase / StartupFailures / *Exception) +- **`StartupErrorCode` — 네 가지 시작 실패 원인을 (레지스트리 error code, 표준 프로세스 exit 코드, + `StartupPhase`)로 묶는다.** exit 78(env 누락/오류)과 70(마이그레이션 실패)은 sysexits 표준 + (`EX_CONFIG`, `EX_SOFTWARE`)과 정렬돼 외부적으로 방어 가능하다. 반면 exit 71(프로파일 불일치)과 + 72(필수 어댑터 비활성)는 ca-tmpl 내부 규약이다 — sysexits 의 `EX_OSERR`/`EX_OSFILE`이 이 의미와 + 맞지 않아, 외부 표준이 아니라 내부 룩업 테이블 값으로 정했다. exit 코드는 carrying 예외가 + `ExitCodeGenerator`를 구현하기 때문에만 JVM 종료 상태가 된다. +- **`StartupPhase` — 실패 원인을 운영자가 로그에서 구분할 수 있게 하는 `startup.phase` 필드다.** + 시작 실패는 요청이 아직 없으므로 HTTP 에러 응답에 절대 실리지 않는다. 따라서 구조화 로그의 + `startup.phase`가 `kubectl logs`/`describe`에서 네 가지 원인을 가려내는 유일한 계약이다. wire + 이름은 dash-case(`env-validation | migration | adapter-enablement | profile-check`)이며, MDC 키가 + 아니라 Logstash structured argument 로 방출되므로 snake_case MDC 키 레지스트리 규칙은 적용되지 + 않는다. +- **`StartupFailures` — 시작 실패를 던지는 단일 출처로, 예외 생성 *전에* 구조화 로그를 먼저 + 방출한다.** "throw 직전 각 validator 가 직접 logger 를 호출"하는 패턴을 중앙화해, 어떤 시작 가드도 + 원인/구분자 없는 실패를 던지지 못하게 한다. 로그는 `startup.phase` / `error.code` / + `error.category` 세 필드를 Logstash structured argument 로 싣고, cause 가 있으면 + `error.root_cause.class` / `error.root_cause.message` 요약 필드만 추가한다. 원본 cause 는 반환되는 + `StartupFailureException`에 보존하지만 SLF4J throwable 인자로 넘기지 않는다. 따라서 canonical + startup-failure 로그는 운영자가 분류할 수 있는 요약만 남기고 프레임워크/드라이버 stacktrace 를 + 출력하지 않는다. +- **`StartupFailureException` — 네 실패 원인의 베이스 타입이며 `ExitCodeGenerator`로 exit 코드를 JVM + 종료 상태로 만든다.** context refresh 가 실패하면 `SpringApplication`의 `SpringBootExceptionHandler` + (부팅 스레드의 uncaught-exception handler 로 설치됨)가 실패 예외에서 `getExitCode()`를 읽어 + `System.exit(code)`를 호출한다 — `main()` 수정이 필요 없다. `IllegalStateException`을 상속하는 + 이유는 과거 `StartupSafetyValidator`가 `IllegalStateException`을 던졌기 때문으로, 타입드 예외로 + 업그레이드한 뒤에도 소스/동작 호환을 유지하기 위함이다. +- **`StartupFailureExceptionReporter` / `StartupFailureSpringBootLogFilter` — Spring Boot 의 중복 + startup stacktrace 를 억제한다.** reporter 는 failure cause chain 에 `StartupFailureException`이 + 있을 때만 `true`를 반환해 Boot 의 generic `Application run failed` 출력을 "이미 보고됨"으로 + 처리한다. 이후 Boot 가 반쯤 초기화된 context 를 닫다가 `Unable to close ApplicationContext`를 다시 + 남길 수 있으므로, canonical startup-failure 로그가 이미 찍힌 프로세스에서는 해당 + `org.springframework.boot.SpringApplication` 중복 메시지만 logback turbo filter 가 차단한다. 일반 + startup failure 가 아닌 예외와 다른 logger/message 는 기존 Spring Boot 로그 경로를 유지한다. +- **`RequiredAdapterDisabledException`은 런타임-라이프사이클의 `ADAPTER_DISABLED`와 의도적으로 + 구분된다.** 이쪽은 *시작* 검증(필수 어댑터/조정 빈이 꺼져 있음)이고, 후자는 비활성 옵션 어댑터에 + 대한 *런타임* 호출이다. + +--- + +## runtime — 시크릿 소스 선택과 런타임 안전 가드 + +시크릿을 어디서 읽을지(env / Vault / 클라우드 시크릿 매니저)를 인터페이스 뒤로 숨기고, 위험한 +런타임 설정값은 기동 시점에 막는다. + +### SecretSource +- **시크릿 해석을 인터페이스 한 겹 뒤로 숨긴 backend seam 이다.** 시크릿이 필요한 코드는 이 + 인터페이스에만 의존하고, 실제 백엔드(env / Vault / AWS Secrets Manager / GCP Secret Manager)는 + `SecretSourceFactory`가 설정값으로 고른다. `RateLimiter` / `RateLimiterFactory`와 같은 패턴이다. + 백엔드를 추가하는 비용이 "새 `SecretSource` 구현 1개 + `SecretSourceStrategy` enum 값 1개 + + factory case 1개"로 고정되고, 소비자(`SecretSourceValidator`, 향후 어댑터)는 전혀 손대지 않는다. +- **빈 문자열은 "없음"으로 취급한다.** `resolve`가 blank 값을 `Optional.empty()`로 돌려주지 않으면, + "값은 존재하지만 비어 있는" 시크릿이 존재 검사(presence check)를 통과해 버려 prod 부팅을 막을 수 + 없게 된다. + +### SecretSourceStrategy +- **enum 값 하나가 곧 백엔드 하나라는 확장 규약을 못 박는다.** `ca-skeleton.secret-source.strategy`로 + 바인딩되며 `ENVIRONMENT`만 기본 제공한다. 미래 백엔드(VAULT, AWS_SECRETS_MANAGER, + GCP_SECRET_MANAGER)는 주석으로만 남겨 두어, 추가 절차가 "enum 값 + `SecretSource` 구현 + factory + case"임을 코드에서 바로 읽히게 했다(rate-limit algorithm enum 패턴과 동일). + +### SecretSourceSettings +- **설정이 없어도 동작하는 기본값(ENVIRONMENT)을 박았다.** 스켈레톤은 별도 설정 없이 바로 부팅돼야 + 하므로, `strategy`가 `null`이면 `ENVIRONMENT`로 보정한다. 백엔드 교체는 코드 수정이 아니라 설정 + 한 줄로 끝난다. + +### SecretSourceConfig +- **시크릿 백엔드와 그 시작 가드를 composition root 에서만 와이어링한다.** `SecretSourceFactory`로 + 선택된 `SecretSource`(기본 `ENVIRONMENT`)와, 그것을 통해 필수 시크릿을 검증하는 + `SecretSourceValidator`를 빈으로 노출한다. 검증기를 Spring 스테레오타입 없는 일반 클래스로 둔 + 이유는 단위 테스트 가능성을 지키기 위함이고, 이 config 가 유일한 production 와이어링이다. +- **`RuntimeSafetyConfig`와 일부러 분리했다.** 시크릿 계약은 secrets-config-source-contract 가 + 소유하고, 런타임 안전 토글은 env-driven-runtime-configuration 이 소유한다. 소유 경계를 코드 구조로 + 보존하기 위해 한 config 로 합치지 않았다. + +### SecretSourceFactory +- **유일한 확장 지점을 `switch` 하나로 모았다.** 새 백엔드는 `SecretSourceStrategy` 값 + + `SecretSource` 구현 + 이 `switch`의 case 추가로 끝나고 소비자는 바뀌지 않는다. + `RateLimiterFactory`와 같은 형태로, "확장 비용이 어디에 있는가"를 한곳에서 보이게 했다. + +### EnvironmentSecretSource +- **기본 백엔드는 Spring `Environment`에서 읽는 것이다.** `ENVIRONMENT` 전략은 시크릿이 env var / + 마운트 파일 / `application.yml`로 들어와 Spring 자체 property resolution 으로 바인딩됨을 전제한다. + Vault·AWS·GCP 연동은 소비자를 건드리지 않고 `SecretSourceFactory` 뒤에 별도 `SecretSource` + 구현으로 추가하면 된다. + +### SecretSourceValidator +- **prod 에서 깨지면 부팅 자체를 거부하는 fail-fast 가드다.** 두 가지 prod 불변식을 context refresh + 완료 전에 강제한다. (1) `__LOCAL_DEV_` 접두사를 가진 dev/local 가짜 자격증명 값이 prod 프로파일에 + 도달하는 것은 잘못 로드된 사고이므로 부팅을 중단시킨다. (2) 레지스트리에서 `secret` + + `prod_default: null`로 분류된 모든 키는 prod 에서 비어 있지 않게 주입돼야 한다 — 빈 시크릿으로 + 부팅하는 것은 금지다. dev/local 프로파일은 두 검사를 건너뛴다(`__LOCAL_DEV_*`가 의도된 로컬 + 폴백이므로). +- **`SmartInitializingSingleton` 타이밍을 의도적으로 받아들였다.** 이 콜백은 모든 싱글톤 생성 *후* + context refresh 완료 *전*에 한 번 실행된다. 따라서 eager `DataSource`가 이 가드보다 먼저 + `__LOCAL_DEV_` 자격증명으로 연결을 시도할 가능성은 있다. 그러나 prod 에서 `__LOCAL_DEV_` 값에 + 도달하는 것 자체가 예외적 오적재이고, 트래픽을 받기 전에 context 가 중단되므로 이 타이밍으로 + 충분하다고 판단했다. 더 이른 차단이 필요해지면 `EnvironmentPostProcessor`로 승격한다. +- **필수 시크릿 검사는 Spring 환경이 아니라 `SecretSource` 백엔드를 통해 한다.** 존재 검사를 + 백엔드(`secretSource.resolve(key)`) 경유로 하기 때문에, env → Vault/AWS 백엔드 교체 시에도 검사가 + 자동으로 따라간다. 검사를 Spring `Environment`에 고정했다면 백엔드를 바꿀 때 검증 로직이 함께 + 끊겼을 것이다. +- **`REQUIRED_PROD_SECRETS`는 레지스트리와 1:1로 강제된다.** 이 in-code 상수는 + `docs/registries/secrets-classification.yaml`의 secret 행과 정확히 일치해야 하며, + `SecretsClassificationRegistryTest`가 1:1 일치를 단언한다. 그래서 레지스트리에 시크릿을 추가/제거하고 + 이 상수를 갱신하지 않으면 빌드가 깨진다 — drift 를 컴파일·테스트 단계에서 잡는다. +- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 같은 배포 오타도 prod 가드를 + 발동시켜야 하므로 `equalsIgnoreCase`로 비교한다(`Environment#matchesProfiles`는 대소문자를 구분해 + "PROD"를 통과시킨다). + +### HikariPoolConstraintValidator +- **HikariCP 노브 사이의 상호 제약을 부팅 실패로 바꾼다.** Hikari/Tomcat 은 잘못된 값을 결국 + 거부하지만 pool/connector 초기화 시점에 난해한 메시지로만 거부한다. 이 검증기는 계약을 일찍·명확 + 하게 만든다: resolved Spring property 를 읽고, 위반을 모두 모아 운영자가 실제로 설정한 `APP_*` + 환경 키를 메시지에 담아 `IllegalStateException`을 던진다. 속성이 *없으면* 건너뛰고(placeholder/ + 프레임워크 기본값이 소유), *있을 때만* 검사한다. +- **강제하는 제약과 그 숫자의 근거.** `connection-timeout >= 250ms`(너무 짧으면 정상 연결도 타임 + 아웃). `validation-timeout < connection-timeout`(둘 다 있을 때; validation-timeout 기본 5000ms 가 + 흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다 + 있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이 + 아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐). +- **모든 노브를 `String`으로 읽어 직접 파싱하는 방어적 처리.** `env-keys.yaml`의 `connection-timeout` + 기본값은 Duration 문자열 `5s`인데 `src/.env`는 `30000`(ms)을 준다. `Environment#getProperty(..., + Long.class)`를 `"5s"`에 호출하면 `ConversionFailedException`이 난다. 그래서 각 값을 `String`으로 + 읽어 `parseMillis`로 넘기고, `null`/blank 또는 plain-integer 가 아닌 값은 `null`(= 부재로 간주, + 조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다. +- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` / + `max-lifetime`만 `env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time, + leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다. + +### OpenInViewSafetyValidator +- **OSIV(Open Session In View) OFF 를 hard stop 으로 강제한다.** OSIV 가 켜져 있으면 Hibernate + 세션이 view 렌더링까지 열려 있어, presentation 레이어에서 lazy 연관을 건드리면 거기서 조용히 DB + 쿼리가 나간다 — 이것이 정확히 금지된 레이어 경계 위반이다. Spring Boot 는 + `spring.jpa.open-in-view`가 암묵 기본값일 때 WARN 만 찍을 뿐 명시적으로 `true`로 설정한 배포는 + 막지 못한다. WARN 은 놓치기 쉬우므로, resolved 값이 `true`면 운영자가 실제 설정한 + `APP_DATASOURCE_OPEN_IN_VIEW` 키를 메시지에 담아 부팅을 실패시킨다. +- **읽기만 하고 re-bind 하지 않으며, 부재 값은 Spring 기본에 맡긴다.** `SmartInitializingSingleton` + 으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off + 가 기본)에 맡기며 *있는 `true`*만 거부한다. + +### RuntimeNumericBoundsValidator +- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는 + Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`이 + 각각을 `positive_int` / `non_negative_int`로 표시하지만, 이 표시는 그동안 강제되지 않았다 — + Hikari/Tomcat 이 결국 거부하더라도 난해한 메시지로 init 시점에야 거부했다. 이 검증기는 resolved + Spring property 를 읽어, 운영자가 실제 설정한 `APP_*` 키를 담은 메시지로 일찍 부팅을 실패시킨다. + 잘못 설정하면 실제 런타임 장애로 이어지는 키(pool/thread 사이징)에만 범위를 한정했고, Logback + 소유 로그 사이징·Spring `Duration` 키는 (해당 라이브러리가 검증하므로) 범위 밖이다. +- **읽기 전용으로 Spring 바인딩을 중복하지 않고, 부재 키는 skip 한다.** 값이 없으면 placeholder/ + 프레임워크 기본값이 소유하므로 건너뛰고, 범위를 벗어난 *있는* 값만 거부한다("lenient default + 금지"). + +### StartupSafetyValidator +- **`SmartInitializingSingleton` 타이밍을 고른 이유가 핵심이다.** 검사는 모든 싱글톤 생성 후 context + refresh 완료 전에 한 번 돈다. `EnvironmentPostProcessor`는 bean 정의가 생기기 전에 실행돼 bean + *존재 여부*(multi-instance 검사에 필요)를 검사할 수 없고, `ApplicationReadyEvent` 리스너는 트래픽 + 직전에야 발동해 잘못된 부팅을 거부하기엔 너무 늦다. 그래서 이 중간 타이밍을 택했고, 위반 시 + throw 하여 context 가 시작을 거부한다. +- **prod 안전 토글은 잘못 켜져 있으면 PROFILE_MISMATCH(exit 71)로 죽인다.** prod 프로파일에서 내부 + 상세 노출/요청 본문 캡처 토글이 켜져 있으면 구조화된 startup-failure 로그와 함께 부팅을 중단한다. +- **multi-instance 가 켜지면 조율 bean 이 모두 존재해야 한다.** `APP_MULTI_INSTANCE_ENABLED=true`일 + 때 `REQUIRED_MULTI_INSTANCE_BEANS`(distributed lock / cache stampede protection / outbox leader + election / distributed rate limiter / migration startup job)가 하나라도 빠지면 + REQUIRED_ADAPTER_DISABLED(exit 72)로 실패시킨다. 이 bean 들은 각각 다른 브랜치가 소유하고, 이 + 검증기는 존재 여부만 단언한다. +- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 오타도 prod-safety 가드를 + 발동시켜야 하므로 `equalsIgnoreCase`로 비교한다. + +--- + +## concurrency — 도메인 컨텍스트 전파 전략 조립 + +### DomainContextConfig +- **`DomainContextPropagator`를 빈으로 노출하고, 설정에서 고른 `DomainContextStrategy`로 + `DomainContextPropagatorFactory`를 통해 조립한다.** `application-core`는 설정을 직접 읽지 않는다는 + 원칙 때문에, 전략 해석과 propagator 생성은 composition root(여기)에서 끝내고 완성된 propagator 만 + 주입한다. 그래서 use case 코드는 어떤 전파 전략을 쓰는지 몰라도 되고, 전략 교체는 코드가 아니라 + 설정으로만 일어난다. + +### DomainContextSettings +- **`ca-skeleton.domain-context.strategy`가 비어 있으면 `THREAD_LOCAL`로 기본값을 채운다.** 스켈레톤은 + "설정 없이도 바로 동작하는 기본값"을 제공하는 것을 원칙으로 하므로, 전략을 강제로 지정하게 만들지 + 않고 가장 안전한 `THREAD_LOCAL`을 디폴트로 둔다. 이 전략은 rate-limit 알고리즘처럼 전용 환경변수 + 를 두지 않는데, 보안 비밀이나 배포별 값이 아니라 운영상의 선택지라 설정 바인딩만으로 충분하기 + 때문이다. + +--- + +## async — 비동기 executor 컨텍스트 전파와 포화 처리 + +### AsyncContextTaskDecorator +- **executor 경계를 넘을 때 caller 스레드의 컨텍스트를 worker 스레드로 옮기는 단 하나의 + `TaskDecorator`다.** MDC 는 submit 시점에 맵 전체를 복사한다(`MDC.getCopyOfContextMap()`). 그래서 + async 전파 대상 4개 foundation 키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`)와 + `span_id`(SLF4J-Micrometer tracing bridge 가 MDC 에 써넣는 값)가 worker 로그 라인에 그대로 + 따라온다. 특정 상수 holder(`MdcKeys`)에 결합하지 않고 맵을 통째로 복사하는 이유는, 그래야 그 + 시점에 존재하는 모든 키를 빠짐없이 옮길 수 있기 때문이다. +- **domain 컨텍스트는 직접 복사하지 않고 shared seam(`DomainContextPropagator.wrap`)에 위임한다.** 이 + 브랜치는 executor 배선만 소유하고, caller→worker 의 실제 hand-off 는 seam 이 소유한다는 책임 분리에 + 따른 것이다. +- **trace/span "문자열"만 옮기지 Micrometer `Observation` scope 는 worker 스레드에서 다시 열지 + 않는다.** 그래서 로그 연속성은 유지되지만, worker 에서 새로 만든 child observation 은 caller 의 + span 아래로 nesting 되지 않는다. 완전한 scope 전파는 `io.micrometer:context-propagation` + Spring + 의 `ContextPropagatingTaskDecorator`가 필요한데, 그 라이브러리를 의도적으로 classpath 에 두지 + 않았다. 따라서 "수동 4-key copy decorator" 경로를 채택하고, worker 스레드 span 이 정말 필요한 + 프로젝트를 위해 라이브러리 업그레이드를 확장 지점(seam)으로 문서화해 둔다. +- **`SecurityContext` principal 은 일부러 전파하지 않는다.** `user_principal`은 전파 대상이 아니고, + pooled 로 재사용되는 worker 스레드에 `SecurityContext`를 복사하는 것은 stale-context 위험이기 + 때문이다. principal 이 worker 에서 진짜 필요한 use case 는 자기 executor 를 Spring Security 의 + `DelegatingSecurityContextTaskExecutor`로 감싸서 명시적으로 opt-in 한다. +- **decorator 는 대칭적이다.** 태스크 실행 후 worker 스레드의 이전 MDC 를 복원하므로, pooled + 스레드가 한 태스크의 컨텍스트를 다음 태스크로 흘리지 않는다. + +### AsyncExecutorConfig +- **Spring Boot 가 auto-config 하는 `applicationTaskExecutor`를 bounded pool 로 교체한다.** Boot 기본 + executor 의 큐는 unbounded(`Integer.MAX_VALUE`)라 금지된다. 빈 이름을 `applicationTaskExecutor`로 + 두는 이유는 Boot 의 `@ConditionalOnMissingBean(Executor.class)` auto-config 를 back-off 시키면서, + 동시에 `@Async`가 resolve 하는 바로 그 executor 가 되게 하기 위함이다. +- **TaskDecorator 미설정 executor 등록은 ApplicationContext 기동 실패로 만든다.** + `AsyncContextTaskDecorator`를 executor 빈의 *필수* 생성자 의존으로 두었기 때문에, decorator 없이 + executor 를 등록하려 하면 컨텍스트가 뜨지 않는다. +- **`awaitTerminationSeconds(19)`는 컨테이너 app-shutdown 예산 20s 에서 cleanup margin 1s 를 뺀 + 값이다.** 25s 는 금지된다 — 컨테이너가 강제 종료하기 전에 graceful drain 이 끝나야 하기 때문이다. +- **`executor.saturation` gauge 는 살아있는 큐 깊이를 읽는다.** 큐 > 용량 80% 면 p2, rejection + 발생이면 p1 알림 기준이다. + +### AsyncExecutorSettings +- **큐는 반드시 bounded 여야 한다.** unbounded 큐는 `maxPoolSize`를 도달 불가능하게 만든다 — JDK + `ThreadPoolExecutor`는 큐가 가득 찼을 때만 core 이상으로 스레드를 늘리기 때문이다. 게다가 Spring + `ThreadPoolTaskExecutor`의 큐 기본값은 `Integer.MAX_VALUE`, 즉 사실상 unbounded 다. 그래서 compact + 생성자가 큐 용량으로 `Integer.MAX_VALUE`를 거부한다 — 그만큼 큰 용량은 이름만 다른 unbounded + sentinel 이기 때문이다. +- **검증은 fail-fast(ApplicationContext 기동 실패)다.** 잘못 설정된 pool 은 런타임 fault 가 아니라 + deploy-time 버그이므로, 런타임까지 끌고 가지 않고 기동 시점에 즉시 터뜨린다. +- **cross-field 불변식: `maxSize`는 `coreSize`보다 작을 수 없다.** core 보다 작은 max 는 무의미하고, + pool 이 설정된 core 크기에조차 도달하지 못하게 만들기 때문이다. + +### BackgroundJobMetrics +- **이 브랜치가 소유하는 background-job/async-executor 메트릭 어휘 SSOT 다.** 메트릭 이름·태그는 + `docs/registries/metrics.yaml`과 verbatim 일치한다: `executor.saturation`(gauge, `executor_name`), + `executor.rejected.total`(counter, `executor_name`+`policy`), `job.retry.total`(counter, + `job_name`+`outcome`), `job.dlq.total`(counter, `job_name`). +- **`executor.*` 두 meter 는 `AsyncExecutorConfig`가 라이브로 배선하고, `job.*` 두 recorder 는 어휘 + 표면만 먼저 출시한다.** retry/DLQ 어휘는 outbox/outbound 브랜치가 나중에 retry carrier 를 고르면 + 소비하라고 이 브랜치가 소유한다. carrier 자체는 아직 정해지지 않았으므로, retry 엔진이 아니라 + vocabulary surface 만 출시하는 것이다. +- **`MeterRegistry` 빈이 없으면(=classpath 에 Actuator 없음) 모든 연산이 no-op 다.** `ObjectProvider`로 + registry 를 resolve 하며, 이는 `OutboxMetrics` 선례를 그대로 따른다. +- **`RetryOutcome` enum 은 retry 어휘를 type-safe 하게 표기한 것이다.** `job.retry.total`의 `outcome` + 태그 값은 metrics.yaml 에서 4개(SUCCESS/RETRY/EXHAUSTED/DLQ)로 bounded 되어 있고, 미래의 retry + carrier 가 이 값을 emit 한다. + +### LoggingAbortPolicy +- **기본 포화 정책은 `AbortPolicy`다.** bounded 큐가 가득 차고 pool 이 `maxPoolSize`에 도달하면 + 태스크를 거부하고 그 거부를 caller 에게 전달한다. 이 wrapper 는 bare + `ThreadPoolExecutor.AbortPolicy`가 빠뜨리는 두 가지 계약 의무를, abort 를 다시 던지기 *전에* + 추가한다: (1) `error.code=JOB_EXECUTOR_REJECTED` + category + `executor_name` + `policy`를 담은 + structured ERROR 로그, (2) `executor.rejected.total{executor_name, policy}` counter 증가. +- **그 다음 `RejectedExecutionException`을 던져 AbortPolicy 의미를 유지한다.** fire-and-forget + `@Async` caller 의 거부가 조용히 삼켜지지 않도록 하기 위함이다 — 이 예외가 async-exception 계약이 + 흡수하는 신호다. +- **허용되는 유일한 대안은 `CallerRunsPolicy`뿐이고, 그것도 명시적 use-case 선언이 있을 때만이다.** + caller-runs 의 back-pressure 는 request 스레드 latency 를 갉아먹기 때문이다. unbounded 큐는 아예 + 금지다. + +--- + +## idempotency — 멱등성 런타임 조립과 TTL 상한 + +### IdempotencyConfig +- **공유 `Clock` 빈과 `IdempotencyExecutor`를 composition root 에서 조립하고, `@EnableScheduling`으로 + 만료 레코드 reaper 의 스케줄 purge 를 켠다.** `Clock`을 빈으로 한 번만 정의해 executor·persistence + store 어댑터·reaper·rate-limit 인터셉터가 같은 시계를 쓰게 만들어 테스트에서 시간 고정이 쉽고 + 분기마다 시간 해석이 어긋나지 않는다. executor 를 여기서 만드는 이유는 TTL 같은 설정을 + `application-core`가 직접 읽으면 안 되기 때문이다. `@Scheduled` purge 는 `@EnableScheduling` 없이는 + 동작하지 않으므로 이 설정 클래스에서 명시적으로 활성화한다. + +### IdempotencySettings +- **TTL 기본값은 24h 이고 상한은 72h 이며, 이 상한 검사는 컴팩트 생성자에서 fail-fast 로 던진다.** + `Duration`의 상한은 JSR-303(`@Valid`) 애너테이션으로 표현할 수 없는 교차 필드 불변식이라 생성자에서 + 직접 검증한다. 검증을 느슨하게 두고 넘어가면 과도하게 긴 TTL 이 저장소를 조용히 부풀리고 멱등성 키 + 추측 공격의 유효 시간 창을 넓히기 때문에, 부팅 시점에 즉시 실패시키는 쪽을 택했다. +- **`MAX_TTL`(72h) / 기본 TTL(24h)을 바꿀 때는 보안 베이스라인의 JWT 키 회전 겹침(overlap) 윈도와 + 반드시 함께 검토해야 한다.** 멱등성 레코드의 수명이 키 회전 겹침 윈도(현재 24h)보다 길면, 가명화 + (pseudonym) 기준이 회전된 principal 에 대해 레코드가 재생(replay)될 수 있다. 이 불변식을 강제하는 + CI 게이트는 security-operational-baseline 쪽에 위임돼 있다. +- **`reaperInterval`은 전용 환경변수가 없고 기본 10분이다.** reaper 실행 주기는 비밀이나 배포별 값이 + 아니라 순수 운영 튜닝 값이라 env key 를 따로 두지 않고, 비어 있거나 0/음수면 10분으로 채운다. + +--- + +## lock — 분산 락 메트릭 데코레이터 배선 + +### DistributedLockConfig +- **`distributedLockProvider` 빈은 `ca-skeleton.runtime.multi-instance-enabled=true`일 때만 + `@ConditionalOnProperty`로 등록되고 `@Primary`로 우선 적용된다.** 다중 인스턴스 환경에서만 + `adapter-persistence`의 원시 `jdbcDistributedLock`(JdbcLockRegistry, Flyway 가 프로비저닝한 + `INT_LOCK` 기반) 어댑터를 `MeteredDistributedLockPort`로 감싸 `lock.acquisition` 카운터를 기록한다. +- **단일 인스턴스에서는 이 조건부 빈이 아예 없으므로 `adapter-persistence`가 등록한 `@Primary + inProcessDistributedLock`(in-process `DefaultLockRegistry`)이 그대로 선택된다.** 이 경로는 의도적 + 으로 계측하지 않는다 — `lock.acquisition` 카운터는 인스턴스 간 분산 조율을 관측할 때만 의미가 + 있고, JVM 안의 단순 뮤텍스에는 무의미하기 때문이다. +- **빈 이름 `"distributedLockProvider"`는 `StartupSafetyValidator`가 이름으로 조회해 검증하는 + 계약이다.** 절대 이름을 바꾸지 말 것. + +### MeteredDistributedLockPort +- **`DistributedLockPort`를 감싸는 얇은 Micrometer 데코레이터다.** `tryAcquire` 호출마다 + `lock.acquisition` 카운터를 `outcome` 태그와 함께 1 증가시킨다. 태그 값은 세 가지: `acquired`(락 + 획득 성공, 위임 핸들 반환), `timeout`(`LockAcquisitionTimeoutException` 발생), `error`(그 외 모든 + `RuntimeException` 발생). 메트릭 이름과 태그 값은 `docs/registries/metrics.yaml`의 `lock.acquisition` + 행과 일치해야 한다. +- **리스 만료(lease-expiry) CME 흡수.** 보유자가 `close()`를 호출하기 전에 리스(저장소 TTL)가 + 만료되면 그 락 행은 이미 다른 인스턴스가 회수해 갔을 수 있고, 그 시점에 내부 `JdbcLock.unlock()`은 + `ConcurrentModificationException`(CME)을 던진다. 이 예외를 호출자의 `finally { lock.close(); }` + 블록 밖으로 그대로 전파하면 보호 구간(critical section)에서 발생한 본래 예외를 가려버린다 + (masking). 그래서 CME 만 잡아 WARN 로그를 남기고 태그 없는 `lock.lease.expired` 카운터를 1 + 증가시킨 뒤 `close()`는 정상 복귀시킨다. 이렇게 해야 신호(로그+메트릭)는 남기면서도 호출자의 정상 + 흐름과 예외 전파를 방해하지 않는다. 오직 `ConcurrentModificationException`만 흡수하며, DB 장애 같은 + 다른 예외(예: `DataAccessResourceFailureException`)는 리스 만료 신호가 아니므로 손대지 않고 그대로 + 전파한다. +- **MeterRegistry 부재 시 no-op.** 레지스트리를 `ObjectProvider.getIfAvailable()`로 해석하므로 + 클래스패스에 `MeterRegistry` 빈이 없으면(Actuator 미탑재) 모든 메트릭 연산이 조용히 no-op 이 + 된다. `BackgroundJobMetrics` 선례와 동일한 패턴이다. +- **메트릭 실패가 락 경로를 절대 깨뜨리지 않음.** 카운터 등록·증가는 모두 try/catch 로 감싸 예외를 + 로깅 후 삼킨다(log-and-swallow). Micrometer 쪽 실패가 락 획득·보유·실패 보고를 막는 일은 없어야 + 하기 때문이다. + +--- + +## outbox — 트랜잭셔널 아웃박스 릴레이 와이어링 + +### OutboxConfig +- **릴레이 use case 를 `@Service`가 아니라 `app-bootstrap`에서 수동 조립한다.** + `PublishPendingOutboxEventsUseCase`는 `batchSize`와 `inFlightTimeout` 같은 설정값을 생성자로 받아야 + 하는데, `application-core`는 설정(`OutboxSettings`)을 직접 읽으면 안 된다. 그래서 설정을 볼 수 있는 + 합성 루트(`OutboxConfig`)가 값을 꺼내 use case 를 손으로 만들어 넘긴다. use case 클래스의 + `@UseCaseCapability` 애너테이션은 와이어링 방식과 무관하게 유지된다(ArchUnit 이 강제). +- **릴레이 use case 를 독립 컨텍스트 빈으로 등록하지 않는다.** 만약 빈으로 올리면 `adapter-web`의 + `MethodSecurityConfig` 메서드 보안 pointcut(`@RequiresPermission`)이 이 타입을 CGLIB 프록시로 + 감싼다. 그런데 use case 가 `final` 클래스라 프록시 생성 자체가 실패하고, 설령 된다 해도 스케줄러 + 스레드에는 `Authentication`이 없어 매 릴레이 틱이 fail-closed 로 거부된다. 그래서 빈으로 올리지 + 않고, `outbox:relay` 권한 확인은 스케줄러 컨텍스트에서 관례로 둔다. +- **`outboxRelayScheduler` 빈은 `ca-skeleton.outbox.relay-enabled`로 게이팅한다(기본 true, + `matchIfMissing=true`).** 릴레이를 끄고 싶을 때 빈 자체가 만들어지지 않게 하기 위함. 키가 없으면 + 켜진 것으로 본다. +- **`outboxLeaderElection` 토큰 빈은 조건 없이(unconditional) 항상 등록한다.** `StartupSafetyValidator` + 가 `APP_MULTI_INSTANCE_ENABLED=true`일 때 이 빈을 반드시 찾을 수 있어야 하기 때문이다. SKIP + LOCKED 는 멀티 인스턴스 여부와 무관하게 언제나 릴레이의 리더십 메커니즘이라, `@ConditionalOnProperty` + 로 끌 수 있게 만들면 단순 조정 기능이 아니라 릴레이 정확성 자체가 깨진다. +- **`outboxMetrics` 빈은 `MeterRegistry`가 없을 때 no-op 이다.** Actuator 가 클래스패스에 없는 + 환경에서도 와이어링이 깨지지 않도록 `ObjectProvider`로 레지스트리를 선택적으로 주입한다. + +### OutboxLeaderElectionToken +- **로직이 전혀 없는 마커(documentation artifact) 빈이다.** 이 타입은 동작을 갖지 않고, Spring + 컨텍스트에 존재한다는 사실만으로 멀티 인스턴스 조정 빈 요구사항을 충족시킨다. +- **리더 선출을 외부 코디네이터 없이 PostgreSQL `FOR UPDATE SKIP LOCKED`로 구현한다는 것을 + 표현한다.** claim 쿼리에 SKIP LOCKED 를 걸면 각 릴레이 인스턴스가 서로 겹치지 않는(disjoint) 행 + 집합을 가져가므로, 별도 코디네이터 없이도 "각 인스턴스가 자기 파티션의 리더" 형태로 리더 선출 + 의미가 성립한다. + +### OutboxMetrics +- **`MeterRegistry`가 없으면 모든 메트릭 연산이 no-op 이다.** Actuator 가 없는 환경에서도 릴레이가 + 정상 동작해야 하므로, 생성자에서 `ObjectProvider`로 레지스트리를 조회해 없으면 게이지도 만들지 + 않고 기록도 건너뛴다. +- **카운터는 PUBLISHED/FAILED/DEAD 만 집계하고 IN_FLIGHT 는 제외한다.** IN_FLIGHT 는 릴레이 사이클의 + 종료 결과(terminal outcome)가 아니라 처리 중을 나타내는 일시적 상태라 카운터 outcome 으로 의미가 + 없다. +- **MultiGauge 갱신 시 `overwrite=true`로 등록한다.** 매 스케줄러 틱마다 store 를 다시 조회해 + 게이지를 갱신하는데, 이전 틱에 있었지만 지금은 사라진 event type 같은 오래된 time-series 태그를 + 덮어써 제거하기 위함이다. + +### OutboxRelayScheduler +- **합성 루트가 직접 등록하고 컴포넌트 스캔하지 않는다.** 이 스케줄러가 구동하는 릴레이 use case 가 + (위 OutboxConfig 사유로) 일부러 빈이 아니기 때문에, 그 use case 를 조립하는 `OutboxConfig`가 + 스케줄러 등록까지 같이 소유한다. +- **릴레이 사이클에서 발생하는 예상치 못한 예외를 잡아 ERROR 로 로깅만 하고 삼킨다.** 스케줄러 + 스레드가 죽으면 릴레이가 조용히 멈추므로, 다음 틱을 위해 스레드를 살려둔다. 단, 개별 발행 실패 + (FAILED/DEAD 전이)는 릴레이 use case 내부에서 이미 상태 전이와 ERROR 로그로 처리되어 결과에 + 반영되므로 이 catch 블록까지 오지 않는다 — 여기서 삼키는 것은 어디까지나 "예상치 못한" 예외다. +- **`@EnableScheduling`을 직접 켜지 않고 fixed-delay 를 쓴다.** 스케줄링은 이미 `IdempotencyConfig`를 + 통해 활성화돼 있어 중복으로 켤 필요가 없고, fixed-delay 는 릴레이 실행 시간과 무관하게 사이클이 + 겹치지 않도록(non-overlapping) 보장한다. + +### OutboxSettings +- **여섯 개 설정값 모두 코드 리터럴 기본값을 쓰고 env placeholder 를 두지 않는다.** 신규 env key 를 + 추가하지 않는다는 결정에 따라, 값이 없으면 컴팩트 생성자에서 직접 기본값(예: `PT5S`, `20`, + `PT5M`, `PT10M`, `P7D`)을 채운다. +- **`reaper-interval`과 `published-retention`은 `adapter-persistence`의 `OutboxReaper`도 property + 문자열로 읽는다.** 두 모듈이 같은 키를 공유하지만, 이 properties record 가 여섯 값 전체를 문서화 + 하는 단일 지점(single place) 역할을 한다. + +--- + +## logging — 로그 시크릿 마스킹·샘플링·가명화 + +### LogMaskingPatterns +- **마스킹 정규식 규칙을 한 곳에만 둔 단일 진실 원천(SSOT)이다.** JSON 인코더 경로 + (`SecretMaskingJsonGeneratorDecorator`, staging/prod/default 프로파일)와 사람이 읽기 쉬운 패턴 경로 + (`SecretMaskingMessageConverter`, local/dev)가 같은 규칙을 공유한다. 규칙을 한 군데로 모아둔 이유는, + 프로파일이나 로그 포맷을 바꿔도 가려지는 시크릿의 범위가 절대 달라지지 않게 하기 위해서다. 즉 + "가독성을 위해 포맷을 바꿨더니 시크릿이 다시 노출되는" 사고를 구조적으로 막는다. +- **시크릿 값만 가리고 키/스킴은 남긴다.** `token=abc123` → `token=****`, + `Authorization: Bearer eyJ...` → `Authorization: Bearer ****`처럼 동작한다. 키와 인증 스킴(`Bearer` + 등)을 남기는 이유는 진단할 때 "어떤 종류의 자격증명이 있었는지"는 알아야 하기 때문이다. +- **정규식 마스킹은 보증이 아니라 심층 방어(defence-in-depth)의 보조 수단이다.** 1차 방어선은 + "로거가 애초에 본문/페이로드를 받지 않도록 설계한 것"(`FailOpenDependencyLogger`)이고, 이 정규식은 + 그걸 빠져나간 누출을 잡는 그물이다. 키 접두어 없는 Base64URL 블롭 같은 난독화된 형태는 놓칠 수 + 있으므로, 새로운 누출 형태가 보이면 이 규칙 목록을 운영 중에 계속 보강해야 한다. + +### MetricsAsyncAppender +- **백프레셔로 로그가 버려질 때 그 사실을 메트릭으로 노출한다.** Logback `AsyncAppender`는 큐 여유가 + `discardingThreshold` 아래로 떨어지면 `INFO`/`DEBUG` 같은 낮은 심각도 이벤트를 조용히 버린다 + (`WARN`/`ERROR`는 항상 보존). 이렇게 조용히 사라지는 드롭은 관측이 안 되면 장애 분석 때 "로그가 + 비어 있는데 왜 비었는지 모르는" 상황을 만들기 때문에, 버릴 때마다 `log.appender.dropped.total` + Micrometer 카운터를 올리고(`appender`·`level` 태그) 그다음 상위 클래스에 실제 드롭을 위임한다. +- **카운터를 `Metrics.globalRegistry`로 발행한다.** Logback 은 Spring 컨텍스트보다 먼저 초기화되는데, + Spring Boot 가 시작 시 애플리케이션 `MeterRegistry`를 이 전역 컴포지트에 합류시킨다. 그래서 초기화 + 순서가 어긋나도 결국 평소 메트릭 엔드포인트에서 이 수치가 보인다. +- **`level` 태그를 `INFO`/`DEBUG`로만 한정한다.** `metrics.yaml`의 `allowed_values` 계약 때문이다. + 루트 레벨을 TRACE 로 낮춰야만 가능한 `TRACE` 드롭은 상위 클래스가 여전히 버리지만 카운트하지는 + 않아서, 태그 카디널리티가 레지스트리 계약을 벗어나지 않게 유지한다. + +### PseudonymizationConfig +- **`user_principal`(로그에 남는 사용자 식별자)를 전체 HMAC 방식으로 가명화한다.** + `UserPrincipalPseudonymizerPort`(application-core)를 HMAC-SHA-256 구현(adapter-identifier)에 + 바인딩하고, 솔트는 `PrivacySettings`에서 가져온다. 원본 식별자를 그대로 로그에 남기지 않으려는 + 개인정보 보호 결정이다. 실제 사용처는 `adapter-web`의 `RequestLoggingFilter`로, 보안 principal 이 + MDC/로그에 닿기 전에 이 포트로 가린다. +- **`@ConditionalOnMissingBean`으로 기본 구현을 둔다.** 이 템플릿을 포크한 프로젝트가 자기만의 + 가명화기(예: 향후 솔트 회전을 지원하는 구현)를 등록하면 이 기본 빈이 비켜주도록, 즉 기본값은 + 제공하되 교체를 막지 않도록 하기 위해서다. + +### SamplingTurboFilter +- **레벨을 의식하는 로그 샘플러다.** `WARN`/`ERROR`는 절대 샘플링하지 않고 항상 통과시킨다 + (`FilterReply.NEUTRAL`). 진단·장애 신호인 경고/오류는 100% 보장해야 하고, 샘플링 대상은 `INFO` + 이하만이라는 정책 때문이다. `INFO` 이하는 확률 `rate`로 보존하고 나머지는 `FilterReply.DENY`로 + 버린다. +- **단일 `rate` 노브로 제어한다.** `APP_LOG_SAMPLING_RATE`(logback `springProperty`)에서 주입되고, + 운영자가 프로파일별로 설정한다(prod `0.1` = INFO 10% 샘플링, staging/dev/local `1.0` = 전부 보존). + 고트래픽 vs 일반 엔드포인트의 더 세밀한 분기는 이 전역 rate 위에 마커 기반 확장으로 문서화만 되어 + 있고 여기서는 구현하지 않았다. +- **검증은 "경고 후 기본값(warn-and-default)" 방식이다.** `rate`가 `[0.0, 1.0]` 범위를 벗어나면 + Logback 상태 시스템에 경고를 남기고 `1.0`(전부 보존)으로 폴백한다. 잘못된 설정이 로그를 소리 없이 + 버리는 쪽으로 가지 않고, 항상 안전한 "샘플링 안 함"으로 degrade 되게 한 선택이다. + +### SecretMaskingJsonGeneratorDecorator +- **시크릿 마스킹의 JSON 인코더 쪽 팔이다.** `LogMaskingPatterns` 목록을 미리 적재한 + `MaskingJsonGeneratorDecorator`로, staging/prod/default 프로파일의 `LogstashEncoder`에 연결된다. + JSON 생성 시점에 마스킹하기 때문에 어떤 구조화 필드가 시크릿을 담았든 상관없이 message·MDC + 값·스택 트레이스 텍스트 등 모든 문자열 값을 덮는다. 이 "어디서 새든 잡는 그물(catch-net)" 성질이 + Redaction Layer 1에 요구되는 핵심이다. + +### SecretMaskingMessageConverter +- **시크릿 마스킹의 사람이 읽는 `PatternLayout` 쪽 팔이다.** local/dev 콘솔 패턴의 `%maskedMsg` + 변환 워드로 등록되어, 가독성 위주의 인코더도 운영 JSON 경로와 똑같은 마스킹을 적용한다. 사람이 + 읽기 좋은 포맷으로 바꿨다는 이유로 시크릿이 다시 노출되지 않게 하려는 것이다. 여기서는 개발자 + 콘솔의 현실적 누출 벡터인 message 본문만 가린다(JSON 경로는 추가로 MDC·스택 트레이스 값까지 + 마스킹). + +--- + +## metrics — 메트릭 계약 MeterFilter 설치 + +### MetricsCardinalityMeterFilter +- **금지 태그가 붙은 미터를 런타임에서 막는 방어 필터다.** 미터의 `Meter.Id`에 붙은 태그 키 중 + 하나라도 `ForbiddenMetricTags.FORBIDDEN`에 들어 있으면 그 미터를 `DENY`하고, 아니면 `NEUTRAL`을 + 반환한다. `user_id`, `request_id`, `raw_url`, `raw_query`, `raw_header_value`, `ip_address` 같은 + 고카디널리티(high-cardinality) 라벨 키는 고유 값마다 Prometheus 시계열을 하나씩 만들어 수백만 + 개로 폭증할 수 있어 차단한다. +- **이 필터는 카디널리티 계약의 런타임(runtime) 절반이다.** 정적(static) 절반은 레지스트리 계약 + 테스트(`MetricsAlertingContractTest`)가 담당한다 — 둘이 짝을 이뤄 심층 방어를 구성한다. +- **상태가 없어(stateless) 여러 레지스트리에 공유해도 안전하다.** public no-arg 생성자는 + `MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다. + +### MetricsContractConfig +- **`@PostConstruct`에서 `MeterFilter`들을 `MeterRegistry`에 직접 설치한다.** `@Bean MeterFilter`로 + 등록하지 않는 이유: 이 템플릿 클래스패스에는 `@Bean MeterFilter`를 자동 수집하는 Spring Boot + Actuator 자동설정(`MeterRegistryCustomizer`)이 없어서 `@Bean MeterFilter`는 그냥 동작하지 않는 + (inert) 죽은 빈이 되기 때문이다. `OutboundHttpResilienceConfig`에서 확립한 선례를 따른다. +- **설치 순서가 중요하다.** (1) `MetricsCardinalityMeterFilter`(deny-list)를 먼저 설치해 금지 태그가 + 붙은 미터가 분포 필터에 닿기 전에 거부되도록 하고, (2) 그다음 `MetricsDistributionMeterFilter` + (SLO 기반 히스토그램 설정)를 설치한다. +- **필터는 설치 이후 등록되는 미터에만 적용된다.** `@PostConstruct`는 Spring 빈 생명주기 중 애플리 + 케이션 코드가 어떤 미터든 등록하기 전에 실행되므로 모든 미터가 필터 적용 대상이 된다. +- **`MeterRegistry` 부재 시 no-op.** `ObjectProvider.getIfAvailable()`로 해석해 레지스트리 빈이 + 없으면(Actuator 미탑재) DEBUG 로그만 남기고 설치를 건너뛴다. +- **`install(MeterRegistry)`를 public static 으로 둔 이유.** 계약 테스트가 Spring 컨텍스트 없이 + `SimpleMeterRegistry`에 대해 직접 구동할 수 있게 하기 위함이다. + +### MetricsDistributionMeterFilter +- **소유한(owned) 타이머 메트릭 5종에만 SLO 기반 히스토그램·백분위 설정을 적용한다.** 대상: + `http.server.requests`, `http.server.requests.latency`, `dependency.client.requests`, + `db.query.duration`, `jvm.gc.pause`. 이들은 `metrics.yaml`에서 이 브랜치가 소유하고 + `histogram_buckets: slo_driven`인 행이다. `resilience4j.circuitbreaker.calls`, + `hikaricp.connections.acquire`처럼 소비만 하고 소유하지 않는(consumed-but-not-owned) 행은 다른 + 브랜치 소유라 의도적으로 제외 — 덮어쓰면 안 된다. +- **히스토그램 전략은 두 축이다.** + - *인스턴스 간 집계 가능한 진실의 원천*: `percentilesHistogram(true)`(= `publishPercentileHistogram`)가 + `_bucket` 시계열을 생성하고, Prometheus 에서 `histogram_quantile()`로 인스턴스 간 집계할 수 있다. + 이것이 다중 인스턴스 배포의 정식 p50/p95/p99 다. + - *클라이언트 측 편의값(집계 불가)*: `percentiles(0.5, 0.9, 0.95, 0.99)`(= `publishPercentiles`)는 + 단일 인스턴스 가시성을 위한 사전 계산 분위 게이지를 제공한다. 이 값들은 인스턴스 간 평균을 내면 + 안 된다(통계적으로 틀림). 인스턴스 간 집계는 반드시 `histogram_quantile()`로 한다. +- **SLO 경계값 `100ms / 500ms / 1s / 5s`는 잠정 SLO 역산이며 외부 표준에서 유도한 값이 아니다.** + 정식 SLO 가 채택되면 재검토 대상이다. 함께 설정하는 `minimumExpectedValue=1ms`, + `maximumExpectedValue=10s`는 히스토그램 버킷의 관측 범위를 한정한다. +- **상태가 없어 여러 레지스트리에 공유해도 안전하고,** public no-arg 생성자는 + `MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다. + +--- + +## management/security — 액추에이터 엔드포인트 보안 체인 + +### ManagementSecurityConfig +- **`actuatorSecurityFilterChain`은 `@Order(0)`으로 메인 앱 체인(adapter-web `SecurityConfig`)보다 + 앞서 실행된다.** 그래서 액추에이터 엔드포인트에 매칭되는 요청은 앱 체인이 아니라 이 체인이 + 처리한다. 보안 매처는 `EndpointRequest.toAnyEndpoint()`로, 관리 포트의 `/actuator/**` 경로만 이 + 체인 범위에 든다. +- **접근 정책.** `health`, `info`, `prometheus`는 permit-all 이다. 자격 증명 없이 Kubernetes 프로브와 + Prometheus 스크레이프가 접근할 수 있어야 하고, 이 엔드포인트들은 민감 데이터를 노출하지 않기 + 때문이다(health 상세는 when-authorized). 나머지 액추에이터 엔드포인트는 모두 인증이 필요하다. +- **`loggers` 쓰기 차단.** 런타임 로그 레벨 변경은 `POST /actuator/loggers/{name}`이고, 레벨 리셋은 + `DELETE /actuator/loggers/{name}`이다. 둘 다 변형(mutation) 작업이므로 `denyAll()`로 모두에게 + (인증된 사용자 포함) 거부한다 — 인증만으로 쓰기 권한을 주지 않는다. `loggers` 읽기는 아래 + `authenticated()`로 흘러 인증을 요구한다. +- **인증 실패 시 기본값 403 대신 401(자격 증명 필요)을 반환한다(`HttpStatusEntryPoint(UNAUTHORIZED)`).** + 대화형 로그인이나 basic-auth realm 은 제공하지 않는다 — 관리 포트는 네트워크 ACL 뒤에 있으므로 + 올바른 HTTP 의미(credentials required)만 신호하면 된다. +- **운영에서 deny-by-default 보장은 관리 포트를 별도 포트로 분리해 네트워크 ACL 수준에서 강제한다.** + 이 in-process 체인은 그 위의 심층 방어 계층이다. +- **SHAPE-OWNERSHIP(하드 규칙): 이 클래스는 `HealthEndpoint` / `HealthIndicator` / `HealthComponent`를 + import·구현·의존해선 안 된다** — health 엔드포인트의 형태(shape)는 runtime-health 브랜치 소유다. + 보안 매칭은 오직 엔드포인트 id 문자열로만 하고 health 내부 구조에는 절대 손대지 않는다. ArchUnit + 규칙 `management_security_does_not_depend_on_health_internals`(`CleanArchitectureTest`)가 이를 정적 + 으로 강제한다. + +--- + +## tracing — 분산 트레이싱 wiring과 샘플링 정책 + +### TracingConfig +- **컴포지션 루트에서 트레이싱을 조립한다.** 두 가지를 한다: (1) 시작 시 `tracing.sampling.rate` + 게이지 등록, (2) `SpanErrorRecorder` 빈 등록. +- **`SpanErrorRecorder` 빈은 `@ConditionalOnBean(Tracer.class)`가 아니라 `ObjectProvider<Tracer>`로 + 런타임 존재 여부를 직접 조회한다.** 이유: `@ConditionalOnBean`은 사용자 정의 `@Configuration`에서 + autoconfiguration 이 만드는 빈(여기서는 `Tracer`)을 조건으로 쓸 때 빈 등록 순서가 보장되지 않아 + 신뢰할 수 없기 때문이다. `Tracer`가 있으면 `MicrometerSpanErrorRecorder`를, 없으면 + `SpanErrorRecorder.NOOP`을 반환한다. +- **`@ConditionalOnMissingBean(SpanErrorRecorder.class)`를 붙여, 테스트 mock 이나 fork 가 자체 + `SpanErrorRecorder` 빈을 이미 등록한 경우 이 빈은 backoff 한다.** adapter-web 의 + `GlobalExceptionHandler`는 `ObjectProvider`로 `SpanErrorRecorder`를 조회하므로, 자기 자신의 NOOP + self-default 에서 여기서 등록한 실제 구현으로 자동 교체된다. +- **OTel/Micrometer tracer 런타임은 이 repo 에서 seam 이 활성 상태다.** `micrometer-tracing-bridge-otel` + + `opentelemetry-exporter-otlp`가 `app-bootstrap/build.gradle`에 실제로 wiring 되어 있다. 단, span + exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비어 있는 동안 꺼져 있고, 엔드포인트를 설정하면 + export 가 시작된다. +- **게이지 등록 시 active profile 은 `Environment.getActiveProfiles()`의 첫 번째 값을 쓴다.** 멀티 + 프로파일 배포에서는 맨 앞에 나열된 프로파일이 권위 있는 배포 환경이라는 규약이다. `MeterRegistry`가 + classpath 에 없으면(Actuator 미탑재) 게이지 등록은 조용히 no-op 이 된다. +- **`TracingSamplingRateGaugeRegistrar` record 는 등록된 active profile 과 resolved rate 를 노출하는 + 값 홀더다.** 정식 포트나 use case 가 아니라, 살아있는 `MeterRegistry` 없이도 테스트가 "무엇이 + 등록되었는지" 검사할 수 있게 하는 bootstrap 내부 전용 장치다. + +### TracingSettings +- **`ca-skeleton.tracing.*` 바인딩 `@ConfigurationProperties` record.** 세 env 키가 들어온다: + `APP_TRACING_ENABLED` → `enabled`, `APP_TRACING_SAMPLE_RATE` → `sampleRate`, + `OTEL_EXPORTER_OTLP_ENDPOINT` → `exporter.otlpEndpoint`. +- **`sampleRate`가 blank 이면 숫자 검증을 건너뛴다.** blank 는 "값이 잘못됐다"가 아니라 "프로파일별 + 기본값에 위임하라"는 신호이기 때문이다. 실제 프로파일별 기본값(prod=0.01, staging=0.10, + dev/local=1.0)은 `TracingSampleRateResolver`가 소유하고, resolved 된 값은 + `TracingSamplingEnvironmentPostProcessor`가 Spring Boot 네이티브 + `management.tracing.sampling.probability`로 브리지한다. +- **검증은 시작 시 fail-fast 다.** `sampleRate`가 non-blank 인데 [0.0, 1.0] float 이 아니면 시작이 + 실패하고, `otlpEndpoint`가 non-empty 인데 scheme 없는 잘못된 URL 이면 시작이 실패한다. `enabled`는 + Spring 바인딩이 `boolean` 타입을 강제하므로 추가 검증이 필요 없다. `otlpEndpoint`가 비어 있으면 + exporter off 상태(seam 기본값)이고, 이것이 정상 동작이므로 검증을 통과시킨다. +- **`sampleRateValue()`는 호출 전 `sampleRate().isBlank()` 확인이 caller 규약이다.** blank 인 상태로 + 호출하면 `IllegalStateException`을 던지는데, 이는 파싱 오류가 아니라 caller 규약 위반을 드러내기 + 위한 것이다. + +### TracingSampleRateResolver +- **프로파일별 트레이싱 샘플 비율 SSOT 다.** prod=0.01(1%), staging=0.10(10%), dev/local=1.0(100%), + 그 외=1.0. `APP_TRACING_SAMPLE_RATE`가 non-blank 이고 [0,1] float 로 파싱되면 프로파일 기본값을 + override 한다. +- **Spring 의존성이 전혀 없는 순수 Java 로 작성했다.** 어떤 컨텍스트도 없이 단위 테스트할 수 있고, + `EnvironmentPostProcessor`(컨텍스트 생성 이전 단계)와 `TracingConfig`(빈 생성 단계) 양쪽에서 동일 + 로직을 재사용하기 위함이다. + +### TracingSamplingRateGauge +- **`tracing.sampling.rate` 게이지를 등록한다.** metric 계약: type=gauge, tag=`profile`(prod/staging/ + dev/local 허용), value=resolved effective sample rate(double in [0.0, 1.0]). +- **오직 active Spring profile 의 시리즈 하나만 방출하므로 cardinality 는 1이다.** tag 는 프로파일 + 이름을 정규화하지 않고 그대로 쓴다. 따라서 비표준 프로파일(예: "qa")로 띄워도 시리즈 하나는 정상 + 방출되고, 단지 4-value allowlist 를 벗어날 뿐이다 — 이는 런타임 에러가 아니라 레지스트리 문서상의 + 관심사다. +- **`MeterRegistry`를 `ObjectProvider`로 조회하며, Actuator 미탑재로 빈이 없으면 등록을 조용히 + 건너뛴다.** 이는 `BackgroundJobMetrics` / `OutboxMetrics`의 선례를 그대로 따른다. + +### TracingSamplingEnvironmentPostProcessor +- **resolved 된 유효 샘플 비율을 Spring Boot 네이티브 키 `management.tracing.sampling.probability`로 + 단일화(브리지)하는 `EnvironmentPostProcessor`다.** 이를 통해 "프로파일별 기본값 + env override"라는 + 우리 규약을 Spring Boot 의 기본 샘플링 메커니즘에 그대로 연결한다. +- **브리지 우선순위.** (1) 사용자가 `management.tracing.sampling.probability`를 이미 명시했으면 + 덮어쓰지 않고 그대로 둔다, (2) `ca-skeleton.tracing.sample-rate`가 non-blank 면 그 값 사용, (3) + blank 면 `TracingSampleRateResolver.defaultRateForProfile(...)`로 active profile 기본값 적용. +- **`META-INF/spring.factories`에 `org.springframework.boot.EnvironmentPostProcessor` 키로 등록된다.** + `EnvironmentPostProcessor`는 ApplicationContext 가 생성되기 전에 실행되므로 일반 빈이 아니라 + Spring Boot bootstrap factory 로 등록해야 한다. + +### MicrometerSpanErrorRecorder +- **NOOP 을 대체하는 Micrometer/OTel 기반 실제 `SpanErrorRecorder`다.** 현재 span 에 예외와 + `error.code` 태그를 기록한다. 계약 이행: `Span.error(Throwable)` 호출(OTel bridge 를 통해 + `recordException` + span status ERROR 매핑), `span.tag("error.code", errorCode)`(ca-tmpl 레지스트리 + 속성명), null `error`는 방어적 no-op, 현재 span 이 없으면 no-op. +- **"sampled span 에만 스택트레이스 부착" 정책은 의도적으로 구현하지 않았다.** Micrometer Tracing + 추상화 레벨에서는 sampled 여부를 직접 제어할 수 없기 때문이다(`Span.isNoop()`은 OTel NOOP span + 여부일 뿐 sampling 여부가 아니다). 다만 OTel SDK 의 unsampled span 은 이미 no-op 으로 처리되므로 + 명시적 sampled-only 분기가 없어도 실제 성능 부담은 없다. + +--- + +## settings — @ConfigurationProperties 검증 정책 + +이 모듈의 설정 record 들은 대체로 두 가지 검증 전략 중 하나를 고른다: **fail-fast**(잘못되면 기동 +중단)와 **warn-and-default**(경고만 남기고 안전한 기본값으로 진행). 어느 쪽을 쓰는지가 각 record 의 +핵심 결정이다. + +### BootstrapSettings +- **`appName`은 비어 있으면 시작을 실패시킨다(fail-fast).** `ca-skeleton.bootstrap.*` 바인딩이며, + 앱 이름은 운영자가 직접 제공해야 하고 합리적인 기본값이 존재하지 않으므로, 비어 있거나 blank 면 + `@NotBlank`로 막는다(Spring Boot 가 `BindValidationException`을 띄우고 컨텍스트 기동을 거부). + 다른 settings 의 "warn-and-default"와 달리 여기서 fail-fast 를 택한 이유는, 잘못된 앱 이름으로 + 조용히 기동되는 것보다 즉시 멈추는 편이 안전하기 때문이다. + +### LoggingSettings +- **모든 항목이 "warn-and-default" 정책이다.** `ca-skeleton.logging.*` 바인딩(원천은 .env). 잘못된 + 값은 경고 로그를 남기고 안전한 기본값으로 진행할 뿐 시작을 실패시키지 않는다. +- **fail-fast 대신 warn-and-default 를 택한 이유.** Logback 은 이 record 가 바인딩되기 전에 자기 + 초기화 단계에서 `<springProperty>` 바인딩으로 이미 같은 값들을 자체 기본값과 함께 소비했다. 따라서 + 이 record 의 역할은 로깅을 다시 강제하는 것이 아니라, 입력이 잘못됐을 때 운영자에게 명확한 경고를 + 표면화하는 것이다. +- **size 문자열(`maxSize`, `totalSizeCap`)은 검증하지 않는다.** 이 값들의 파싱 계약은 logback 이 + 소유하므로 logback 에게 맡긴다. + +### PrivacySettings +- **보안/감사 로그에서 `user_principal`을 가명화하는 데 쓰는 HMAC salt 를 보관한다.** + `ca-skeleton.privacy.*` 바인딩(원천 `APP_PRIVACY_PSEUDONYMIZATION_SALT`). 알고리즘은 HMAC-SHA-256 + + 90일 회전 salt 이고, 이 salt 는 `secret`-tier 값이라 prod 에서는 반드시 secret manager 에서 + 공급되어야 한다. +- **검증은 `LoggingSettings`와 일관되게 "warn-and-default"다.** blank salt 는 경고를 남기고, 명확히 + 표시된 dev sentinel(`__LOCAL_DEV_` 접두사)로 폴백해 local/test 실행이 절대 기동에 실패하지 않게 + 한다. +- **이 sentinel 을 prod 로 승격하는 것은 별도 계약이 독립적으로 + 차단한다** — prod 프로파일에서 `__LOCAL_DEV_` 값이면 시작이 실패한다. 그 게이트는 이 record 의 + 책임 밖이라 여기서 중복 강제하지 않는다. + +### RuntimeSafetySettings +- **시작 시 `StartupSafetyValidator`가 강제하는 운영 안전 토글이다.** `ca-skeleton.runtime.*` + 바인딩(원천 `APP_ERROR_DETAIL_EXPOSURE_ENABLED`, `APP_LOG_BODY_CAPTURE_ENABLED`, + `APP_MULTI_INSTANCE_ENABLED`, 모두 기본값 `false`). +- **`errorDetailExposureEnabled` / `logBodyCaptureEnabled`는 prod-unsafe 토글이다.** `prod` + 프로파일에서 둘 중 하나라도 켜면 시작이 실패한다. 그래야 내부 에러 상세나 요청 본문 캡처가 + 프로덕션에서 조용히 켜진 채 남는 일이 없다. +- **`multiInstanceEnabled`가 `true`면 인스턴스 조율 capability 빈들(lock / cache-stampede / leader / + rate-limit / migration)이 모두 존재하는지 단언하고, 하나라도 없으면 시작을 실패시킨다.** 멀티 + 인스턴스 모드를 켜놓고 조율 인프라가 빠진 채 기동되는 위험한 상태를 막기 위함이다. + +--- + +## build.gradle — 의존성 구성 근거 + +app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결정이 많다. 빌드 파일에는 한 줄 +요약만 두고, 비자명한 근거는 여기에 모은다. 바탕 원칙은 **api vs implementation 정책**(CLAUDE.md): +모듈 간 의존은 기본 `implementation`이라 transitive 로 새지 않는다 — 그래서 테스트에서 그 타입이 +필요하면 여기서 **명시적으로** 다시 선언한다. + +### 런타임(production) 의존성 +- **`flyway-core`를 직접 의존하는 이유.** PostgreSQL vendor 모듈이 `flyway-database-postgresql`와 + 마이그레이션 스크립트를 소유하지만, 합성 루트는 Flyway API 자체가 필요하다. `MigrationStartupConfig`의 + `migrationStartupRunner`(FlywayMigrationStrategy)가 `migrate()`를 직접 구동하고 `FlywayException`을 + exit-70 `MigrationFailedException`으로 번역하기 때문이다. +- **`spring-boot-starter-security`를 compile classpath 에 두는 이유.** adapter-web 이 security 를 + `implementation`(not `api`)으로 선언해서 Spring Security 타입이 app-bootstrap 컴파일 경로로 새지 + 않는다. 그런데 `ManagementSecurityConfig`가 `HttpSecurity`/`SecurityFilterChain`/`EndpointRequest`를 + 쓴다. cross-cutting 보안 와이어링은 합성 루트가 소유한다는 원칙(AGENTS.md)에 따라 여기서 직접 + 의존한다. +- **`micrometer-core`** — `OutboxMetrics` 카운터/게이지용. `ObjectProvider<MeterRegistry>`라 registry + 가 없으면 no-op 이다(registry 는 Actuator 가 제공). +- **tracing(`micrometer-tracing-bridge-otel` + `opentelemetry-exporter-otlp`)** — OTel/Micrometer + tracer 런타임. exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비면 켜진 채로 대기만 하고 export 는 + 안 한다. 버전은 Spring Boot BOM 이 관리. +- **`logstash-logback-encoder`를 `runtimeOnly`가 아니라 `implementation`으로 올린 이유.** 보통 JSON + 로그 인코더는 런타임에만 있으면 되지만, `StartupFailures`가 `StructuredArguments`를 **compile + time**에 호출해 `startup.phase`/`error.code`/`error.category` JSON 필드를 방출하므로 compile + classpath 에 있어야 한다. + +### 테스트 의존성 (transitive 로 안 새서 명시 선언) +- **`testImplementation project(':adapter-persistence-postgresql')`** — vendor 클래스 + (`PostgreSqlSqlStateErrorMapping`/`PostgreSqlOutboxClaimRepository`)를 test compile 경로에 올려, + full-matrix contract 테스트가 실제 production composition 을 배선하게 한다(런타임 의존은 `runtimeOnly`). +- **`testRuntimeOnly postgresql`** — PG JDBC 드라이버. vendor 모듈이 `runtimeOnly`로 선언해 + app-bootstrap 테스트 경로로 안 새므로 여기서 명시한다. +- **`spring-boot-starter-data-jpa` + `HikariCP`** — adapter-persistence-rdbms 가 `implementation`이라 + JPA/Hikari 가 compile 경로로 오지 않는다. outbox contract 테스트가 minimal Spring context 를 직접 + 만들어 이들이 컴파일 시점에 필요하다. +- **`spring-integration-jdbc`** — `JdbcLockRegistry`/`DefaultLockRepository`도 rdbms 의 + `implementation`이라 안 샌다. `DistributedLockProviderContractTest`가 같은 Testcontainers PG + DataSource 에 대해 독립 registry 두 개(= 두 인스턴스 시뮬레이션)를 직접 만들어 상호배제·리스 만료를 + 검증한다. 테스트 전용 — production 와이어링은 전부 `DistributedLockPersistenceConfig`(rdbms) 소유. +- **`spring-boot-starter-json`** — `JacksonAutoConfiguration`은 jackson-databind 가 test 경로에 있어야 + 활성화되고, deserialization-policy 경계 테스트가 `DeserializationFeature` enum 을 직접 읽는다. +- **Testcontainers(`postgresql`/`junit-jupiter`)** — outbox contract 테스트용. 버전은 Spring Boot + BOM 이 관리. +- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated + loggers / loggers 쓰기 거부)를 검증한다. + +### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용) +ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지 +증명한다. production classpath 엔 없어야 하므로 전부 `testCompileOnly`이고, 각 금지 glob 을 독립적으로 +증명하려고 의존성을 쪼개 둔다. +- `spring-tx` — `..architecture.violations.*`의 일반 위반 fixture. +- `spring-webmvc` / `spring-websocket` / `jakarta.websocket-api` — streaming 위반 fixture + + over-block 가드(streaming-response-contract). spring-web(`org.springframework.http..`)는 + spring-webmvc 를 통해 transitive 로 도착한다. +- `kafka-clients` / `jakarta.ws.rs-api` — transport-free 도메인 이벤트 fixture + (domain-modeling-guardrails)가 금지된 broker/wire/HTTP 패키지를 import 한다. +- `spring-cloud-context` — `@RefreshScope` 금지(no-refresh-scope) fixture 용. Spring Cloud 는 + production 에 없고 규칙은 애너테이션을 FQN 문자열로 참조한다. BOM 이 spring-cloud 좌표를 관리하지 + 않아 버전을 명시 고정한다. +- **`testImplementation project(':sample-portfolio')`** — ArchUnit 이 템플릿 reference 구현을 분석하려고 + test 경로에만 둔다. **production 은 절대 sample-portfolio 에 의존 금지** — + `production_code_does_not_depend_on_sample_portfolio` 규칙이 강제한다. + +### 빌드 설정 +- **test JVM UTC 고정(`-Duser.timezone=UTC`)** — `RuntimeHealthLifecycleContractTest`가 + `TimeZone.getDefault().getID() == "UTC"`를 단언해, 호스트 로케일과 무관하게 타임스탬프 산술이 + 결정적이게 한다. production UTC 는 여기서 강제하지 않으며 container-runtime-contract(Dockerfile + `TZ=UTC`)가 소유한다. +- **`bootRun.workingDir = rootProject.projectDir`** — `src/.env`를 읽도록 Gradle 루트(src/)에서 + 실행한다. diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle new file mode 100644 index 00000000..bc66a677 --- /dev/null +++ b/src/app-bootstrap/build.gradle @@ -0,0 +1,192 @@ +// Application entry point. Wires every module together and runs Spring Boot. +apply plugin: 'org.springframework.boot' + +// The sample fixture (sampleFixture -> sample-portfolio -> objectstorage) pulls software.amazon.awssdk:s3 +// onto the sample-on test classpath; its version is managed by the AWS SDK v2 BOM (NOT the Spring Boot +// BOM). Import that BOM at this module's scope so the transitive s3 dependency resolves for the ArchUnit +// sample-on analysis. AWS-SDK version SSOT = ext.awsSdkVersion. +dependencyManagement { + imports { + mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}" + } +} + +// feature-sample-removal-adoption-contract D7 — ordinary tests may inspect the sample fixture, +// while sampleOffTest compiles the same core suite without the sample project on either classpath. +configurations { + sampleFixture { + canBeConsumed = false + canBeResolved = false + } +} + +sourceSets { + sampleOffTest { + java.srcDirs = sourceSets.test.java.srcDirs + resources.srcDirs = sourceSets.test.resources.srcDirs + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +configurations { + testCompileClasspath.extendsFrom sampleFixture + testRuntimeClasspath.extendsFrom sampleFixture + sampleOffTestImplementation.extendsFrom testImplementation + sampleOffTestCompileOnly.extendsFrom testCompileOnly + sampleOffTestRuntimeOnly.extendsFrom testRuntimeOnly + sampleOffTestAnnotationProcessor.extendsFrom testAnnotationProcessor +} + +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':adapter:outbound:persistence-jpa') + implementation project(':adapter:outbound:support') + implementation project(':adapter:outbound:messaging') + implementation project(':adapter:outbound:cache-redis') + implementation project(':adapter:outbound:notification') + implementation project(':adapter:outbound:httpclient') + implementation project(':adapter:outbound:identifier') + implementation project(':adapter:inbound:web') + implementation project(':shared-contract') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'me.paulschwarz:spring-dotenv:4.0.0' + // Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration + // (MigrationStartupConfig). See README. + implementation 'org.springframework.boot:spring-boot-flyway' + // Flyway API: MigrationStartupRunner directly invokes Flyway. Kept explicit for readability. + implementation 'org.flywaydb:flyway-core' + + // Micrometer core for OutboxMetrics meters (no-op without a MeterRegistry). See README. + implementation 'io.micrometer:micrometer-core' + + // OTel/Micrometer tracer runtime (exporter stays off until an OTLP endpoint is set). See README. + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + + // Actuator + Prometheus registry (health/info/prometheus/loggers endpoints). + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + // Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README. + implementation 'org.springframework.boot:spring-boot-starter-security' + + // test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README. + testImplementation 'org.springframework.boot:spring-boot-starter-actuator' + // test-only: @WithMockUser for the actuator security authorization tests. See README. + testImplementation 'org.springframework.security:spring-security-test' + + // test-only: Testcontainers PostgreSQL for the outbox contract tests. + testImplementation 'org.testcontainers:testcontainers-postgresql' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + // test-only: Boot 4 split JPA slice annotations into dedicated test modules. + testImplementation 'org.springframework.boot:spring-boot-data-jpa-test' + // test-only: PG JDBC driver (the vendor module's runtimeOnly does not leak here). See README. + testRuntimeOnly 'org.postgresql:postgresql' + // test-only: JPA/Hikari for the outbox tests' minimal context (not leaked from rdbms). See README. + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa' + testImplementation 'com.zaxxer:HikariCP' + + // test-only: JdbcLockRegistry for the distributed-lock contract test (two simulated instances). See README. + testImplementation 'org.springframework.integration:spring-integration-jdbc' + + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + // test-only: jackson-databind for the deserialization-policy boundary test. See README. + testImplementation 'org.springframework.boot:spring-boot-starter-json' + // sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it. + sampleFixture project(':sample-portfolio') + // test-only: ArchUnit violation fixtures intentionally import forbidden types. See README. + testCompileOnly 'org.springframework:spring-tx' + // test-only: streaming/websocket violation fixtures import these forbidden packages. See README. + testCompileOnly 'org.springframework:spring-webmvc' // SseEmitter, ResponseBodyEmitter, StreamingResponseBody + testCompileOnly 'org.springframework:spring-websocket' // org.springframework.web.socket.. + testCompileOnly 'jakarta.websocket:jakarta.websocket-api' // jakarta.websocket.. + // test-only: transport-free domain-event fixtures import these forbidden broker/wire packages. See README. + testCompileOnly 'org.apache.kafka:kafka-clients' // org.apache.kafka.. + testCompileOnly 'jakarta.ws.rs:jakarta.ws.rs-api' // jakarta.ws.rs.. + // test-only: @RefreshScope for the no-refresh-scope violation fixture (version pinned; not in the BOM). See README. + testCompileOnly 'org.springframework.cloud:spring-cloud-context:4.1.4' // org.springframework.cloud.context.. + + // JSON log encoder; implementation (not runtimeOnly) because StartupFailures uses StructuredArguments at compile time. See README. + implementation 'net.logstash.logback:logstash-logback-encoder:8.0' + + // test-only: ApprovalTests JSON snapshot verification for the contract-verification suite + // (feature-contract-verification-test-suite D7 — envelope/error shape snapshots). See README. + testImplementation 'com.approvaltests:approvaltests:31.0.0' + // test-only: JUnit Platform Test Kit — proves optional-adapter tests report SKIPPED (never FAILED) + // when their enable-flag env var is unset (feature-contract-verification-test-suite D3, Claims #7). See README. + testImplementation 'org.junit.platform:junit-platform-testkit' +} + +// Pin UTC for the TEST JVM so timestamp tests are host-locale-independent (production UTC owned elsewhere). See README. +tasks.named('test') { + jvmArgs '-Duser.timezone=UTC' +} + +tasks.register('sampleOffTest', Test) { + group = 'verification' + description = 'Compiles and runs the core test suite without sample-portfolio on the classpath.' + testClassesDirs = sourceSets.sampleOffTest.output.classesDirs + classpath = sourceSets.sampleOffTest.runtimeClasspath + useJUnitPlatform { + excludeTags 'quarantine' + } + shouldRunAfter tasks.named('test') + outputs.upToDateWhen { false } + systemProperty 'ca.sample.mode', 'off' + jvmArgs '-Duser.timezone=UTC' +} + +// The custom source set compiles the same test corpus, so it follows the repository-wide +// warning-only policy already applied to checkstyleTest and spotbugsTest in the root build. +tasks.named('checkstyleSampleOffTest') { + ignoreFailures = true +} + +tasks.named('spotbugsSampleOffTest') { + ignoreFailures = true +} + +// feature-developer-experience-contract D2/D7 delegation: the DX entrypoint verifies production +// isolation and the sample-on build contract. sample-off is the separate sampleOffTest task. +tasks.register('bootstrapSampleContract', Test) { + group = 'developer experience' + description = 'Runs the delegated sample production-isolation/build contract for bootstrap.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching 'dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest' + } + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +bootJar { + mainClass = 'dev.caskeleton.bootstrap.CaSkeletonApplication' +} + +// Run from the repo's src/ root and inject src/.env into the Java process environment. +// Boot 4 initializes profiles/logging before spring-dotenv can reliably contribute .env values. +bootRun { + workingDir = rootProject.projectDir + doFirst { + File envFile = rootProject.file('.env') + if (!envFile.isFile()) { + return + } + envFile.eachLine { raw -> + String line = raw.trim() + if (line.isEmpty() || line.startsWith('#') || !line.contains('=')) { + return + } + int separator = line.indexOf('=') + String key = line.substring(0, separator).trim() + String value = line.substring(separator + 1).trim() + if (!key.isEmpty() && System.getenv(key) == null && !environment.containsKey(key)) { + environment key, value + } + } + } +} diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile new file mode 100644 index 00000000..122fb5bd --- /dev/null +++ b/src/app-bootstrap/gradle.lockfile @@ -0,0 +1,464 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.34=sampleFixture +ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.34=sampleFixture +com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture +com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture +com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture +com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.3=sampleFixture +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.f4b6a3:uuid-creator:6.1.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=sampleFixture,spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=sampleFixture +com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=sampleFixture +com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=sampleFixture +com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:6.3.3=sampleFixture +com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.1.4=sampleFixture +io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.15.12=sampleFixture +io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.15.12=sampleFixture +io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture +io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.15.12=sampleFixture +io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture +io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture +io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.5.12=sampleFixture +io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-compression:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-http2:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-http:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.7.Final=testRuntimeClasspath +io.netty:netty-codec:4.2.7.Final=testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.7.Final=testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-common:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp-common:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-extension-trace-propagators:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-logs:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture +io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.7.19=sampleFixture +io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture +io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture +io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.3.10=sampleFixture +io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.3.10=sampleFixture +io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.3.10=sampleFixture +io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture +io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.smallrye:jandex:3.2.0=sampleFixture +io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.1.0=sampleFixture +jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath +jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture +javax.inject:javax.inject:1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath +org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath +org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath +org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:10.1.55=sampleFixture +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=sampleOffTestCompileClasspath,testCompileClasspath +org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.aspectj:aspectjweaver:1.9.25.1=sampleFixture +org.assertj:assertj-core:3.27.6=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.7.2=sampleFixture +org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture +org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture +org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture +org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.9=sampleFixture +org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture +org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture +org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture +org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture +org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +org.postgresql:postgresql:42.7.11=sampleFixture +org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reflections:reflections:0.10.2=checkstyle +org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=sampleFixture +org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=sampleFixture +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springdoc:springdoc-openapi-starter-common:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture +org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:3.5.16=sampleFixture +org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath +org.springframework.data:spring-data-commons:3.5.13=sampleFixture +org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:3.5.13=sampleFixture +org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:6.5.10=sampleFixture +org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture +org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.retry:spring-retry:2.0.13=sampleFixture +org.springframework.security:spring-security-config:6.5.11=sampleFixture +org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:6.5.11=sampleFixture +org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:6.5.11=sampleFixture +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture +org.springframework.security:spring-security-oauth2-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture +org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:6.5.11=sampleFixture +org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:6.2.19=sampleFixture +org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:6.2.19=sampleFixture +org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:6.2.19=sampleFixture +org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:6.2.19=sampleFixture +org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:6.2.19=sampleFixture +org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:6.2.19=sampleFixture +org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jcl:6.2.19=sampleFixture +org.springframework:spring-jdbc:6.2.19=sampleFixture +org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:6.2.19=sampleFixture +org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:6.2.19=sampleFixture +org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:6.2.19=sampleFixture +org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:6.2.19=sampleFixture +org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:6.2.19=sampleFixture +org.springframework:spring-webmvc:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-websocket:7.0.1=sampleOffTestCompileClasspath,testCompileClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.4=sampleFixture +org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath +software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath +software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath +software.amazon.awssdk:auth:2.30.0=testRuntimeClasspath +software.amazon.awssdk:aws-core:2.30.0=testRuntimeClasspath +software.amazon.awssdk:aws-query-protocol:2.30.0=testRuntimeClasspath +software.amazon.awssdk:aws-xml-protocol:2.30.0=testRuntimeClasspath +software.amazon.awssdk:checksums-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:checksums:2.30.0=testRuntimeClasspath +software.amazon.awssdk:crt-core:2.30.0=testRuntimeClasspath +software.amazon.awssdk:endpoints-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=testRuntimeClasspath +software.amazon.awssdk:http-auth-aws:2.30.0=testRuntimeClasspath +software.amazon.awssdk:http-auth-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:http-auth:2.30.0=testRuntimeClasspath +software.amazon.awssdk:http-client-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:identity-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:json-utils:2.30.0=testRuntimeClasspath +software.amazon.awssdk:metrics-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:netty-nio-client:2.30.0=testRuntimeClasspath +software.amazon.awssdk:profiles:2.30.0=testRuntimeClasspath +software.amazon.awssdk:protocol-core:2.30.0=testRuntimeClasspath +software.amazon.awssdk:regions:2.30.0=testRuntimeClasspath +software.amazon.awssdk:retries-spi:2.30.0=testRuntimeClasspath +software.amazon.awssdk:retries:2.30.0=testRuntimeClasspath +software.amazon.awssdk:s3:2.30.0=testRuntimeClasspath +software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath +software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath +software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath +software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java new file mode 100644 index 00000000..3f8f0c26 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java @@ -0,0 +1,28 @@ +package dev.caskeleton.bootstrap; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication( + scanBasePackages = { + "dev.caskeleton.bootstrap", + "dev.caskeleton.adapter", + "dev.caskeleton.application", + "dev.caskeleton.domain", + "dev.caskeleton.shared" + }) +@ConfigurationPropertiesScan( + basePackages = { + "dev.caskeleton.bootstrap", + "dev.caskeleton.adapter", + "dev.caskeleton.application", + "dev.caskeleton.domain", + "dev.caskeleton.shared" + }) +public class CaSkeletonApplication { + + public static void main(String[] args) { + SpringApplication.run(CaSkeletonApplication.class, args); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncContextTaskDecorator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncContextTaskDecorator.java new file mode 100644 index 00000000..9e13f7e2 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncContextTaskDecorator.java @@ -0,0 +1,47 @@ +package dev.caskeleton.bootstrap.async; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import java.util.Map; +import org.jspecify.annotations.NonNull; +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +/** + * Carries caller-thread context (MDC + domain context) across the executor boundary onto the worker + * thread. The {@code SecurityContext} principal is deliberately NOT propagated here. See README for + * the design rationale. + */ +public final class AsyncContextTaskDecorator implements TaskDecorator { + + private final DomainContextPropagator domainContextPropagator; + + public AsyncContextTaskDecorator(DomainContextPropagator domainContextPropagator) { + this.domainContextPropagator = domainContextPropagator; + } + + @Override + public @NonNull Runnable decorate(@NonNull Runnable runnable) { + // Capture the caller-thread context NOW (at submit time), not when the task runs. + Map<String, String> callerMdc = MDC.getCopyOfContextMap(); + // Domain context capture/restore/revert is owned by the shared seam. + Runnable domainWrapped = domainContextPropagator.wrap(runnable); + return () -> { + Map<String, String> previousMdc = MDC.getCopyOfContextMap(); + setOrClearMdc(callerMdc); + try { + domainWrapped.run(); + } finally { + // Revert so a pooled worker thread does not carry this task's MDC into the next. + setOrClearMdc(previousMdc); + } + }; + } + + private static void setOrClearMdc(Map<String, String> context) { + if (context != null) { + MDC.setContextMap(context); + } else { + MDC.clear(); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfig.java new file mode 100644 index 00000000..ec45a6b3 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfig.java @@ -0,0 +1,60 @@ +package dev.caskeleton.bootstrap.async; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Wires the async executor. Replaces Spring Boot's auto-configured {@code applicationTaskExecutor} + * with a bounded pool; naming the bean {@value #EXECUTOR_BEAN_NAME} backs off Boot's auto-config + * and makes this the executor {@code @Async} resolves to. See README for the design rationale. + */ +@Configuration +@EnableAsync +@EnableConfigurationProperties(AsyncExecutorSettings.class) +public class AsyncExecutorConfig { + + /** Spring's default {@code @Async} executor bean name (Boot's auto-config backs off). */ + public static final String EXECUTOR_BEAN_NAME = "applicationTaskExecutor"; + + /** Shutdown await: container app-shutdown budget (20s) minus a 1s cleanup margin. */ + static final int AWAIT_TERMINATION_SECONDS = 19; + + @Bean + TaskDecorator asyncContextTaskDecorator(DomainContextPropagator domainContextPropagator) { + return new AsyncContextTaskDecorator(domainContextPropagator); + } + + @Bean + BackgroundJobMetrics backgroundJobMetrics(ObjectProvider<MeterRegistry> meterRegistryProvider) { + return new BackgroundJobMetrics(meterRegistryProvider); + } + + @Bean(name = EXECUTOR_BEAN_NAME) + @Primary + ThreadPoolTaskExecutor applicationTaskExecutor( + AsyncExecutorSettings settings, + TaskDecorator asyncContextTaskDecorator, + BackgroundJobMetrics metrics) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix("app-async-"); + executor.setCorePoolSize(settings.coreSize()); + executor.setMaxPoolSize(settings.maxSize()); + executor.setQueueCapacity(settings.queueCapacity()); + executor.setTaskDecorator(asyncContextTaskDecorator); + executor.setRejectedExecutionHandler(new LoggingAbortPolicy(EXECUTOR_BEAN_NAME, metrics)); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + executor.initialize(); + metrics.registerSaturationGauge( + EXECUTOR_BEAN_NAME, () -> executor.getThreadPoolExecutor().getQueue().size()); + return executor; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorSettings.java new file mode 100644 index 00000000..4cf2675b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorSettings.java @@ -0,0 +1,64 @@ +package dev.caskeleton.bootstrap.async; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Async executor pool sizing knobs, bound from {@code ca-skeleton.async.executor.*}. Rejects {@code + * Integer.MAX_VALUE} as a queue capacity so the queue stays bounded. See README for the design + * rationale. + * + * @param coreSize always-alive worker count (≥ 1) + * @param maxSize hard ceiling on workers (≥ 1 and ≥ {@code coreSize}) + * @param queueCapacity bounded backlog depth (1 ≤ capacity < {@code Integer.MAX_VALUE}) + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.async.executor") +public record AsyncExecutorSettings(Integer coreSize, Integer maxSize, Integer queueCapacity) { + + private static final int DEFAULT_CORE_SIZE = 10; + private static final int DEFAULT_MAX_SIZE = 50; + private static final int DEFAULT_QUEUE_CAPACITY = 200; + + public AsyncExecutorSettings { + if (coreSize == null) { + coreSize = DEFAULT_CORE_SIZE; + } else if (coreSize < 1) { + throw StartupFailures.envValidation( + "APP_ASYNC_EXECUTOR_CORE_SIZE (ca-skeleton.async.executor.core-size) must be >= 1 " + + "(positive_int), was " + + coreSize); + } + if (queueCapacity == null) { + queueCapacity = DEFAULT_QUEUE_CAPACITY; + } else if (queueCapacity < 1) { + throw StartupFailures.envValidation( + "APP_ASYNC_EXECUTOR_QUEUE_CAPACITY (ca-skeleton.async.executor.queue-capacity) must be >= 1 " + + "(positive_int_bounded), was " + + queueCapacity); + } else if (queueCapacity == Integer.MAX_VALUE) { + throw StartupFailures.envValidation( + "APP_ASYNC_EXECUTOR_QUEUE_CAPACITY (ca-skeleton.async.executor.queue-capacity) must be bounded " + + "(< Integer.MAX_VALUE) — an unbounded queue makes max-size unreachable and is " + + "forbidden (D7, positive_int_bounded)"); + } + if (maxSize == null) { + maxSize = DEFAULT_MAX_SIZE; + } else if (maxSize < 1) { + throw StartupFailures.envValidation( + "APP_ASYNC_EXECUTOR_MAX_SIZE (ca-skeleton.async.executor.max-size) must be >= 1 " + + "(positive_int_ge_core), was " + + maxSize); + } + // cross-field invariant: max must not be below core. + if (maxSize < coreSize) { + throw StartupFailures.envValidation( + "APP_ASYNC_EXECUTOR_MAX_SIZE (ca-skeleton.async.executor.max-size=" + + maxSize + + ") must be >= APP_ASYNC_EXECUTOR_CORE_SIZE (core-size=" + + coreSize + + ") (positive_int_ge_core)"); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/BackgroundJobMetrics.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/BackgroundJobMetrics.java new file mode 100644 index 00000000..4fbbe27e --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/BackgroundJobMetrics.java @@ -0,0 +1,89 @@ +package dev.caskeleton.bootstrap.async; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.function.DoubleSupplier; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; + +/** + * Background-job / async-executor metric recorder. The {@code job.*} recorders are the retry/DLQ + * vocabulary for future retry carriers to consume. With no {@code MeterRegistry} bean (no Actuator + * on the classpath) every operation is a no-op. See README for the design rationale. + */ +public final class BackgroundJobMetrics { + + private static final Logger log = LoggerFactory.getLogger(BackgroundJobMetrics.class); + + static final String EXECUTOR_SATURATION = "executor.saturation"; + static final String EXECUTOR_REJECTED = "executor.rejected.total"; + static final String JOB_RETRY = "job.retry.total"; + static final String JOB_DLQ = "job.dlq.total"; + + static final String TAG_EXECUTOR_NAME = "executor_name"; + static final String TAG_POLICY = "policy"; + static final String TAG_JOB_NAME = "job_name"; + static final String TAG_OUTCOME = "outcome"; + + private final MeterRegistry registry; // null when no Actuator on classpath + + public BackgroundJobMetrics(ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.registry = meterRegistryProvider.getIfAvailable(); + } + + /** + * Registers the {@code executor.saturation} gauge for {@code executorName}, reading the current + * queue depth from {@code queueDepth} on each scrape. No-op without a registry. + */ + public void registerSaturationGauge(String executorName, DoubleSupplier queueDepth) { + if (registry == null) { + return; + } + Gauge.builder(EXECUTOR_SATURATION, queueDepth, DoubleSupplier::getAsDouble) + .tag(TAG_EXECUTOR_NAME, executorName) + .description("Current async executor queue depth") + .register(registry); + } + + public void recordRejection(String executorName, String policy) { + increment( + EXECUTOR_REJECTED, + () -> + Counter.builder(EXECUTOR_REJECTED) + .tag(TAG_EXECUTOR_NAME, executorName) + .tag(TAG_POLICY, policy)); + } + + public void recordRetryOutcome(String jobName, RetryOutcome outcome) { + increment( + JOB_RETRY, + () -> + Counter.builder(JOB_RETRY).tag(TAG_JOB_NAME, jobName).tag(TAG_OUTCOME, outcome.name())); + } + + public void recordDeadLetter(String jobName) { + increment(JOB_DLQ, () -> Counter.builder(JOB_DLQ).tag(TAG_JOB_NAME, jobName)); + } + + private void increment(String meterName, Supplier<Counter.Builder> builder) { + if (registry == null) { + return; + } + try { + builder.get().register(registry).increment(); + } catch (RuntimeException ex) { + log.warn("background-job metrics: failed to record counter {}", meterName, ex); + } + } + + /** Retry-cycle outcome tag values for {@code job.retry.total} (bounded to 4). */ + public enum RetryOutcome { + SUCCESS, + RETRY, + EXHAUSTED, + DLQ + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/LoggingAbortPolicy.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/LoggingAbortPolicy.java new file mode 100644 index 00000000..3cdd92f1 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/async/LoggingAbortPolicy.java @@ -0,0 +1,52 @@ +package dev.caskeleton.bootstrap.async; + +import static net.logstash.logback.argument.StructuredArguments.kv; + +import dev.caskeleton.shared.error.OperationalError; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The async executor's saturation policy. An {@code AbortPolicy} that logs and increments a + * rejection counter, then re-throws {@link RejectedExecutionException} so a fire-and-forget + * {@code @Async} caller's rejection is not silently swallowed. See README for the design rationale. + */ +public final class LoggingAbortPolicy implements RejectedExecutionHandler { + + private static final Logger log = LoggerFactory.getLogger(LoggingAbortPolicy.class); + + /** Tag value for {@code executor.rejected.total{policy}} (metrics.yaml allowed set). */ + static final String POLICY = "AbortPolicy"; + + private final String executorName; + private final BackgroundJobMetrics metrics; + + public LoggingAbortPolicy(String executorName, BackgroundJobMetrics metrics) { + this.executorName = executorName; + this.metrics = metrics; + } + + @Override + public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) { + metrics.recordRejection(executorName, POLICY); + log.error( + "async executor rejected task — pool saturated (core/max/queue exhausted): {} {} {} {} {}", + kv("error.code", OperationalError.JOB_EXECUTOR_REJECTED.code()), + kv("error.category", OperationalError.JOB_EXECUTOR_REJECTED.category().name()), + kv("executor_name", executorName), + kv("policy", POLICY), + kv("queue_size", executor.getQueue().size())); + // Preserve AbortPolicy semantics: the caller must see the rejection. + throw new RejectedExecutionException( + "Task " + + task + + " rejected from async executor '" + + executorName + + "' (" + + OperationalError.JOB_EXECUTOR_REJECTED.code() + + ")"); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextConfig.java new file mode 100644 index 00000000..27046361 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextConfig.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.concurrency; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Wires the {@link DomainContextPropagator} bean. See README for the design rationale. */ +@Configuration +@EnableConfigurationProperties(DomainContextSettings.class) +public class DomainContextConfig { + + @Bean + public DomainContextPropagator domainContextPropagator(DomainContextSettings properties) { + return DomainContextPropagatorFactory.create(properties.strategy()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextSettings.java new file mode 100644 index 00000000..163cdd74 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextSettings.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.concurrency; + +import dev.caskeleton.shared.concurrency.DomainContextStrategy; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Domain-context propagation strategy selection. See README for the design rationale. + * + * @param strategy the propagation strategy; {@code null} → {@code THREAD_LOCAL} + */ +@ConfigurationProperties(prefix = "ca-skeleton.domain-context") +public record DomainContextSettings(DomainContextStrategy strategy) { + + public DomainContextSettings { + if (strategy == null) { + strategy = DomainContextStrategy.THREAD_LOCAL; + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java new file mode 100644 index 00000000..bea76cea --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java @@ -0,0 +1,28 @@ +package dev.caskeleton.bootstrap.idempotency; + +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Wires idempotency execution. {@code @EnableScheduling} activates the {@code IdempotencyReaper}'s + * scheduled purge. See README for the design rationale. + */ +@Configuration +@EnableScheduling +public class IdempotencyConfig { + + @Bean + public Clock systemClock() { + return Clock.systemUTC(); + } + + @Bean + public IdempotencyExecutor idempotencyExecutor( + IdempotencyStorePort store, Clock clock, IdempotencySettings properties) { + return new IdempotencyExecutor(store, clock, properties.ttl()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencySettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencySettings.java new file mode 100644 index 00000000..c864dd0b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencySettings.java @@ -0,0 +1,37 @@ +package dev.caskeleton.bootstrap.idempotency; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Idempotency runtime knobs. The {@code <= 72h} TTL cap is enforced fail-fast in the compact + * constructor. See README for the design rationale. + * + * @param ttl default idempotency record TTL (≤ 72h) + * @param reaperInterval how often the expired-record reaper runs + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.idempotency") +public record IdempotencySettings(Duration ttl, Duration reaperInterval) { + + private static final Duration MAX_TTL = Duration.ofHours(72); + + public IdempotencySettings { + if (ttl == null) { + ttl = Duration.ofHours(24); + } + if (ttl.isZero() || ttl.isNegative()) { + throw StartupFailures.envValidation( + "APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be positive, was " + ttl); + } + if (ttl.compareTo(MAX_TTL) > 0) { + throw StartupFailures.envValidation( + "APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be <= 72h (D6), was " + ttl); + } + if (reaperInterval == null || reaperInterval.isZero() || reaperInterval.isNegative()) { + reaperInterval = Duration.ofMinutes(10); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/lock/DistributedLockConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/lock/DistributedLockConfig.java new file mode 100644 index 00000000..5ae09f17 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/lock/DistributedLockConfig.java @@ -0,0 +1,34 @@ +package dev.caskeleton.bootstrap.lock; + +import dev.caskeleton.application.lock.DistributedLockPort; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * Composition-root wiring for the distributed-lock metrics decorator. See README for the design + * rationale. + */ +@Configuration(proxyBeanMethods = false) +public class DistributedLockConfig { + + /** + * The bean name {@code "distributedLockProvider"} is the contract with {@code + * StartupSafetyValidator} — do not rename. See README for the design rationale. + */ + @Bean(name = "distributedLockProvider") + @Primary + @ConditionalOnProperty( + prefix = "ca-skeleton.runtime", + name = "multi-instance-enabled", + havingValue = "true") + DistributedLockPort distributedLockProvider( + @Qualifier("jdbcDistributedLock") DistributedLockPort jdbcDistributedLock, + ObjectProvider<MeterRegistry> meterRegistry) { + return new MeteredDistributedLockPort(jdbcDistributedLock, meterRegistry); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/lock/MeteredDistributedLockPort.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/lock/MeteredDistributedLockPort.java new file mode 100644 index 00000000..6ec2bd1b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/lock/MeteredDistributedLockPort.java @@ -0,0 +1,99 @@ +package dev.caskeleton.bootstrap.lock; + +import dev.caskeleton.application.lock.DistributedLock; +import dev.caskeleton.application.lock.DistributedLockPort; +import dev.caskeleton.application.lock.LockAcquisitionTimeoutException; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.Duration; +import java.util.ConcurrentModificationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; + +/** + * Metrics decorator for {@link DistributedLockPort}. Metric failures are logged-and-swallowed so + * they never affect the lock path. See README for the design rationale. + */ +public final class MeteredDistributedLockPort implements DistributedLockPort { + + private static final Logger log = LoggerFactory.getLogger(MeteredDistributedLockPort.class); + + static final String LOCK_ACQUISITION = "lock.acquisition"; + static final String TAG_OUTCOME = "outcome"; + static final String OUTCOME_ACQUIRED = "acquired"; + static final String OUTCOME_TIMEOUT = "timeout"; + static final String OUTCOME_ERROR = "error"; + + static final String LOCK_LEASE_EXPIRED = "lock.lease.expired"; + + private final DistributedLockPort delegate; + private final MeterRegistry registry; // null when no MeterRegistry on classpath + + public MeteredDistributedLockPort( + DistributedLockPort delegate, ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.delegate = delegate; + this.registry = meterRegistryProvider.getIfAvailable(); + } + + @Override + public DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl) { + try { + DistributedLock handle = delegate.tryAcquire(key, waitTime, leaseTtl); + increment(OUTCOME_ACQUIRED); + return () -> closeHandlingLeaseExpiry(key, handle); + } catch (LockAcquisitionTimeoutException e) { + increment(OUTCOME_TIMEOUT); + throw e; + } catch (RuntimeException e) { + increment(OUTCOME_ERROR); + throw e; + } + } + + /** + * Absorbs a lease-expiry {@link ConcurrentModificationException} on close so it does not disrupt + * the caller's {@code finally} block; any other exception propagates unchanged. See README for + * the design rationale. + */ + private void closeHandlingLeaseExpiry(String key, DistributedLock handle) { + try { + handle.close(); + } catch (ConcurrentModificationException e) { + // Lease expired before release: the lock row was reclaimed, so another instance may + // have entered the critical section. Surface it (log + metric) but return normally. + log.warn( + "distributed lock '{}' had already been released by lease expiry before close() " + + "— another instance may have entered the critical section (D6 efficiency-lock boundary)", + key, + e); + incrementLeaseExpired(); + } + } + + private void increment(String outcome) { + if (registry == null) { + return; + } + try { + Counter.builder(LOCK_ACQUISITION).tag(TAG_OUTCOME, outcome).register(registry).increment(); + } catch (RuntimeException ex) { + log.warn( + "distributed-lock metrics: failed to record counter {}[outcome={}]", + LOCK_ACQUISITION, + outcome, + ex); + } + } + + private void incrementLeaseExpired() { + if (registry == null) { + return; + } + try { + Counter.builder(LOCK_LEASE_EXPIRED).register(registry).increment(); + } catch (RuntimeException ex) { + log.warn("distributed-lock metrics: failed to record counter {}", LOCK_LEASE_EXPIRED, ex); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/LogMaskingPatterns.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/LogMaskingPatterns.java new file mode 100644 index 00000000..4fa092d9 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/LogMaskingPatterns.java @@ -0,0 +1,74 @@ +package dev.caskeleton.bootstrap.logging; + +import com.fasterxml.jackson.core.JsonStreamContext; +import java.util.List; +import java.util.regex.Pattern; +import net.logstash.logback.mask.ValueMasker; + +/** + * SSOT for secret-masking regex rules (shared by the JSON-encoder and pattern-layout paths). See + * README for the design rationale. + */ +public final class LogMaskingPatterns { + + public static final String MASK = "****"; + + private record Rule(Pattern pattern, String replacement) {} + + // Value char class shared by the key=value / authorization rules: a secret token runs until + // the next whitespace, quote, comma, ampersand, or closing brace (JSON / query / kv delimiters). + private static final String VALUE = "[^\\s\"',&}]+"; + private static final String SEP = "[\"']?\\s*[:=]\\s*[\"']?"; + + private static final List<Rule> RULES = + List.of( + // 1. key=value / "key":"value" secrets — keep the key + separator, mask the value. + new Rule( + Pattern.compile( + "(?i)(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token" + + "|refresh[_-]?token|client[_-]?secret)(" + + SEP + + ")(" + + VALUE + + ")"), + "$1$2" + MASK), + // 2. Authorization header (optional auth scheme kept) — mask the credential. + new Rule( + Pattern.compile( + "(?i)(authorization" + SEP + ")((?:bearer|basic|negotiate)\\s+)?(" + VALUE + ")"), + "$1$2" + MASK), + // 3. Standalone bearer token not preceded by an "authorization" key. + new Rule(Pattern.compile("(?i)(bearer\\s+)([A-Za-z0-9._~+/=-]{8,})"), "$1" + MASK)); + + private LogMaskingPatterns() {} + + /** + * Returns {@code input} unchanged (same reference) when nothing matched; never returns {@code + * null} for a non-null argument. + */ + public static String mask(String input) { + if (input == null || input.isEmpty()) { + return input; + } + String out = input; + for (Rule rule : RULES) { + out = rule.pattern().matcher(out).replaceAll(rule.replacement()); + } + return out; + } + + /** + * Adapts {@link #mask(String)} to the logstash {@link ValueMasker} SPI. Returns the original + * object reference when nothing was masked so the decorator writes the value untouched. + */ + public static ValueMasker valueMasker() { + return (JsonStreamContext context, Object value) -> { + if (value instanceof CharSequence cs) { + String original = cs.toString(); + String masked = mask(original); + return masked.equals(original) ? value : masked; + } + return value; + }; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppender.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppender.java new file mode 100644 index 00000000..b5da889a --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppender.java @@ -0,0 +1,33 @@ +package dev.caskeleton.bootstrap.logging; + +import ch.qos.logback.classic.AsyncAppender; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import io.micrometer.core.instrument.Metrics; + +/** + * {@link AsyncAppender} that counts log events dropped under back-pressure. Published through + * {@link Metrics#globalRegistry} because Logback initializes before the Spring context. See README + * for the design rationale. + */ +public class MetricsAsyncAppender extends AsyncAppender { + + static final String DROPPED_METER = "log.appender.dropped.total"; + + @Override + protected void append(ILoggingEvent eventObject) { + if (isQueueBelowDiscardingThreshold() && isDiscardable(eventObject)) { + Level level = eventObject.getLevel(); + if (level == Level.INFO || level == Level.DEBUG) { + Metrics.counter(DROPPED_METER, "appender", appenderName(), "level", level.toString()) + .increment(); + } + } + super.append(eventObject); + } + + private String appenderName() { + String name = getName(); + return name == null ? "unknown" : name; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfig.java new file mode 100644 index 00000000..9bb9f221 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfig.java @@ -0,0 +1,25 @@ +package dev.caskeleton.bootstrap.logging; + +import dev.caskeleton.adapter.outbound.identifier.HmacUserPrincipalPseudonymizer; +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.bootstrap.settings.PrivacySettings; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Composition-root wiring for {@code user_principal} pseudonymization. + * {@code @ConditionalOnMissingBean} lets a forking project substitute its own pseudonymizer. See + * README for the design rationale. + */ +@Configuration +@EnableConfigurationProperties(PrivacySettings.class) +public class PseudonymizationConfig { + + @Bean + @ConditionalOnMissingBean + UserPrincipalPseudonymizerPort userPrincipalPseudonymizer(PrivacySettings privacySettings) { + return new HmacUserPrincipalPseudonymizer(privacySettings.saltBytes()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilter.java new file mode 100644 index 00000000..4240b0cb --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilter.java @@ -0,0 +1,58 @@ +package dev.caskeleton.bootstrap.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; +import java.util.concurrent.ThreadLocalRandom; +import org.slf4j.Marker; + +/** + * Level-aware log sampler. {@code WARN}/{@code ERROR} are never sampled because they are + * diagnostic/incident signals that must be guaranteed; only {@code INFO} and below are sampled. See + * README for the design rationale. + */ +public class SamplingTurboFilter extends TurboFilter { + + private static final double KEEP_ALL = 1.0d; + + private volatile double rate = KEEP_ALL; + + /** Logback Joran setter — {@code <rate>${LOG_SAMPLING_RATE}</rate>}. */ + public void setRate(double rate) { + this.rate = rate; + } + + public double getRate() { + return rate; + } + + @Override + public void start() { + if (rate < 0.0d || rate > 1.0d || Double.isNaN(rate)) { + addWarn( + "APP_LOG_SAMPLING_RATE must be in [0.0, 1.0] (got " + rate + "); using 1.0 (keep all)"); + rate = KEEP_ALL; + } + super.start(); + } + + @Override + public FilterReply decide( + Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) { + if (!isStarted() || level == null) { + return FilterReply.NEUTRAL; + } + if (level.toInt() >= Level.WARN_INT) { + return FilterReply.NEUTRAL; + } + // <= INFO: keep with probability `rate`. + if (rate >= KEEP_ALL) { + return FilterReply.NEUTRAL; + } + if (rate <= 0.0d) { + return FilterReply.DENY; + } + return ThreadLocalRandom.current().nextDouble() < rate ? FilterReply.NEUTRAL : FilterReply.DENY; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingJsonGeneratorDecorator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingJsonGeneratorDecorator.java new file mode 100644 index 00000000..68caa810 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingJsonGeneratorDecorator.java @@ -0,0 +1,16 @@ +package dev.caskeleton.bootstrap.logging; + +import net.logstash.logback.mask.MaskingJsonGeneratorDecorator; + +/** + * JSON-encoder arm of secret redaction ({@link LogMaskingPatterns} catalog). Masks at + * JSON-generation time, so it covers every emitted string value (message, MDC, stack trace). See + * README for the design rationale. + */ +public class SecretMaskingJsonGeneratorDecorator extends MaskingJsonGeneratorDecorator { + + public SecretMaskingJsonGeneratorDecorator() { + setDefaultMask(LogMaskingPatterns.MASK); + addValueMasker(LogMaskingPatterns.valueMasker()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverter.java new file mode 100644 index 00000000..4bc5d5c5 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverter.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.logging; + +import ch.qos.logback.classic.pattern.MessageConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; + +/** + * {@code PatternLayout} arm of secret redaction ({@link LogMaskingPatterns} catalog). Registered as + * the {@code %maskedMsg} conversion word; unlike the JSON arm, only the message body is masked + * here. See README for the design rationale. + */ +public class SecretMaskingMessageConverter extends MessageConverter { + + @Override + public String convert(ILoggingEvent event) { + return LogMaskingPatterns.mask(super.convert(event)); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilter.java new file mode 100644 index 00000000..0890d626 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilter.java @@ -0,0 +1,53 @@ +package dev.caskeleton.bootstrap.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; +import dev.caskeleton.bootstrap.runtime.startup.StartupFailureLogState; +import java.util.Set; +import org.slf4j.Marker; + +/** + * Drops Spring Boot's generic duplicate startup-failure logs after {@code StartupFailures} has + * already emitted the canonical structured record. + */ +public class StartupFailureSpringBootLogFilter extends TurboFilter { + + private static final String SPRING_APPLICATION_LOGGER = + "org.springframework.boot.SpringApplication"; + private static final String FAILURE_ANALYSIS_LOGGER = + "org.springframework.boot.diagnostics.LoggingFailureAnalysisReporter"; + private static final String SPRING_LOGGER_PREFIX = "org.springframework."; + private static final String CONTEXT_REFRESH_CANCELLED_PREFIX = + "Exception encountered during context initialization - cancelling refresh attempt"; + + private static final Set<String> DUPLICATE_MESSAGES = + Set.of("Application run failed", "Unable to close ApplicationContext"); + + @Override + public FilterReply decide( + Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) { + if (!isStarted() || !StartupFailureLogState.startupFailureReported()) { + return FilterReply.NEUTRAL; + } + if (logger == null) { + return FilterReply.NEUTRAL; + } + String loggerName = logger.getName(); + if (SPRING_APPLICATION_LOGGER.equals(loggerName) + && format != null + && DUPLICATE_MESSAGES.contains(format)) { + return FilterReply.DENY; + } + if (FAILURE_ANALYSIS_LOGGER.equals(loggerName)) { + return FilterReply.DENY; + } + if (loggerName.startsWith(SPRING_LOGGER_PREFIX) + && format != null + && format.startsWith(CONTEXT_REFRESH_CANCELLED_PREFIX)) { + return FilterReply.DENY; + } + return FilterReply.NEUTRAL; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/management/security/ManagementSecurityConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/management/security/ManagementSecurityConfig.java new file mode 100644 index 00000000..a5ade0c1 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/management/security/ManagementSecurityConfig.java @@ -0,0 +1,45 @@ +package dev.caskeleton.bootstrap.management.security; + +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; + +/** + * Actuator endpoint security chain, ordered ahead of the main app chain. Security matching is done + * by endpoint id string only, never by health endpoint internals. See README for the design + * rationale. + */ +@Configuration +public class ManagementSecurityConfig { + + @Bean + @Order(0) + public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception { + http.securityMatcher(EndpointRequest.toAnyEndpoint()) + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Answer 401 (credentials required) instead of the default 403; no interactive login. + .exceptionHandling( + ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .authorizeHttpRequests( + auth -> + auth + // permit-all for probes and Prometheus scrape. + .requestMatchers(EndpointRequest.to("health", "info", "prometheus")) + .permitAll() + .requestMatchers(HttpMethod.POST, "/actuator/loggers/**") + .denyAll() + .requestMatchers(HttpMethod.DELETE, "/actuator/loggers/**") + .denyAll() + .anyRequest() + .authenticated()); + return http.build(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsCardinalityMeterFilter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsCardinalityMeterFilter.java new file mode 100644 index 00000000..308abd1b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsCardinalityMeterFilter.java @@ -0,0 +1,31 @@ +package dev.caskeleton.bootstrap.metrics; + +import dev.caskeleton.shared.metrics.ForbiddenMetricTags; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.config.MeterFilterReply; + +/** + * {@link MeterFilter} that blocks high-cardinality label keys at runtime. See README for the design + * rationale. + * + * @see ForbiddenMetricTags + * @see MetricsContractConfig + */ +public final class MetricsCardinalityMeterFilter implements MeterFilter { + + public MetricsCardinalityMeterFilter() { + // public no-arg constructor — required by MetricsContractConfig.install() + } + + @Override + public MeterFilterReply accept(Meter.Id id) { + for (Tag tag : id.getTags()) { + if (ForbiddenMetricTags.isForbidden(tag.getKey())) { + return MeterFilterReply.DENY; + } + } + return MeterFilterReply.NEUTRAL; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfig.java new file mode 100644 index 00000000..8eba4858 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfig.java @@ -0,0 +1,51 @@ +package dev.caskeleton.bootstrap.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Configuration; + +/** + * Installs the metrics MeterFilters onto the MeterRegistry (deny-list before histogram config). See + * README for the design rationale. + * + * @see MetricsCardinalityMeterFilter + * @see MetricsDistributionMeterFilter + */ +@Configuration +public class MetricsContractConfig { + + private static final Logger log = LoggerFactory.getLogger(MetricsContractConfig.class); + + private final ObjectProvider<MeterRegistry> meterRegistryProvider; + + public MetricsContractConfig(ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.meterRegistryProvider = meterRegistryProvider; + } + + @PostConstruct + void installFilters() { + MeterRegistry registry = meterRegistryProvider.getIfAvailable(); + if (registry == null) { + log.debug( + "metrics-alerting-contract: no MeterRegistry available; " + + "MeterFilter install skipped (no Actuator on classpath)"); + return; + } + install(registry); + } + + /** + * Public and static so contract tests can drive it directly against a {@link + * io.micrometer.core.instrument.simple.SimpleMeterRegistry} without a Spring context. See README + * for the design rationale. + * + * @param registry the registry to configure; must not be {@code null} + */ + public static void install(MeterRegistry registry) { + registry.config().meterFilter(new MetricsCardinalityMeterFilter()); + registry.config().meterFilter(new MetricsDistributionMeterFilter()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsDistributionMeterFilter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsDistributionMeterFilter.java new file mode 100644 index 00000000..10c3e1ee --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsDistributionMeterFilter.java @@ -0,0 +1,48 @@ +package dev.caskeleton.bootstrap.metrics; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import java.time.Duration; +import java.util.Set; + +/** + * {@link MeterFilter} that applies SLO-driven histogram and percentile configuration to a fixed set + * of owned timer metrics. See README for the design rationale. + * + * @see MetricsContractConfig + * @see MetricsCardinalityMeterFilter + */ +public final class MetricsDistributionMeterFilter implements MeterFilter { + + private static final Set<String> SLO_DRIVEN_TIMERS = + Set.of( + "http.server.requests", + "http.server.requests.latency", + "dependency.client.requests", + "db.query.duration", + "jvm.gc.pause"); + + public MetricsDistributionMeterFilter() { + // public no-arg constructor — required by MetricsContractConfig.install() + } + + @Override + public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) { + if (!SLO_DRIVEN_TIMERS.contains(id.getName())) { + return config; + } + return DistributionStatisticConfig.builder() + .percentilesHistogram(true) + .percentiles(0.5, 0.9, 0.95, 0.99) + .serviceLevelObjectives( + (double) Duration.ofMillis(100).toNanos(), + (double) Duration.ofMillis(500).toNanos(), + (double) Duration.ofSeconds(1).toNanos(), + (double) Duration.ofSeconds(5).toNanos()) + .minimumExpectedValue((double) Duration.ofMillis(1).toNanos()) + .maximumExpectedValue((double) Duration.ofSeconds(10).toNanos()) + .build() + .merge(config); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java new file mode 100644 index 00000000..c7c37680 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java @@ -0,0 +1,77 @@ +package dev.caskeleton.bootstrap.outbox; + +import dev.caskeleton.application.outbox.OutboxBackoffPolicy; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.OutboxStorePort; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.Clock; +import java.util.SplittableRandom; +import java.util.random.RandomGenerator; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Composition-root wiring for the transactional outbox relay: assembles the relay use case manually + * and registers the leader-election token, metrics, and scheduler. See README for the design + * rationale. + */ +@Configuration +@EnableConfigurationProperties(OutboxSettings.class) +public class OutboxConfig { + + /** + * Relay use case is assembled manually here (not a bean) from {@link OutboxSettings} values. See + * README for the design rationale. + */ + @Bean + @ConditionalOnProperty( + name = "ca-skeleton.outbox.relay-enabled", + havingValue = "true", + matchIfMissing = true) + public OutboxRelayScheduler outboxRelayScheduler( + OutboxStorePort store, + OutboxMessagePublishPort publishPort, + TransactionPort tx, + Clock clock, + RandomGenerator outboxRandomGenerator, + OutboxSettings properties, + OutboxMetrics metrics) { + PublishPendingOutboxEventsUseCase relayUseCase = + new PublishPendingOutboxEventsUseCase( + store, + publishPort, + tx, + new OutboxBackoffPolicy(outboxRandomGenerator), + clock, + properties.batchSize(), + properties.inFlightTimeout()); + return new OutboxRelayScheduler(relayUseCase, metrics, clock); + } + + /** Uses a {@code java.base} RNG so the relay also starts on the slim Temurin JRE image. */ + @Bean + public RandomGenerator outboxRandomGenerator() { + return new SplittableRandom(); + } + + /** + * Registered unconditionally so the SKIP LOCKED leadership mechanism is always present. See + * README for the design rationale. + */ + @Bean(name = "outboxLeaderElection") + public OutboxLeaderElectionToken outboxLeaderElection() { + return new OutboxLeaderElectionToken(); + } + + /** Outbox metrics collector; no-op when {@link MeterRegistry} is absent. */ + @Bean + public OutboxMetrics outboxMetrics( + OutboxStorePort store, ObjectProvider<MeterRegistry> meterRegistryProvider) { + return new OutboxMetrics(store, meterRegistryProvider); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLeaderElectionToken.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLeaderElectionToken.java new file mode 100644 index 00000000..5d79de32 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLeaderElectionToken.java @@ -0,0 +1,21 @@ +package dev.caskeleton.bootstrap.outbox; + +/** + * Marker bean: presence in the context satisfies the multi-instance coordination requirement (no + * logic; SKIP LOCKED is the leadership mechanism). See README for the design rationale. + */ +public final class OutboxLeaderElectionToken { + + private static final String STRATEGY_DESCRIPTION = + "SKIP LOCKED claim — each relay instance claims a disjoint row partition; " + + "no external coordinator required (PostgreSQL FOR UPDATE SKIP LOCKED, I3/D8)"; + + public String strategyDescription() { + return STRATEGY_DESCRIPTION; + } + + @Override + public String toString() { + return "OutboxLeaderElectionToken{strategy=SKIP_LOCKED}"; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java new file mode 100644 index 00000000..cba18518 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java @@ -0,0 +1,117 @@ +package dev.caskeleton.bootstrap.outbox; + +import dev.caskeleton.application.outbox.OutboxEventStatus; +import dev.caskeleton.application.outbox.OutboxRelayResult; +import dev.caskeleton.application.outbox.OutboxStorePort; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.Tags; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; + +/** + * Outbox relay metric collector. No-op when no {@link MeterRegistry} is present (resolved via + * {@link ObjectProvider}). See README for the design rationale. + */ +public final class OutboxMetrics { + + private static final Logger log = LoggerFactory.getLogger(OutboxMetrics.class); + + private static final String COUNTER_NAME = "outbox.publisher.published.total"; + private static final String SIZE_GAUGE = "outbox.pending.size"; + private static final String LAG_GAUGE = "outbox.publisher.lag"; + + private final OutboxStorePort store; + private final MeterRegistry registry; // null when no Actuator on classpath + private final MultiGauge pendingSizeGauge; + private final MultiGauge publisherLagGauge; + + public OutboxMetrics(OutboxStorePort store, ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.store = store; + this.registry = meterRegistryProvider.getIfAvailable(); + + if (this.registry != null) { + this.pendingSizeGauge = + MultiGauge.builder(SIZE_GAUGE) + .description("Number of outbox rows grouped by status") + .register(this.registry); + this.publisherLagGauge = + MultiGauge.builder(LAG_GAUGE) + .description("Age in seconds of the oldest unpublished row per event type") + .baseUnit("seconds") + .register(this.registry); + } else { + this.pendingSizeGauge = null; + this.publisherLagGauge = null; + } + } + + /** Counts each terminal outcome (PUBLISHED, FAILED, DEAD; not IN_FLIGHT). */ + public void recordRelayResult(OutboxRelayResult result) { + if (registry == null || result == null) { + return; + } + for (OutboxRelayResult.EventOutcome eo : result.outcomes()) { + try { + Counter.builder(COUNTER_NAME) + .tag("event_type", eo.eventType()) + .tag("outcome", eo.outcome().name()) + .register(registry) + .increment(); + } catch (Exception ex) { + log.warn( + "outbox metrics: failed to record counter for eventType={} outcome={}", + eo.eventType(), + eo.outcome(), + ex); + } + } + } + + /** + * Uses {@code overwrite=true} so stale time-series tags from removed event types are replaced + * rather than left dangling. + * + * @param now current wall-clock instant (for the lag gauge age computation) + */ + public void refresh(Instant now) { + if (registry == null) { + return; + } + refreshPendingSize(); + refreshPublisherLag(now); + } + + private void refreshPendingSize() { + try { + Map<OutboxEventStatus, Long> counts = store.countByStatus(); + List<MultiGauge.Row<?>> rows = new ArrayList<>(OutboxEventStatus.values().length); + for (OutboxEventStatus status : OutboxEventStatus.values()) { + long count = counts.getOrDefault(status, 0L); + rows.add(MultiGauge.Row.of(Tags.of("status", status.name()), count)); + } + pendingSizeGauge.register(rows, true); + } catch (Exception ex) { + log.warn("outbox metrics: failed to refresh pending-size gauge", ex); + } + } + + private void refreshPublisherLag(Instant now) { + try { + Map<String, Long> lagByType = store.oldestUnpublishedAgeSecondsByEventType(now); + List<MultiGauge.Row<?>> rows = new ArrayList<>(lagByType.size()); + for (Map.Entry<String, Long> entry : lagByType.entrySet()) { + rows.add(MultiGauge.Row.of(Tags.of("event_type", entry.getKey()), entry.getValue())); + } + publisherLagGauge.register(rows, true); + } catch (Exception ex) { + log.warn("outbox metrics: failed to refresh publisher-lag gauge", ex); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java new file mode 100644 index 00000000..233d661d --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java @@ -0,0 +1,56 @@ +package dev.caskeleton.bootstrap.outbox; + +import dev.caskeleton.application.outbox.OutboxRelayResult; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; +import java.time.Clock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * Thin bootstrap scheduler that drives the outbox relay use case on a fixed-delay polling loop. + * Registered by {@code OutboxConfig}. See README for the design rationale. + */ +public class OutboxRelayScheduler { + + private static final Logger log = LoggerFactory.getLogger(OutboxRelayScheduler.class); + + private final PublishPendingOutboxEventsUseCase relayUseCase; + private final OutboxMetrics metrics; + private final Clock clock; + + public OutboxRelayScheduler( + PublishPendingOutboxEventsUseCase relayUseCase, OutboxMetrics metrics, Clock clock) { + this.relayUseCase = relayUseCase; + this.metrics = metrics; + this.clock = clock; + } + + /** + * Unexpected exceptions are caught (not propagated) so the scheduler thread stays alive for the + * next tick. + */ + @Scheduled(fixedDelayString = "${ca-skeleton.outbox.poll-interval:PT5S}") + public void relay() { + try { + OutboxRelayResult result = relayUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + if (result.claimedCount() > 0) { + log.debug( + "outbox relay cycle: claimed={} outcomes={}", + result.claimedCount(), + result.outcomes().size()); + } + + metrics.recordRelayResult(result); + metrics.refresh(clock.instant()); + + } catch (Exception ex) { + log.error( + "outbox relay scheduler: unexpected error in relay cycle — " + + "relay will retry on the next tick", + ex); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxSettings.java new file mode 100644 index 00000000..01899318 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxSettings.java @@ -0,0 +1,70 @@ +package dev.caskeleton.bootstrap.outbox; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Outbox relay runtime knobs bound from {@code ca-skeleton.outbox.*} (literal defaults, no env + * placeholders). See README for the design rationale. + * + * @param relayEnabled whether the relay scheduler is enabled; default {@code true} + * @param pollInterval how often the relay polls the outbox table; default {@code PT5S} + * @param batchSize maximum rows claimed per relay cycle; default {@code 20} + * @param inFlightTimeout in-flight orphan visibility window; default {@code PT5M} + * @param reaperInterval how often the reaper purges old PUBLISHED rows; default {@code PT10M} + * @param publishedRetention how long PUBLISHED rows are kept before the reaper deletes them; + * default {@code P7D} + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.outbox") +public record OutboxSettings( + Boolean relayEnabled, + Duration pollInterval, + Integer batchSize, + Duration inFlightTimeout, + Duration reaperInterval, + Duration publishedRetention) { + + public OutboxSettings { + if (relayEnabled == null) { + relayEnabled = true; + } + + if (pollInterval == null) { + pollInterval = Duration.ofSeconds(5); + } else if (pollInterval.isZero() || pollInterval.isNegative()) { + throw StartupFailures.envValidation( + "ca-skeleton.outbox.pollInterval must be positive, was " + pollInterval); + } + + if (batchSize == null) { + batchSize = 20; + } else if (batchSize <= 0) { + throw StartupFailures.envValidation( + "ca-skeleton.outbox.batchSize must be > 0, was " + batchSize); + } + + if (inFlightTimeout == null) { + inFlightTimeout = Duration.ofMinutes(5); + } else if (inFlightTimeout.isZero() || inFlightTimeout.isNegative()) { + throw StartupFailures.envValidation( + "ca-skeleton.outbox.inFlightTimeout must be positive, was " + inFlightTimeout); + } + + if (reaperInterval == null) { + reaperInterval = Duration.ofMinutes(10); + } else if (reaperInterval.isZero() || reaperInterval.isNegative()) { + throw StartupFailures.envValidation( + "ca-skeleton.outbox.reaperInterval must be positive, was " + reaperInterval); + } + + if (publishedRetention == null) { + publishedRetention = Duration.ofDays(7); + } else if (publishedRetention.isZero() || publishedRetention.isNegative()) { + throw StartupFailures.envValidation( + "ca-skeleton.outbox.publishedRetention must be positive, was " + publishedRetention); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/EnvironmentSecretSource.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/EnvironmentSecretSource.java new file mode 100644 index 00000000..79f8a2ba --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/EnvironmentSecretSource.java @@ -0,0 +1,23 @@ +package dev.caskeleton.bootstrap.runtime; + +import java.util.Optional; +import org.springframework.core.env.Environment; + +/** + * Default {@link SecretSource} ({@link SecretSourceStrategy#ENVIRONMENT}): resolves secrets from + * the Spring {@link Environment}. See README for the design rationale. + */ +public final class EnvironmentSecretSource implements SecretSource { + + private final Environment environment; + + public EnvironmentSecretSource(Environment environment) { + this.environment = environment; + } + + @Override + public Optional<String> resolve(String key) { + String value = environment.getProperty(key); + return (value == null || value.isBlank()) ? Optional.empty() : Optional.of(value); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java new file mode 100644 index 00000000..7fc4b152 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java @@ -0,0 +1,124 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Fail-fast startup guard for HikariCP inter-knob constraints. Reads (never re-binds) resolved + * Spring properties as a {@link SmartInitializingSingleton}; an absent property is skipped. See + * README for the design rationale. + */ +public class HikariPoolConstraintValidator implements SmartInitializingSingleton { + + static final String CONNECTION_TIMEOUT_KEY = "spring.datasource.hikari.connection-timeout"; + static final String VALIDATION_TIMEOUT_KEY = "spring.datasource.hikari.validation-timeout"; + static final String KEEPALIVE_TIME_KEY = "spring.datasource.hikari.keepalive-time"; + static final String MAX_LIFETIME_KEY = "spring.datasource.hikari.max-lifetime"; + static final String LEAK_DETECTION_KEY = "spring.datasource.hikari.leak-detection-threshold"; + + // Operator-facing env keys, so a boot failure names the APP_* variable the operator set. + // Knobs without a registered env key emit ENV_KEY_PENDING instead of a fabricated name. + static final String CONNECTION_TIMEOUT_ENV_KEY = "APP_DATASOURCE_CONNECTION_TIMEOUT"; + static final String MAX_LIFETIME_ENV_KEY = "APP_DATASOURCE_POOL_MAX_LIFETIME"; + static final String ENV_KEY_PENDING = "env key pending feature-env-driven-runtime-configuration"; + + private final Environment environment; + + public HikariPoolConstraintValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + List<String> violations = new ArrayList<>(); + + Long connectionTimeout = parseMillis(environment.getProperty(CONNECTION_TIMEOUT_KEY)); + Long validationTimeout = parseMillis(environment.getProperty(VALIDATION_TIMEOUT_KEY)); + Long keepaliveTime = parseMillis(environment.getProperty(KEEPALIVE_TIME_KEY)); + Long maxLifetime = parseMillis(environment.getProperty(MAX_LIFETIME_KEY)); + Long leakDetection = parseMillis(environment.getProperty(LEAK_DETECTION_KEY)); + + if (connectionTimeout != null && connectionTimeout < 250L) { + violations.add( + "D2/HIKARI-CFG-C1: " + + CONNECTION_TIMEOUT_ENV_KEY + + " (" + + CONNECTION_TIMEOUT_KEY + + ") must be >= 250 ms, was " + + connectionTimeout); + } + + if (validationTimeout != null + && connectionTimeout != null + && validationTimeout >= connectionTimeout) { + violations.add( + "D7/HIKARI-CFG-C6: validation-timeout (" + + VALIDATION_TIMEOUT_KEY + + ", " + + ENV_KEY_PENDING + + ") must be < connection-timeout (" + + CONNECTION_TIMEOUT_ENV_KEY + + " / " + + CONNECTION_TIMEOUT_KEY + + "); was validation-timeout=" + + validationTimeout + + ", connection-timeout=" + + connectionTimeout); + } + + if (keepaliveTime != null && maxLifetime != null && keepaliveTime >= maxLifetime) { + violations.add( + "D4/HIKARI-CFG-C4: keepalive-time (" + + KEEPALIVE_TIME_KEY + + ", " + + ENV_KEY_PENDING + + ") must be < max-lifetime (" + + MAX_LIFETIME_ENV_KEY + + " / " + + MAX_LIFETIME_KEY + + "); was keepalive-time=" + + keepaliveTime + + ", max-lifetime=" + + maxLifetime); + } + + if (leakDetection != null && leakDetection != 0L && leakDetection < 2000L) { + violations.add( + "D5/HIKARI-CFG-C5: leak-detection-threshold (" + + LEAK_DETECTION_KEY + + ", " + + ENV_KEY_PENDING + + ") must be >= 2000 ms to enable leak detection" + + " (0 = disabled/allowed); was " + + leakDetection); + } + + if (!violations.isEmpty()) { + throw StartupFailures.envValidation( + "invalid HikariCP configuration (feature-database-connection-pool-contract" + + " D2/D4/D5/D7): " + + violations); + } + } + + /** + * Parses a raw property string as a plain long (milliseconds). A non-plain-integer value (e.g. a + * Duration string such as {@code "5s"}) yields {@code null}, which the caller treats as absent. + * See README for the design rationale. + * + * @return the parsed milliseconds, or {@code null} when absent/non-numeric + */ + private static Long parseMillis(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + return null; // non-numeric (e.g. Duration string) — treat as absent + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/OpenInViewSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/OpenInViewSafetyValidator.java new file mode 100644 index 00000000..833364ef --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/OpenInViewSafetyValidator.java @@ -0,0 +1,41 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Fail-fast startup guard that enforces OSIV (Open Session In View) being OFF. Reads (never + * re-binds) the resolved property as a {@link SmartInitializingSingleton}; an absent value is left + * to Spring Boot's default and only a present {@code true} is rejected. See README for the design + * rationale. + */ +public class OpenInViewSafetyValidator implements SmartInitializingSingleton { + + static final String OPEN_IN_VIEW_KEY = "spring.jpa.open-in-view"; + static final String ENV_KEY = "APP_DATASOURCE_OPEN_IN_VIEW"; + + private final Environment environment; + + public OpenInViewSafetyValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + Boolean openInView = environment.getProperty(OPEN_IN_VIEW_KEY, Boolean.class); + if (Boolean.TRUE.equals(openInView)) { + throw StartupFailures.envValidation( + ENV_KEY + + " (" + + OPEN_IN_VIEW_KEY + + ") must be false " + + "(feature-persistence-failure-baseline D2 — OSIV off baseline): " + + "open-in-view keeps the Hibernate session open through view rendering, " + + "so a lazy association touched in the presentation layer issues a DB query " + + "there, violating the layer boundary. Set " + + ENV_KEY + + "=false."); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeNumericBoundsValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeNumericBoundsValidator.java new file mode 100644 index 00000000..05fd7518 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeNumericBoundsValidator.java @@ -0,0 +1,62 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Fail-fast startup guard for the high-risk numeric runtime knobs (pool/thread sizing). Reads the + * resolved Spring property; an absent key is skipped. See README for the design rationale. + */ +public class RuntimeNumericBoundsValidator implements SmartInitializingSingleton { + + /** A numeric property bound: resolved Spring key, the {@code APP_*} env key, and minimum. */ + record Bound(String springKey, String envKey, int minInclusive) {} + + static final List<Bound> BOUNDS = + List.of( + // positive (>= 1) + new Bound( + "spring.datasource.hikari.maximum-pool-size", "APP_DATASOURCE_POOL_MAX_SIZE", 1), + new Bound("server.tomcat.threads.max", "APP_SERVER_TOMCAT_MAX_THREADS", 1), + new Bound("server.tomcat.max-connections", "APP_SERVER_TOMCAT_MAX_CONNECTIONS", 1), + // non-negative (>= 0) + new Bound("spring.datasource.hikari.minimum-idle", "APP_DATASOURCE_POOL_MIN_IDLE", 0), + new Bound("server.tomcat.threads.min-spare", "APP_SERVER_TOMCAT_MIN_SPARE_THREADS", 0), + new Bound("server.tomcat.accept-count", "APP_SERVER_TOMCAT_ACCEPT_COUNT", 0)); + + private final Environment environment; + + public RuntimeNumericBoundsValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + List<String> violations = new ArrayList<>(); + for (Bound bound : BOUNDS) { + Integer value = environment.getProperty(bound.springKey(), Integer.class); + if (value == null) { + continue; // absent → skip (framework default owns it) + } + if (value < bound.minInclusive()) { + violations.add( + bound.envKey() + + " (" + + bound.springKey() + + ") must be >= " + + bound.minInclusive() + + ", was " + + value); + } + } + if (!violations.isEmpty()) { + throw StartupFailures.envValidation( + "invalid runtime numeric configuration (feature-env-driven-runtime-configuration " + + "D10 — no lenient default): " + + violations); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java new file mode 100644 index 00000000..f22e3c4c --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java @@ -0,0 +1,36 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Wires the runtime-safety startup fail-fast validators into the running application. See README + * for the design rationale. + */ +@Configuration +public class RuntimeSafetyConfig { + + @Bean + StartupSafetyValidator startupSafetyValidator( + Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) { + return new StartupSafetyValidator(environment, settings, beanFactory); + } + + @Bean + RuntimeNumericBoundsValidator runtimeNumericBoundsValidator(Environment environment) { + return new RuntimeNumericBoundsValidator(environment); + } + + @Bean + OpenInViewSafetyValidator openInViewSafetyValidator(Environment environment) { + return new OpenInViewSafetyValidator(environment); + } + + @Bean + HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) { + return new HikariPoolConstraintValidator(environment); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSource.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSource.java new file mode 100644 index 00000000..4935c50b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSource.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.runtime; + +import java.util.Optional; + +/** + * Backend seam for secret resolution (the concrete backend is chosen by {@link + * SecretSourceFactory}). See README for the design rationale. + */ +public interface SecretSource { + + /** + * A blank value MUST be treated as absent so a present-but-empty secret cannot pass a presence + * check. + * + * @return the resolved non-blank value, or empty when absent/blank + */ + Optional<String> resolve(String key); +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceConfig.java new file mode 100644 index 00000000..9da7f3a2 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceConfig.java @@ -0,0 +1,26 @@ +package dev.caskeleton.bootstrap.runtime; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Wires the secret/config-source backend and its startup fail-fast guard. See README for the design + * rationale. + */ +@Configuration +@EnableConfigurationProperties(SecretSourceSettings.class) +public class SecretSourceConfig { + + @Bean + SecretSource secretSource(SecretSourceSettings properties, ConfigurableEnvironment environment) { + return SecretSourceFactory.create(properties.strategy(), environment); + } + + @Bean + SecretSourceValidator secretSourceValidator( + ConfigurableEnvironment environment, SecretSource secretSource) { + return new SecretSourceValidator(environment, secretSource); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceFactory.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceFactory.java new file mode 100644 index 00000000..6c7dad2b --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceFactory.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.runtime; + +import org.springframework.core.env.Environment; + +/** + * Builds the configured {@link SecretSource} backend; the {@code switch} below is the one extension + * point for adding a backend. See README for the design rationale. + */ +public final class SecretSourceFactory { + + private SecretSourceFactory() {} + + public static SecretSource create(SecretSourceStrategy strategy, Environment environment) { + return switch (strategy) { + case ENVIRONMENT -> new EnvironmentSecretSource(environment); + }; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceSettings.java new file mode 100644 index 00000000..861ee367 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceSettings.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.runtime; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Secret backend selection, bound from {@code ca-skeleton.secret-source.strategy}. See README for + * the design rationale. + * + * @param strategy the secret backend; {@code null} → {@code ENVIRONMENT} + */ +@ConfigurationProperties(prefix = "ca-skeleton.secret-source") +public record SecretSourceSettings(SecretSourceStrategy strategy) { + + public SecretSourceSettings { + if (strategy == null) { + strategy = SecretSourceStrategy.ENVIRONMENT; + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceStrategy.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceStrategy.java new file mode 100644 index 00000000..92da8a45 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceStrategy.java @@ -0,0 +1,14 @@ +package dev.caskeleton.bootstrap.runtime; + +/** + * Selectable secret backend, bound from {@code ca-skeleton.secret-source.strategy}. {@link + * #ENVIRONMENT} is the shipped default. See README for the design rationale. + */ +public enum SecretSourceStrategy { + ENVIRONMENT + + // Future backends (each: new SecretSource impl + factory case): + // VAULT, // HashiCorp Vault + // AWS_SECRETS_MANAGER, // AWS Secrets Manager + // GCP_SECRET_MANAGER // GCP Secret Manager +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java new file mode 100644 index 00000000..0049c429 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java @@ -0,0 +1,106 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.PropertySource; + +/** + * Fail-fast startup guard for the secret/config-source contract (prod-profile only; a violation + * throws so the application refuses to start). See README for the design rationale. + */ +public class SecretSourceValidator implements SmartInitializingSingleton { + + /** Prefix marking a dev/local fake credential; forbidden to reach the prod profile. */ + static final String LOCAL_DEV_SENTINEL_PREFIX = "__LOCAL_DEV_"; + + private static final String PROD_PROFILE = "prod"; + + /** + * Secret keys that must be injected under the {@code prod} profile. {@code + * SecretsClassificationRegistryTest} asserts this list matches the registry 1:1. See README for + * the design rationale. + */ + public static final List<String> REQUIRED_PROD_SECRETS = + List.of( + "APP_DATASOURCE_PASSWORD", + "APP_SECURITY_JWT_SIGNING_KEY", + "APP_SECURITY_OAUTH_CLIENT_SECRET", + "APP_EXTERNAL_API_KEY", + "APP_CACHE_REDIS_PASSWORD", + "APP_PRIVACY_PSEUDONYMIZATION_SALT"); + + private final ConfigurableEnvironment environment; + private final SecretSource secretSource; + + public SecretSourceValidator(ConfigurableEnvironment environment, SecretSource secretSource) { + this.environment = environment; + this.secretSource = secretSource; + } + + @Override + public void afterSingletonsInstantiated() { + validateNoLocalDevSentinelInProd(); + validateRequiredSecretsPresent(); + } + + private void validateNoLocalDevSentinelInProd() { + if (!isProdActive()) { + return; + } + List<String> offenders = new ArrayList<>(); + for (PropertySource<?> source : environment.getPropertySources()) { + if (source instanceof EnumerablePropertySource<?> enumerable) { + for (String name : enumerable.getPropertyNames()) { + Object value = enumerable.getProperty(name); + if (value instanceof String text + && text.startsWith(LOCAL_DEV_SENTINEL_PREFIX) + && !offenders.contains(name)) { + offenders.add(name); + } + } + } + } + if (!offenders.isEmpty()) { + throw StartupFailures.envValidation( + "prod profile forbids '" + + LOCAL_DEV_SENTINEL_PREFIX + + "' sentinel credential " + + "values, but these keys carry one: " + + offenders + + " — inject real secrets from the secret manager / mounted env for prod."); + } + } + + private void validateRequiredSecretsPresent() { + if (!isProdActive()) { + return; + } + List<String> missing = new ArrayList<>(); + for (String key : REQUIRED_PROD_SECRETS) { + if (secretSource.resolve(key).isEmpty()) { + missing.add(key); + } + } + if (!missing.isEmpty()) { + throw StartupFailures.envValidation( + "prod profile requires these secrets to be injected, but they are missing/blank: " + + missing + + " — booting with an empty secret is forbidden " + + "(feature-secrets-config-source-contract §테스트 계약)."); + } + } + + private boolean isProdActive() { + // Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match. + for (String profile : environment.getActiveProfiles()) { + if (PROD_PROFILE.equalsIgnoreCase(profile)) { + return true; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidator.java new file mode 100644 index 00000000..c89a5531 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidator.java @@ -0,0 +1,103 @@ +package dev.caskeleton.bootstrap.runtime; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Fail-fast startup guard for env-combination invariants. Implemented as a {@link + * SmartInitializingSingleton} so the check runs once after every singleton is instantiated but + * before the context finishes refreshing; a violation throws so the context refuses to start. See + * README for the design rationale. + */ +public class StartupSafetyValidator implements SmartInitializingSingleton { + + /** + * Bean names every instance-coordination capability must register when {@code + * APP_MULTI_INSTANCE_ENABLED=true}; this validator only asserts their presence. + */ + static final List<String> REQUIRED_MULTI_INSTANCE_BEANS = + List.of( + "distributedLockProvider", // distributed lock (JdbcLockRegistry) + "cacheStampedeProtection", // cache stampede protection (Redisson RLock) + "outboxLeaderElection", // outbox leader election (SKIP LOCKED) + "distributedRateLimiter", // distributed rate limiter (Redis counter) + "migrationStartupRunner" // platform migration startup job + ); + + private static final String PROD_PROFILE = "prod"; + + private final Environment environment; + private final RuntimeSafetySettings settings; + private final ListableBeanFactory beanFactory; + + public StartupSafetyValidator( + Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) { + this.environment = environment; + this.settings = settings; + this.beanFactory = beanFactory; + } + + @Override + public void afterSingletonsInstantiated() { + validateProdSafety(); + validateMultiInstance(); + } + + /** Under the {@code prod} profile, internal-detail and body-capture toggles must be off. */ + private void validateProdSafety() { + if (!isProdActive()) { + return; + } + List<String> unsafe = new ArrayList<>(); + if (settings.errorDetailExposureEnabled()) { + unsafe.add("APP_ERROR_DETAIL_EXPOSURE_ENABLED"); + } + if (settings.logBodyCaptureEnabled()) { + unsafe.add("APP_LOG_BODY_CAPTURE_ENABLED"); + } + if (!unsafe.isEmpty()) { + // prod-unsafe toggle left on under prod → PROFILE_MISMATCH (exit 71). + throw StartupFailures.profileMismatch( + "prod profile forbids these toggles being enabled: " + + unsafe + + " — set them to false for the prod profile."); + } + } + + /** When multi-instance is on, every instance-coordination capability bean must exist. */ + private void validateMultiInstance() { + if (!settings.multiInstanceEnabled()) { + return; + } + List<String> missing = new ArrayList<>(); + for (String beanName : REQUIRED_MULTI_INSTANCE_BEANS) { + if (!beanFactory.containsBean(beanName)) { + missing.add(beanName); + } + } + if (!missing.isEmpty()) { + // required coordination bean absent under multi-instance → REQUIRED_ADAPTER_DISABLED (exit + // 72). + throw StartupFailures.requiredAdapterDisabled( + "APP_MULTI_INSTANCE_ENABLED=true requires the instance-coordination beans " + + REQUIRED_MULTI_INSTANCE_BEANS + + ", but these are missing: " + + missing); + } + } + + private boolean isProdActive() { + // Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match. + for (String profile : environment.getActiveProfiles()) { + if (PROD_PROFILE.equalsIgnoreCase(profile)) { + return true; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/FlywayProdSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/FlywayProdSafetyValidator.java new file mode 100644 index 00000000..eea2bc1d --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/FlywayProdSafetyValidator.java @@ -0,0 +1,69 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Keeps the Flyway prod safety nets on; raises PROFILE_MISMATCH (exit 71) when an override + * re-enables a forbidden option. See README for the design rationale. + */ +public class FlywayProdSafetyValidator implements SmartInitializingSingleton { + + static final String BASELINE_ON_MIGRATE_KEY = "spring.flyway.baseline-on-migrate"; + static final String OUT_OF_ORDER_KEY = "spring.flyway.out-of-order"; + static final String CLEAN_DISABLED_KEY = "spring.flyway.clean-disabled"; + + private static final String PROD_PROFILE = "prod"; + + private final Environment environment; + + public FlywayProdSafetyValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + if (!isProdActive()) { + return; + } + List<String> violations = new ArrayList<>(); + if (isTrue(BASELINE_ON_MIGRATE_KEY)) { + violations.add(BASELINE_ON_MIGRATE_KEY + "=true (removes the missing-migration safety net)"); + } + if (isTrue(OUT_OF_ORDER_KEY)) { + violations.add(OUT_OF_ORDER_KEY + "=true (breaks migration ordering consistency)"); + } + if (isFalse(CLEAN_DISABLED_KEY)) { + violations.add(CLEAN_DISABLED_KEY + "=false (re-arms destructive Flyway clean)"); + } + if (!violations.isEmpty()) { + throw StartupFailures.profileMismatch( + "prod profile forbids these Flyway options (feature-migration-startup-contract" + + " D2/D4): " + + violations + + " — partial-schema recovery goes through runbook://migration/manual-recovery," + + " never an in-prod Flyway repair."); + } + } + + private boolean isTrue(String key) { + return Boolean.parseBoolean(environment.getProperty(key)); + } + + private boolean isFalse(String key) { + String value = environment.getProperty(key); + return value != null && "false".equalsIgnoreCase(value.trim()); + } + + private boolean isProdActive() { + // Case-insensitive so a SPRING_PROFILES_ACTIVE=PROD typo still triggers the guard. + for (String profile : environment.getActiveProfiles()) { + if (PROD_PROFILE.equalsIgnoreCase(profile)) { + return true; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationFailedException.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationFailedException.java new file mode 100644 index 00000000..f9c29111 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationFailedException.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +/** + * A Flyway forward-only migration failed during startup (MIGRATION_FAILED, exit 70 — sysexits + * {@code EX_SOFTWARE}). Phase: migration. Wraps the underlying {@code FlywayException} as the cause + * to preserve the root error for triage. + */ +public class MigrationFailedException extends StartupFailureException { + + private static final long serialVersionUID = 1L; + + public MigrationFailedException(String message, Throwable cause) { + super(StartupErrorCode.MIGRATION_FAILED, message, cause); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java new file mode 100644 index 00000000..b58d75c6 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java @@ -0,0 +1,35 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings; +import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Composition-root wiring for the startup migration guards and the Flyway migration strategy. See + * README for the design rationale. + */ +@Configuration +public class MigrationStartupConfig { + + /** + * Bean name must stay {@code migrationStartupRunner} (Flyway autoconfiguration delegates to it). + */ + @Bean + FlywayMigrationStrategy migrationStartupRunner(RuntimeSafetySettings settings) { + return new MigrationStartupRunner(settings); + } + + /** Env-validation (exit 78): datasource connection env present before migration runs. */ + @Bean + RequiredEnvironmentValidator requiredEnvironmentValidator(Environment environment) { + return new RequiredEnvironmentValidator(environment); + } + + /** Profile check (exit 71): forbidden Flyway options stay off under the prod profile. */ + @Bean + FlywayProdSafetyValidator flywayProdSafetyValidator(Environment environment) { + return new FlywayProdSafetyValidator(environment); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunner.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunner.java new file mode 100644 index 00000000..5b4a3c66 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunner.java @@ -0,0 +1,53 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static net.logstash.logback.argument.StructuredArguments.kv; + +import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.flywaydb.core.api.output.MigrateResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy; + +/** + * Runs the Flyway migration during context refresh so it completes before readiness, and translates + * a {@link FlywayException} into a {@link MigrationFailedException} (exit 70). See README for the + * design rationale. + */ +public class MigrationStartupRunner implements FlywayMigrationStrategy { + + private static final Logger log = + LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup"); + + private final RuntimeSafetySettings settings; + + public MigrationStartupRunner(RuntimeSafetySettings settings) { + this.settings = settings; + } + + @Override + public void migrate(Flyway flyway) { + if (!settings.migrationOnStartup()) { + log.info( + "startup phase {}: in-app Flyway migration is disabled via configuration (ca-skeleton.runtime.migration-on-startup=false)", + kv("startup.phase", StartupPhase.MIGRATION.wireName())); + return; + } + log.info( + "startup phase {}: applying Flyway forward-only migrations", + kv("startup.phase", StartupPhase.MIGRATION.wireName())); + try { + MigrateResult result = flyway.migrate(); + int executed = (result != null) ? result.migrationsExecuted : 0; + log.info( + "startup phase {}: migration complete, {} migration(s) applied", + kv("startup.phase", StartupPhase.MIGRATION.wireName()), + executed); + } catch (FlywayException e) { + // StartupFailures emits the structured failure log before constructing the exception. + throw StartupFailures.migrationFailed( + "Flyway forward-only migration failed during startup", e); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/ProfileMismatchException.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/ProfileMismatchException.java new file mode 100644 index 00000000..3ddf1902 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/ProfileMismatchException.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +/** + * An active profile conflicts with an enabled prod-forbidden setting — e.g. a prod-unsafe toggle or + * a forbidden Flyway option left on under the {@code prod} profile (PROFILE_MISMATCH, exit 71 — + * ca-tmpl internal convention, see {@link StartupErrorCode}). Phase: profile-check. + */ +public class ProfileMismatchException extends StartupFailureException { + + private static final long serialVersionUID = 1L; + + public ProfileMismatchException(String message) { + super(StartupErrorCode.PROFILE_MISMATCH, message, null); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredAdapterDisabledException.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredAdapterDisabledException.java new file mode 100644 index 00000000..8e9872a8 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredAdapterDisabledException.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +/** + * A required capability adapter / coordination bean is disabled or absent at startup + * (REQUIRED_ADAPTER_DISABLED, exit 72 — ca-tmpl internal convention, see {@link StartupErrorCode}). + * Phase: adapter-enablement. Distinct from the runtime-lifecycle {@code ADAPTER_DISABLED}: this is + * a startup validation, not a runtime invoke against a disabled optional adapter. See README for + * the design rationale. + */ +public class RequiredAdapterDisabledException extends StartupFailureException { + + private static final long serialVersionUID = 1L; + + public RequiredAdapterDisabledException(String message) { + super(StartupErrorCode.REQUIRED_ADAPTER_DISABLED, message, null); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidator.java new file mode 100644 index 00000000..be460938 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidator.java @@ -0,0 +1,49 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.env.Environment; + +/** + * Raises STARTUP_VALIDATION_FAILED (exit 78) naming every missing operator env key before a + * migration fails with an opaque driver error. Absent or blank counts as missing. See README for + * the design rationale. + */ +public class RequiredEnvironmentValidator implements SmartInitializingSingleton { + + /** Spring property key → operator-facing env key (named in the failure message). */ + static final Map<String, String> REQUIRED_DATASOURCE_ENV = + Map.of( + "spring.datasource.url", "APP_DATASOURCE_URL", + "spring.datasource.username", "APP_DATASOURCE_USERNAME", + "spring.datasource.driver-class-name", "APP_DATASOURCE_DRIVER"); + + private final Environment environment; + + public RequiredEnvironmentValidator(Environment environment) { + this.environment = environment; + } + + @Override + public void afterSingletonsInstantiated() { + List<String> missing = new ArrayList<>(); + // Sorted for a stable, deterministic message regardless of map iteration order. + REQUIRED_DATASOURCE_ENV.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .forEach( + entry -> { + String value = environment.getProperty(entry.getKey()); + if (value == null || value.isBlank()) { + missing.add(entry.getValue() + " (" + entry.getKey() + ")"); + } + }); + if (!missing.isEmpty()) { + throw StartupFailures.envValidation( + "required datasource environment is missing or blank — a Flyway migration" + + " cannot run without it: " + + missing); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCode.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCode.java new file mode 100644 index 00000000..a21974ed --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCode.java @@ -0,0 +1,38 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +/** + * The four startup-failure causes. Each exit code becomes the JVM exit status only because the + * carrying exception implements {@link org.springframework.boot.ExitCodeGenerator} (see {@link + * StartupFailureException}). See README for the design rationale. + */ +public enum StartupErrorCode { + STARTUP_VALIDATION_FAILED(78, StartupPhase.ENV_VALIDATION), + MIGRATION_FAILED(70, StartupPhase.MIGRATION), + PROFILE_MISMATCH(71, StartupPhase.PROFILE_CHECK), + REQUIRED_ADAPTER_DISABLED(72, StartupPhase.ADAPTER_ENABLEMENT); + + /** Registry-fixed category for all four codes. */ + private static final String CATEGORY = "INTERNAL"; + + private final int exitCode; + private final StartupPhase phase; + + StartupErrorCode(int exitCode, StartupPhase phase) { + this.exitCode = exitCode; + this.phase = phase; + } + + public int exitCode() { + return exitCode; + } + + /** The startup phase this cause belongs to ({@code startup.phase} log field). */ + public StartupPhase phase() { + return phase; + } + + /** Registry category — always {@code INTERNAL} ({@code error.category} log field). */ + public String category() { + return CATEGORY; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureException.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureException.java new file mode 100644 index 00000000..59659e67 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureException.java @@ -0,0 +1,31 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import org.springframework.boot.ExitCodeGenerator; + +/** + * Base type for the four startup-failure causes. Implementing {@link ExitCodeGenerator} turns the + * cause's {@link StartupErrorCode#exitCode()} into the JVM exit status when context refresh fails. + * Extends {@link IllegalStateException} for source/behaviour compatibility with the prior {@code + * IllegalStateException}-based startup validation. See README for the design rationale. + */ +public abstract class StartupFailureException extends IllegalStateException + implements ExitCodeGenerator { + + private static final long serialVersionUID = 1L; + + private final transient StartupErrorCode errorCode; + + protected StartupFailureException(StartupErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public StartupErrorCode errorCode() { + return errorCode; + } + + @Override + public int getExitCode() { + return errorCode.exitCode(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporter.java new file mode 100644 index 00000000..2e2d1828 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporter.java @@ -0,0 +1,27 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import org.springframework.boot.SpringBootExceptionReporter; + +/** + * Marks typed startup failures as already reported by {@link StartupFailures}, suppressing Spring + * Boot's generic "Application run failed" stacktrace for those fatal paths only. + */ +public class StartupFailureExceptionReporter implements SpringBootExceptionReporter { + + @Override + public boolean reportException(Throwable failure) { + return containsStartupFailure(failure); + } + + private static boolean containsStartupFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof StartupFailureException) { + return true; + } + if (current.getCause() == current) { + break; + } + } + return false; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureLogState.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureLogState.java new file mode 100644 index 00000000..c4e294c8 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureLogState.java @@ -0,0 +1,23 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Process-local marker that the canonical startup failure log has already been emitted. */ +public final class StartupFailureLogState { + + private static final AtomicBoolean STARTUP_FAILURE_REPORTED = new AtomicBoolean(false); + + private StartupFailureLogState() {} + + public static void markStartupFailureReported() { + STARTUP_FAILURE_REPORTED.set(true); + } + + public static boolean startupFailureReported() { + return STARTUP_FAILURE_REPORTED.get(); + } + + public static void clearForTest() { + STARTUP_FAILURE_REPORTED.set(false); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailures.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailures.java new file mode 100644 index 00000000..655c9e1d --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailures.java @@ -0,0 +1,75 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static net.logstash.logback.argument.StructuredArguments.kv; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Single source for raising a startup failure. Every factory method emits the structured failure + * log (with the {@code startup.phase} / {@code error.code} / {@code error.category} fields) before + * returning the exception to throw. See README for the design rationale. + */ +public final class StartupFailures { + + private static final Logger log = + LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup"); + + private StartupFailures() {} + + public static StartupValidationException envValidation(String detail) { + emit(StartupErrorCode.STARTUP_VALIDATION_FAILED, detail, null); + return new StartupValidationException(detail); + } + + public static ProfileMismatchException profileMismatch(String detail) { + emit(StartupErrorCode.PROFILE_MISMATCH, detail, null); + return new ProfileMismatchException(detail); + } + + public static RequiredAdapterDisabledException requiredAdapterDisabled(String detail) { + emit(StartupErrorCode.REQUIRED_ADAPTER_DISABLED, detail, null); + return new RequiredAdapterDisabledException(detail); + } + + public static MigrationFailedException migrationFailed(String detail, Throwable cause) { + emit(StartupErrorCode.MIGRATION_FAILED, detail, cause); + return new MigrationFailedException(detail, cause); + } + + /** Emits the structured startup-failure log. {@code cause} may be {@code null}. */ + static void emit(StartupErrorCode code, String detail, Throwable cause) { + StartupFailureLogState.markStartupFailureReported(); + if (cause == null) { + log.error( + "startup failure in phase {}: {}", + kv("startup.phase", code.phase().wireName()), + detail, + kv("error.code", code.name()), + kv("error.category", code.category())); + } else { + Throwable rootCause = rootCause(cause); + log.error( + "startup failure in phase {}: {} (root cause {}: {})", + kv("startup.phase", code.phase().wireName()), + detail, + kv("error.root_cause.class", rootCause.getClass().getName()), + kv("error.root_cause.message", safeMessage(rootCause)), + kv("error.code", code.name()), + kv("error.category", code.category())); + } + } + + private static Throwable rootCause(Throwable cause) { + Throwable current = cause; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private static String safeMessage(Throwable cause) { + String message = cause.getMessage(); + return message == null ? "" : message; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupPhase.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupPhase.java new file mode 100644 index 00000000..fd9fa9d1 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupPhase.java @@ -0,0 +1,29 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +/** + * The startup lifecycle phase a fail-fast guard belongs to, written to the {@code startup.phase} + * structured-log field so an operator can tell the four failure causes apart in the logs. See + * README for the design rationale. + */ +public enum StartupPhase { + + /** Required environment / configuration value missing or malformed (exit 78). */ + ENV_VALIDATION("env-validation"), + /** Flyway forward-only migration failed (exit 70). */ + MIGRATION("migration"), + /** A required capability adapter/bean was disabled or absent (exit 72). */ + ADAPTER_ENABLEMENT("adapter-enablement"), + /** An active profile conflicts with an enabled prod-forbidden setting (exit 71). */ + PROFILE_CHECK("profile-check"); + + private final String wireName; + + StartupPhase(String wireName) { + this.wireName = wireName; + } + + /** The verbatim value written to the {@code startup.phase} structured-log field. */ + public String wireName() { + return wireName; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupValidationException.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupValidationException.java new file mode 100644 index 00000000..cfbfcee4 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupValidationException.java @@ -0,0 +1,14 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +/** + * A required environment / configuration value is missing or malformed (STARTUP_VALIDATION_FAILED, + * exit 78 — sysexits {@code EX_CONFIG}). Phase: env-validation. + */ +public class StartupValidationException extends StartupFailureException { + + private static final long serialVersionUID = 1L; + + public StartupValidationException(String message) { + super(StartupErrorCode.STARTUP_VALIDATION_FAILED, message, null); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/BootstrapSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/BootstrapSettings.java new file mode 100644 index 00000000..c478995c --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/BootstrapSettings.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.settings; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Bootstrap-scope settings bound from {@code ca-skeleton.bootstrap.*}. {@code appName} is fail-fast + * (@NotBlank): no sensible default, so a blank value must stop startup rather than warn-and-default + * like the other settings. See README for the design rationale. + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.bootstrap") +public record BootstrapSettings( + @NotBlank( + message = "APP_NAME (ca-skeleton.bootstrap.app-name) is required and must not be blank") + String appName) {} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/LoggingSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/LoggingSettings.java new file mode 100644 index 00000000..f334f46e --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/LoggingSettings.java @@ -0,0 +1,87 @@ +package dev.caskeleton.bootstrap.settings; + +import java.time.ZoneId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Logging settings bound from {@code ca-skeleton.logging.*}. Every knob is "warn-and-default": a + * bad value logs a warning and proceeds with a safe fallback rather than failing startup. See + * README for the design rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.logging") +public record LoggingSettings(File file, Async async, Json json) { + + public record File( + boolean enabled, String path, String maxSize, int maxHistory, String totalSizeCap) { + private static final Logger log = LoggerFactory.getLogger(File.class); + + public File { + if (enabled) { + if (path == null || path.isBlank()) { + log.warn( + "APP_LOG_FILE_PATH is blank while APP_LOG_FILE_ENABLED=true; using 'logs/app.json'"); + path = "logs/app.json"; + } + if (maxHistory <= 0) { + log.warn("APP_LOG_FILE_MAX_HISTORY must be >= 1 (got {}); using 14", maxHistory); + maxHistory = 14; + } + // Size strings (maxSize, totalSizeCap) are parsed by logback; let it own that contract. + } + } + } + + public record Async(boolean enabled, int queueSize, int discardingThreshold) { + private static final Logger log = LoggerFactory.getLogger(Async.class); + + public Async { + if (enabled) { + if (queueSize <= 0) { + log.warn("APP_LOG_ASYNC_QUEUE_SIZE must be >= 1 (got {}); using 512", queueSize); + queueSize = 512; + } + if (discardingThreshold < 0) { + log.warn( + "APP_LOG_ASYNC_DISCARDING_THRESHOLD must be >= 0 (got {}); using 20", + discardingThreshold); + discardingThreshold = 20; + } + } + } + } + + public record Json( + String timezone, String timestampPattern, boolean includeCallerData, int loggerNameLength) { + private static final Logger log = LoggerFactory.getLogger(Json.class); + private static final String DEFAULT_TIMEZONE = "UTC"; + private static final String DEFAULT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; + + public Json { + if (timezone == null || timezone.isBlank()) { + log.warn("APP_LOG_JSON_TIMEZONE is blank; using '{}'", DEFAULT_TIMEZONE); + timezone = DEFAULT_TIMEZONE; + } else if (!"default".equalsIgnoreCase(timezone)) { + try { + ZoneId.of(timezone); + } catch (Exception e) { + log.warn( + "APP_LOG_JSON_TIMEZONE '{}' is not a valid IANA zone; using '{}'", + timezone, + DEFAULT_TIMEZONE); + timezone = DEFAULT_TIMEZONE; + } + } + if (timestampPattern == null || timestampPattern.isBlank()) { + log.warn("APP_LOG_JSON_TIMESTAMP_PATTERN is blank; using ISO 8601 default"); + timestampPattern = DEFAULT_PATTERN; + } + if (loggerNameLength < 0) { + log.warn( + "APP_LOG_JSON_LOGGER_NAME_LENGTH must be >= 0 (got {}); using 0", loggerNameLength); + loggerNameLength = 0; + } + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/PrivacySettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/PrivacySettings.java new file mode 100644 index 00000000..d237d4c0 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/PrivacySettings.java @@ -0,0 +1,34 @@ +package dev.caskeleton.bootstrap.settings; + +import java.nio.charset.StandardCharsets; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Privacy settings bound from {@code ca-skeleton.privacy.*}. Holds the HMAC salt used to + * pseudonymize {@code user_principal} in security/audit logs. A blank salt is "warn-and-default": + * it logs a warning and falls back to a dev sentinel so local/test runs never fail to start. See + * README for the design rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.privacy") +public record PrivacySettings(String pseudonymizationSalt) { + + private static final Logger log = LoggerFactory.getLogger(PrivacySettings.class); + + /** Dev/local fallback salt — the {@code __LOCAL_DEV_} prefix is the prod-unsafe sentinel. */ + static final String DEV_SENTINEL_SALT = "__LOCAL_DEV_pseudonymization_salt"; + + public PrivacySettings { + if (pseudonymizationSalt == null || pseudonymizationSalt.isBlank()) { + log.warn( + "APP_PRIVACY_PSEUDONYMIZATION_SALT is blank; using a dev sentinel salt. " + + "Set a real secret-manager value before production."); + pseudonymizationSalt = DEV_SENTINEL_SALT; + } + } + + public byte[] saltBytes() { + return pseudonymizationSalt.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/RuntimeSafetySettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/RuntimeSafetySettings.java new file mode 100644 index 00000000..4bf6ab58 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/settings/RuntimeSafetySettings.java @@ -0,0 +1,16 @@ +package dev.caskeleton.bootstrap.settings; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * Operational safety toggles bound from {@code ca-skeleton.runtime.*} (all default {@code false} + * except migrationOnStartup which defaults to {@code true}), enforced at startup by {@code + * StartupSafetyValidator}. See README for the design rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.runtime") +public record RuntimeSafetySettings( + boolean errorDetailExposureEnabled, + boolean logBodyCaptureEnabled, + boolean multiInstanceEnabled, + @DefaultValue("true") boolean migrationOnStartup) {} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/MicrometerSpanErrorRecorder.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/MicrometerSpanErrorRecorder.java new file mode 100644 index 00000000..fcc358c5 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/MicrometerSpanErrorRecorder.java @@ -0,0 +1,30 @@ +package dev.caskeleton.bootstrap.tracing; + +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; + +/** Micrometer/OTel-backed {@link SpanErrorRecorder}. See README for the design rationale. */ +public final class MicrometerSpanErrorRecorder implements SpanErrorRecorder { + + private final Tracer tracer; + + public MicrometerSpanErrorRecorder(Tracer tracer) { + this.tracer = tracer; + } + + @Override + public void recordException(Throwable error, String errorCode) { + if (error == null) { + return; + } + Span span = tracer.currentSpan(); + if (span == null) { + return; + } + span.error(error); + if (errorCode != null) { + span.tag("error.code", errorCode); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingConfig.java new file mode 100644 index 00000000..4cb25546 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingConfig.java @@ -0,0 +1,96 @@ +package dev.caskeleton.bootstrap.tracing; + +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.otel.bridge.OtelBaggageManager; +import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext; +import io.micrometer.tracing.otel.bridge.OtelTracer; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.List; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** Composition-root wiring for distributed tracing. See README for the design rationale. */ +@Configuration +@EnableConfigurationProperties(TracingSettings.class) +public class TracingConfig { + + private final TracingSettings properties; + private final Environment environment; + private final ObjectProvider<MeterRegistry> meterRegistryProvider; + + // ObjectProvider<MeterRegistry> so wiring works even when no MeterRegistry bean exists. + public TracingConfig( + TracingSettings properties, + Environment environment, + ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.properties = properties; + this.environment = environment; + this.meterRegistryProvider = meterRegistryProvider; + } + + @Bean + public TracingSamplingRateGaugeRegistrar tracingSamplingRateGaugeRegistrar() { + String activeProfile = resolveActiveProfile(); + double effectiveRate = + new TracingSampleRateResolver().resolve(activeProfile, properties.sampleRate()); + TracingSamplingRateGauge.register(activeProfile, effectiveRate, meterRegistryProvider); + return new TracingSamplingRateGaugeRegistrar(activeProfile, effectiveRate); + } + + private String resolveActiveProfile() { + String[] profiles = environment.getActiveProfiles(); + return (profiles != null && profiles.length > 0) ? profiles[0] : "local"; + } + + @Bean + @ConditionalOnMissingBean(Tracer.class) + @ConditionalOnProperty( + prefix = "ca-skeleton.tracing", + name = "enabled", + havingValue = "true", + matchIfMissing = true) + Tracer micrometerTracer() { + double sampleRate = + new TracingSampleRateResolver().resolve(resolveActiveProfile(), properties.sampleRate()); + SdkTracerProvider tracerProvider = + SdkTracerProvider.builder() + .setSampler(Sampler.parentBased(Sampler.traceIdRatioBased(sampleRate))) + .build(); + OpenTelemetrySdk openTelemetry = + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build(); + OtelCurrentTraceContext currentTraceContext = new OtelCurrentTraceContext(); + return new OtelTracer( + openTelemetry.getTracer("dev.caskeleton"), + currentTraceContext, + event -> {}, + new OtelBaggageManager(currentTraceContext, List.of(), List.of())); + } + + /** + * {@link ObjectProvider}, not {@code @ConditionalOnBean(Tracer.class)}: a missing {@link Tracer} + * yields {@link SpanErrorRecorder#NOOP} rather than no bean, so downstream injection points never + * break when tracing is off. + */ + @Bean + @ConditionalOnMissingBean(SpanErrorRecorder.class) + SpanErrorRecorder micrometerSpanErrorRecorder(ObjectProvider<Tracer> tracerProvider) { + Tracer tracer = tracerProvider.getIfAvailable(); + if (tracer == null) { + return SpanErrorRecorder.NOOP; + } + return new MicrometerSpanErrorRecorder(tracer); + } + + /** Exists so tests can inspect what was registered without a live MeterRegistry. */ + public record TracingSamplingRateGaugeRegistrar( + String activeProfile, double effectiveSampleRate) {} +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSampleRateResolver.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSampleRateResolver.java new file mode 100644 index 00000000..3a5d4c7a --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSampleRateResolver.java @@ -0,0 +1,47 @@ +package dev.caskeleton.bootstrap.tracing; + +import java.util.Locale; + +/** + * Resolves the effective tracing sample rate from the active profile and the configured override. + * Plain Java, no Spring dependency, so it is reusable from both the {@code + * EnvironmentPostProcessor} and bean phases. See README for the design rationale. + */ +public final class TracingSampleRateResolver { + + /** + * @param profile Spring active profile name; {@code null}/blank treated as "anything else" + */ + public static double defaultRateForProfile(String profile) { + if (profile == null) { + return 1.0; + } + return switch (profile.trim().toLowerCase(Locale.ROOT)) { + case "prod" -> 0.01; + case "staging" -> 0.10; + case "dev" -> 1.0; + case "local" -> 1.0; + default -> 1.0; + }; + } + + /** + * A configured override wins over the per-profile default. + * + * @param configuredRate raw string from {@code APP_TRACING_SAMPLE_RATE}; may be blank + * @return effective sample rate in [0.0, 1.0] + */ + public double resolve(String profile, String configuredRate) { + if (configuredRate != null && !configuredRate.isBlank()) { + try { + double v = Double.parseDouble(configuredRate.trim()); + if (v >= 0.0 && v <= 1.0) { + return v; + } + } catch (NumberFormatException ignored) { + // fall through to profile default + } + } + return defaultRateForProfile(profile); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessor.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessor.java new file mode 100644 index 00000000..38e25a18 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessor.java @@ -0,0 +1,38 @@ +package dev.caskeleton.bootstrap.tracing; + +import java.util.Map; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * Bridges the resolved tracing sample rate into Spring Boot's native {@code + * management.tracing.sampling.probability}. Registered via {@code META-INF/spring.factories}. See + * README for the design rationale. + */ +public class TracingSamplingEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String NATIVE_KEY = "management.tracing.sampling.probability"; + private static final String CA_SAMPLE_RATE_KEY = "ca-skeleton.tracing.sample-rate"; + + private final TracingSampleRateResolver resolver = new TracingSampleRateResolver(); + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, SpringApplication application) { + // A user-supplied value takes precedence. + if (environment.containsProperty(NATIVE_KEY)) { + return; + } + String[] profiles = environment.getActiveProfiles(); + String profile = (profiles != null && profiles.length > 0) ? profiles[0] : "local"; + String configured = environment.getProperty(CA_SAMPLE_RATE_KEY); + double rate = resolver.resolve(profile, configured); + environment + .getPropertySources() + .addLast( + new MapPropertySource( + "tracingSamplingBridge", Map.of(NATIVE_KEY, String.valueOf(rate)))); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingRateGauge.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingRateGauge.java new file mode 100644 index 00000000..31061adc --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingRateGauge.java @@ -0,0 +1,41 @@ +package dev.caskeleton.bootstrap.tracing; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; + +/** + * Registers the {@code tracing.sampling.rate} gauge. Resolves the registry via an {@link + * ObjectProvider} so it stays a no-op when no {@code MeterRegistry} bean is present. See README for + * the design rationale. + */ +public final class TracingSamplingRateGauge { + + private static final Logger log = LoggerFactory.getLogger(TracingSamplingRateGauge.class); + + public static final String METRIC_NAME = "tracing.sampling.rate"; + public static final String TAG_PROFILE = "profile"; + + private TracingSamplingRateGauge() {} + + public static void register( + String activeProfile, + double effectiveSampleRate, + ObjectProvider<MeterRegistry> meterRegistryProvider) { + + MeterRegistry registry = meterRegistryProvider.getIfAvailable(); + if (registry == null) { + return; // no-op: no Actuator / MeterRegistry on classpath + } + try { + Gauge.builder(METRIC_NAME, () -> effectiveSampleRate) + .tag(TAG_PROFILE, activeProfile != null ? activeProfile : "unknown") + .description("Effective distributed-tracing sample rate for the active profile") + .register(registry); + } catch (RuntimeException ex) { + log.warn("tracing metrics: failed to register gauge {}", METRIC_NAME, ex); + } + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSettings.java new file mode 100644 index 00000000..0038e641 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/tracing/TracingSettings.java @@ -0,0 +1,100 @@ +package dev.caskeleton.bootstrap.tracing; + +import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; +import java.net.URI; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Tracing settings bound from {@code ca-skeleton.tracing.*}. A blank {@code sampleRate} means + * "defer to the per-profile default". See README for the design rationale. + */ +@ConfigurationProperties(prefix = "ca-skeleton.tracing") +@Validated +public record TracingSettings(boolean enabled, String sampleRate, Exporter exporter) { + + /** Nested properties for the OTLP exporter endpoint. */ + public record Exporter(String otlpEndpoint) { + + public Exporter { + if (otlpEndpoint == null) { + otlpEndpoint = ""; + } + } + + public boolean isConfigured() { + return otlpEndpoint != null && !otlpEndpoint.isBlank(); + } + } + + public TracingSettings { + if (sampleRate == null) { + sampleRate = ""; + } + if (exporter == null) { + exporter = new Exporter(""); + } + // Blank sampleRate = defer to the per-profile default; skip numeric validation. + if (!sampleRate.isBlank()) { + validateSampleRate(sampleRate); + } + validateOtlpEndpoint(exporter.otlpEndpoint()); + } + + /** + * Precondition: {@link #sampleRate()} must be non-blank — blank means "defer to the per-profile + * default", so callers must check {@code sampleRate().isBlank()} first (throws {@link + * IllegalStateException} otherwise). + */ + public double sampleRateValue() { + if (sampleRate.isBlank()) { + throw new IllegalStateException( + "sampleRateValue() called on a blank sampleRate — blank means " + + "\"defer to TracingSampleRateResolver per-profile default\". " + + "Check sampleRate().isBlank() before calling this method."); + } + return Double.parseDouble(sampleRate.trim()); + } + + private static void validateSampleRate(String raw) { + try { + double v = Double.parseDouble(raw.trim()); + if (v < 0.0 || v > 1.0) { + throw StartupFailures.envValidation( + "APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0] " + + "(feature-distributed-tracing-contract D6 float_between_0_and_1); " + + "got: " + + raw); + } + } catch (NumberFormatException ex) { + throw StartupFailures.envValidation( + "APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0] " + + "(feature-distributed-tracing-contract D6 float_between_0_and_1); " + + "got: " + + raw); + } + } + + private static void validateOtlpEndpoint(String endpoint) { + if (endpoint == null || endpoint.isBlank()) { + return; // empty = exporter off + } + try { + URI uri = URI.create(endpoint.trim()); + String scheme = uri.getScheme(); + if (scheme == null || scheme.isBlank()) { + throw StartupFailures.envValidation( + "OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL with a scheme " + + "(feature-distributed-tracing-contract D1 url_or_empty); " + + "got: " + + endpoint); + } + } catch (IllegalArgumentException ex) { + throw StartupFailures.envValidation( + "OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL or empty " + + "(feature-distributed-tracing-contract D1 url_or_empty); " + + "got: " + + endpoint); + } + } +} diff --git a/src/app-bootstrap/src/main/resources/META-INF/spring.factories b/src/app-bootstrap/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..ba406412 --- /dev/null +++ b/src/app-bootstrap/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +org.springframework.boot.EnvironmentPostProcessor=\ +dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor + +org.springframework.boot.SpringBootExceptionReporter=\ +dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml new file mode 100644 index 00000000..dd7c7366 --- /dev/null +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -0,0 +1,529 @@ +# ============================================================================= +# Mirrors src/.env into Spring's Environment. Comments document allowed values; +# input validation lives in the *Settings records under each module. +# ============================================================================= + +spring: + application: + # free-form string (also exposed as the JSON log "app" field) + name: ${APP_NAME} + mvc: + problemdetails: + # Architectural decision D1/D5: RFC 7807 ProblemDetail is rejected in favour + # of the custom {success,data,error,meta} envelope. Pin OFF explicitly so a + # future Spring Boot default flip cannot silently re-enable it. + enabled: false + web: + error: + # always | never | on_param + include-stacktrace: ${APP_SERVER_ERROR_INCLUDE_STACKTRACE} + # always | never | on_param + include-message: ${APP_SERVER_ERROR_INCLUDE_MESSAGE} + profiles: + # common values: local | dev | stage | prod (free-form) + active: ${SPRING_PROFILES_ACTIVE:local} + datasource: + # jdbc URL: jdbc:postgresql://host:5432/db + url: ${APP_DATASOURCE_URL} + username: ${APP_DATASOURCE_USERNAME} + password: ${APP_DATASOURCE_PASSWORD} + driver-class-name: ${APP_DATASOURCE_DRIVER} + hikari: + # D1 (feature-database-connection-pool-contract): small-pool axiom + PostgreSQL formula + # starting point (maximumPoolSize = cores * 2 + effective_spindle_count, adjust via load + # test). Fixed-size pool recommended (minimumIdle = maximumPoolSize). Must satisfy + # application-port D12 REQUIRES_NEW lower bound: + # maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1 + # Value owner: feature-env-driven-runtime-configuration (APP_DATASOURCE_POOL_MAX_SIZE). + # integer >= 1 + maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE} + # D1: fixed-size pool recommended (minimumIdle = maximumPoolSize per HikariCP #HIKARI-CFG-C8). + # Current registry value min-idle=2 is a MIN_IDLE_POLICY_DRIFT vs the fixed-size + # recommendation; value alignment is delegated to feature-env-driven-runtime-configuration. + # integer >= 0 + minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE} + # D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than + # holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by + # HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds. + # CONNECTION_TIMEOUT_FORMAT_DRIFT: env-keys.yaml default is "5s" (Duration string) while + # src/.env carries 30000 (ms). HikariPoolConstraintValidator reads this defensively as a + # String to avoid ConversionFailedException on the drift value. Alignment is delegated to + # feature-env-driven-runtime-configuration (APP_DATASOURCE_CONNECTION_TIMEOUT). + # milliseconds (or Spring Duration string when env-keys default overrides) + connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT} + # milliseconds + idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT} + # D3 (HIKARI-CFG-C2): must be several seconds shorter than the DB/infrastructure idle + # timeout (DB wait_timeout, PgBouncer idle_transaction_timeout, firewall NAT timeout). + # Current default (30 min) is a placeholder until the actual DB wait_timeout is confirmed + # (see §Claims "DB wait_timeout 미확인" — needs-confirmation). Recommended: DB_idle_limit + # minus at least 60 s as a conservative margin. Value owner: feature-env-driven. + # milliseconds + max-lifetime: ${APP_DATASOURCE_POOL_MAX_LIFETIME} + # D4 (HIKARI-CFG-C4): greenfield — ping idle connections to prevent NAT/firewall/DB + # idle-kill from silently dropping them. Constraint: keepalive-time < max-lifetime + # (enforced by HikariPoolConstraintValidator). Provisional literal 120 000 ms (2 min); + # adjust once actual DB/firewall idle timeout is confirmed (§Claims). + # UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default. + # New env key APP_DATASOURCE_KEEPALIVE_TIME registration delegated to + # feature-env-driven-runtime-configuration. + keepalive-time: 120000 + # D5 (HIKARI-CFG-C5): greenfield — enable connection leak early warning. Value must be + # >= 2000 ms to activate (enforced by HikariPoolConstraintValidator; 0 = disabled). + # Provisional literal 30 000 ms (30 s) — chosen well above the estimated longest normal + # transaction (~5 s) to avoid false positives on legitimate slow operations. + # UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default. + # New env key APP_DATASOURCE_LEAK_DETECTION_THRESHOLD delegated to + # feature-env-driven-runtime-configuration. + leak-detection-threshold: 30000 + # D7 (HIKARI-CFG-C6): greenfield — must be < connection-timeout (HIKARI-CFG-C6), + # enforced by HikariPoolConstraintValidator. Resolves VALIDATION_TIMEOUT_CONFLICT: + # HikariCP default 5000 ms equals connection-timeout 5 s / 5000 ms → constraint + # violation. Provisional literal 3000 ms satisfies the constraint for connection-timeout + # values >= 3001 ms. + # UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default. + # New env key APP_DATASOURCE_VALIDATION_TIMEOUT delegated to + # feature-env-driven-runtime-configuration. + validation-timeout: 3000 + # D6 (HIKARI-CFG-C7): greenfield — keep positive (default = 1 ms) to fail fast when DB + # is unavailable at startup. Aligns with runtime-health startup validation and project-note + # §9 "잘못된 env 값 startup fail-fast" policy. Negative value disables fail-fast (allowed + # only in orchestration environments where DB may start after the app — coordinate with + # runtime-health-lifecycle branch). + # UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default. + # New env key APP_DATASOURCE_INIT_FAIL_TIMEOUT delegated to + # feature-env-driven-runtime-configuration. + initialization-fail-timeout: 1 + # --------------------------------------------------------------------------- + # D8 (feature-database-connection-pool-contract) — SLOW QUERY DETECTION + # POLICY ONLY — no library dependency added here. Implementation is DEFERRED + # pending local verification (TODO #3: confirm ParameterTransformer masking + # applies to slow-query listener output). + # + # Baseline (app layer, param-safe): + # datasource-proxy SlowQueryListener + ParameterTransformer ([REDACTED] masking). + # Requires the datasource-proxy / spring-boot-data-source-decorator dependency + # and a @Bean ParameterTransformer; deferred until masking behavior on slow-query + # output is locally verified. + # + # Production augment (DBA-owned): + # DB-side log_min_duration_statement — parameters included in extended-protocol + # output (PostgreSQL official security warning). DBA controls and redacts. + # + # Dev only (PROD-FORBIDDEN): + # Hibernate SQL_SLOW (LOG_QUERIES_SLOWER_THAN_MS) logs materialized SQL with + # substituted parameters — violates the "SQL/param 로그 금지" hard rule from + # feature-persistence-failure-baseline. NEVER enable in prod. + # + # Rejected: + # P6Spy — no built-in masking API; effective SQL exposes parameters by default + # with no safe override. Rejected per D8 (#C3/#C4). + # --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- + # Flyway forward-only migration (feature-migration-startup-contract D1/D2/D4). + # Flyway runs during context refresh (Spring Boot autoconfig + the + # migrationStartupRunner FlywayMigrationStrategy), BEFORE the app reports + # readiness — so migration is inherently readiness-gated (D5): a failed or + # in-progress migration can never serve traffic. + # + # The three options below are PINNED, not env-driven: they are forbidden under + # prod (D2/D4) and a static pin means a future Flyway/Spring Boot default flip + # cannot silently re-enable them (same reasoning as spring.mvc.problemdetails. + # enabled above). FlywayProdSafetyValidator fails the boot (exit 71) if any + # per-environment override re-enables them under the prod profile. + # --------------------------------------------------------------------------- + flyway: + # false: never auto-baseline an existing schema — keep the missing-migration + # safety net (FLYWAY-C6). Enabling under prod is forbidden (D4). + baseline-on-migrate: false + # false: reject out-of-order migrations — preserve cross-developer ordering + # consistency (FLYWAY-C5). Enabling under prod is forbidden (D4). + out-of-order: false + # true: keep Flyway `clean` (drops the whole schema) disarmed. Disabling this + # under prod is forbidden — clean is destructive. + clean-disabled: true + jpa: + hibernate: + # none | validate | update | create | create-drop + ddl-auto: ${APP_DATASOURCE_DDL_AUTO} + # true | false + show-sql: ${APP_DATASOURCE_SHOW_SQL} + # true | false (don't enable in prod) + open-in-view: ${APP_DATASOURCE_OPEN_IN_VIEW} + properties: + hibernate: + # true | false (paired with show-sql) + format_sql: ${APP_DATASOURCE_FORMAT_SQL} + security: + oauth2: + resourceserver: + jwt: + # OIDC issuer (e.g., Keycloak realm URL). Spring Boot resolves JWKS from + # /.well-known/openid-configuration at this URI. + issuer-uri: ${APP_SECURITY_JWT_ISSUER} + # validated against the JWT `aud` claim; blank disables the check + audiences: ${APP_SECURITY_JWT_AUDIENCE} + main: + # off | console | log + banner-mode: ${SPRING_BANNER_MODE} + # true | false + lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION} + # true | false + log-startup-info: ${SPRING_MAIN_LOG_STARTUP_INFO} + threads: + virtual: + # true | false (Java 21 virtual threads for Tomcat request handlers) + enabled: ${SPRING_THREADS_VIRTUAL_ENABLED} + servlet: + multipart: + # feature-api-contract-baseline D8: bound request body size so an oversized + # upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via + # GlobalExceptionHandler#handleMaxUploadSizeExceededException), never a raw 500. + # Multipart-specific upload limits (UPLOAD_SIZE_EXCEEDED) are refined by + # feature-file-resource-handling-contract. + max-file-size: ${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:10MB} + max-request-size: ${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:10MB} + # Jackson deserialization policy (feature-boundary-validation-mapping-contract B1). + # Every request DTO crosses this boundary; the switches make malformed payloads + # fail at the edge rather than silently coercing or dropping fields. + jackson: + deserialization: + # true | false (Jackson 2.13+ default; reject unknown JSON keys) + fail-on-unknown-properties: ${SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES} + # true | false (block JSON null → primitive 0/false coercion) + fail-on-null-for-primitives: ${SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES} + # true | false (surface JSON containing fields the target @JsonIgnore'd) + fail-on-ignored-properties: ${SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES} + # Serialization output policy (feature-schema-serialization-contract D2/D3). + # These mirror current defaults but are pinned so a future Spring Boot default + # flip cannot silently break the datetime / decimal wire contract — the same + # reasoning as spring.mvc.problemdetails.enabled above. + datatype: + enum: + # true | false (false = Jackson default; unknown enum -> throw, not null) + read-unknown-enum-values-as-null: ${SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL} + datetime: + # true | false (false: java.time -> ISO-8601 string via JavaTimeModule, D2) + write-dates-as-timestamps: ${SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS} + lifecycle: + # duration: 30s | 1m | 500ms + timeout-per-shutdown-phase: ${APP_SERVER_SHUTDOWN_TIMEOUT} + +# --------------------------------------------------------------------------- +# Actuator / management — MERGED ownership at integration: +# - feature-management-actuator-security-contract owns server.port, the exposure +# allowlist/exclude, health show-details, shutdown-disabled, and info. +# - feature-runtime-health-lifecycle-contract owns the health probe SHAPE +# (probes.enabled + the liveness/readiness/startup groups + dependency taxonomy). +# --------------------------------------------------------------------------- +management: + # feature-management-actuator-security-contract D1: separate management port so actuator + # endpoints are not exposed on the same socket as the app API. Default: 9001. + server: + port: ${MANAGEMENT_SERVER_PORT:9001} + endpoints: + web: + # D2: production allowlist — only safe, scrape-friendly endpoints are exposed. + exposure: + include: health,prometheus,info,loggers + # D2/D4/D5: explicitly excluded dangerous endpoints (env leaks secrets, + # heapdump/threaddump = memory forensics, shutdown = remote kill, configprops = secret leak). + exclude: env,configprops,heapdump,threaddump,shutdown + endpoint: + health: + # D8: never expose health details to unauthenticated callers. + show-details: when-authorized + # feature-runtime-health-lifecycle-contract: expose the Kubernetes-ready + # liveness/readiness/startup probe paths. + probes: + enabled: true + group: + # Liveness: JVM can continue (OOM → liveness DOWN → pod restart). + # Only livenessState is included; dependency health MUST NOT be here — + # a DOWN DB must never trigger a pod restart when the JVM is healthy. + liveness: + include: livenessState + # Readiness: ready to serve traffic AND all REQUIRED dependencies up. + # Required: db (primary DB — auto-contributed by Spring Boot DataSource). + # Optional: cache / broker / notification adapters are NOT in this group + # (they are conditional or optional per the dependency taxonomy). + readiness: + include: readinessState,db + # Startup: startup/migration validation complete. + # readinessState acts as the startup completion gate — it flips UP only + # after the context is fully initialized (Flyway migration included). + startup: + include: readinessState + shutdown: + # D4: shutdown endpoint disabled globally — even if somehow exposed, it cannot be invoked. + access: none + info: + build: + # Build-info only (no env leak). + enabled: true + +server: + # integer 1-65535 + port: ${APP_SERVER_PORT} + # graceful | immediate + shutdown: ${APP_SERVER_SHUTDOWN} + # none | native | framework (trust X-Forwarded-* behind LB/proxy) + forward-headers-strategy: ${APP_SERVER_FORWARD_HEADERS_STRATEGY} + tomcat: + threads: + # integer >= 1 + max: ${APP_SERVER_TOMCAT_MAX_THREADS} + # integer >= 0 + min-spare: ${APP_SERVER_TOMCAT_MIN_SPARE_THREADS} + # integer >= 0 (OS-level backlog queue depth) + accept-count: ${APP_SERVER_TOMCAT_ACCEPT_COUNT} + # integer >= 1 (hard cap on simultaneously open connections) + max-connections: ${APP_SERVER_TOMCAT_MAX_CONNECTIONS} + # duration: 20s | 1m + connection-timeout: ${APP_SERVER_TOMCAT_CONNECTION_TIMEOUT} + compression: + # true | false + enabled: ${APP_SERVER_COMPRESSION_ENABLED} + # bytes or sized: 1024 | 1KB | 2KB + min-response-size: ${APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE} +logging: + level: + # TRACE | DEBUG | INFO | WARN | ERROR | OFF + root: ${APP_LOG_LEVEL_ROOT} + dev.caskeleton: ${APP_LOG_LEVEL_APP} + org.springframework: ${APP_LOG_LEVEL_SPRING} + org.springframework.web: ${APP_LOG_LEVEL_WEB} + # DEBUG here prints SQL once JPA/jdbc is wired in + org.hibernate.SQL: ${APP_LOG_LEVEL_SQL} + +# Module-scoped knobs. Each block is bound into a *Settings @ConfigurationProperties +# record in the corresponding module, which is where allowed-value validation lives. +ca-skeleton: + bootstrap: + # required, non-blank — startup fails if blank (see BootstrapSettings) + app-name: ${APP_NAME} + runtime: + # feature-env-driven-runtime-configuration D8: enforced at startup by + # StartupSafetyValidator. The two prod-unsafe toggles fail startup if true under + # the prod profile; multi-instance fails startup if its coordination beans are absent. + # true | false (internal error detail in responses — forbidden under prod) + error-detail-exposure-enabled: ${APP_ERROR_DETAIL_EXPOSURE_ENABLED:false} + # true | false (request/response body capture in logs — forbidden under prod) + log-body-capture-enabled: ${APP_LOG_BODY_CAPTURE_ENABLED:false} + # true | false (requires the 5 instance-coordination beans when true) + multi-instance-enabled: ${APP_MULTI_INSTANCE_ENABLED:false} + # true | false (whether to run Flyway migrations automatically at startup) + migration-on-startup: ${APP_MIGRATION_ON_STARTUP:true} + # feature-distributed-lock-contract D5 — distributed lock acquisition contract (try-lock + + # finite wait + lease TTL). Bound to LockSettings (adapter-persistence). Plain values, NOT + # APP_* env keys — new env-key registration is feature-env-driven-runtime-configuration's + # domain (out of scope for this branch); code defaults in LockSettings mirror these. + lock: + wait-time: 3s + lease-ttl: 30s + presentation: + # feature-api-contract-baseline D2: API version prefix. Default is the URI + # prefix "/v1" (major-version path, AIP-185); override via env, or set "" for + # no prefix. The supplemental "X-Api-Version" header never overrides the path. + api-base-path: ${PRESENTATION_API_BASE_PATH:/v1} + rate-limit: + # feature-rate-limit-idempotency-contract D1/§G. enabled is env-driven + # (restart-only); limit/window are the fixed-window mechanism's literal tuning + # parameters (no env key — UNSUPPORTED_IMPL per-key counter, single-node D5). + enabled: ${APP_RATE_LIMIT_ENABLED} + limit: 100 + window: 1s + # RateLimiter strategy: fixed-window (default) | (extend: sliding-window | token-bucket) + algorithm: fixed-window + # Client IP source for unauthenticated rate-limit keys: + # remote-addr-only (safe default) | forwarded-headers-trusted (only behind trusted ingress/LB) + client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only} + idempotency: + # feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h, + # validated in IdempotencyProperties); reaper-interval is literal operational tuning. + ttl: ${APP_IDEMPOTENCY_TTL} + reaper-interval: 10m + async: + # feature-background-job-async-contract D7 — @Async ThreadPoolTaskExecutor pool sizing. + # Registry SSOT: docs/registries/env-keys.yaml (APP_ASYNC_EXECUTOR_* rows 1337-1377). + # Bound into AsyncExecutorSettings @ConfigurationProperties(prefix = "ca-skeleton.async.executor"). + # Bounded queue is mandatory (D7) — an unbounded queue makes max-size unreachable. + executor: + # int >= 1 (positive_int). Always-alive worker count. + core-size: ${APP_ASYNC_EXECUTOR_CORE_SIZE:10} + # int >= core-size (positive_int_ge_core). Hard ceiling on workers. + max-size: ${APP_ASYNC_EXECUTOR_MAX_SIZE:50} + # int in 1..<Integer.MAX_VALUE (positive_int_bounded). Bounded backlog depth. + queue-capacity: ${APP_ASYNC_EXECUTOR_QUEUE_CAPACITY:200} + outbox: + # feature-domain-event-outbox-contract I11 — all six values are literal defaults; + # NO env placeholders (spec: 신규 env key 없음). Bound into OutboxProperties. + # true | false — enable/disable the relay scheduler (OutboxRelayScheduler) + relay-enabled: true + # ISO-8601 duration — how often the relay polls for pending rows + poll-interval: PT5S + # integer >= 1 — maximum rows claimed per relay cycle + batch-size: 20 + # ISO-8601 duration — IN_FLIGHT orphan visibility window (I6: next_attempt_at reuse) + in-flight-timeout: PT5M + # ISO-8601 duration — read also by adapter-persistence OutboxReaper @Scheduled + reaper-interval: PT10M + # ISO-8601 duration — PUBLISHED row retention before reaper deletes them (I3) + # read also by adapter-persistence OutboxReaper via ${ca-skeleton.outbox.published-retention:P7D} + published-retention: P7D + security: + # REQUIRED; startup fails if blank + issuer-uri: ${APP_SECURITY_JWT_ISSUER} + # blank to skip audience check + audience: ${APP_SECURITY_JWT_AUDIENCE} + # comma-separated list (Spring binds to List<String>) + public-paths: ${SECURITY_PUBLIC_PATHS} + authz: + # feature-authentication-authorization-contract D2/D3/D8: app-side role→permission + # mapping (the default source; IdP-issued permission claims are an out-of-scope + # alternative). Keys are RAW IdP role names (no ROLE_ prefix — that prefix only + # exists on Spring authorities, not on the principal's raw role set), looked up + # case-insensitively. Values are explicitly enumerated `resource:action` permissions + # (no wildcards — least-privilege, OWASP-AUTHZ-C4). The values below are the + # sample-portfolio demonstration (§5): `user` may read/write, only `admin` may close. + role-permissions: + user: worklog:read,worklog:write + admin: worklog:read,worklog:write,worklog:close + cors: + # true | false + enabled: ${APP_SECURITY_CORS_ENABLED} + # comma-separated + allowed-origins: ${APP_SECURITY_CORS_ORIGINS} + # comma-separated; empty -> defaults + allowed-methods: ${APP_SECURITY_CORS_ALLOWED_METHODS} + # comma-separated; "*" allows any + allowed-headers: ${APP_SECURITY_CORS_ALLOWED_HEADERS} + # true | false + allow-credentials: ${APP_SECURITY_CORS_ALLOW_CREDENTIALS} + # seconds + max-age-seconds: ${APP_SECURITY_CORS_MAX_AGE} + logging: + file: + # true | false (wraps console + adds rolling JSON file appender) + enabled: ${APP_LOG_FILE_ENABLED} + # relative (to src/) or absolute + path: ${APP_LOG_FILE_PATH} + # size with unit: KB | MB | GB + max-size: ${APP_LOG_FILE_MAX_SIZE} + # integer >= 1 + max-history: ${APP_LOG_FILE_MAX_HISTORY} + # size with unit or 0 + total-size-cap: ${APP_LOG_FILE_TOTAL_SIZE_CAP} + async: + # true | false (AsyncAppender wrapper for non-blocking I/O) + enabled: ${APP_LOG_ASYNC_ENABLED} + # integer >= 1 + queue-size: ${APP_LOG_ASYNC_QUEUE_SIZE} + # integer >= 0 (0 = never drop) + discarding-threshold: ${APP_LOG_ASYNC_DISCARDING_THRESHOLD} + json: + # IANA timezone (UTC | Asia/Seoul | ...) or "default" + timezone: ${APP_LOG_JSON_TIMEZONE} + # ISO 8601 pattern + timestamp-pattern: ${APP_LOG_JSON_TIMESTAMP_PATTERN} + # true | false (file/method/line — expensive) + include-caller-data: ${APP_LOG_JSON_INCLUDE_CALLER_DATA} + # integer; 0 = full name, positive = abbreviated + logger-name-length: ${APP_LOG_JSON_LOGGER_NAME_LENGTH} + # float in [0.0, 1.0] — keep-probability for <=INFO logs (prod 0.1 = 10% sampling; + # WARN/ERROR always 100%). Consumed by SamplingTurboFilter via logback <springProperty>. + sampling-rate: ${APP_LOG_SAMPLING_RATE} + privacy: + # secret-tier HMAC salt for user_principal pseudonymization (DRIFT-6; algorithm SSOT + # feature-data-retention-privacy-contract). Blank -> PrivacySettings warns + uses a dev + # sentinel; prod MUST supply a real secret-manager value. + pseudonymization-salt: ${APP_PRIVACY_PSEUDONYMIZATION_SALT:} + # feature-distributed-tracing-contract D1/D4/D6 — tracing seam settings. + # The OTel/Micrometer tracer runtime is active in this repo (Plan A — seam activated): + # micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp are wired in + # app-bootstrap/build.gradle. The exporter remains off while OTEL_EXPORTER_OTLP_ENDPOINT + # is blank (url_or_empty default). A fork can supply the endpoint via env or override + # SpanErrorRecorder with its own bean. + # D4 disabled-fallback: even when enabled=false, RequestLoggingFilter always + # generates a W3C traceparent so meta.traceId and log trace_id are never null. + # D6 per-profile defaults: prod=0.01 / staging=0.10 / dev·local=1.0. + # APP_TRACING_SAMPLE_RATE overrides the per-profile default when set. + tracing: + # true | false (boolean_strict). Tracing seam on/off. + enabled: ${APP_TRACING_ENABLED:true} + # float in [0.0, 1.0]. Per-profile override (D6 float_between_0_and_1 validation at startup). + # blank = per-profile default via TracingSampleRateResolver (D-1 ISSUE-1 fix). + sample-rate: ${APP_TRACING_SAMPLE_RATE:} + exporter: + # url_or_empty: blank = exporter off (D1 SEAM default); non-blank must be a valid URL. + otlp-endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:} + +# feature-integration-adapter-templates — optional integration adapter toggles. +# The `app.<domain>.<adapter>.enabled` flags feed Spring @ConditionalOnProperty (Layer 1): +# disabled (default): messaging binds a fail-fast Disabled* sentinel bean; cache/notification +# contribute nothing and fail fast in their router (CacheStoreRouter / RoutingNotifier) on +# unbound access. Enabled registers the real adapter +# (which needs its project-supplied integration client bean). Domain namespace, NOT a +# generic `app.adapter.*` prefix (branch-note §Audit A1). Env keys are the registry SSOT. +app: + cache: + redis: + # true | false (boolean_strict). Redis cache adapter on/off. + enabled: ${APP_CACHE_REDIS_ENABLED} + # Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default — + # forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis. + # A binding to a backend that is not enabled fails startup (Layer 3 moved to router). + messaging: + # Active message broker id (e.g. kafka). Blank = messaging disabled (fail-fast on use). + # Selects the single MessageBroker; adding a broker is new files only (MessagingConfig). + broker: ${APP_MESSAGING_BROKER} + kafka: + # CSV of host:port; required (non-empty) only when broker=kafka. Bound + + # validated by KafkaAdapterSettings (format) + KafkaAdapterConfig (required-when-active). + brokers: ${APP_MESSAGING_KAFKA_BROKERS:} + notification: + # Active provider id per kind; blank = that kind disabled (fail-fast on use). + # Add a provider = new files only (NotificationConfig); select it here. + slack: + provider: ${APP_NOTIFICATION_SLACK_PROVIDER} + email: + provider: ${APP_NOTIFICATION_EMAIL_PROVIDER} + # --------------------------------------------------------------------------- + # feature-outbound-http-client-baseline D5/D3/D7 + # Registry SSOT: docs/registries/env-keys.yaml (APP_OUTBOUND_HTTP_* rows 489–570) + # Bound into OutboundHttpSettings @ConfigurationProperties(prefix = "app.outbound.http"). + # --------------------------------------------------------------------------- + outbound: + http: + # duration (e.g. 2s). REQUIRED — non-zero (spring_duration_shorthand_non_zero). + connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT} + # duration (e.g. 5s). REQUIRED — non-zero. + read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT} + # duration (e.g. 10s). REQUIRED — non-zero (deadline budget for the whole call incl. retries). + global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT} + # true | false (boolean_strict). Resilience4j retry — default disabled (D3). + retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} + # retry 튜닝 (retry-enabled=true 일 때 적용). 기본값 = 기존 하드코딩 동작 보존. + retry: + # int >= 1 (positive_int). 총 시도 횟수(최초 시도 포함). + max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} + # duration (spring_duration_shorthand_non_zero). exponential backoff 시작 간격. + initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms} + # double >= 1.0 (double_ge_1). exponential backoff 배수. + backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0} + # true | false (boolean_strict). Resilience4j circuit breaker — default disabled. + circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false} + # circuit-breaker 튜닝 (circuit-breaker-enabled=true 일 때 적용). 기본값 = Resilience4j ofDefaults(). + circuit-breaker: + # float in (0, 100] (float_in_0_exclusive_to_100). open 전환 실패율 임계치(%). + failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50} + # int >= 1 (positive_int). COUNT_BASED sliding window 크기. + sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100} + # int >= 1 (positive_int). 실패율 계산을 시작하는 최소 호출 수. + minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100} + # duration (spring_duration_shorthand_non_zero). open 상태 유지 시간. + wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s} + # int >= 1 (positive_int). half-open 상태에서 허용하는 시험 호출 수. + permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10} + # data size (e.g. 10MB). Streaming threshold — buffered reads above this fail (D7). + response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB} diff --git a/src/app-bootstrap/src/main/resources/logback-spring.xml b/src/app-bootstrap/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..cdf865be --- /dev/null +++ b/src/app-bootstrap/src/main/resources/logback-spring.xml @@ -0,0 +1,194 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration> + <include resource="org/springframework/boot/logging/logback/defaults.xml"/> + + <!-- Early logging initializes before dotenv-backed application.yml placeholders are reliable. --> + <springProperty scope="context" name="APP_NAME" + source="APP_NAME" defaultValue="ca-skeleton"/> + <springProperty scope="context" name="APP_PROFILE" + source="SPRING_PROFILES_ACTIVE" defaultValue="local"/> + + <springProperty scope="context" name="FILE_ENABLED" + source="APP_LOG_FILE_ENABLED" defaultValue="false"/> + <springProperty scope="context" name="FILE_PATH" + source="APP_LOG_FILE_PATH" defaultValue="logs/ca-skeleton.json"/> + <springProperty scope="context" name="FILE_MAX_SIZE" + source="APP_LOG_FILE_MAX_SIZE" defaultValue="100MB"/> + <springProperty scope="context" name="FILE_MAX_HISTORY" + source="APP_LOG_FILE_MAX_HISTORY" defaultValue="14"/> + <springProperty scope="context" name="FILE_TOTAL_SIZE_CAP" + source="APP_LOG_FILE_TOTAL_SIZE_CAP" defaultValue="3GB"/> + + <springProperty scope="context" name="ASYNC_ENABLED" + source="APP_LOG_ASYNC_ENABLED" defaultValue="true"/> + <springProperty scope="context" name="ASYNC_QUEUE_SIZE" + source="APP_LOG_ASYNC_QUEUE_SIZE" defaultValue="512"/> + <springProperty scope="context" name="ASYNC_DISCARDING_THRESHOLD" + source="APP_LOG_ASYNC_DISCARDING_THRESHOLD" defaultValue="20"/> + + <springProperty scope="context" name="JSON_TIMEZONE" + source="APP_LOG_JSON_TIMEZONE" defaultValue="UTC"/> + <springProperty scope="context" name="JSON_TIMESTAMP_PATTERN" + source="APP_LOG_JSON_TIMESTAMP_PATTERN" + defaultValue="yyyy-MM-dd'T'HH:mm:ss.SSSXXX"/> + <springProperty scope="context" name="JSON_INCLUDE_CALLER_DATA" + source="APP_LOG_JSON_INCLUDE_CALLER_DATA" defaultValue="false"/> + <springProperty scope="context" name="JSON_LOGGER_NAME_LENGTH" + source="APP_LOG_JSON_LOGGER_NAME_LENGTH" defaultValue="0"/> + + <!-- D5/D8: ≤INFO sampling rate (prod 0.1 = 10%); WARN/ERROR always kept. --> + <springProperty scope="context" name="LOG_SAMPLING_RATE" + source="APP_LOG_SAMPLING_RATE" defaultValue="1.0"/> + + <!-- Redaction Layer 1 (DRIFT-2): %maskedMsg masks secrets in the human-readable pattern, + the SAME catalog (LogMaskingPatterns) the JSON decorator uses. --> + <conversionRule conversionWord="maskedMsg" + class="dev.caskeleton.bootstrap.logging.SecretMaskingMessageConverter"/> + + <!-- Sampling Policy (final): drop a share of ≤INFO events; WARN/ERROR pass unconditionally. --> + <turboFilter class="dev.caskeleton.bootstrap.logging.SamplingTurboFilter"> + <rate>${LOG_SAMPLING_RATE}</rate> + </turboFilter> + <turboFilter class="dev.caskeleton.bootstrap.logging.StartupFailureSpringBootLogFilter"/> + + <!-- ===================== Console appender (per profile, D10) ===================== --> + + <!-- local/dev: human-readable PatternLayout, with %maskedMsg redaction (DX exception). --> + <springProfile name="local,dev"> + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> + <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> + <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} req=%X{request_id:-} trace=%X{trace_id:-} user=%X{user_principal:-} - %maskedMsg%n</pattern> + </encoder> + </appender> + </springProfile> + + <!-- everything else (staging/prod/default/test): structured JSON + Layer 1 masking decorator. --> + <springProfile name="!local & !dev"> + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> + <encoder class="net.logstash.logback.encoder.LogstashEncoder"> + <includeContext>false</includeContext> + <customFields>{"app":"${APP_NAME}","profile":"${APP_PROFILE}"}</customFields> + <includeMdcKeyName>trace_id</includeMdcKeyName> + <includeMdcKeyName>span_id</includeMdcKeyName> + <includeMdcKeyName>request_id</includeMdcKeyName> + <includeMdcKeyName>correlation_id</includeMdcKeyName> + <includeMdcKeyName>user_principal</includeMdcKeyName> + <timeZone>${JSON_TIMEZONE}</timeZone> + <timestampPattern>${JSON_TIMESTAMP_PATTERN}</timestampPattern> + <includeCallerData>${JSON_INCLUDE_CALLER_DATA}</includeCallerData> + <shortenedLoggerNameLength>${JSON_LOGGER_NAME_LENGTH}</shortenedLoggerNameLength> + <jsonGeneratorDecorator + class="dev.caskeleton.bootstrap.logging.SecretMaskingJsonGeneratorDecorator"/> + </encoder> + </appender> + </springProfile> + + <!-- ===================== File appender (always JSON, gated) ====================== --> + + <condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition"> + <key>FILE_ENABLED</key><value>true</value> + </condition> + <if> + <then> + <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> + <file>${FILE_PATH}</file> + <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> + <fileNamePattern>${FILE_PATH}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern> + <maxFileSize>${FILE_MAX_SIZE}</maxFileSize> + <maxHistory>${FILE_MAX_HISTORY}</maxHistory> + <totalSizeCap>${FILE_TOTAL_SIZE_CAP}</totalSizeCap> + </rollingPolicy> + <encoder class="net.logstash.logback.encoder.LogstashEncoder"> + <includeContext>false</includeContext> + <customFields>{"app":"${APP_NAME}","profile":"${APP_PROFILE}"}</customFields> + <includeMdcKeyName>trace_id</includeMdcKeyName> + <includeMdcKeyName>span_id</includeMdcKeyName> + <includeMdcKeyName>request_id</includeMdcKeyName> + <includeMdcKeyName>correlation_id</includeMdcKeyName> + <includeMdcKeyName>user_principal</includeMdcKeyName> + <timeZone>${JSON_TIMEZONE}</timeZone> + <timestampPattern>${JSON_TIMESTAMP_PATTERN}</timestampPattern> + <includeCallerData>${JSON_INCLUDE_CALLER_DATA}</includeCallerData> + <shortenedLoggerNameLength>${JSON_LOGGER_NAME_LENGTH}</shortenedLoggerNameLength> + <jsonGeneratorDecorator + class="dev.caskeleton.bootstrap.logging.SecretMaskingJsonGeneratorDecorator"/> + </encoder> + </appender> + </then> + </if> + + <!-- ===================== Async wrap (drop-counting, DRIFT-5) ===================== --> + + <condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition"> + <key>ASYNC_ENABLED</key><value>true</value> + </condition> + <if> + <then> + <appender name="ASYNC_CONSOLE" class="dev.caskeleton.bootstrap.logging.MetricsAsyncAppender"> + <queueSize>${ASYNC_QUEUE_SIZE}</queueSize> + <discardingThreshold>${ASYNC_DISCARDING_THRESHOLD}</discardingThreshold> + <neverBlock>false</neverBlock> + <appender-ref ref="CONSOLE"/> + </appender> + <condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition"> + <key>FILE_ENABLED</key><value>true</value> + </condition> + <if> + <then> + <appender name="ASYNC_FILE" class="dev.caskeleton.bootstrap.logging.MetricsAsyncAppender"> + <queueSize>${ASYNC_QUEUE_SIZE}</queueSize> + <discardingThreshold>${ASYNC_DISCARDING_THRESHOLD}</discardingThreshold> + <neverBlock>false</neverBlock> + <appender-ref ref="JSON_FILE"/> + </appender> + </then> + </if> + </then> + </if> + + <!-- ============================== Root =============================== --> + + <!-- <if> may not be nested inside <root> (logback IfNestedWithinSecondPhaseElementSC); wrap + each <root> in a top-level <condition>+<if>, one per ASYNC×FILE combination. --> + <condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition"> + <key>ASYNC_ENABLED</key><value>true</value> + </condition> + <if> + <then> + <condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition"> + <key>FILE_ENABLED</key><value>true</value> + </condition> + <if> + <then> + <root level="INFO"> + <appender-ref ref="ASYNC_CONSOLE"/> + <appender-ref ref="ASYNC_FILE"/> + </root> + </then> + <else> + <root level="INFO"> + <appender-ref ref="ASYNC_CONSOLE"/> + </root> + </else> + </if> + </then> + <else> + <condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition"> + <key>FILE_ENABLED</key><value>true</value> + </condition> + <if> + <then> + <root level="INFO"> + <appender-ref ref="CONSOLE"/> + <appender-ref ref="JSON_FILE"/> + </root> + </then> + <else> + <root level="INFO"> + <appender-ref ref="CONSOLE"/> + </root> + </else> + </if> + </else> + </if> +</configuration> diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/DisabledAdapterSentinelTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/DisabledAdapterSentinelTest.java new file mode 100644 index 00000000..14849c42 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/DisabledAdapterSentinelTest.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter; +import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; +import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Layer 3 (D4) — a disabled optional adapter that is nonetheless invoked must fail fast with {@link + * AdapterDisabledException}, never a silent no-op or timeout wait (required_test {@code + * adapter-contract:adapter-disabled-runtime-call}). + * + * <p>Notification uses the router-fail-fast shape (no per-channel {@code Disabled*Notifier} + * sentinel): an unbound route on a {@link RoutingNotifier} with no providers/routes throws {@link + * AdapterDisabledException} — mirrors the cache D4 contract. + */ +class DisabledAdapterSentinelTest { + + private static final Notification STUB_NOTIFICATION = + new Notification("test@example.com", "s", "b"); + + @Test + void disabledMessagingFailsFast() { + assertThatThrownBy( + () -> new DisabledMessagePublisher().publish(new OutboundMessage("t", "k", "p"))) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("messaging"); + } + + @Test + void unwiredCacheGetFailsFast() { + assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of()).get("worklog", "k")) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("cache"); + } + + @Test + void unwiredCachePutFailsFast() { + assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of()).put("worklog", "k", "v")) + .isInstanceOf(AdapterDisabledException.class); + } + + @Test + void unwiredNotificationEmailFailsFast() { + // router-fail-fast: no providers, no routes → AdapterDisabledException on first call + assertThatThrownBy( + () -> new RoutingNotifier(List.of(), Map.of()).notify(Channel.EMAIL, STUB_NOTIFICATION)) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("notification"); + } + + @Test + void unwiredNotificationSlackFailsFast() { + assertThatThrownBy( + () -> + new RoutingNotifier(List.of(), Map.of()) + .notify(Channel.SLACK, "alerts", STUB_NOTIFICATION)) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("notification"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java new file mode 100644 index 00000000..54303c5b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java @@ -0,0 +1,263 @@ +package dev.caskeleton.adapter.outbound; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.cache.CacheRouterConfig; +import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; +import dev.caskeleton.adapter.outbound.cache.core.CacheStore; +import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter; +import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig; +import dev.caskeleton.adapter.outbound.cache.redis.RedisClient; +import dev.caskeleton.adapter.outbound.messaging.MessagingConfig; +import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher; +import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig; +import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender; +import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher; +import dev.caskeleton.adapter.outbound.notification.NotificationConfig; +import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider; +import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier; +import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailClient; +import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig; +import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackClient; +import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig; +import dev.caskeleton.adapter.outbound.support.OutboundSupportConfig; +import dev.caskeleton.application.notification.Channel; +import dev.caskeleton.application.notification.Notification; +import dev.caskeleton.application.notification.NotificationPort; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.shared.error.AdapterDisabledException; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Layer 1 (D2) — {@code @ConditionalOnProperty} bean-gating contract (required_test {@code + * adapter-contract:{redis,kafka,slack-webhook,google-email}-disabled-default}). + * + * <p>Asserts that with the default (env absent → disabled) the real adapter bean count is 0 and the + * router fails fast on unbound access; and that flipping the enable flag (with the integration + * client supplied) registers the real adapter and routes correctly. Also validates OCP: a second + * backend plugs in via new files only without changing existing configs. + * + * <p>Notification: uses the router-fail-fast shape (mirrors cache D4). No per-channel {@code + * Disabled*Notifier} sentinel — unbound route → {@link AdapterDisabledException} from {@link + * RoutingNotifier}. + */ +class OptionalAdapterBeanGatingTest { + + private static final Notification STUB_NOTIFICATION = + new Notification("test@example.com", "test", "body"); + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration( + OutboundSupportConfig.class, + MessagingConfig.class, + KafkaAdapterConfig.class, + RedisCacheAdapterConfig.class, + CacheRouterConfig.class, + NotificationConfig.class, + SlackNotificationAdapterConfig.class, + GoogleEmailNotificationAdapterConfig.class, + StubClientsConfig.class); + + @Test + void allOptionalAdaptersAreDisabledByDefaultAndTheAppStillStarts() { + // L262: an optional adapter must NOT become a required startup dependency. + runner.run( + context -> { + assertThat(context).hasNotFailed(); + + // real provider beans absent (Layer 1 — disabled, contributes nothing) + assertThat(context.getBeansOfType(MessageBroker.class)).isEmpty(); + assertThat(context.getBeansOfType(CacheStore.class)).isEmpty(); + assertThat(context.getBeansOfType(NotificationProvider.class)).isEmpty(); + + // messaging: fail-fast sentinels satisfy the ports (Layer 3 fallback) + assertThat(context.getBean(MessagePublisher.class)) + .isInstanceOf(DisabledMessagePublisher.class); + assertThat(context.getBean(OutboxMessagePublishPort.class)) + .isInstanceOf(DisabledOutboxMessagePublisher.class); + + // cache D4: zero backends boot fine, unwired access fails fast in the router + CacheStoreRouter cacheRouter = context.getBean(CacheStoreRouter.class); + assertThatThrownBy(() -> cacheRouter.get("worklog", "k")) + .isInstanceOf(AdapterDisabledException.class); + + // notification D4: zero providers boot fine, unbound route fails fast in RoutingNotifier + NotificationPort notificationPort = context.getBean(NotificationPort.class); + assertThat(notificationPort).isInstanceOf(RoutingNotifier.class); + assertThatThrownBy(() -> notificationPort.notify(Channel.EMAIL, STUB_NOTIFICATION)) + .isInstanceOf(AdapterDisabledException.class) + .extracting("adapterName") + .isEqualTo("notification"); + }); + } + + @Test + void kafkaSelectedRegistersTheBrokerAndBindsTheRealPublishers() { + runner + .withPropertyValues( + "app.messaging.broker=kafka", "app.messaging.kafka.brokers=broker-1:9092") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(MessageBroker.class)).hasSize(1); + assertThat(context.getBean(MessagePublisher.class)) + .isInstanceOf(OutboundMessagePublisher.class); + assertThat(context.getBeansOfType(DisabledMessagePublisher.class)).isEmpty(); + assertThat(context.getBeansOfType(DisabledOutboxMessagePublisher.class)).isEmpty(); + }); + } + + @Test + void redisEnabledContributesTheBackendAndRoutesBoundLogicalCaches() { + runner + .withPropertyValues("app.cache.redis.enabled=true", "app.cache.bindings.worklog=redis") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(CacheStore.class)).hasSize(1); + CacheStoreRouter router = context.getBean(CacheStoreRouter.class); + assertThat(router.get("worklog", "k")).isEmpty(); + }); + } + + @Test + void slackWebhookEnabledContributesTheProviderAndRoutesBoundNotifications() { + runner + .withPropertyValues( + "app.notification.slack-webhook.enabled=true", + "app.notification.routes.slack.default=slack-webhook") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(NotificationProvider.class)).hasSize(1); + NotificationPort port = context.getBean(NotificationPort.class); + assertThat(port).isInstanceOf(RoutingNotifier.class); + // stub SlackClient is a no-op — call succeeds without exception + assertThatCode(() -> port.notify(Channel.SLACK, STUB_NOTIFICATION)) + .doesNotThrowAnyException(); + }); + } + + @Test + void googleEmailEnabledContributesTheProviderAndRoutesBoundNotifications() { + runner + .withPropertyValues( + "app.notification.google-email.enabled=true", + "app.notification.routes.email.default=google-email") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(NotificationProvider.class)).hasSize(1); + NotificationPort port = context.getBean(NotificationPort.class); + assertThat(port).isInstanceOf(RoutingNotifier.class); + assertThatCode(() -> port.notify(Channel.EMAIL, STUB_NOTIFICATION)) + .doesNotThrowAnyException(); + }); + } + + @Test + void aSecondBackendPlugsInWithNewFilesOnlyAndBothRouteByLogicalName() { + // OCP proof: SecondBackendConfig simulates a future backend added as a NEW config + // only — RedisCacheAdapterConfig / CacheRouterConfig are not touched. + runner + .withUserConfiguration(SecondBackendConfig.class) + .withPropertyValues( + "app.cache.redis.enabled=true", + "app.cache.test-second.enabled=true", + "app.cache.bindings.worklog=redis", + "app.cache.bindings.session=test-second") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(CacheStore.class)).hasSize(2); + CacheStoreRouter router = context.getBean(CacheStoreRouter.class); + assertThat(router.get("session", "k")).contains("from-second-backend"); + assertThat(router.get("worklog", "k")).isEmpty(); + }); + } + + @Test + void aBindingToADisabledBackendFailsStartup() { + runner + .withPropertyValues("app.cache.bindings.worklog=redis") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void aRouteBindingToADisabledProviderFailsStartup() { + // configuration contradiction: route references a provider that contributed no bean + runner + .withPropertyValues("app.notification.routes.email.default=google-email") + .run(context -> assertThat(context).hasFailed()); + } + + /** Simulates a forking project's additional cache backend — new files only. */ + @Configuration + static class SecondBackendConfig { + + @Bean + @ConditionalOnProperty(name = "app.cache.test-second.enabled", havingValue = "true") + CacheBackend secondBackend() { + return new CacheBackend() { + @Override + public String backendId() { + return "test-second"; + } + + @Override + public Optional<String> get(String key) { + return Optional.of("from-second-backend"); + } + + @Override + public void put(String key, String value) {} + }; + } + } + + /** Supplies the integration-seam client beans an enabled adapter requires. */ + @Configuration + static class StubClientsConfig { + + @Bean + KafkaSender kafkaSender() { + return message -> {}; + } + + @Bean + RedisClient redisClient() { + return new RedisClient() { + @Override + public Optional<String> read(String key) { + return Optional.empty(); + } + + @Override + public void write(String key, String value) {} + }; + } + + @Bean + SlackClient slackClient() { + return notification -> {}; + } + + @Bean + GoogleEmailClient googleEmailClient() { + return notification -> {}; + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/contract/.gitkeep b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/contract/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/contract/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java new file mode 100644 index 00000000..b82c95d0 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java @@ -0,0 +1,705 @@ +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.EvaluationResult; +import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; +import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase; +import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository; +import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort; +import dev.caskeleton.bootstrap.architecture.violations.application.JakartaValidationApplicationFixture; +import dev.caskeleton.bootstrap.architecture.violations.application.MissingTransactionBoundaryUseCase; +import dev.caskeleton.bootstrap.architecture.violations.application.OutboundWithoutPermissionUseCase; +import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakQueryPort; +import dev.caskeleton.bootstrap.architecture.violations.application.ReadOnlyRepositoryWriteUseCase; +import dev.caskeleton.bootstrap.architecture.violations.domain.AnnotatedPublicNoArgValueObjectFixture; +import dev.caskeleton.bootstrap.architecture.violations.domain.JakartaValidationDomainFixture; +import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity; +import dev.caskeleton.bootstrap.architecture.violations.domain.vo.PackagePublicNoArgValueObjectFixture; +import dev.caskeleton.bootstrap.architecture.violations.streaming.JakartaWebSocketEndpointFixture; +import dev.caskeleton.bootstrap.architecture.violations.streaming.SpringWebSocketHandlerFixture; +import org.junit.jupiter.api.Test; + +/** + * Negative ("violations-as-data") tests proving each architecture rule actually catches the + * violation it claims to. Each test loads the intentional fixture classes under {@code + * dev.caskeleton.bootstrap.architecture.violations.*} and asserts that the corresponding rule from + * {@link CleanArchitectureTest} reports at least one violation. + * + * <p>Without this layer, a rule that silently no-matches in production (e.g. a predicate + * referencing a package nothing in the codebase happens to populate) would pass vacuously — exactly + * the regression caught in {@code + * raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28}. + * + * <p>Pattern reference: Spring Modulith's {@code modules.detectViolations().getMessages()} on an + * {@code example/ninvalid} fixture package ({@code + * raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data} + * SPRING-MOD-AU-C2). + */ +class ArchitectureViolationFixtureTest { + + private static final JavaClasses VIOLATION_CLASSES = + new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations"); + + /** Loaded in isolation to verify D3 rules do NOT over-block StreamingResponseBody. */ + private static final JavaClasses ALLOWED_STREAMING_CLASSES = + new ClassFileImporter() + .importPackages("dev.caskeleton.bootstrap.architecture.allowed.streaming"); + + // Each jakarta.validation fixture is imported in ISOLATION so the two package globs in + // VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY ("..domain.." vs "..application..") are + // verified independently — evaluating both against the shared VIOLATION_CLASSES pool would + // let either fixture satisfy hasViolation(), so a silently broken glob would pass vacuously. + private static final JavaClasses VALIDATION_IN_DOMAIN_FIXTURE_ONLY = + new ClassFileImporter().importClasses(JakartaValidationDomainFixture.class); + private static final JavaClasses VALIDATION_IN_APPLICATION_FIXTURE_ONLY = + new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class); + + // Each WebSocket fixture is imported in ISOLATION so the two package globs in + // NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..") + // are verified independently. Evaluating both against the shared VIOLATION_CLASSES pool + // would let either fixture satisfy hasViolation() — so a silently broken jakarta (or + // spring) glob would still pass vacuously, the exact failure mode this test layer exists + // to prevent. (importClasses is safe here: both fixtures are annotation-only, so the JVM + // does not resolve the testCompileOnly types at link time.) + private static final JavaClasses SPRING_WEBSOCKET_FIXTURE_ONLY = + new ClassFileImporter().importClasses(SpringWebSocketHandlerFixture.class); + private static final JavaClasses JAKARTA_WEBSOCKET_FIXTURE_ONLY = + new ClassFileImporter().importClasses(JakartaWebSocketEndpointFixture.class); + + // feature-application-query-bypass-contract D1: each *QueryPort leak fixture is imported + // in ISOLATION (with FakeDomainEntity so the forbidden type resolves) so the raw-leak and + // generic-leak cases are proven independently. The generic-leak corpus is what proves the + // rule inspects generic type arguments — a raw-return-type check would pass it vacuously. + private static final JavaClasses RAW_LEAK_QUERY_PORT_ONLY = + new ClassFileImporter().importClasses(RawLeakQueryPort.class, FakeDomainEntity.class); + private static final JavaClasses GENERIC_LEAK_QUERY_PORT_ONLY = + new ClassFileImporter().importClasses(GenericLeakQueryPort.class, FakeDomainEntity.class); + + /** Over-block guard corpus: a legitimate projection port the D1 rule must NOT flag. */ + private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY = + new ClassFileImporter().importClasses(CleanProjectionQueryPort.class); + + // feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation + // branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a + // silently broken branch cannot pass vacuously via the other one in the shared pool. + private static final JavaClasses VO_ANNOTATION_FIXTURE_ONLY = + new ClassFileImporter().importClasses(AnnotatedPublicNoArgValueObjectFixture.class); + private static final JavaClasses VO_PACKAGE_FIXTURE_ONLY = + new ClassFileImporter().importClasses(PackagePublicNoArgValueObjectFixture.class); + + // feature-domain-modeling-guardrails D4/D8: each transport glob in + // DOMAIN_EVENTS_ARE_TRANSPORT_FREE (kafka / spring-http / jax-rs) is proven on its own + // isolated corpus — mirrors the NO_WEBSOCKET_HANDLER isolation. Each fixture lives in its + // own subpackage and is loaded with importPackages, which reads .class bytes directly + // (no JVM classloading), so the testCompileOnly transport types need not resolve at runtime + // — the same mechanism the streaming websocket fixtures rely on. + private static final JavaClasses KAFKA_DOMAIN_EVENT_FIXTURE_ONLY = + new ClassFileImporter() + .importPackages("dev.caskeleton.bootstrap.architecture.violations.domain.event.kafka"); + private static final JavaClasses SPRING_HTTP_DOMAIN_EVENT_FIXTURE_ONLY = + new ClassFileImporter() + .importPackages( + "dev.caskeleton.bootstrap.architecture.violations.domain.event.springhttp"); + private static final JavaClasses JAXRS_DOMAIN_EVENT_FIXTURE_ONLY = + new ClassFileImporter() + .importPackages("dev.caskeleton.bootstrap.architecture.violations.domain.event.jaxrs"); + private static final JavaClasses NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY = + new ClassFileImporter() + .importPackages( + "dev.caskeleton.bootstrap.architecture.violations.domain.event.nonrecord"); + + // feature-repository-access-permission-contract D12 / D6: each coherence fixture is imported + // in ISOLATION so the two distinct rules are proven independently (a write-call violation must + // not be allowed to satisfy the bulk-access assertion in a shared pool, and vice versa). The + // D12 corpus includes FixtureRepository so the write-method call target resolves. + private static final JavaClasses READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY = + new ClassFileImporter() + .importClasses(ReadOnlyRepositoryWriteUseCase.class, FixtureRepository.class); + private static final JavaClasses BULK_WRITE_WITHOUT_WRITE_ACCESS_FIXTURE_ONLY = + new ClassFileImporter().importClasses(BulkWriteWithoutWriteAccessUseCase.class); + private static final JavaClasses MISSING_TRANSACTION_BOUNDARY_FIXTURE_ONLY = + new ClassFileImporter().importClasses(MissingTransactionBoundaryUseCase.class); + // D7: externalOutboundAllowed=false use case that calls OutboxMessagePublishPort + // (bound to OutboxMessagePublishAdapter under ..adapter.outbound..). Imported in isolation; the + // outbound-port set the rule checks against is precomputed from production classes. + private static final JavaClasses OUTBOUND_WITHOUT_PERMISSION_FIXTURE_ONLY = + new ClassFileImporter().importClasses(OutboundWithoutPermissionUseCase.class); + + // feature-secrets-config-source-contract D3/D10: the @RefreshScope fixture is loaded via + // importPackages (NOT importClasses) so the JVM never link-resolves the testCompileOnly + // spring-cloud-context annotation type — mirrors the streaming WebSocket isolation rationale. + private static final JavaClasses REFRESH_SCOPE_FIXTURE_ONLY = + new ClassFileImporter() + .importPackages("dev.caskeleton.bootstrap.architecture.violations.secrets"); + + @Test + void noRefreshScopeAnywhereCatchesRefreshScopeAnnotation() { + EvaluationResult result = + CleanArchitectureTest.NO_REFRESH_SCOPE_ANYWHERE.evaluate(REFRESH_SCOPE_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("NO_REFRESH_SCOPE_ANYWHERE must catch RefreshScopeUsingFixture (D3/D10)") + .isTrue(); + } + + @Test + void domainIsPureCatchesSpringDependencyInDomainPackage() { + EvaluationResult result = CleanArchitectureTest.DOMAIN_IS_PURE.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("DOMAIN_IS_PURE must catch SpringDependentDomainFixture") + .isTrue(); + } + + @Test + void applicationDoesNotUseSpringTransactionalAnnotationCatchesViolation() { + EvaluationResult result = + CleanArchitectureTest.APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION must catch " + + "TransactionalAnnotatedFixture") + .isTrue(); + } + + @Test + void applicationDoesNotDependOnApplicationContextCatchesViolation() { + EvaluationResult result = + CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT must catch " + + "ApplicationContextDependentFixture") + .isTrue(); + } + + @Test + void inboundPortImplementationsEndWithUseCaseCatchesBadlyNamedImpl() { + EvaluationResult result = + CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE must catch " + "BadlyNamedHandler") + .isTrue(); + } + + @Test + void inboundPortImplementationsDeclareCapabilityCatchesMissingAnnotation() { + EvaluationResult result = + CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY must catch " + + "MissingCapabilityUseCase") + .isTrue(); + } + + // The KEYED-idempotency freeze rule + its KeyedIdempotencyUseCase fixture were + // removed when feature-rate-limit-idempotency-contract merged (Idempotency.KEYED is + // now a supported capability), so the corresponding fixture test is gone too. + + @Test + void readOnlyUseCasesDoNotCallRepositoryWriteMethodsCatchesReadToWriteUpgrade() { + EvaluationResult result = + CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS.evaluate( + READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS (D12) must catch " + + "ReadOnlyRepositoryWriteUseCase calling FixtureRepository.save") + .isTrue(); + } + + @Test + void bulkWriteCapabilityRequiresWriteRepositoryAccessCatchesReadAccessBulk() { + EvaluationResult result = + CleanArchitectureTest.BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS.evaluate( + BULK_WRITE_WITHOUT_WRITE_ACCESS_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS (D6) must catch " + + "BulkWriteWithoutWriteAccessUseCase declaring bulkWrite=true with " + + "repositoryAccess=READ_REPOSITORY") + .isTrue(); + } + + @Test + void useCaseCapabilityMatchesTransactionPortBoundaryCatchesMissingWriteBoundary() { + EvaluationResult result = + CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate( + MISSING_TRANSACTION_BOUNDARY_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch " + + "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without " + + "TransactionPort.inWrite") + .isTrue(); + } + + @Test + void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() { + EvaluationResult result = + CleanArchitectureTest.SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES must catch " + + "a domain-specific package under ..shared.worklog..") + .isTrue(); + } + + @Test + void externalOutboundCallsRequireExternalOutboundAllowedCapabilityCatchesUnpermittedCall() { + EvaluationResult result = + CleanArchitectureTest.EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY + .evaluate(OUTBOUND_WITHOUT_PERMISSION_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY (D7) must " + + "catch OutboundWithoutPermissionUseCase calling OutboxMessagePublishPort without " + + "externalOutboundAllowed=true") + .isTrue(); + } + + @Test + void requestDtosDoNotSilenceUnknownFieldsCatchesClassLevelIgnoreUnknown() { + EvaluationResult result = + CleanArchitectureTest.REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS must catch " + + "JsonIgnoreUnknownRequestFixture (B1)") + .isTrue(); + } + + @Test + void noJacksonLaissezFaireSubtypeValidatorCatchesUnsafeDefaultTyping() { + EvaluationResult result = + CleanArchitectureTest.NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR must catch " + "DefaultTypingFixture (B5)") + .isTrue(); + } + + @Test + void noJacksonEnableDefaultTypingCallCatchesActivateCall() { + EvaluationResult result = + CleanArchitectureTest.NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL must catch " + + "DefaultTypingFixture.unsafe() (B5)") + .isTrue(); + } + + @Test + void noInheritableThreadLocalCatchesInheritableThreadLocalField() { + EvaluationResult result = + CleanArchitectureTest.NO_INHERITABLE_THREAD_LOCAL.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("NO_INHERITABLE_THREAD_LOCAL must catch " + "InheritableThreadLocalFixture (B6)") + .isTrue(); + } + + @Test + void controllersDoNotReturnDomainOrEntityTypesCatchesDomainReturningFixture() { + EvaluationResult result = + CleanArchitectureTest.CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES must catch " + + "DomainReturningControllerFixture (판정 기준 Forbidden)") + .isTrue(); + } + + @Test + void applicationMethodsDoNotAcceptWebDtosCatchesWebDtoAcceptingFixture() { + EvaluationResult result = + CleanArchitectureTest.APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS must catch " + + "WebDtoAcceptingApplicationFixture (판정 기준 Forbidden + 테스트 계약)") + .isTrue(); + } + + @Test + void noProblemDetailUsageCatchesProblemDetailFixture() { + EvaluationResult result = + CleanArchitectureTest.NO_PROBLEM_DETAIL_USAGE.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("NO_PROBLEM_DETAIL_USAGE must catch " + "ProblemDetailUsingFixture (D5)") + .isTrue(); + } + + @Test + void noMergePatchJsonMediaTypeCatchesMergePatchFixture() { + EvaluationResult result = + CleanArchitectureTest.NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING must catch " + "MergePatchJsonFixture (B2)") + .isTrue(); + } + + @Test + void outboundAdapterMethodReturnsOnlyDomainCatchesRawLeakingFixture() { + EvaluationResult result = + CleanArchitectureTest.OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES.evaluate( + VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES must catch " + + "RawTypeLeakingAdapterFixture (B7)") + .isTrue(); + } + + @Test + void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() { + EvaluationResult result = + CleanArchitectureTest.CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122 must catch " + + "KebabPathControllerFixture (D19)") + .isTrue(); + } + + @Test + void validCascadeDepthCatchesDeepCascadeFixture() { + EvaluationResult result = + CleanArchitectureTest.VALID_CASCADE_DEPTH_AT_MOST_THREE.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("VALID_CASCADE_DEPTH_AT_MOST_THREE must catch " + "DeepCascadeRequestFixture (B4)") + .isTrue(); + } + + // ---- feature-business-rule-validation-contract C1/D1 violation fixtures ---- + + @Test + void validationConstraintsStayAtWebBoundaryCatchesJakartaValidationInDomain() { + // Isolated corpus: proves the "..domain.." glob fires on its own. + EvaluationResult result = + CleanArchitectureTest.VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY.evaluate( + VALIDATION_IN_DOMAIN_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY must catch " + + "JakartaValidationDomainFixture (C1/D1)") + .isTrue(); + } + + @Test + void validationConstraintsStayAtWebBoundaryCatchesJakartaValidationInApplication() { + // Isolated corpus: proves the "..application.." glob fires on its own — not vacuously + // via the domain fixture also present in the shared violations tree. + EvaluationResult result = + CleanArchitectureTest.VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY.evaluate( + VALIDATION_IN_APPLICATION_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY must catch " + + "JakartaValidationApplicationFixture (C1/D1)") + .isTrue(); + } + + // ---- feature-streaming-response-contract D3 violation fixtures ---- + + @Test + void noSseEmitterCatchesSseEmitterFixture() { + EvaluationResult result = CleanArchitectureTest.NO_SSE_EMITTER.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("NO_SSE_EMITTER must catch SseEmitterUsingFixture (D3)") + .isTrue(); + } + + @Test + void noResponseBodyEmitterCatchesResponseBodyEmitterFixture() { + EvaluationResult result = + CleanArchitectureTest.NO_RESPONSE_BODY_EMITTER.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("NO_RESPONSE_BODY_EMITTER must catch ResponseBodyEmitterUsingFixture (D3)") + .isTrue(); + } + + @Test + void noWebsocketHandlerCatchesSpringWebsocketFixture() { + // Isolated corpus: proves the "org.springframework.web.socket.." glob fires on its own. + EvaluationResult result = + CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(SPRING_WEBSOCKET_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("NO_WEBSOCKET_HANDLER must catch SpringWebSocketHandlerFixture (D3)") + .isTrue(); + } + + @Test + void noWebsocketHandlerCatchesJakartaWebsocketFixture() { + // Isolated corpus: proves the "jakarta.websocket.." glob fires on its own — not + // vacuously via the spring fixture also present in the shared violations tree. + EvaluationResult result = + CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(JAKARTA_WEBSOCKET_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("NO_WEBSOCKET_HANDLER must catch JakartaWebSocketEndpointFixture (D3)") + .isTrue(); + } + + // ---- feature-streaming-response-contract D3 over-block guard (spec Claim #3) ---- + + @Test + void noSseEmitterDoesNotCatchStreamingResponseBody() { + EvaluationResult result = + CleanArchitectureTest.NO_SSE_EMITTER.evaluate(ALLOWED_STREAMING_CLASSES); + + assertThat(result.hasViolation()) + .as( + "NO_SSE_EMITTER must NOT catch StreamingResponseBodyAllowedFixture — " + + "StreamingResponseBody is request-response download, not server-push " + + "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)") + .isFalse(); + } + + @Test + void noResponseBodyEmitterDoesNotCatchStreamingResponseBody() { + EvaluationResult result = + CleanArchitectureTest.NO_RESPONSE_BODY_EMITTER.evaluate(ALLOWED_STREAMING_CLASSES); + + assertThat(result.hasViolation()) + .as( + "NO_RESPONSE_BODY_EMITTER must NOT catch StreamingResponseBodyAllowedFixture — " + + "StreamingResponseBody FQN is not ResponseBodyEmitter " + + "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)") + .isFalse(); + } + + @Test + void noWebsocketHandlerDoesNotCatchStreamingResponseBody() { + EvaluationResult result = + CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(ALLOWED_STREAMING_CLASSES); + + assertThat(result.hasViolation()) + .as( + "NO_WEBSOCKET_HANDLER must NOT catch StreamingResponseBodyAllowedFixture — " + + "StreamingResponseBody is not in org.springframework.web.socket.. " + + "or jakarta.websocket.. packages " + + "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)") + .isFalse(); + } + + // ---- feature-schema-serialization-contract D3 (BigDecimal precision trap ban) ---- + + @Test + void noBigdecimalDoubleConstructorCatchesDoubleAndFloatConstructors() { + EvaluationResult result = + CleanArchitectureTest.NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR must catch " + + "BigDecimalDoubleConstructorFixture (D3 / SBMS-C3)") + .isTrue(); + } + + // ---- feature-application-query-bypass-contract D1 (read/query port purity) ---- + + @Test + void queryPortsPurityRuleCatchesRawDomainReturn() { + EvaluationResult result = + CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate( + RAW_LEAK_QUERY_PORT_ONLY); + + assertThat(result.hasViolation()) + .as( + "QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must catch " + + "RawLeakQueryPort returning a domain type directly (D1)") + .isTrue(); + } + + @Test + void queryPortsPurityRuleCatchesGenericDomainReturn() { + // Proves the rule inspects generic type arguments (List<FakeDomainEntity>) — a + // raw-return-type check would pass this vacuously. + EvaluationResult result = + CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate( + GENERIC_LEAK_QUERY_PORT_ONLY); + + assertThat(result.hasViolation()) + .as( + "QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must catch " + + "GenericLeakQueryPort leaking a domain type via List<DomainType> (D1)") + .isTrue(); + } + + @Test + void queryPortsPurityRuleDoesNotFlagCleanProjectionPort() { + EvaluationResult result = + CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate( + CLEAN_PROJECTION_QUERY_PORT_ONLY); + + assertThat(result.hasViolation()) + .as( + "QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must NOT flag " + + "CleanProjectionQueryPort returning List<String> — no over-block (D1)") + .isFalse(); + } + + // ---- feature-domain-modeling-guardrails violation fixtures ---- + + @Test + void domainHasNoLoggerCatchesLoggerInDomainPackage() { + EvaluationResult result = + CleanArchitectureTest.DOMAIN_HAS_NO_LOGGER.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as("DOMAIN_HAS_NO_LOGGER must catch LoggerUsingDomainFixture (D3)") + .isTrue(); + } + + @Test + void valueObjectRuleCatchesAnnotatedPublicNoArgConstructor() { + // Isolated corpus: proves the @ValueObject annotation branch fires on its own. + EvaluationResult result = + CleanArchitectureTest.VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR.evaluate( + VO_ANNOTATION_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR must catch " + + "AnnotatedPublicNoArgValueObjectFixture (D5/D6)") + .isTrue(); + } + + @Test + void valueObjectRuleCatchesPackageConventionPublicNoArgConstructor() { + // Isolated corpus: proves the ..domain.vo.. package branch fires on its own — not + // vacuously via the annotation fixture also present in the shared violations tree. + EvaluationResult result = + CleanArchitectureTest.VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR.evaluate( + VO_PACKAGE_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR must catch " + + "PackagePublicNoArgValueObjectFixture via the ..domain.vo.. branch (D5/D6)") + .isTrue(); + } + + @Test + void aggregateRootSettersAreNotPublicCatchesPublicSetter() { + EvaluationResult result = + CleanArchitectureTest.AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC.evaluate(VIOLATION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC must catch " + + "PublicSetterAggregateFixture (D7)") + .isTrue(); + } + + @Test + void domainEventsAreRecordsCatchesNonRecordEvent() { + EvaluationResult result = + CleanArchitectureTest.DOMAIN_EVENTS_ARE_RECORDS.evaluate( + NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("DOMAIN_EVENTS_ARE_RECORDS must catch NonRecordDomainEventFixture (D4/D8)") + .isTrue(); + } + + @Test + void domainEventsAreRecordsDoesNotFlagARecordEvent() { + // Over-block guard: a @DomainEvent that IS a record must NOT be flagged. + EvaluationResult result = + CleanArchitectureTest.DOMAIN_EVENTS_ARE_RECORDS.evaluate(KAFKA_DOMAIN_EVENT_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("DOMAIN_EVENTS_ARE_RECORDS must NOT flag KafkaDomainEventFixture — it is a record") + .isFalse(); + } + + @Test + void domainEventsAreTransportFreeCatchesKafkaType() { + // Isolated corpus: proves the org.apache.kafka.. glob fires on its own. + EvaluationResult result = + CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate( + KAFKA_DOMAIN_EVENT_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch KafkaDomainEventFixture (D4/D8)") + .isTrue(); + } + + @Test + void domainEventsAreTransportFreeCatchesSpringHttpType() { + // Isolated corpus: proves the org.springframework.http.. glob fires on its own. + EvaluationResult result = + CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate( + SPRING_HTTP_DOMAIN_EVENT_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch SpringHttpDomainEventFixture (D4/D8)") + .isTrue(); + } + + @Test + void domainEventsAreTransportFreeCatchesJaxRsType() { + // Isolated corpus: proves the jakarta.ws.rs.. glob fires on its own — not vacuously + // via the kafka or spring fixtures in the shared tree. + EvaluationResult result = + CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate( + JAXRS_DOMAIN_EVENT_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch JaxRsDomainEventFixture (D4/D8)") + .isTrue(); + } + + @Test + void domainEventsAreTransportFreeDoesNotFlagACleanEvent() { + // Over-block guard: a transport-free @DomainEvent must NOT be flagged. + EvaluationResult result = + CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate( + NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "DOMAIN_EVENTS_ARE_TRANSPORT_FREE must NOT flag NonRecordDomainEventFixture — " + + "it references no broker/HTTP type") + .isFalse(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java new file mode 100644 index 00000000..a745a9c6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -0,0 +1,1901 @@ +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.core.domain.JavaCall.Predicates.target; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo; +import static com.tngtech.archunit.core.domain.properties.HasName.Predicates.name; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaAnnotation; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaConstructor; +import com.tngtech.archunit.core.domain.JavaEnumConstant; +import com.tngtech.archunit.core.domain.JavaField; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.JavaMethodCall; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.domain.identifier.ResourceId; +import dev.caskeleton.domain.stereotype.AggregateRoot; +import dev.caskeleton.domain.stereotype.DomainEvent; +import dev.caskeleton.domain.stereotype.ValueObject; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import org.hibernate.type.SqlTypes; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; + +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class CleanArchitectureTest { + + @ArchTest + static final ArchRule DOMAIN_IS_PURE = + noClasses() + .that() + .resideInAPackage("..domain..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "org.springframework..", + "jakarta.persistence..", + "javax.persistence..", + "jakarta.servlet..", + "org.hibernate..", + "lombok..", + "..application..", + "..adapter..", + "..bootstrap..", + "..service..", + "..infra..", + "..presentation..", + "..cmd..") + .as( + "domain-core must be framework-neutral POJO — no Spring / JPA / " + + "Hibernate / Lombok generated bytecode " + + "(feature-architecture-enforcement-rules D3)") + .allowEmptyShould(true); + + // ---- feature-domain-modeling-guardrails (modeling-specific rules; owner = this branch) ---- + // Domain framework-neutrality is delegated to DOMAIN_IS_PURE above + // (feature-architecture-enforcement-rules D3). The rules below add the + // modeling-specific guardrails this branch owns: logger ban (D3), value-object + // invariant surface (D5/D6), aggregate mutator visibility (D7), and transport-free + // domain events (D4/D8). They key off the dev.caskeleton.domain.stereotype markers. + + @ArchTest + static final ArchRule DOMAIN_HAS_NO_LOGGER = + noClasses() + .that() + .resideInAPackage("..domain..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "org.slf4j..", + "java.util.logging..", + "ch.qos.logback..", + "org.apache.logging.log4j..") + .as( + "D3 DOMAIN_HAS_NO_LOGGER: the domain layer must not depend on a logging " + + "framework (SLF4J / java.util.logging / Logback / Log4j). An invariant " + + "violation is expressed as a domain exception carrying a safe reason " + + "value (noun enum); the application layer translates that into a log " + + "line. This is a ca-tmpl convention (UNSUPPORTED_DECISION — no official " + + "standard source), kept SEPARATE from DOMAIN_IS_PURE to preserve the " + + "owner boundary of feature-architecture-enforcement-rules D3 " + + "(feature-domain-modeling-guardrails D3, §2)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR = + classes() + .that() + .areAnnotatedWith(ValueObject.class) + .or() + .resideInAPackage("..domain.vo..") + .should(notHaveAPublicNoArgConstructor()) + .as( + "D5/D6 VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR: a @ValueObject " + + "(or any type under ..domain.vo..) must validate its invariant on the " + + "sole construction path and must NOT expose a public no-arg constructor " + + "— an empty constructor is an invariant-bypassing back door. Records " + + "with components satisfy this automatically " + + "(feature-domain-modeling-guardrails D5/D6, §3)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC = + methods() + .that() + .haveNameMatching("set.*") + .and() + .areDeclaredInClassesThat() + .areAnnotatedWith(AggregateRoot.class) + .should() + .notBePublic() + .as( + "D7 AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC: a @AggregateRoot mutates its " + + "state only through intention-revealing methods that enforce invariants " + + "— a raw set* mutator must be package-private/protected (the visibility " + + "Vernon Option A ORM-external reconstruction relies on). ArchUnit only " + + "statically reaches the set* surface; other state-changing methods stay " + + "a code-review concern (feature-domain-modeling-guardrails D7, §4)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule DOMAIN_EVENTS_ARE_RECORDS = + classes() + .that() + .areAnnotatedWith(DomainEvent.class) + .should(beRecordTypes()) + .as( + "D4/D8 DOMAIN_EVENTS_ARE_RECORDS: a @DomainEvent is an immutable fact and " + + "must be declared as a record (feature-domain-modeling-guardrails D4/D8, §5)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule DOMAIN_EVENTS_ARE_TRANSPORT_FREE = + noClasses() + .that() + .areAnnotatedWith(DomainEvent.class) + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.apache.kafka..", "org.springframework.http..", "jakarta.ws.rs..") + .as( + "D4/D8 DOMAIN_EVENTS_ARE_TRANSPORT_FREE: a @DomainEvent carries domain data " + + "only and must not depend on a broker/wire/HTTP type (Kafka SDK, Spring " + + "HTTP, JAX-RS). Translating a domain event into an integration/wire event " + + "is the application/infrastructure boundary's job. UNSUPPORTED_IMPL_DECISION: " + + "the forbidden list enumerates only the SDKs ca-tmpl could plausibly add — " + + "extend it when a new broker/transport is introduced " + + "(feature-domain-modeling-guardrails D4/D8, §5)") + .allowEmptyShould(true); + + // ---- feature-persistence-auditing-contract D2 (domain stays free of audit metadata) ---- + // DOMAIN_IS_PURE already bans jakarta.persistence.. / org.springframework.. (incl. + // org.springframework.data..) from ..domain.., so @CreatedDate / @Column / AuditableEntity + // cannot reach the domain. This rule adds the audit-specific guardrail D2 owns: even as + // plain POJO fields, a domain aggregate must carry ZERO audit metadata — who/when + // created/modified is an adapter-persistence concern (AuditableEntity @MappedSuperclass), + // and putting it in the domain would invert the dependency (the domain would know "who + // logged in") and leak the infrastructure concern inward. + @ArchTest + static final ArchRule DOMAIN_ENTITIES_DO_NOT_CARRY_AUDIT_FIELDS = + noClasses() + .that() + .resideInAPackage("..domain..") + .should(declareAnAuditField()) + .as( + "D2 DOMAIN_ENTITIES_DO_NOT_CARRY_AUDIT_FIELDS: a domain class must not declare " + + "an audit field (createdAt / updatedAt / createdBy / updatedBy) — audit " + + "metadata lives only on the adapter-persistence AuditableEntity " + + "@MappedSuperclass so the domain aggregate carries none of it " + + "(feature-persistence-auditing-contract D2)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT = + noClasses() + .that() + .resideInAPackage("..application..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..adapter..", + "..bootstrap..", + "..infra..", + "..presentation..", + "..cmd..", + "jakarta.persistence..", + "javax.persistence..", + "jakarta.servlet..", + "org.springframework.web..", + "org.hibernate..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION = + noClasses() + .that() + .resideInAPackage("..application..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional") + .as( + "application package must use TransactionPort instead of " + + "@Transactional (feature-application-port-usecase-contract D3)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE = + classes() + .that() + .implement(CommandUseCase.class) + .or() + .implement(QueryUseCase.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should() + .haveSimpleNameEndingWith("UseCase") + .as( + "CommandUseCase / QueryUseCase implementations must end with 'UseCase' " + + "(feature-application-port-usecase-contract D1)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY = + classes() + .that() + .implement(CommandUseCase.class) + .or() + .implement(QueryUseCase.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should() + .beAnnotatedWith(UseCaseCapability.class) + .as( + "every CommandUseCase / QueryUseCase implementation must declare " + + "@UseCaseCapability (feature-application-port-usecase-contract §판정 기준)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT = + noClasses() + .that() + .resideInAPackage("..application..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName("org.springframework.context.ApplicationContext") + .as( + "application package must not depend on Spring ApplicationContext " + + "(blocks getBean(Class) reflection-style bypass — " + + "feature-architecture-enforcement-rules D11). string-key bean " + + "lookup / Class.forName(String) bypass remains ArchUnit's " + + "static-analysis limit per D12.") + .allowEmptyShould(true); + + // NOTE: the former `inbound_port_implementations_do_not_declare_keyed_idempotency` + // freeze rule (feature-application-port-usecase-contract D14) is removed now that + // feature-rate-limit-idempotency-contract has merged: Idempotency.KEYED is backed + // by IdempotencyExecutor + IdempotencyStorePort + the idempotency_record table, so a + // use case may declare it. + + // ---- feature-repository-access-permission-contract D12 / D6 (repositoryAccess coherence) ---- + + @ArchTest + static final ArchRule READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS = + classes() + .that() + .areAnnotatedWith(UseCaseCapability.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should(notCallRepositoryWriteUnlessWriteRepository()) + .as( + "D12 READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS: a use case " + + "whose @UseCaseCapability(repositoryAccess) is not WRITE_REPOSITORY must " + + "not DIRECTLY call a write method " + + "(save / saveAll / saveAndFlush / delete* / remove* / update / insert / " + + "persist / merge / store) of a *Repository port. Declaring READ_REPOSITORY " + + "(or NONE) then mutating is the silent read-to-write upgrade this contract " + + "blocks (feature-repository-access-permission-contract D12). " + + "UNSUPPORTED_IMPL_DECISION: ArchUnit static analysis only reaches DIRECT " + + "calls — a write reached through a helper / mapper escapes the call graph " + + "and stays a code-review concern (D12 static-analysis limit). The write " + + "method-name allowlist is a ca-tmpl convention; extend it when a new " + + "mutating verb enters the repository vocabulary.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS = + classes() + .that() + .areAnnotatedWith(UseCaseCapability.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should(haveWriteRepositoryAccessWhenBulkWrite()) + .as( + "D6 BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS: " + + "@UseCaseCapability(bulkWrite = true) requires repositoryAccess = " + + "WRITE_REPOSITORY — a bulk write (N > 100) is still a write, and the " + + "capability registry binds BULK_WRITE to WRITE_REPOSITORY " + + "(feature-repository-access-permission-contract D6).") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY = + classes() + .that() + .areAnnotatedWith(UseCaseCapability.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should(callTransactionPortMethodRequiredByCapability()) + .as( + "feature-domain-feature-onboarding-contract D4: a use case that declares a " + + "repository-backed transaction capability must call the matching " + + "TransactionPort boundary directly: READ_REPOSITORY+READ_ONLY -> inRead, " + + "WRITE_REPOSITORY+WRITE -> inWrite, REQUIRES_NEW -> inNew. " + + "RepositoryAccess.NONE may intentionally skip a DB transaction. " + + "UNSUPPORTED_IMPL_DECISION: static analysis reaches direct calls only; a " + + "transaction hidden behind a helper remains a code-review concern.") + .allowEmptyShould(true); + + // ---- feature-authentication-authorization-contract D1 / D4 (product authorization) ---- + + @ArchTest + static final ArchRule MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION = + classes() + .that() + .areAnnotatedWith(UseCaseCapability.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should(declareRequiredPermissionWhenMutating()) + .as( + "D4 MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION: a use case whose " + + "@UseCaseCapability(repositoryAccess) is WRITE_REPOSITORY MUST also declare " + + "@RequiresPermission, so a mutating operation cannot silently ship without a " + + "permission gate (feature-authentication-authorization-contract D4). The " + + "skeleton baseline is mutating-only; public reads are exempt. " + + "UNSUPPORTED_IMPL_DECISION: this rule checks the type-level annotation (the " + + "@UseCaseCapability convention); a method-only @RequiresPermission on a use " + + "case still trips it. AOP self-invocation / non-bean entry points stay a " + + "static-analysis blind spot (D4 limit).") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule APPLICATION_AND_DOMAIN_DO_NOT_DEPEND_ON_SPRING_SECURITY = + noClasses() + .that() + .resideInAnyPackage("..application..", "..domain..") + .should() + .dependOnClassesThat() + .resideInAPackage("org.springframework.security..") + .as( + "D1 APPLICATION_AND_DOMAIN_DO_NOT_DEPEND_ON_SPRING_SECURITY: application-core and " + + "domain-core must stay free of Spring Security types. The authorization " + + "DECISION is a framework-free port (@RequiresPermission / AuthorizationPort / " + + "AuthorizationPrincipal); the Spring-aware enforcement MECHANISM " + + "(AuthorizationManager) lives only in adapter:inbound:web " + + "(feature-authentication-authorization-contract D1).") + .allowEmptyShould(true); + + // ---- feature-management-actuator-security-contract D6 (health shape-ownership guardrail) ---- + // ManagementSecurityConfig (in ..management.security..) must NOT import or depend on + // HealthEndpoint, HealthIndicator, or Health — the health endpoint shape belongs to + // the runtime-health branch. The security config only matches/permits endpoints by id string; + // it never inspects health internals. + + @ArchTest + static final ArchRule MANAGEMENT_SECURITY_DOES_NOT_DEPEND_ON_HEALTH_INTERNALS = + noClasses() + .that() + .resideInAPackage("..management.security..") + .should() + .dependOnClassesThat() + .areAssignableTo(HealthEndpoint.class) + .orShould() + .dependOnClassesThat() + .areAssignableTo(HealthIndicator.class) + .orShould() + .dependOnClassesThat() + .areAssignableTo(Health.class) + .as( + "D6 MANAGEMENT_SECURITY_DOES_NOT_DEPEND_ON_HEALTH_INTERNALS: classes in " + + "..management.security.. must not import or depend on HealthEndpoint, " + + "HealthIndicator, or Health — the health endpoint shape is owned " + + "by the runtime-health branch. The actuator security config matches " + + "endpoints by id only, never by health internals " + + "(feature-management-actuator-security-contract D6).") + .allowEmptyShould(true); + + private static final Set<String> REPOSITORY_WRITE_METHOD_NAMES = + Set.of( + "save", + "saveAll", + "saveAndFlush", + "delete", + "deleteById", + "deleteAll", + "remove", + "removeAll", + "removeById", + "update", + "insert", + "persist", + "merge", + "store"); + + private static ArchCondition<JavaClass> notCallRepositoryWriteUnlessWriteRepository() { + return new ArchCondition<>( + "not call a *Repository write method unless " + "repositoryAccess = WRITE_REPOSITORY") { + @Override + public void check(JavaClass item, ConditionEvents events) { + JavaAnnotation<?> annotation = item.getAnnotationOfType(UseCaseCapability.class.getName()); + Object repositoryAccess = annotation.get("repositoryAccess").orElse(null); + boolean writeRepository = + repositoryAccess instanceof JavaEnumConstant constant + && "WRITE_REPOSITORY".equals(constant.name()); + if (writeRepository) { + return; // WRITE_REPOSITORY use cases are permitted to mutate. + } + for (JavaMethodCall call : item.getMethodCallsFromSelf()) { + String targetOwner = call.getTargetOwner().getSimpleName(); + String targetName = call.getName(); + if (targetOwner.endsWith("Repository") + && REPOSITORY_WRITE_METHOD_NAMES.contains(targetName)) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " declares repositoryAccess != " + + "WRITE_REPOSITORY but directly calls " + + targetOwner + + "." + + targetName + + "(...) — a repository write " + + "(feature-repository-access-permission-contract D12)")); + } + } + } + }; + } + + private static ArchCondition<JavaClass> declareRequiredPermissionWhenMutating() { + return new ArchCondition<>( + "declare @RequiresPermission when repositoryAccess = WRITE_REPOSITORY") { + @Override + public void check(JavaClass item, ConditionEvents events) { + JavaAnnotation<?> annotation = item.getAnnotationOfType(UseCaseCapability.class.getName()); + Object repositoryAccess = annotation.get("repositoryAccess").orElse(null); + boolean writeRepository = + repositoryAccess instanceof JavaEnumConstant constant + && "WRITE_REPOSITORY".equals(constant.name()); + if (!writeRepository) { + return; + } + if (!item.isAnnotatedWith(RequiresPermission.class.getName())) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " declares @UseCaseCapability(repositoryAccess = " + + "WRITE_REPOSITORY) but is missing @RequiresPermission — a mutating use " + + "case must declare the permission it requires " + + "(feature-authentication-authorization-contract D4)")); + } + } + }; + } + + private static ArchCondition<JavaClass> haveWriteRepositoryAccessWhenBulkWrite() { + return new ArchCondition<>( + "declare repositoryAccess = WRITE_REPOSITORY when bulkWrite = true") { + @Override + public void check(JavaClass item, ConditionEvents events) { + JavaAnnotation<?> annotation = item.getAnnotationOfType(UseCaseCapability.class.getName()); + boolean bulkWrite = Boolean.TRUE.equals(annotation.get("bulkWrite").orElse(false)); + if (!bulkWrite) { + return; + } + Object repositoryAccess = annotation.get("repositoryAccess").orElse(null); + boolean writeRepository = + repositoryAccess instanceof JavaEnumConstant constant + && "WRITE_REPOSITORY".equals(constant.name()); + if (!writeRepository) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " declares @UseCaseCapability(bulkWrite = true) " + + "without repositoryAccess = WRITE_REPOSITORY " + + "(feature-repository-access-permission-contract D6)")); + } + } + }; + } + + @SuppressWarnings("resource") + private static ArchCondition<JavaClass> callTransactionPortMethodRequiredByCapability() { + return new ArchCondition<>("call the TransactionPort method required by @UseCaseCapability") { + @Override + public void check(JavaClass item, ConditionEvents events) { + JavaAnnotation<?> annotation = item.getAnnotationOfType(UseCaseCapability.class.getName()); + String transactionMode = enumAnnotationValue(annotation, "transactionMode"); + String repositoryAccess = enumAnnotationValue(annotation, "repositoryAccess"); + + String requiredMethod = null; + if ("REQUIRES_NEW".equals(transactionMode)) { + requiredMethod = "inNew"; + } else if ("WRITE".equals(transactionMode) && "WRITE_REPOSITORY".equals(repositoryAccess)) { + requiredMethod = "inWrite"; + } else if ("READ_ONLY".equals(transactionMode) + && "READ_REPOSITORY".equals(repositoryAccess)) { + requiredMethod = "inRead"; + } + + if (requiredMethod == null) { + return; + } + + for (JavaMethodCall call : item.getMethodCallsFromSelf()) { + if ("dev.caskeleton.application.transaction.TransactionPort" + .equals(call.getTargetOwner().getFullName()) + && requiredMethod.equals(call.getName())) { + return; + } + } + + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " declares @UseCaseCapability(transactionMode = " + + transactionMode + + ", repositoryAccess = " + + repositoryAccess + + ") but does not directly call TransactionPort." + + requiredMethod + + "(...)")); + } + }; + } + + private static String enumAnnotationValue(JavaAnnotation<?> annotation, String property) { + Object value = annotation.get(property).orElse(null); + return value instanceof JavaEnumConstant constant ? constant.name() : ""; + } + + private static ArchCondition<JavaClass> resideInAllowedSharedContractPackage() { + return new ArchCondition<>("reside in an allowed shared operational contract package") { + private final Set<String> allowedPackages = + Set.of( + "dev.caskeleton.shared", + "dev.caskeleton.shared.response", + "dev.caskeleton.shared.request", + "dev.caskeleton.shared.error", + "dev.caskeleton.shared.operation", + "dev.caskeleton.shared.headers", + "dev.caskeleton.shared.logging", + "dev.caskeleton.shared.tracing", + "dev.caskeleton.shared.metrics", + "dev.caskeleton.shared.registry", + "dev.caskeleton.shared.annotation", + // feature-authentication-authorization-contract: the Permission + // (resource:action) value object is a Java-only operational + // authorization contract consumed by AuthorizationPort. + "dev.caskeleton.shared.security", + // feature-runtime-context-propagation-contract: the domain-context + // propagation port + default ThreadLocal strategy + factory + // (swappable abstraction, the rate-limit pattern). + "dev.caskeleton.shared.concurrency"); + + @Override + public void check(JavaClass item, ConditionEvents events) { + String packageName = item.getPackageName(); + boolean allowed = false; + for (String allowedPackage : allowedPackages) { + if (packageName.equals(allowedPackage) || packageName.startsWith(allowedPackage + ".")) { + allowed = true; + break; + } + } + if (!allowed) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " resides in " + + packageName + + ", which is not an allowed shared operational contract package")); + } + } + }; + } + + // ---- feature-repository-access-permission-contract D7 (outbound capability coherence) ---- + + /** + * Outbound ports are the interfaces a use case must hold {@code externalOutboundAllowed} to call. + * They are identified by their adapter binding — any interface implemented by a production class + * residing under {@code ..adapter.outbound..} (e.g. RepoStatsPort ← RepoStatsPortClient), + * EXCLUDING the {@code ..adapter.outbound.identifier..} sub-package: UUIDv7 id/event-id factories + * are a non-IO driven adapter (in-process generation/codec, no external HTTP / message / cache + * call — see IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP), so calling one + * directly does not require {@code externalOutboundAllowed = true}. Also EXCLUDING the {@code + * ..adapter.outbound.persistence..} sub-package: repositories/stores are internal (driven) + * outbound adapters — a use case calling a repository does not require {@code + * externalOutboundAllowed = true}, and their package moved under {@code adapter.outbound} only as + * a topology rename (Task 7 owns the holistic external-vs-internal split). Precomputed from + * production classes (tests excluded) so the coherence rule has no application-side marker + * dependency. + */ + private static final Set<String> OUTBOUND_PORT_NAMES = outboundPortNames(); + + private static Set<String> outboundPortNames() { + JavaClasses production = + new ClassFileImporter() + .withImportOption(new ProductionClassImportOption()) + .importPackages("dev.caskeleton"); + Set<String> names = new HashSet<>(); + for (JavaClass type : production) { + if (type.getPackageName().contains(".adapter.outbound.") + && !isInProcessDrivenOutbound(type.getPackageName())) { + for (JavaClass iface : type.getAllRawInterfaces()) { + names.add(iface.getFullName()); + } + } + } + return names; + } + + /** + * True when {@code packageName} is an IN-PROCESS (non-IO) driven outbound concern — {@code + * ..adapter.outbound.persistence..} (repositories / stores) or {@code + * ..adapter.outbound.identifier..} (UUIDv7 id/event-id generation & codec). Both nest under + * {@code ..adapter.outbound..} as a Task-1–6 topology rename but perform NO external IO (no HTTP + * / message / cache network call), so an interface they implement is NOT an external outbound + * port and a use case calling one directly does not require {@code externalOutboundAllowed = + * true}. This single predicate consolidates the two per-package {@code .contains(...)} exclusions + * that Tasks 2/6 added locally to {@link #outboundPortNames()}. + * + * <p>The genuinely external-IO outbound adapters — {@code httpclient}, {@code messaging}, {@code + * notification}, and {@code cache} — are deliberately NOT treated as in-process here: a Redis + * cache call is a real network dependency, so the D7 external-outbound gate must keep covering + * it. Folding cache/support into this exclusion (as an earlier plan sketch suggested) would + * weaken that gate for no current benefit and contradict this heuristic's own IO-vs-in- process + * rationale, so it is intentionally NOT done. + */ + private static boolean isInProcessDrivenOutbound(String packageName) { + return packageName.contains(".adapter.outbound.persistence") + || packageName.contains(".adapter.outbound.identifier"); + } + + @ArchTest + static final ArchRule EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY = + classes() + .that() + .areAnnotatedWith(UseCaseCapability.class) + .and() + .areNotInterfaces() + .and() + .areNotAnonymousClasses() + .and() + .areNotMemberClasses() + .should(notCallOutboundPortUnlessAllowed()) + .as( + "D7 EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY: a " + + "use case that DIRECTLY calls an outbound port (an interface implemented " + + "by a production class under ..adapter.outbound..) must declare " + + "@UseCaseCapability(externalOutboundAllowed = true). Without it, emitting " + + "an external HTTP / message / cache call is forbidden " + + "(feature-repository-access-permission-contract D7). Outbound ports are " + + "identified by their adapter binding (RepoStatsPort ← RepoStatsPortClient), " + + "so no application-side marker is required. UNSUPPORTED_IMPL_DECISION: " + + "ArchUnit reaches DIRECT calls only — a call routed through a helper / " + + "mapper escapes the call graph and stays a code-review concern " + + "(static-analysis limit, mirrors D12).") + .allowEmptyShould(true); + + private static ArchCondition<JavaClass> notCallOutboundPortUnlessAllowed() { + return new ArchCondition<>("not call an outbound port unless externalOutboundAllowed = true") { + @Override + public void check(JavaClass item, ConditionEvents events) { + JavaAnnotation<?> annotation = item.getAnnotationOfType(UseCaseCapability.class.getName()); + boolean allowed = + Boolean.TRUE.equals(annotation.get("externalOutboundAllowed").orElse(false)); + if (allowed) { + return; // externalOutboundAllowed use cases may emit outbound calls. + } + for (JavaMethodCall call : item.getMethodCallsFromSelf()) { + String targetOwner = call.getTargetOwner().getFullName(); + if (OUTBOUND_PORT_NAMES.contains(targetOwner)) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " declares externalOutboundAllowed = " + + "false but directly calls outbound port " + + targetOwner + + " (feature-repository-access-permission-contract D7)")); + } + } + } + }; + } + + @ArchTest + static final ArchRule WEB_ADAPTER_DOES_NOT_DEPEND_ON_PERSISTENCE_OR_OUTBOUND_ADAPTERS = + noClasses() + .that() + .resideInAPackage("..adapter.inbound.web..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..adapter.outbound.persistence..", + "..adapter.outbound..", + "..adapter.outbound.identifier..", + "org.springframework.data.repository..", + "org.springframework.data.jpa.repository..", + "jakarta.persistence..", + "javax.persistence..", + "org.hibernate.."); + + @ArchTest + static final ArchRule PERSISTENCE_ADAPTER_DOES_NOT_DEPEND_ON_WEB_OR_OUTBOUND_ADAPTERS = + noClasses() + .that() + .resideInAPackage("..adapter.outbound.persistence..") + .should() + // persistence now nests under adapter.outbound (topology rename), so the forbidden + // outbound target must exclude persistence's own package — otherwise the abundant + // intra-persistence references (adapter -> entity/mapper/repository) self-overlap. + // Mirrors the IDENTIFIER_ADAPTER_... predicate-form self-exclusion below. + .dependOnClassesThat( + JavaClass.Predicates.resideInAnyPackage("..adapter.inbound.web..", "..bootstrap..") + .or( + JavaClass.Predicates.resideInAPackage("..adapter.outbound..") + .and( + JavaClass.Predicates.resideOutsideOfPackage( + "..adapter.outbound.persistence..")))) + .as( + "persistence-jpa is an internal (driven) outbound adapter — it must not reach into " + + "the web adapter, sibling outbound adapters, or the composition root " + + "(intra-persistence references are allowed).") + .allowEmptyShould(true); + + // ---- persistence-rdbms/no-sql-extensibility (RDBMS base vendor-neutrality) ---- + // adapter-persistence-rdbms (all of ..adapter.outbound.persistence.. EXCEPT + // ..adapter.outbound.persistence.postgresql..) + // must not import PostgreSQL driver or flyway-database-postgresql types. Those vendor-specific + // dependencies belong only in adapter-persistence-postgresql. + // + // Future NoSQL adapter modules (for example adapter-persistence-mongodb) must be added as + // sibling modules that implement application/domain ports directly. When such a module exists, + // add an ArchUnit rule forbidding jakarta.persistence.., org.hibernate.., + // org.springframework.data.jpa.., org.flywaydb.., and + // dev.caskeleton.adapter.outbound.persistence.postgresql.. + // dependencies from that module. + + @ArchTest + static final ArchRule PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL = + noClasses() + .that() + .resideInAPackage("dev.caskeleton.adapter.outbound.persistence..") + .and() + .resideOutsideOfPackage("dev.caskeleton.adapter.outbound.persistence.postgresql..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.postgresql..", "org.flywaydb.database.postgresql..") + .as( + "adapter:outbound:persistence-jpa (non-postgresql) must stay vendor-neutral: " + + "PostgreSQL driver / flyway-database-postgresql types live only in the " + + ".postgresql subpackage of adapter:outbound:persistence-jpa.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule PERSISTENCE_RDBMS_ENTITIES_DO_NOT_PIN_VENDOR_COLUMN_DEFINITIONS = + fields() + .that() + .areAnnotatedWith("jakarta.persistence.Column") + .and() + .areDeclaredInClassesThat() + .resideInAPackage("dev.caskeleton.adapter.outbound.persistence..") + .and() + .areDeclaredInClassesThat() + .resideOutsideOfPackage("dev.caskeleton.adapter.outbound.persistence.postgresql..") + .should(notDeclareColumnDefinition()) + .as( + "adapter:outbound:persistence-jpa (non-postgresql) must not pin vendor SQL " + + "columnDefinition strings; RDBMS physical types belong to vendor Flyway " + + "migrations, while rdbms entities may use portable JPA/Hibernate type hints.") + .allowEmptyShould(true); + + // NOTE: adapter.outbound.identifier AND adapter.outbound.persistence are both nested under + // adapter.outbound (sibling adapters, not top-level packages), so the rules below exclude each + // one's own package from the side that would otherwise match it against itself — mirroring the + // resideOutsideOfPackage sibling-exclusion style already used for + // PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL / PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES + // above. Without this, an intra-identifier or intra-persistence reference (a class referencing a + // class in its own nested adapter) would spuriously violate one of the rules. + + @ArchTest + static final ArchRule OUTBOUND_ADAPTER_DOES_NOT_DEPEND_ON_WEB_OR_PERSISTENCE_ADAPTERS = + noClasses() + .that() + .resideInAPackage("..adapter.outbound..") + .and() + .resideOutsideOfPackage("..adapter.outbound.identifier..") + // persistence-jpa is itself a nested outbound adapter; exclude its own package from the + // selector so its intra-references (which land in the ..adapter.outbound.persistence.. + // forbidden target below) do not self-overlap. Generic outbound adapters (messaging, + // cache, http, ...) are still forbidden from depending on persistence. + .and() + .resideOutsideOfPackage("..adapter.outbound.persistence..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..adapter.inbound.web..", + "..adapter.outbound.persistence..", + "..adapter.outbound.identifier..", + "..bootstrap..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP = + noClasses() + .that() + .resideInAPackage("..adapter.outbound.identifier..") + .should() + .dependOnClassesThat( + JavaClass.Predicates.resideInAnyPackage( + "..adapter.inbound.web..", + "..adapter.outbound.persistence..", + "..bootstrap..", + "org.springframework.data.repository..", + "org.springframework.data.jpa.repository..", + "jakarta.persistence..", + "javax.persistence..", + "org.hibernate..") + .or( + JavaClass.Predicates.resideInAPackage("..adapter.outbound..") + .and( + JavaClass.Predicates.resideOutsideOfPackage( + "..adapter.outbound.identifier..")))) + .as( + "adapter:outbound:identifier is a non-IO driven adapter (UUIDv7 generation/codec) — it " + + "must not reach into sibling adapters, persistence, or the composition " + + "root (feature-resource-identifier-contract §4 taxonomy).") + .allowEmptyShould(true); + + // ---- Task 7: explicit inbound/outbound adapter topology (hexagonal driving/driven split) ---- + // The moves in Tasks 1–6 established the new package topology; the three rules below make it an + // ENFORCED invariant rather than a convention. The inbound (driving) and outbound (driven) + // adapter families must never reach across to each other — every interaction between them is + // mediated by an application port — and the outbound concerns are peers that share code only + // through :adapter:outbound:support. These generalise the per-adapter isolation rules above + // (WEB_..., PERSISTENCE_..., OUTBOUND_..., IDENTIFIER_...) into the two family-level directions. + + @ArchTest + static final ArchRule INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS = + noClasses() + .that() + .resideInAPackage("..adapter.inbound..") + .should() + .dependOnClassesThat() + .resideInAPackage("..adapter.outbound..") + .as( + "inbound (driving) adapters must not depend on outbound (driven) adapters — an " + + "inbound adapter drives the application through its inbound ports and reaches " + + "persistence / messaging / cache / http only via application outbound ports, " + + "never by touching an outbound adapter directly (Task 7 adapter topology)"); + + @ArchTest + static final ArchRule OUTBOUND_ADAPTERS_DO_NOT_DEPEND_ON_INBOUND_ADAPTERS = + noClasses() + .that() + .resideInAPackage("..adapter.outbound..") + .should() + .dependOnClassesThat() + .resideInAPackage("..adapter.inbound..") + .as( + "outbound (driven) adapters must not depend on inbound (driving) adapters — a driven " + + "adapter implements an application outbound port and is oblivious to which " + + "inbound adapter (web today, a future messaging-inbound tomorrow) triggered " + + "the call (Task 7 adapter topology)"); + + @ArchTest + static final ArchRule OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT = + slices() + .matching("..adapter.outbound.(*)..") + .namingSlices("$1") + .should() + .notDependOnEachOther() + // :adapter:outbound:support is the shared base (OutboundCorrelation, fail-open + // dependency logging, the @Configuration seam) that every outbound adapter is allowed + // to build on — a dependency whose TARGET resides in support is the sanctioned + // shared-code path, not peer-to-peer coupling, so it is ignored. Every other + // cross-slice edge (e.g. messaging -> cache, persistence -> httpclient) is forbidden. + .ignoreDependency( + DescribedPredicate.alwaysTrue(), + JavaClass.Predicates.resideInAPackage("..adapter.outbound.support..")) + .as( + "outbound adapters (persistence / identifier / cache / httpclient / messaging / " + + "notification) are peers: none may depend on a sibling; shared outbound code " + + "lives in :adapter:outbound:support (Task 7 adapter topology)"); + + @ArchTest + static final ArchRule CONTROLLERS_DO_NOT_ACCESS_REPOSITORIES_OR_JPA_ENTITIES = + noClasses() + .that() + .resideInAPackage("..adapter.inbound.web..controller..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..repository..", + "..adapter.outbound.persistence..", + "..adapter.outbound.persistence..entity..", + "org.springframework.data.repository..", + "org.springframework.data.jpa.repository..", + "jakarta.persistence..", + "javax.persistence.."); + + @ArchTest + static final ArchRule WEB_DTOS_STAY_IN_WEB_ADAPTER = + classes() + .that() + .resideInAPackage("..adapter.inbound.web..dto..") + .should() + .onlyBeAccessed() + .byClassesThat() + .resideInAnyPackage("..adapter.inbound.web..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule MAPPERS_DO_NOT_DEPEND_ON_WEB_OR_APPLICATION_BOUNDARIES = + noClasses() + .that() + .resideInAPackage("..adapter.outbound.persistence..mapper..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..adapter.inbound.web..", "..bootstrap..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES = + classes() + .that() + .resideInAPackage("..shared..") + .should(resideInAllowedSharedContractPackage()) + .allowEmptyShould(true); + + @ArchTest + static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO = + noClasses() + .that() + .resideOutsideOfPackage("..sample.portfolio..") + .should() + .dependOnClassesThat() + .resideInAPackage("..sample.portfolio.."); + + @ArchTest + static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .and() + .resideOutsideOfPackage("..fixtures..") + .should() + .dependOnClassesThat() + .resideInAPackage("..fixtures..") + .as( + "feature-test-taxonomy-fixture-contract D6: production code must never depend on a " + + "test fixture — fixtures live in src/test/.../fixtures/ (test-only); this guards " + + "against a fixture leaking onto the main classpath.") + .allowEmptyShould(true); + + // ---- feature-boundary-validation-mapping-contract ---- + + @ArchTest + static final ArchRule REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS = + classes() + .that() + .resideInAPackage("..adapter.inbound.web..dto..") + .should(notBeAnnotatedWithJsonIgnoreUnknown()) + .as( + "class-level @JsonIgnoreProperties(ignoreUnknown = true) on a request DTO " + + "silently swallows extra fields and defeats the B1 fail-fast boundary " + + "(feature-boundary-validation-mapping-contract B1)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName( + "com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator") + .as( + "LaissezFaireSubTypeValidator allows any subtype during polymorphic " + + "deserialization and is the CVE-2019-14379 RCE entry point " + + "(feature-boundary-validation-mapping-contract B5)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .callMethodWhere(target(name("enableDefaultTyping"))) + .as( + "ObjectMapper.enableDefaultTyping() (deprecated since Jackson 2.10) opens " + + "polymorphic deserialization to all classes — the documented " + + "CVE-2019-14379 RCE vector (feature-boundary-validation-mapping-contract B5). " + + "Use @JsonTypeInfo(use = NAME) + @JsonSubTypes, or " + + "activateDefaultTyping(BasicPolymorphicTypeValidator) with an explicit " + + "allowlist — the LaissezFaireSubTypeValidator class reference rule " + + "still blocks the unsafe variant.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_INHERITABLE_THREAD_LOCAL = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName("java.lang.InheritableThreadLocal") + .as( + "InheritableThreadLocal does not propagate cleanly to virtual threads " + + "and leaks context across pooled carrier threads — use MDC (SLF4J " + + "2.0+) or RequestContextHolder instead " + + "(feature-boundary-validation-mapping-contract B6)") + .allowEmptyShould(true); + + // ---- feature-runtime-context-propagation-contract (domain extension of B6) ---- + // Strategy guard for the domain-context propagation seam. The shipped default is + // ThreadLocal (DomainContextStrategy.THREAD_LOCAL — plain ThreadLocal, no preview + // API); the MICROMETER / SCOPED_VALUE strategies are reserved-but-unshipped. This + // rule keeps the two Java 21 PREVIEW primitives (ScopedValue / StructuredTaskScope) + // out of production code until the SCOPED_VALUE strategy is actually activated + // through the factory seam. The build has no --enable-preview (build fact C1), so + // those APIs cannot even compile today — the compile gate is the first line of + // defence and is why a compilable violation fixture is infeasible; this rule is the + // dormant defence-in-depth that fires the moment --enable-preview is enabled and + // someone reaches for the preview primitive without adding the strategy + factory + // case. It is NOT the deferred S4/D7 activation-correctness rule + // (UNSUPPORTED_IMPL_DECISION — rule shape intentionally not invented). The + // implicit-inheritance ban (S3, the ThreadLocal default's contract) cross-cites + // NO_INHERITABLE_THREAD_LOCAL above (owner: boundary-validation B6). + + @ArchTest + static final ArchRule DOMAIN_CONTEXT_PROPAGATION_PRIMITIVES_STAY_UNSHIPPED = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .haveNameMatching( + "(java\\.lang\\.ScopedValue" + + "|java\\.util\\.concurrent\\.StructuredTaskScope)(\\$.*)?") + .as( + "the domain-context default is ThreadLocal; the ScopedValue / " + + "StructuredTaskScope (preview) strategy must not appear in production " + + "code until activated through the DomainContextStrategy + factory seam " + + "(feature-runtime-context-propagation-contract S2)") + .allowEmptyShould(true); + + // ---- feature-boundary-validation-mapping-contract — 판정 기준 §Forbidden + 테스트 계약 ---- + + @ArchTest + static final ArchRule CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES = + methods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..adapter.inbound.web..controller..") + .and() + .arePublic() + .should() + .notHaveRawReturnType( + JavaClass.Predicates.resideInAnyPackage( + "..domain..", + "..adapter.outbound.persistence.entity..", + "..adapter.outbound.persistence.repository..")) + .as( + "controller methods must return DTO / record / Envelope / ResponseEntity — " + + "never a domain entity, JPA entity, or repository type " + + "(feature-boundary-validation-mapping-contract §Forbidden + §테스트 계약)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS = + methods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..application..") + .and() + .arePublic() + .should() + .notHaveRawParameterTypes( + DescribedPredicate.describe( + "any in ..adapter.inbound.web..dto..", + (List<JavaClass> params) -> { + for (JavaClass p : params) { + if (JavaClass.Predicates.resideInAPackage("..adapter.inbound.web..dto..") + .test(p)) { + return true; + } + } + return false; + })) + .as( + "application methods must not accept request/response DTOs — " + + "controllers must translate to Command/Query before invoking the use case " + + "(feature-boundary-validation-mapping-contract §Forbidden + §테스트 계약)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_PROBLEM_DETAIL_USAGE = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName("org.springframework.http.ProblemDetail") + .as( + "D5: RFC 7807 ProblemDetail is explicitly rejected — error responses must use " + + "the shared Envelope shape " + + "(feature-boundary-validation-mapping-contract D5). Production code is " + + "scanned only — test fixtures in ..architecture.violations.. are out of " + + "scope under ImportOption.DoNotIncludeTests.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING = + classes() + .that() + .resideInAPackage("dev.caskeleton..") + .should(notReferenceMediaTypeString("application/merge-patch+json")) + .as( + "B2: RFC 7396 application/merge-patch+json content type is explicitly rejected " + + "— PATCH endpoints must use JSON with JsonNullable 3-state " + + "(feature-boundary-validation-mapping-contract B2)") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES = + methods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..adapter.outbound..") + // @Configuration factory classes are excluded: a @Bean method legitimately + // returns the adapter's own outbound port type for DI wiring (e.g. + // MessagingConfig#messagePublisher -> MessagePublisher) — that is + // composition-root assembly, not an adapter *response* method leaking an + // external type. B7 targets the latter (ACL leakage), not DI factories + // (feature-integration-adapter-templates Layer 1 @ConditionalOnProperty configs). + .and() + .areDeclaredInClassesThat() + .areNotAnnotatedWith("org.springframework.context.annotation.Configuration") + // @ConfigurationProperties settings holders are excluded for the same reason as + // @Configuration factories: they are config-binding types, not adapter *response* + // surfaces. Their accessors return bound config values (primitives, Duration, + // DataSize) and nested config records (e.g. OutboundHttpSettings#retry -> + // OutboundHttpSettings.Retry) — internal settings, not external HTTP / messaging + // responses, so B7's ACL-leakage target does not apply. + .and() + .areDeclaredInClassesThat() + .areNotAnnotatedWith( + "org.springframework.boot.context.properties.ConfigurationProperties") + .and() + .arePublic() + .and() + .areNotStatic() + .should() + .notHaveRawReturnType( + JavaClass.Predicates.resideInAnyPackage( + "..adapter.outbound..", + "..adapter.inbound.web..", + "..adapter.outbound.persistence..")) + .as( + "B7: outbound adapter public methods must return domain types (or " + + "primitives/wrappers/Optional) — raw external response types must not " + + "escape the adapter package " + + "(feature-boundary-validation-mapping-contract B7 ACL). @Configuration " + + "@Bean factory methods and @ConfigurationProperties settings holders are " + + "excluded — they assemble port bindings / bind config, not adapter " + + "response surfaces.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE = + classes() + .that() + .resideInAPackage("..adapter.inbound.web..dto..") + .should(notHaveValidCascadeDeeperThan(3)) + .as( + "B4: @Valid cascade depth must be ≤ 3 to bound nested-DTO recursion cost " + + "(feature-boundary-validation-mapping-contract B4 — DoS defense)") + .allowEmptyShould(true); + + // ---- feature-business-rule-validation-contract C1/D1 (validation stays at the web boundary) + // ---- + + @ArchTest + static final ArchRule VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY = + noClasses() + .that() + .resideInAnyPackage("..domain..", "..application..") + .should() + .dependOnClassesThat() + .resideInAPackage("jakarta.validation..") + .as( + "C1/D1: Bean Validation (jakarta.validation @NotNull / @Valid / ...) is the " + + "syntax layer and must stay at the adapter-web request boundary. Domain " + + "invariants live in domain model / value-object constructors and application " + + "policy is plain Java — neither is expressed with annotation-driven " + + "validation (feature-business-rule-validation-contract C1/D1, boundary B4 " + + "layering). Production code is scanned only — fixtures in " + + "..architecture.violations.. are out of scope under " + + "ImportOption.DoNotIncludeTests.") + .allowEmptyShould(true); + + // ---- feature-api-contract-baseline D19 (resource URL naming, AIP-122) ---- + + @ArchTest + static final ArchRule CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122 = + classes() + .that() + .resideInAPackage("..adapter.inbound.web..controller..") + .should(haveAip122CompliantRequestMappingPaths()) + .as( + "D19: controller @RequestMapping path segments must follow AIP-122 — lowercase " + + "first char + ASCII alphanumerics ([a-z][a-zA-Z0-9]*), so kebab-case " + + "(/work-logs), CamelCase (/Tickets) and SNAKE_CASE are rejected. " + + "{pathVariables} and AIP-136 colon-verb custom methods " + + "(/worklogs:batchCreate) are allowed (feature-api-contract-baseline D19)") + .allowEmptyShould(true); + + private static final Pattern AIP122_SEGMENT = Pattern.compile("[a-z][a-zA-Z0-9]*"); + private static final Pattern AIP136_VERB = Pattern.compile("[a-zA-Z][a-zA-Z0-9]*"); + private static final Set<String> MAPPING_ANNOTATION_TYPES = + Set.of( + "org.springframework.web.bind.annotation.RequestMapping", + "org.springframework.web.bind.annotation.GetMapping", + "org.springframework.web.bind.annotation.PostMapping", + "org.springframework.web.bind.annotation.PutMapping", + "org.springframework.web.bind.annotation.PatchMapping", + "org.springframework.web.bind.annotation.DeleteMapping"); + + private static ArchCondition<JavaClass> haveAip122CompliantRequestMappingPaths() { + return new ArchCondition<>("have AIP-122 compliant @RequestMapping path segments") { + @Override + public void check(JavaClass item, ConditionEvents events) { + checkMappingPaths(item, item.getAnnotations(), events); + for (JavaMethod m : item.getMethods()) { + checkMappingPaths(item, m.getAnnotations(), events); + } + } + }; + } + + private static void checkMappingPaths( + JavaClass owner, Set<? extends JavaAnnotation<?>> annotations, ConditionEvents events) { + for (JavaAnnotation<?> annotation : annotations) { + if (!MAPPING_ANNOTATION_TYPES.contains(annotation.getRawType().getName())) { + continue; + } + for (String key : List.of("value", "path")) { + annotation + .get(key) + .ifPresent( + value -> { + for (String path : toPathStrings(value)) { + for (String segment : path.split("/")) { + validatePathSegment(owner, segment, path, events); + } + } + }); + } + } + } + + private static List<String> toPathStrings(Object value) { + if (value instanceof String s) { + return List.of(s); + } + if (value instanceof Object[] arr) { + List<String> out = new ArrayList<>(); + for (Object e : arr) { + if (e != null) { + out.add(e.toString()); + } + } + return out; + } + return List.of(); + } + + private static void validatePathSegment( + JavaClass owner, String segment, String path, ConditionEvents events) { + if (segment.isEmpty() || (segment.startsWith("{") && segment.endsWith("}"))) { + return; // empty segment or {pathVariable} + } + String[] colonParts = segment.split(":", 2); + String resource = colonParts[0]; + if (!resource.isEmpty() && !AIP122_SEGMENT.matcher(resource).matches()) { + events.add( + SimpleConditionEvent.violated( + owner, + "Class " + + owner.getName() + + " maps non-AIP-122 path segment '" + + segment + + "' in '" + + path + + "' — use lowercase [a-z][a-zA-Z0-9]* (no kebab/Camel/snake)")); + return; + } + if (colonParts.length == 2 && !AIP136_VERB.matcher(colonParts[1]).matches()) { + events.add( + SimpleConditionEvent.violated( + owner, + "Class " + + owner.getName() + + " maps non-AIP-136 colon-verb '" + + segment + + "' in '" + + path + + "'")); + } + } + + private static ArchCondition<JavaClass> notReferenceMediaTypeString(String mediaType) { + return new ArchCondition<>("not reference media type string '" + mediaType + "'") { + @Override + public void check(JavaClass item, ConditionEvents events) { + // The realistic carriers — @RequestMapping(consumes=...), + // @PostMapping(consumes=...), and so on — sit on classes AND methods. + // Bytecode does not retain String initializer values for non-final + // fields, so free-form references inside method bodies cannot be + // detected at the ArchUnit level. We catch the annotation carriers + // (class and method) which is where the realistic usage lives. + scanAnnotations(item, item.getAnnotations(), mediaType, events); + for (JavaMethod m : item.getMethods()) { + scanAnnotations(item, m.getAnnotations(), mediaType, events); + } + } + }; + } + + private static void scanAnnotations( + JavaClass owner, + Set<? extends JavaAnnotation<?>> annotations, + String mediaType, + ConditionEvents events) { + for (JavaAnnotation<?> annotation : annotations) { + // Annotation properties on Spring's @RequestMapping family carry their values as + // String[] which boxes to Object[]. Some ArchUnit versions also expose property + // values as plain String. Walk both shapes and also fall back to the rendered + // annotation text so single-value carriers (e.g. MediaType constants resolved at + // bytecode level) are caught. + for (Object v : annotation.getProperties().values()) { + if (matches(v, mediaType)) { + events.add( + SimpleConditionEvent.violated( + owner, + "Class " + + owner.getName() + + " uses '" + + mediaType + + "' via @" + + annotation.getRawType().getSimpleName())); + return; + } + } + if (annotation.toString().contains(mediaType)) { + events.add( + SimpleConditionEvent.violated( + owner, + "Class " + + owner.getName() + + " uses '" + + mediaType + + "' (detected in @" + + annotation.getRawType().getSimpleName() + + " text)")); + } + } + } + + private static boolean matches(Object value, String mediaType) { + if (value == null) { + return false; + } + if (value instanceof String s) { + return s.equals(mediaType); + } + if (value instanceof Object[] arr) { + for (Object e : arr) { + if (matches(e, mediaType)) { + return true; + } + } + } + if (value instanceof Iterable<?> iter) { + for (Object e : iter) { + if (matches(e, mediaType)) { + return true; + } + } + } + // Fallback: rendered representation. Catches Spring annotation values that + // get wrapped in opaque ArchUnit holders whose toString carries the literal. + return value.toString().contains(mediaType); + } + + private static ArchCondition<JavaClass> notHaveValidCascadeDeeperThan(int maxDepth) { + return new ArchCondition<>("not have @Valid cascade deeper than " + maxDepth + " nested DTOs") { + @Override + public void check(JavaClass item, ConditionEvents events) { + int depth = computeValidDepth(item, new HashSet<>(), 0); + if (depth > maxDepth) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " has @Valid cascade depth " + + depth + + " (max allowed: " + + maxDepth + + ")")); + } + } + + private int computeValidDepth(JavaClass type, Set<String> visited, int currentDepth) { + if (!visited.add(type.getName())) { + return currentDepth; + } + int maxFound = currentDepth; + for (JavaField field : type.getFields()) { + boolean hasValid = false; + for (JavaAnnotation<?> a : field.getAnnotations()) { + if (a.getRawType().getName().equals("jakarta.validation.Valid")) { + hasValid = true; + break; + } + } + if (hasValid) { + JavaClass fieldType = field.getRawType(); + if (fieldType.getPackageName().startsWith("dev.caskeleton")) { + int sub = computeValidDepth(fieldType, visited, currentDepth + 1); + if (sub > maxFound) { + maxFound = sub; + } + } + } + } + return maxFound; + } + }; + } + + private static ArchCondition<JavaClass> notBeAnnotatedWithJsonIgnoreUnknown() { + return new ArchCondition<>( + "not be annotated with @JsonIgnoreProperties(ignoreUnknown = true)") { + @Override + public void check(JavaClass item, ConditionEvents events) { + item.tryGetAnnotationOfType("com.fasterxml.jackson.annotation.JsonIgnoreProperties") + .ifPresent( + annotation -> { + Object ignoreUnknown = annotation.get("ignoreUnknown").orElse(null); + if (Boolean.TRUE.equals(ignoreUnknown)) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " is annotated with " + + "@JsonIgnoreProperties(ignoreUnknown = true) — " + + "use field-level @JsonIgnore on the specific " + + "field instead (B1)")); + } + }); + } + }; + } + + // ---- feature-resource-identifier-contract D17 (decision SSOT: resource-identifier branch) ---- + + @ArchTest + static final ArchRule NO_LONG_ID_PK = + fields() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..domain..") + .and() + .haveNameMatching("id") + .should() + .haveRawType(assignableTo(ResourceId.class)) + .as( + "D17 NO_LONG_ID_PK: a domain entity 'id' field must be a ResourceId value " + + "object (e.g. WorkLogId), never Long/long/int/Integer " + + "(feature-resource-identifier-contract D17). JPA @Id UUID columns in " + + "..adapter.outbound.persistence.. are out of scope — they store the UUID as the " + + "PostgreSQL native uuid type per D10.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_UUID_RANDOM_IN_CONTROLLER = + noClasses() + .that() + .resideInAnyPackage("..adapter.inbound.web..controller..", "..application..") + .should() + .callMethod(UUID.class, "randomUUID") + .orShould() + .dependOnClassesThat() + .haveFullyQualifiedName("com.github.f4b6a3.uuid.UuidCreator") + .as( + "D5/D17 NO_UUID_RANDOM_IN_CONTROLLER: controllers and the application layer " + + "(use cases / services) must not generate resource identifiers directly " + + "(UUID.randomUUID / UuidCreator) — inject the domain WorkLogIdFactory port " + + "instead (feature-resource-identifier-contract D5/D17). Scope follows the " + + "D17 decision text (controller / service / use case); request/trace-id " + + "generation in web filters is a distinct concern owned by D18 / " + + "feature-distributed-tracing-contract and is intentionally out of scope.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_MATH_RANDOM_FOR_ID = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .callMethod(Math.class, "random") + .as( + "D9/D17 NO_MATH_RANDOM_FOR_ID: Math.random() is not a CSPRNG and must never " + + "seed an identifier (feature-resource-identifier-contract D9/D17).") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_VARCHAR_255_FOR_ID_COLUMN = + fields() + .that() + .areAnnotatedWith("jakarta.persistence.Column") + .and() + .haveNameMatching(".*[iI]d") + .should(haveExplicitIdColumnMapping()) + .as( + "D17 NO_VARCHAR_255_FOR_ID_COLUMN: an @Column-mapped id field must declare an " + + "explicit non-default length or a portable UUID type hint — never fall " + + "back to varchar(255) (feature-resource-identifier-contract D17).") + .allowEmptyShould(true); + + // ---- feature-streaming-response-contract D3 (event/server-push streaming ban) ---- + + @ArchTest + static final ArchRule NO_SSE_EMITTER = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName( + "org.springframework.web.servlet.mvc.method.annotation.SseEmitter") + .as( + "D3 NO_SSE_EMITTER: production code must not import SseEmitter — " + + "SSE server-push streaming (W3C EventSource / text/event-stream) is " + + "not supported in ca-skeleton (feature-streaming-response-contract D3). " + + "Production code is scanned only — test fixtures in " + + "..architecture.violations.. are out of scope under " + + "ImportOption.DoNotIncludeTests.") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_RESPONSE_BODY_EMITTER = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .haveFullyQualifiedName( + "org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter") + .as( + "D3 NO_RESPONSE_BODY_EMITTER: production code must not import " + + "ResponseBodyEmitter — it is the SSE base class and incremental " + + "object-emit surface (SPRING-ASYNC-C3) treated as server-push " + + "(UNSUPPORTED_IMPL_DECISION④). Note: StreamingResponseBody " + + "(large-file download, request-response model) is NOT blocked — " + + "it is owned by feature-file-resource-handling-contract D8 " + + "(feature-streaming-response-contract D3).") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule NO_WEBSOCKET_HANDLER = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.springframework.web.socket..", "jakarta.websocket..") + .as( + "D3 NO_WEBSOCKET_HANDLER: production code must not depend on " + + "org.springframework.web.socket.. or jakarta.websocket.. — " + + "these cover the spring-websocket handler/STOMP surfaces and " + + "Jakarta @ServerEndpoint (RFC 6455 full-duplex WebSocket model). " + + "Full-duplex streaming is not supported in ca-skeleton " + + "(feature-streaming-response-contract D3). Production code is " + + "scanned only under ImportOption.DoNotIncludeTests.") + .allowEmptyShould(true); + + // ---- feature-secrets-config-source-contract D3/D10 (no-runtime-reload static enforcement) ---- + + @ArchTest + static final ArchRule NO_REFRESH_SCOPE_ANYWHERE = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .beAnnotatedWith("org.springframework.cloud.context.config.annotation.RefreshScope") + .as( + "D3/D10 NO_REFRESH_SCOPE_ANYWHERE: secrets default to no-runtime-reload " + + "(rotation = restart validation), so @RefreshScope is banned across the " + + "whole production tree — it is the in-process config-reload machinery the " + + "VAULT-DYN-C2 dynamic-reload rejection forbids " + + "(feature-secrets-config-source-contract D3/D10). The ban is total (no " + + "carve-out): an explicit rotation handler (e.g. JWT 24h overlap, owned by " + + "another branch) rotates via a mutable holder + scheduled swap, never " + + "@RefreshScope. spring-cloud-context is not on the production classpath, so " + + "the annotation is referenced by fully-qualified name. Production code is " + + "scanned only — the fixture in ..architecture.violations.secrets.. is out " + + "of scope under ImportOption.DoNotIncludeTests.") + .allowEmptyShould(true); + + // ---- feature-schema-serialization-contract D3 (BigDecimal precision trap ban) ---- + + @ArchTest + static final ArchRule NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR = + noClasses() + .that() + .resideInAPackage("dev.caskeleton..") + .should() + .callConstructor(BigDecimal.class, double.class) + .orShould() + .callConstructor(BigDecimal.class, float.class) + .as( + "D3 NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR: new BigDecimal(double) / " + + "new BigDecimal(float) captures the binary floating-point error " + + "(new BigDecimal(0.1) == 0.1000000000000000055...) instead of the exact " + + "decimal — money/decimal values must be built from a String " + + "(new BigDecimal(\"0.1\") or BigDecimal.valueOf(double)) " + + "(feature-schema-serialization-contract D3 / SBMS-C3). Production code is " + + "scanned only — test fixtures in ..architecture.violations.. are out of " + + "scope under ImportOption.DoNotIncludeTests.") + .allowEmptyShould(true); + + // ---- feature-application-query-bypass-contract D1 (read/query port purity guardrail) ---- + + @ArchTest + static final ArchRule QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES = + methods() + .that() + .areDeclaredInClassesThat( + JavaClass.Predicates.resideInAPackage("..application..") + .and(JavaClass.Predicates.simpleNameEndingWith("QueryPort"))) + .and() + .arePublic() + .should( + notLeakDomainJpaOrWebThroughReturnType( + "..domain..", + "..adapter..", + "jakarta.persistence..", + "javax.persistence..", + "org.springframework.web..", + "org.hibernate..")) + .as( + "D1 QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES: application read/query ports " + + "(classes whose simple name ends with 'QueryPort') must return " + + "application-layer projection DTOs — never a domain aggregate, JPA entity, or " + + "web type, INCLUDING through generic type arguments like List<DomainType> " + + "(checked via JavaType.getAllInvolvedRawTypes(), since a raw-return-type check " + + "alone misses generic leakage) " + + "(feature-application-query-bypass-contract D1 core purity guardrail). The " + + "through-aggregate read path (repository ports returning the domain aggregate) " + + "is a separate, equally-valid choice and is intentionally out of this rule's scope.") + .allowEmptyShould(true); + + private static ArchCondition<JavaMethod> notLeakDomainJpaOrWebThroughReturnType( + String... forbiddenPackages) { + DescribedPredicate<JavaClass> forbidden = + JavaClass.Predicates.resideInAnyPackage(forbiddenPackages); + return new ArchCondition<>( + "return application projection DTOs only " + + "(no domain / adapter / JPA / web types, including generic type arguments)") { + @Override + public void check(JavaMethod method, ConditionEvents events) { + // getAllInvolvedRawTypes() flattens the return type plus every generic type + // argument (recursively) to their raw classes — so List<DomainType> surfaces + // both java.util.List AND DomainType, which a raw-return-type predicate misses. + for (JavaClass involved : method.getReturnType().getAllInvolvedRawTypes()) { + if (forbidden.test(involved)) { + events.add( + SimpleConditionEvent.violated( + method, + "Method " + + method.getFullName() + + " leaks " + + involved.getName() + + " through its return type (directly or as a generic type argument)" + + " — a *QueryPort must return application-layer projection DTOs " + + "(feature-application-query-bypass-contract D1)")); + } + } + } + }; + } + + // ---- feature-domain-modeling-guardrails custom conditions ---- + + private static final Set<String> AUDIT_FIELD_NAMES = + Set.of("createdat", "updatedat", "createdby", "updatedby"); + + private static ArchCondition<JavaClass> declareAnAuditField() { + return new ArchCondition<>( + "declare an audit field " + "(createdAt / updatedAt / createdBy / updatedBy)") { + @Override + public void check(JavaClass item, ConditionEvents events) { + for (JavaField field : item.getFields()) { + if (AUDIT_FIELD_NAMES.contains(field.getName().toLowerCase(Locale.ROOT))) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " declares audit field '" + + field.getName() + + "' — audit metadata must live only on the adapter-persistence " + + "AuditableEntity @MappedSuperclass, not on a domain class " + + "(feature-persistence-auditing-contract D2)")); + } + } + } + }; + } + + private static ArchCondition<JavaClass> notHaveAPublicNoArgConstructor() { + return new ArchCondition<>("not have a public no-arg constructor") { + @Override + public void check(JavaClass item, ConditionEvents events) { + for (JavaConstructor constructor : item.getConstructors()) { + if (constructor.getRawParameterTypes().isEmpty() + && constructor.getModifiers().contains(JavaModifier.PUBLIC)) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " exposes a public no-arg constructor — " + + "a value object must validate its invariant on the sole " + + "construction path and offer no empty back door " + + "(feature-domain-modeling-guardrails D5/D6)")); + } + } + } + }; + } + + private static ArchCondition<JavaClass> beRecordTypes() { + return new ArchCondition<>("be a record") { + @Override + public void check(JavaClass item, ConditionEvents events) { + if (!item.isRecord()) { + events.add( + SimpleConditionEvent.violated( + item, + "Class " + + item.getName() + + " is annotated @DomainEvent but is not a " + + "record — a domain event is an immutable fact and must be a " + + "record (feature-domain-modeling-guardrails D4/D8)")); + } + } + }; + } + + private static ArchCondition<JavaField> notDeclareColumnDefinition() { + return new ArchCondition<>("not declare @Column columnDefinition") { + @Override + public void check(JavaField field, ConditionEvents events) { + field + .tryGetAnnotationOfType("jakarta.persistence.Column") + .ifPresent( + column -> { + String columnDefinition = + column.get("columnDefinition").map(Object::toString).orElse(""); + if (!columnDefinition.isBlank()) { + events.add( + SimpleConditionEvent.violated( + field, + "Field " + + field.getFullName() + + " pins vendor SQL columnDefinition='" + + columnDefinition + + "' in adapter:outbound:persistence-jpa; move the " + + "physical type to the vendor migration.")); + } + }); + } + }; + } + + private static ArchCondition<JavaField> haveExplicitIdColumnMapping() { + return new ArchCondition<>( + "declare an explicit non-default length or portable UUID type hint") { + @Override + public void check(JavaField field, ConditionEvents events) { + field + .tryGetAnnotationOfType("jakarta.persistence.Column") + .ifPresent( + column -> { + int length = column.get("length").map(v -> ((Number) v).intValue()).orElse(255); + boolean explicit = length != 255 || hasPortableUuidTypeHint(field); + if (!explicit) { + events.add( + SimpleConditionEvent.violated( + field, + "Field " + + field.getFullName() + + " maps an id column with the implicit " + + "varchar(255) default — declare an explicit length or " + + "@JdbcTypeCode(SqlTypes.UUID) for UUID fields " + + "(feature-resource-identifier-contract D17)")); + } + }); + } + }; + } + + private static boolean hasPortableUuidTypeHint(JavaField field) { + if (!field.getRawType().isEquivalentTo(UUID.class)) { + return false; + } + return field + .tryGetAnnotationOfType("org.hibernate.annotations.JdbcTypeCode") + .flatMap(annotation -> annotation.get("value")) + .filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID) + .isPresent(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ContractSuiteIsolationArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ContractSuiteIsolationArchTest.java new file mode 100644 index 00000000..46c1d942 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ContractSuiteIsolationArchTest.java @@ -0,0 +1,157 @@ +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.EvaluationResult; +import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract.SampleFeatureUsingContractFixture; +import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample.SampleFeatureFixture; +import dev.caskeleton.bootstrap.architecture.violations.contractisolation.contract.NonSampleFeatureCoupledContractFixture; +import dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing.BillingFeatureFixture; +import org.junit.jupiter.api.Test; + +/** + * Contract-test domain-isolation ArchUnit rule (feature-contract-verification-test-suite 테스트 계약 + * §1). + * + * <p>Enforces that contract test classes (residing in a {@code ..contract..} package) must NOT + * depend on a non-sample business-domain <em>feature</em> package. The {@code ..features.sample..} + * fixture package is the single allowed exemption. + * + * <p>Uses the <em>manual-importer</em> pattern (plain {@code @Test} + {@code ClassFileImporter}) + * rather than {@code @AnalyzeClasses}, because this rule is <em>about</em> test classes. The + * {@code @AnalyzeClasses} suites in this package all use {@code ImportOption.DoNotIncludeTests} and + * therefore cannot see test classes; the manual importer is the only way to load test bytecode. + * + * <h2>PACKAGE_DRIFT adaptation note</h2> + * + * <p>The branch spec's literal glob uses {@code com.example.caskeleton.features.{도메인}}. This + * project's base package is {@code dev.caskeleton}, not {@code com.example.caskeleton}. ArchUnit's + * {@code ..} wildcards make this mismatch irrelevant: the predicates {@code + * resideInAPackage("..features..")} and {@code resideOutsideOfPackage("..features.sample..")} are + * base-package-agnostic and correctly match any package whose qualified name contains {@code + * features} (or excludes {@code features.sample}) regardless of root prefix. + * + * <h2>Predicate composition note</h2> + * + * <p>ArchUnit does not support regex negation like {@code (?!sample)}. The "a {@code ..features..} + * package that is NOT {@code ..features.sample..}" predicate is expressed by composing two + * predicates with {@code .and(...)}: {@code + * resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample.."))}. + */ +class ContractSuiteIsolationArchTest { + + // ------------------------------------------------------------------------- + // The isolation rule + // ------------------------------------------------------------------------- + + /** + * Contract-test domain-isolation rule: a class in a {@code ..contract..} package must not depend + * on a class in a {@code ..features..} package that is NOT {@code ..features.sample..}. + * + * <p>Package-visible field (no {@code @ArchTest}) because this class uses the manual-importer + * pattern; {@code @ArchTest} wiring only fires under {@code @AnalyzeClasses}. + */ + static final ArchRule CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN = + noClasses() + .that() + .resideInAPackage("..contract..") + .should() + .dependOnClassesThat( + resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample.."))) + .as( + "feature-contract-verification-test-suite 테스트 계약 §1: a contract test (..contract..) " + + "must stay domain-agnostic — it must not depend on a non-sample business-domain " + + "feature package (..features.. except ..features.sample.. fixtures). " + + "PACKAGE_DRIFT: base package is dev.caskeleton, not com.example.caskeleton; " + + ".. wildcards make this base-agnostic.") + .allowEmptyShould(true); + + // ------------------------------------------------------------------------- + // Positive-control / over-block / clean-check corpora + // Deterministic class-literal imports — no package enumeration for controls. + // ------------------------------------------------------------------------- + + /** Violation corpus: a contract class depending on a non-sample feature type. */ + private static final JavaClasses NON_SAMPLE_FEATURE_VIOLATION_CORPUS = + new ClassFileImporter() + .importClasses(NonSampleFeatureCoupledContractFixture.class, BillingFeatureFixture.class); + + /** Allowed corpus: a contract class depending only on the sample feature fixture. */ + private static final JavaClasses SAMPLE_FEATURE_ALLOWED_CORPUS = + new ClassFileImporter() + .importClasses(SampleFeatureUsingContractFixture.class, SampleFeatureFixture.class); + + // ------------------------------------------------------------------------- + // Test 1 — clean-check: real contract tests pass the isolation rule + // ------------------------------------------------------------------------- + + @Test + void realContractTestsAreDomainAgnostic() { + // Package-scan corpus: non-vacuity guard ensures the scan is never silently empty. + JavaClasses corpus = + new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract"); + + assertThat(corpus.size()) + .as( + "contract corpus (dev.caskeleton.bootstrap.contract) must be non-empty — an empty " + + "scan would make this clean-check pass vacuously " + + "(feature-contract-verification-test-suite 테스트 계약 §1 non-vacuity guard)") + .isGreaterThan(0); + + EvaluationResult result = + CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(corpus); + + assertThat(result.hasViolation()) + .as( + "CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN must NOT fire on " + + "dev.caskeleton.bootstrap.contract — real contract tests must remain " + + "domain-agnostic (feature-contract-verification-test-suite 테스트 계약 §1)") + .isFalse(); + } + + // ------------------------------------------------------------------------- + // Test 2 — positive control: rule fires on non-sample feature coupling + // ------------------------------------------------------------------------- + + @Test + void isolationRuleFiresOnNonSampleFeatureCoupling() { + // Deterministic importClasses — proves the rule is non-vacuous. + EvaluationResult result = + CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate( + NON_SAMPLE_FEATURE_VIOLATION_CORPUS); + + assertThat(result.hasViolation()) + .as( + "CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN MUST fire on " + + "NonSampleFeatureCoupledContractFixture (..contract.. → BillingFeatureFixture in " + + "..features.billing..) — proves the rule is non-vacuous " + + "(feature-contract-verification-test-suite 테스트 계약 §1 positive control)") + .isTrue(); + } + + // ------------------------------------------------------------------------- + // Test 3 — over-block guard: rule does NOT fire on sample feature usage + // ------------------------------------------------------------------------- + + @Test + void isolationRuleAllowsSampleFeatureFixture() { + // Deterministic importClasses — proves the rule does not over-block sample fixtures. + EvaluationResult result = + CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate( + SAMPLE_FEATURE_ALLOWED_CORPUS); + + assertThat(result.hasViolation()) + .as( + "CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN must NOT fire on " + + "SampleFeatureUsingContractFixture (..contract.. → SampleFeatureFixture in " + + "..features.sample..) — sample fixture usage is the allowed exemption " + + "(feature-contract-verification-test-suite 테스트 계약 §1 over-block guard)") + .isFalse(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java new file mode 100644 index 00000000..e7bcd96c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java @@ -0,0 +1,83 @@ +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * feature-integration-adapter-templates Layer 2 (D3 / §구현 가이드 §3) — static isolation + gating guard + * for the optional integration adapters (Kafka / Redis / Slack / Google Email). Owner: this branch. + * + * <p>What this layer statically guarantees, and its documented limit (branch-note L111 / D3 Open + * Risk): ArchUnit can prove (1) the application layer never imports an optional adapter package, + * and (2) every optional-adapter {@code @Bean} is gated by {@code @ConditionalOnProperty} — i.e. + * "the adapter candidate class HAS the {@code @ConditionalOnProperty} annotation". Whether the + * adapter is actually <em>active</em> at runtime is a config evaluation ArchUnit cannot reach; that + * runtime guarantee is delegated to Layer 3 ({@code AdapterDisabledException}). + * + * <p>Spring annotation types are referenced by fully-qualified NAME so this test needs no compile + * dependency on spring-context / spring-boot-autoconfigure (they arrive only via adapter-outbound's + * {@code implementation} scope, not transitively here). + */ +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class DisabledAdapterArchitectureTest { + + private static final String[] OPTIONAL_ADAPTER_PACKAGES = { + "..adapter.outbound.messaging.kafka..", + "..adapter.outbound.cache.redis..", + "..adapter.outbound.notification.slack..", + "..adapter.outbound.notification.email.." + }; + + private static final String BEAN = "org.springframework.context.annotation.Bean"; + private static final String CONDITIONAL_ON_PROPERTY = + "org.springframework.boot.autoconfigure.condition.ConditionalOnProperty"; + + /** + * Layer 2 isolation — the application layer must never import an optional adapter package, so a + * disabled adapter's classes can never appear on a use-case path (the static half of the + * disabled-adapter detection; D3). + */ + @ArchTest + static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS = + noClasses() + .that() + .resideInAPackage("..application..") + .should() + .dependOnClassesThat() + .resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES) + .as( + "D3 APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS: the application layer must " + + "not import an optional adapter package (Kafka/Redis/Slack/Google Email) — " + + "the static half of the disabled-adapter detection. Runtime activity is " + + "delegated to Layer 3 (feature-integration-adapter-templates D3)") + .allowEmptyShould(true); + + /** + * Layer 2 gating — every {@code @Bean} factory method that lives in an optional adapter package + * must be gated by {@code @ConditionalOnProperty}, so no optional-adapter bean can ship ungated + * (disabled-default could otherwise be bypassed). This is exactly the "adapter candidate + * HAS @ConditionalOnProperty" guarantee from D3 (L111). + */ + @ArchTest + static final ArchRule OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY = + methods() + .that() + .areAnnotatedWith(BEAN) + .and() + .areDeclaredInClassesThat() + .resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES) + .should() + .beAnnotatedWith(CONDITIONAL_ON_PROPERTY) + .as( + "D3 OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY: every @Bean in an " + + "optional adapter package (Kafka/Redis/Slack/Google Email) must declare " + + "@ConditionalOnProperty(app.<domain>.<adapter>.enabled) — Layer 1 disabled-default " + + "must not be bypassable by an ungated bean. ArchUnit reaches the annotation " + + "presence only; runtime activation is Layer 3's job " + + "(feature-integration-adapter-templates D3, L111)") + .allowEmptyShould(true); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java new file mode 100644 index 00000000..555bc3c4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Contract dry-run for adding a real domain feature without importing sample-portfolio. */ +class DomainFeatureOnboardingContractTest { + + private static final String ROOT = "dev.caskeleton.onboarding"; + + private static final JavaClasses ONBOARDING_CLASSES = + new ClassFileImporter().importPackages(ROOT); + + @Test + void readOnlyOnboardingSliceHasMinimumContractAndNoWriteArtifacts() { + assertThat(classNames()) + .contains( + ROOT + ".application.query.ListFeatureAggregatesQuery", + ROOT + ".application.query.FeatureAggregateSummary", + ROOT + ".application.port.FeatureAggregateSummaryQueryPort", + ROOT + ".application.usecase.ListFeatureAggregatesUseCase", + ROOT + ".adapter.inbound.web.dto.FeatureAggregateSummaryResponse", + ROOT + ".adapter.inbound.web.mapper.FeatureAggregateResponseMapper", + ROOT + ".adapter.inbound.web.controller.FeatureAggregateController") + .doesNotContain( + ROOT + ".application.command.ListFeatureAggregatesCommand", + ROOT + ".application.port.ListFeatureAggregatesWritePort"); + + assertThat( + CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + } + + @Test + void writeOnboardingSliceHasMinimumContractAndStaticRulesPass() { + assertThat(classNames()) + .contains( + ROOT + ".domain.feature.FeatureAggregate", + ROOT + ".domain.feature.FeatureAggregateCreated", + ROOT + ".domain.feature.FeatureAggregateId", + ROOT + ".domain.feature.FeatureAggregateIdFactory", + ROOT + ".application.command.CreateFeatureAggregateCommand", + ROOT + ".application.port.FeatureAggregateWritePort", + ROOT + ".application.usecase.CreateFeatureAggregateUseCase", + ROOT + ".adapter.outbound.persistence.entity.FeatureAggregateEntity", + ROOT + ".adapter.outbound.persistence.mapper.FeatureAggregateEntityMapper", + ROOT + ".adapter.outbound.persistence.repository.FeatureAggregateRepositoryAdapter", + ROOT + ".adapter.inbound.web.dto.CreateFeatureAggregateRequest", + ROOT + ".adapter.inbound.web.dto.FeatureAggregateResponse", + ROOT + ".adapter.inbound.web.controller.FeatureAggregateController"); + + assertThat(CleanArchitectureTest.DOMAIN_IS_PURE.evaluate(ONBOARDING_CLASSES).hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.PERSISTENCE_ADAPTER_DOES_NOT_DEPEND_ON_WEB_OR_OUTBOUND_ADAPTERS + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat( + CleanArchitectureTest.PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO + .evaluate(ONBOARDING_CLASSES) + .hasViolation()) + .isFalse(); + assertThat(getClass().getResource("/db/onboarding-migration/V999__feature_aggregate.sql")) + .as("write onboarding dry-run must include a persistence migration artifact") + .isNotNull(); + } + + private static List<String> classNames() { + return ONBOARDING_CLASSES.stream().map(javaClass -> javaClass.getName()).sorted().toList(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/NamingConventionTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/NamingConventionTest.java new file mode 100644 index 00000000..74d7283b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/NamingConventionTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Naming convention enforcement — the machine-checkable ({@code [객관]}) subset of {@code + * .agents/plugins/ca-superpowers/rules/code-conventions.md}. + * + * <p>The naming/idiom SSOT is {@code code-conventions.md}. This test enforces only the rules that + * ArchUnit can decide reliably (N6, I5). {@code [판단]} naming rules (N2/N7/N8/...) and rules with + * intentional project exceptions (N3/N4 — e.g. the {@code IdFactory} domain port, the {@code + * SpringTransactionPort} canonical impl) stay in {@code ca-quality-reviewer} (doc+review), per the + * code-conventions enforcement matrix. + */ +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class NamingConventionTest { + + // N6 — types we own that bind configuration end with *Settings (operational config) + // or *Policy (authz / domain-policy binding). Spring framework *Properties types + // (JacksonProperties, WebMvcProperties, ...) are excluded by the dev.caskeleton scope. + @ArchTest + static final ArchRule CONFIGURATION_PROPERTIES_END_WITH_SETTINGS_OR_POLICY = + classes() + .that() + .areAnnotatedWith(ConfigurationProperties.class) + .and() + .resideInAPackage("dev.caskeleton..") + .should() + .haveSimpleNameEndingWith("Settings") + .orShould() + .haveSimpleNameEndingWith("Policy") + .as( + "code-conventions N6: dev.caskeleton @ConfigurationProperties types end with " + + "'Settings' (operational config) or 'Policy' (authz/domain policy binding)") + .allowEmptyShould(true); + + // I5 — every throwable we own is named *Exception (failure category = one meaningful type). + @ArchTest + static final ArchRule THROWABLES_ARE_NAMED_EXCEPTION = + classes() + .that() + .areAssignableTo(Throwable.class) + .and() + .resideInAPackage("dev.caskeleton..") + .should() + .haveSimpleNameEndingWith("Exception") + .as("code-conventions I5: dev.caskeleton throwable types end with 'Exception'") + .allowEmptyShould(true); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java new file mode 100644 index 00000000..5b4627f6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; + +/** Excludes both Gradle's default test output and the sample-off custom test output. */ +public final class ProductionClassImportOption implements ImportOption { + + private final ImportOption defaultTestExclusion = new ImportOption.DoNotIncludeTests(); + + @Override + public boolean includes(Location location) { + return defaultTestExclusion.includes(location) && !location.contains("/sampleOffTest/"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java new file mode 100644 index 00000000..947b4eb8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java @@ -0,0 +1,224 @@ +package dev.caskeleton.bootstrap.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.EvaluationResult; +import dev.caskeleton.bootstrap.architecture.allowed.slice.SingleSliceWebMvcFixture; +import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.LeakyProductionConsumerFixture; +import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures.LeakedTestFixture; +import dev.caskeleton.bootstrap.architecture.violations.slice.MixedSliceAnnotationsFixture; +import dev.caskeleton.bootstrap.taxonomyfixtures.TestcontainersUsingFixture; +import org.junit.jupiter.api.Test; + +/** + * Test taxonomy and fixture-contract ArchUnit rules (feature-test-taxonomy-fixture-contract §테스트 계약 + * #4 / D3 / D6 / D7). + * + * <p>Uses the <em>manual-importer</em> pattern (plain {@code @Test} + {@code ClassFileImporter}) + * rather than {@code @AnalyzeClasses}, because the rules here are <em>about</em> test classes. The + * {@code @AnalyzeClasses} suites in this package all use {@code ImportOption.DoNotIncludeTests} and + * therefore cannot see test classes; the manual importer is the only way to load test bytecode. + * + * <h2>Import strategy — determinism over auto-vacuity</h2> + * + * <p>Positive controls and over-block guards import their target by an explicit {@code + * importClasses(SomeFixture.class)} <em>class literal</em>. This is deterministic: it reads a + * specific class file and never enumerates a package against the thread-context classloader. {@code + * importPackages(String)} (used only for the two clean-checks below) was observed to return an + * empty corpus under an inconsistent incremental build — which would make a positive control flake + * and, worse, make a clean-check pass <em>vacuously</em>. To defend against the latter, every + * package-scan clean-check first asserts its corpus is non-empty, so a guard can never silently + * stop enforcing. + */ +class TestTaxonomyArchitectureTest { + + // ------------------------------------------------------------------------- + // Clean-check corpora — real contract / architecture test trees (package scan). + // Guarded for non-vacuity in each test so an empty scan fails loudly, never silently. + // ------------------------------------------------------------------------- + + private static final JavaClasses CONTRACT_TESTS = + new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract"); + + private static final JavaClasses ARCHITECTURE_TESTS = + new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture"); + + // ------------------------------------------------------------------------- + // Positive-control / over-block corpora — deterministic class-literal imports. + // ------------------------------------------------------------------------- + + /** Holds an org.testcontainers field type — proves the TC-ban rule is non-vacuous. */ + private static final JavaClasses TESTCONTAINERS_USING_FIXTURE = + new ClassFileImporter().importClasses(TestcontainersUsingFixture.class); + + /** + * @WebMvcTest + @DataJpaTest on one class — proves the slice-mixing rule fires. + */ + private static final JavaClasses SLICE_VIOLATION_FIXTURE = + new ClassFileImporter().importClasses(MixedSliceAnnotationsFixture.class); + + /** + * @WebMvcTest only — over-block guard for the slice-mixing rule. + */ + private static final JavaClasses SLICE_ALLOWED_FIXTURE = + new ClassFileImporter().importClasses(SingleSliceWebMvcFixture.class); + + /** A non-fixture class depending on a {@code ..fixtures..} class — proves the leak rule fires. */ + private static final JavaClasses FIXTURE_LEAK_VIOLATION_CORPUS = + new ClassFileImporter() + .importClasses(LeakyProductionConsumerFixture.class, LeakedTestFixture.class); + + // ------------------------------------------------------------------------- + // Task 2 — §테스트 계약 #4 / D3: Testcontainers ban for contract + architecture level + // ------------------------------------------------------------------------- + + /** + * ArchUnit rule: contract-level and architecture-level test classes must not depend on + * Testcontainers. Real-service tests belong to the integration level ({@code + * ..bootstrap.integration..}). + * + * <p>Package-visible field (no {@code @ArchTest}) because this class uses the manual-importer + * pattern; {@code @ArchTest} wiring only fires under {@code @AnalyzeClasses}. + */ + static final ArchRule CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS = + noClasses() + .should() + .dependOnClassesThat() + .resideInAPackage("org.testcontainers..") + .as( + "feature-test-taxonomy-fixture-contract §테스트 계약 #4 / D3: unit·contract·" + + "architecture level tests must not depend on Testcontainers — real-service " + + "tests belong to the integration level (..bootstrap.integration..).") + .allowEmptyShould(true); + + @Test + void contractLevelTestsHaveNoTestcontainersDependency() { + // Non-vacuity guard: the contract tree must actually be scanned (never silently empty). + assertThat(CONTRACT_TESTS.size()) + .as( + "contract corpus (dev.caskeleton.bootstrap.contract) must be non-empty — an empty " + + "scan would make this clean-check pass vacuously (§테스트 계약 #4 / D3)") + .isGreaterThan(0); + + EvaluationResult result = + CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(CONTRACT_TESTS); + + assertThat(result.hasViolation()) + .as( + "CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS must NOT fire " + + "on dev.caskeleton.bootstrap.contract — no Testcontainers usage after " + + "Task 1 reclassification (§테스트 계약 #4 / D3)") + .isFalse(); + } + + @Test + void architectureLevelTestsHaveNoTestcontainersDependency() { + assertThat(ARCHITECTURE_TESTS.size()) + .as( + "architecture corpus (dev.caskeleton.bootstrap.architecture) must be non-empty — an " + + "empty scan would make this clean-check pass vacuously (§테스트 계약 #4 / D3)") + .isGreaterThan(0); + + EvaluationResult result = + CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate( + ARCHITECTURE_TESTS); + + assertThat(result.hasViolation()) + .as( + "CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS must NOT fire " + + "on dev.caskeleton.bootstrap.architecture — no Testcontainers usage " + + "(§테스트 계약 #4 / D3)") + .isFalse(); + } + + @Test + void banRuleFiresOnTestcontainersUsage() { + // Positive control / non-vacuity proof: a class with an org.testcontainers field type must + // be flagged. Deterministic importClasses — no package enumeration. + EvaluationResult result = + CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate( + TESTCONTAINERS_USING_FIXTURE); + + assertThat(result.hasViolation()) + .as( + "CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS MUST fire on " + + "TestcontainersUsingFixture (declares a PostgreSQLContainer field) — proves the " + + "rule is non-vacuous (§테스트 계약 #4 / D3 non-vacuity guard)") + .isTrue(); + } + + // ------------------------------------------------------------------------- + // Task 3 — D7 / SB-SLICE-C2: Spring slice annotation mixing ban + // ------------------------------------------------------------------------- + + /** + * ArchUnit rule: a test class must not carry two Spring slice annotations ({@code @WebMvcTest} + + * {@code @DataJpaTest}). Spring documents mixing slice annotations as not supported + * (SB-SLICE-C2). + * + * <p>Annotations referenced by FQN string — consistent with {@link + * DisabledAdapterArchitectureTest}. + */ + static final ArchRule SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS = + noClasses() + .that() + .areAnnotatedWith("org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest") + .should() + .beAnnotatedWith("org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest") + .as( + "feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2: a test class must not " + + "combine two Spring slice annotations (@WebMvcTest + @DataJpaTest) — Spring " + + "documents mixing slice annotations as not supported.") + .allowEmptyShould(true); + + @Test + void sliceMixingRuleFiresOnDualAnnotatedClass() { + EvaluationResult result = + SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS.evaluate(SLICE_VIOLATION_FIXTURE); + + assertThat(result.hasViolation()) + .as( + "SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS MUST fire on " + + "MixedSliceAnnotationsFixture (@WebMvcTest + @DataJpaTest) " + + "(D7 / SB-SLICE-C2 positive control)") + .isTrue(); + } + + @Test + void sliceMixingRuleDoesNotFlagSingleSliceClass() { + EvaluationResult result = + SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS.evaluate(SLICE_ALLOWED_FIXTURE); + + assertThat(result.hasViolation()) + .as( + "SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS must NOT fire on " + + "SingleSliceWebMvcFixture (@WebMvcTest only) " + + "(D7 / SB-SLICE-C2 over-block guard)") + .isFalse(); + } + + // ------------------------------------------------------------------------- + // Task 4 — D6: fixture main-classpath leakage guard (meta-test) + // ------------------------------------------------------------------------- + + @Test + void fixtureLeakRuleFiresOnProductionDependingOnFixture() { + // Evaluates the @ArchTest rule from CleanArchitectureTest against a deterministic + // corpus: LeakyProductionConsumerFixture (outside ..fixtures..) depends on + // LeakedTestFixture (in ..fixtures..). Proves the production-side guard fires. + EvaluationResult result = + CleanArchitectureTest.PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES.evaluate( + FIXTURE_LEAK_VIOLATION_CORPUS); + + assertThat(result.hasViolation()) + .as( + "PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES MUST fire on " + + "LeakyProductionConsumerFixture → LeakedTestFixture " + + "(D6 fixture-leak positive control)") + .isTrue(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/application/CleanProjectionQueryPort.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/application/CleanProjectionQueryPort.java new file mode 100644 index 00000000..1dc3a606 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/application/CleanProjectionQueryPort.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.architecture.allowed.application; + +import java.util.List; + +/** + * Over-block guard fixture for {@code QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES} + * (feature-application-query-bypass-contract D1). + * + * <p>A clean {@code *QueryPort} in an {@code ..application..} package that returns only an + * application/JDK projection shape ({@code List<String>}). The D1 purity rule must NOT flag it — + * proving the rule guards purity without forbidding legitimate projection reads (no over-block). + * Lives under {@code ..allowed.application..} so it is loaded only by the isolated over-block + * corpus, never the production scan. + */ +public interface CleanProjectionQueryPort { + + List<String> findTitles(); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/application/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/application/package-info.java new file mode 100644 index 00000000..b3d4464f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/application/package-info.java @@ -0,0 +1,8 @@ +/** + * "Allowed-as-data" over-block guard fixtures for the feature-application-query-bypass-contract D1 + * purity rule. Classes here are LEGITIMATE read/query ports that the {@code + * QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES} rule must NOT flag — loaded in isolation by + * {@link dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest} to prove the rule + * does not over-block clean projection reads. + */ +package dev.caskeleton.bootstrap.architecture.allowed.application; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/contract/SampleFeatureUsingContractFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/contract/SampleFeatureUsingContractFixture.java new file mode 100644 index 00000000..43838dbb --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/contract/SampleFeatureUsingContractFixture.java @@ -0,0 +1,24 @@ +package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract; + +import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample.SampleFeatureFixture; + +/** + * Over-block guard fixture: a class in a {@code ..contract..} package that depends only on the + * allowed {@code ..features.sample..} type. + * + * <p>This proves the contract isolation rule does not over-block legitimate sample-fixture usage. A + * contract test depending on {@link SampleFeatureFixture} (in {@code ..features.sample..}) must NOT + * be flagged by {@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}. + * + * <p>No {@code @Test} methods; never executed by JUnit. Loaded in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importClasses}. + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +public class SampleFeatureUsingContractFixture { + + // The field reference creates the bytecode dependency edge that ArchUnit detects. + @SuppressWarnings("unused") + private final SampleFeatureFixture sampleFeature = new SampleFeatureFixture(); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/contract/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/contract/package-info.java new file mode 100644 index 00000000..e97f46db --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/contract/package-info.java @@ -0,0 +1,18 @@ +/** + * "Allowed-as-data" over-block guard fixture for the contract isolation rule ({@code + * CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). + * + * <p>This package simulates a contract test that depends only on the allowed {@code + * ..features.sample..} fixture type. Classes here must NOT be flagged by the isolation rule, + * proving the rule correctly exempts sample-fixture dependencies. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only + * source tree). + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/features/sample/SampleFeatureFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/features/sample/SampleFeatureFixture.java new file mode 100644 index 00000000..4587445e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/features/sample/SampleFeatureFixture.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample; + +/** + * Stand-in for the single allowed exemption in the contract isolation rule: a {@code + * ..features.sample..} fixture type that contract tests ARE permitted to depend on. + * + * <p>This class lives under a {@code ..features.sample..} package, making it the over-block guard + * target for the rule ({@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). A + * contract test depending on this class must NOT be flagged by the rule. + * + * <p>No behavior is needed — only its package location matters for the bytecode-based ArchUnit + * analysis. No {@code @Test} methods; never executed. + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +public class SampleFeatureFixture { + // Empty — only its package location matters for ArchUnit dependency analysis. +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/features/sample/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/features/sample/package-info.java new file mode 100644 index 00000000..9686fe7f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/contractisolation/features/sample/package-info.java @@ -0,0 +1,18 @@ +/** + * "Allowed-as-data" over-block guard fixture for the contract isolation rule ({@code + * CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). + * + * <p>This package simulates the single allowed exemption: a {@code ..features.sample..} fixture + * package. Contract tests depending on classes here must NOT be flagged by the isolation rule, + * proving the rule does not over-block legitimate sample-fixture usage. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only + * source tree). + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/slice/SingleSliceWebMvcFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/slice/SingleSliceWebMvcFixture.java new file mode 100644 index 00000000..226bd0b4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/slice/SingleSliceWebMvcFixture.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.architecture.allowed.slice; + +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; + +/** + * Allowed fixture: a class annotated with only {@code @WebMvcTest} (single slice). + * + * <p>This fixture is the over-block guard for the ArchUnit rule {@code + * SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}: a class carrying exactly one Spring slice + * annotation must NOT be flagged. It has NO {@code @Test} methods and is never executed by JUnit — + * only its bytecode is read by {@link com.tngtech.archunit.core.importer.ClassFileImporter}. + * + * <p>(feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2 over-block guard) + * + * <p>Public so the meta-test can reference it via {@code importClasses(...)} from the sibling + * {@code ..architecture} package — a deterministic import that does not depend on + * classpath/classloader package enumeration. + */ +@WebMvcTest +public class SingleSliceWebMvcFixture { + // Legitimate single-slice test class. No test methods — loaded as bytecode only. +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/slice/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/slice/package-info.java new file mode 100644 index 00000000..e09cc6b8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/slice/package-info.java @@ -0,0 +1,14 @@ +/** + * "Allowed-as-data" over-block guard fixtures for the Spring slice annotation mixing ban ({@code + * SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}). + * + * <p>Classes in this package carry only a single Spring slice annotation and must NOT be flagged by + * the rule. They exist to prove the rule does not over-block legitimate single-slice test classes. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.TestTaxonomyArchitectureTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests}. + */ +package dev.caskeleton.bootstrap.architecture.allowed.slice; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/streaming/StreamingResponseBodyAllowedFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/streaming/StreamingResponseBodyAllowedFixture.java new file mode 100644 index 00000000..b5a83418 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/streaming/StreamingResponseBodyAllowedFixture.java @@ -0,0 +1,23 @@ +package dev.caskeleton.bootstrap.architecture.allowed.streaming; + +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +/** + * Over-block guard fixture for the D3 streaming ban rules (feature-streaming-response-contract D3, + * spec Claim #3). + * + * <p>{@link StreamingResponseBody} is explicitly <em>not</em> blocked by D3. It is the large-file + * download / chunked-body mechanism (request-response model, SPRING-ASYNC-C2 "for example, for a + * file download") owned by feature-file-resource-handling-contract D8. Blocking it would break file + * downloads — an OUT_OF_BRANCH_SCOPE regression. + * + * <p>This fixture is loaded in isolation against the D3 ArchUnit rules to assert that {@code + * NO_SSE_EMITTER}, {@code NO_RESPONSE_BODY_EMITTER}, and {@code NO_WEBSOCKET_HANDLER} all return + * {@code hasViolation() == false} when only {@code StreamingResponseBody} is referenced. + */ +public class StreamingResponseBodyAllowedFixture { + + public StreamingResponseBody allowed() { + return outputStream -> outputStream.write("data".getBytes()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/streaming/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/streaming/package-info.java new file mode 100644 index 00000000..90b2036d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/allowed/streaming/package-info.java @@ -0,0 +1,17 @@ +/** + * "Allowed-as-data" guard fixtures used by {@link + * dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest}. + * + * <p>Each class in this package references a type that the D3 streaming ban rules must <em>not</em> + * catch — proving that the rules do not over-block. The primary guard is {@link + * dev.caskeleton.bootstrap.architecture.allowed.streaming.StreamingResponseBodyAllowedFixture}, + * which references {@code StreamingResponseBody} (large-file download, request-response model, + * owned by feature-file-resource-handling-contract D8 — OUT_OF_BRANCH_SCOPE for + * feature-streaming-response-contract D3). + * + * <p>These fixtures are loaded in isolation by {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and asserted to produce + * {@code hasViolation() == false} against the D3 rules. They live under {@code src/test/java/...} + * and are excluded from the production {@code @AnalyzeClasses} suite via {@code DoNotIncludeTests}. + */ +package dev.caskeleton.bootstrap.architecture.allowed.streaming; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/controller/DomainReturningControllerFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/controller/DomainReturningControllerFixture.java new file mode 100644 index 00000000..0de8aee2 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/controller/DomainReturningControllerFixture.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.controller; + +import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity; + +/** + * Negative fixture for {@code CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES}. The class is in + * {@code ..adapter.inbound.web..controller..} (matched by the rule's "declared in" clause) and + * returns a class in {@code ..domain.entity..} — exactly the leak the contract forbids. + */ +public class DomainReturningControllerFixture { + + public FakeDomainEntity readSomething() { + return new FakeDomainEntity(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/controller/KebabPathControllerFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/controller/KebabPathControllerFixture.java new file mode 100644 index 00000000..c782eaff --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/controller/KebabPathControllerFixture.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Negative fixture for {@code CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122} (D19). The class is in + * {@code ..adapter.inbound.web..controller..} and maps kebab-case path segments ({@code + * /work-logs}, {@code /repo-stats}) — exactly the naming the AIP-122 contract forbids + * (feature-api-contract-baseline D19). + */ +@RequestMapping("/work-logs") +public class KebabPathControllerFixture { + + @GetMapping("/repo-stats") + public String kebabSegment() { + return "violation"; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/FakeRequestDto.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/FakeRequestDto.java new file mode 100644 index 00000000..88014510 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/FakeRequestDto.java @@ -0,0 +1,8 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto; + +/** + * Lives in {@code ..adapter.inbound.web..dto..} so the {@code + * APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS} ArchUnit rule treats any application method that + * takes it as a parameter as a violation. + */ +public record FakeRequestDto(String value) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/JsonIgnoreUnknownRequestFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/JsonIgnoreUnknownRequestFixture.java new file mode 100644 index 00000000..b936c9cb --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/JsonIgnoreUnknownRequestFixture.java @@ -0,0 +1,14 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Negative fixture for {@code REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS} (B1). + * + * <p>Annotated with {@code @JsonIgnoreProperties(ignoreUnknown = true)} at the class level — + * exactly the pattern the contract forbids because it cancels the {@code + * FAIL_ON_UNKNOWN_PROPERTIES=true} boundary policy for this DTO alone, masking client-side contract + * drift. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JsonIgnoreUnknownRequestFixture(String value) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/MergePatchJsonFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/MergePatchJsonFixture.java new file mode 100644 index 00000000..48107175 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/MergePatchJsonFixture.java @@ -0,0 +1,13 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto; + +import org.springframework.web.bind.annotation.PostMapping; + +/** + * Negative fixture for {@code NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING}. Uses the RFC 7396 {@code + * application/merge-patch+json} content type that B2 rejects. + */ +public class MergePatchJsonFixture { + + @PostMapping(consumes = "application/merge-patch+json") + public void forbidden() {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/cascade/DeepCascadeRequestFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/cascade/DeepCascadeRequestFixture.java new file mode 100644 index 00000000..6faa3b27 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/inbound/web/dto/cascade/DeepCascadeRequestFixture.java @@ -0,0 +1,28 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto.cascade; + +import jakarta.validation.Valid; + +/** + * Negative fixture for {@code VALID_CASCADE_DEPTH_AT_MOST_THREE} — chained {@code @Valid} fields go + * four levels deep, one over the B4 ceiling. + */ +public class DeepCascadeRequestFixture { + + @Valid public Level1 level1; + + public static class Level1 { + @Valid public Level2 level2; + } + + public static class Level2 { + @Valid public Level3 level3; + } + + public static class Level3 { + @Valid public Level4 level4; + } + + public static class Level4 { + public String leaf; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawExternalResponseFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawExternalResponseFixture.java new file mode 100644 index 00000000..32a9936f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawExternalResponseFixture.java @@ -0,0 +1,7 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound; + +/** + * Stands in for a raw external response wire shape. Lives in {@code ..adapter.outbound..} — exactly + * where the ACL contract says raw types must not escape from. + */ +public class RawExternalResponseFixture {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java new file mode 100644 index 00000000..d7a3e0f4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java @@ -0,0 +1,12 @@ +package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound; + +/** + * Negative fixture for {@code OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES}. Returns a + * class still inside the outbound adapter package — the ACL bypass the contract forbids (B7). + */ +public class RawTypeLeakingAdapterFixture { + + public RawExternalResponseFixture leakRaw() { + return new RawExternalResponseFixture(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/ApplicationContextDependentFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/ApplicationContextDependentFixture.java new file mode 100644 index 00000000..794ae112 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/ApplicationContextDependentFixture.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import org.springframework.context.ApplicationContext; + +/** + * Intentional violation of {@code APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT} (D11): a + * class residing under {@code ..application..} that depends on Spring {@link ApplicationContext}. + */ +final class ApplicationContextDependentFixture { + + @SuppressWarnings("unused") + private final ApplicationContext context; + + ApplicationContextDependentFixture(ApplicationContext context) { + this.context = context; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/BadlyNamedHandler.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/BadlyNamedHandler.java new file mode 100644 index 00000000..70f24cd3 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/BadlyNamedHandler.java @@ -0,0 +1,27 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** + * Intentional violation of {@code INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE} (D1 of + * feature-application-port-usecase-contract): a {@link CommandUseCase} implementation whose name + * does NOT end with {@code UseCase}. + */ +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +final class BadlyNamedHandler implements CommandUseCase<BadlyNamedHandler.DummyCommand, Void> { + + @Override + public Void handle(DummyCommand input) { + return null; + } + + record DummyCommand() implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/BulkWriteWithoutWriteAccessUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/BulkWriteWithoutWriteAccessUseCase.java new file mode 100644 index 00000000..0d6a55a6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/BulkWriteWithoutWriteAccessUseCase.java @@ -0,0 +1,30 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** + * Intentional violation of {@code BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS} (D6): a + * use case that declares {@code bulkWrite = true} without {@code repositoryAccess = + * WRITE_REPOSITORY}. A bulk write is still a write, and the capability registry binds BULK_WRITE to + * WRITE_REPOSITORY (feature-repository-access-permission-contract D6). + */ +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY, + bulkWrite = true) +public final class BulkWriteWithoutWriteAccessUseCase + implements CommandUseCase<BulkWriteWithoutWriteAccessUseCase.DummyCommand, Void> { + + @Override + public Void handle(DummyCommand input) { + return null; + } + + record DummyCommand() implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/FixtureRepository.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/FixtureRepository.java new file mode 100644 index 00000000..8b178593 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/FixtureRepository.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +/** + * Minimal repository-port stand-in for the {@code + * READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS} (D12) violation fixtures. Its simple + * name ends with {@code Repository} and it exposes both a read and a write ({@code save} / {@code + * deleteById}) method so a fixture use case can declare {@code repositoryAccess = READ_REPOSITORY} + * yet call a mutating method (feature-repository-access-permission-contract D12). + */ +public interface FixtureRepository { + + Object findById(String id); + + Object save(Object entity); + + void deleteById(String id); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/GenericLeakQueryPort.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/GenericLeakQueryPort.java new file mode 100644 index 00000000..552454b4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/GenericLeakQueryPort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity; +import java.util.List; + +/** + * Negative fixture for {@code QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES} + * (feature-application-query-bypass-contract D1). + * + * <p>A {@code *QueryPort} that leaks a domain type ONLY through a generic type argument ({@code + * List<FakeDomainEntity>}). The raw return type is {@code java.util.List}, which is NOT forbidden — + * so this fixture proves the rule inspects generic type arguments (via {@code + * JavaType.getAllInvolvedRawTypes()}), not just the raw return type. A {@code + * notHaveRawReturnType}-style check would pass this vacuously. + */ +public interface GenericLeakQueryPort { + + List<FakeDomainEntity> findAll(); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/JakartaValidationApplicationFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/JakartaValidationApplicationFixture.java new file mode 100644 index 00000000..d59d8637 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/JakartaValidationApplicationFixture.java @@ -0,0 +1,21 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import jakarta.validation.constraints.NotBlank; + +/** + * Intentional violation of {@code feature-business-rule-validation-contract} C1/D1: an + * application-package type that drives use-case policy with Bean Validation ({@code + * jakarta.validation}) annotations. The {@code VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY} rule + * must report this — application policy is plain Java, never annotation-driven syntax validation. + * + * <p>Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +public class JakartaValidationApplicationFixture { + + @NotBlank private String policyKey; + + public String policyKey() { + return policyKey; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingCapabilityUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingCapabilityUseCase.java new file mode 100644 index 00000000..d7ad9d93 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingCapabilityUseCase.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** + * Intentional violation of {@code INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY}: a {@link + * CommandUseCase} implementation that omits {@code @UseCaseCapability}. + */ +final class MissingCapabilityUseCase + implements CommandUseCase<MissingCapabilityUseCase.DummyCommand, Void> { + + @Override + public Void handle(DummyCommand input) { + return null; + } + + record DummyCommand() implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java new file mode 100644 index 00000000..1cf3dc55 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java @@ -0,0 +1,26 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** Intentional write-use-case violation: declares a write but skips TransactionPort.inWrite. */ +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +@RequiresPermission("worklog:write") +public final class MissingTransactionBoundaryUseCase + implements CommandUseCase<MissingTransactionBoundaryUseCase.CommandFixture, String> { + + @Override + public String handle(CommandFixture command) { + return command.value(); + } + + public record CommandFixture(String value) implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/OutboundWithoutPermissionUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/OutboundWithoutPermissionUseCase.java new file mode 100644 index 00000000..b18829cb --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/OutboundWithoutPermissionUseCase.java @@ -0,0 +1,52 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Instant; + +/** + * Intentional violation of {@code + * EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY} (D7): a use case that + * directly calls an outbound port ({@link OutboxMessagePublishPort}, bound to the outbound adapter + * {@code OutboxMessagePublishAdapter}) without declaring {@code externalOutboundAllowed = true} — + * {@code externalOutboundAllowed} defaults to {@code false} here + * (feature-repository-access-permission-contract D7). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.NONE) +public final class OutboundWithoutPermissionUseCase + implements CommandUseCase<OutboundWithoutPermissionUseCase.DummyCommand, Void> { + + private final OutboxMessagePublishPort publisher; + + public OutboundWithoutPermissionUseCase(OutboxMessagePublishPort publisher) { + this.publisher = publisher; + } + + @Override + public Void handle(DummyCommand input) { + publisher.publish( + new OutboxEvent( + "event-1", + "fixture.created", + "aggregate-1", + "{}", + Instant.EPOCH, + "correlation-1", + "idempotency-1", + OutboxEventStatus.PENDING, + 0)); + return null; + } + + record DummyCommand() implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/RawLeakQueryPort.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/RawLeakQueryPort.java new file mode 100644 index 00000000..9e3ee3c4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/RawLeakQueryPort.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity; + +/** + * Negative fixture for {@code QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES} + * (feature-application-query-bypass-contract D1). + * + * <p>A {@code *QueryPort} in an {@code ..application..} package that returns a domain type directly + * (raw return-type leak). Lives under {@code ..violations.application..}, which matches the rule's + * {@code ..application..} glob; {@link FakeDomainEntity} lives under {@code + * ..violations.domain.entity..}, which matches the forbidden {@code ..domain..} glob. + */ +public interface RawLeakQueryPort { + + FakeDomainEntity findOne(); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/ReadOnlyRepositoryWriteUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/ReadOnlyRepositoryWriteUseCase.java new file mode 100644 index 00000000..451bc403 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/ReadOnlyRepositoryWriteUseCase.java @@ -0,0 +1,37 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** + * Intentional violation of {@code READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS} (D12): + * a use case that declares {@code repositoryAccess = READ_REPOSITORY} but directly calls a write + * method ({@link FixtureRepository#save(Object)}) of a {@code *Repository} port — the silent + * read-to-write upgrade the coherence rule blocks (feature-repository-access-permission-contract + * D12). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ReadOnlyRepositoryWriteUseCase + implements CommandUseCase<ReadOnlyRepositoryWriteUseCase.DummyCommand, Void> { + + private final FixtureRepository repository; + + public ReadOnlyRepositoryWriteUseCase(FixtureRepository repository) { + this.repository = repository; + } + + @Override + public Void handle(DummyCommand input) { + repository.save(new Object()); + return null; + } + + record DummyCommand() implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/TransactionalAnnotatedFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/TransactionalAnnotatedFixture.java new file mode 100644 index 00000000..d69547d4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/TransactionalAnnotatedFixture.java @@ -0,0 +1,11 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import org.springframework.transaction.annotation.Transactional; + +/** + * Intentional violation of {@code APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION}: a + * class residing under {@code ..application..} that imports the forbidden Spring + * {@code @Transactional} annotation. + */ +@Transactional +final class TransactionalAnnotatedFixture {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/WebDtoAcceptingApplicationFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/WebDtoAcceptingApplicationFixture.java new file mode 100644 index 00000000..3608b81e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/WebDtoAcceptingApplicationFixture.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +import dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto.FakeRequestDto; + +/** + * Negative fixture for {@code APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS}. Public method in {@code + * ..application..} package accepts a class in {@code ..adapter.inbound.web..dto..} — exactly the + * leak the contract forbids (request DTO bypasses Command/Query translation). + */ +public class WebDtoAcceptingApplicationFixture { + + public String handle(FakeRequestDto dto) { + return dto.toString(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/DefaultTypingFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/DefaultTypingFixture.java new file mode 100644 index 00000000..fa9aa233 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/DefaultTypingFixture.java @@ -0,0 +1,36 @@ +package dev.caskeleton.bootstrap.architecture.violations.boundary; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; + +/** + * Negative fixture for the two B5 ArchUnit rules. + * + * <ul> + * <li>{@code NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR} — the {@code + * LaissezFaireSubTypeValidator} class reference is caught even if it is never invoked, + * because importing it is an explicit opt-in to "deserialize anything". + * <li>{@code NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL} — the {@code enableDefaultTyping()} no-arg + * method (deprecated since Jackson 2.10) is caught even if the validator is safe; the spec + * rejects this overload outright. + * </ul> + * + * <p>The narrower {@code activateDefaultTyping(BasicPolymorphicTypeValidator)} overload is + * intentionally NOT banned — the contract explicitly allows that shape; see {@code + * BasicPolymorphicTypeValidatorAllowlistTest}. + */ +@SuppressWarnings("deprecation") +public final class DefaultTypingFixture { + + public static final LaissezFaireSubTypeValidator UNSAFE_VALIDATOR = + LaissezFaireSubTypeValidator.instance; + + private DefaultTypingFixture() {} + + public static ObjectMapper unsafe() { + ObjectMapper mapper = new ObjectMapper(); + // Hits NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL — the deprecated no-arg overload. + mapper.enableDefaultTyping(); + return mapper; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/InheritableThreadLocalFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/InheritableThreadLocalFixture.java new file mode 100644 index 00000000..16e8278b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/InheritableThreadLocalFixture.java @@ -0,0 +1,15 @@ +package dev.caskeleton.bootstrap.architecture.violations.boundary; + +/** + * Negative fixture for {@code NO_INHERITABLE_THREAD_LOCAL} (B6). + * + * <p>Declares an {@link InheritableThreadLocal} field — the type that leaks context across + * virtual-thread carriers and that the contract forbids in favour of MDC (SLF4J 2.0+) or {@code + * RequestContextHolder}. + */ +public final class InheritableThreadLocalFixture { + + public static final InheritableThreadLocal<String> CONTEXT = new InheritableThreadLocal<>(); + + private InheritableThreadLocalFixture() {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/ProblemDetailUsingFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/ProblemDetailUsingFixture.java new file mode 100644 index 00000000..da11b774 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/boundary/ProblemDetailUsingFixture.java @@ -0,0 +1,14 @@ +package dev.caskeleton.bootstrap.architecture.violations.boundary; + +import org.springframework.http.ProblemDetail; + +/** + * Negative fixture for {@code NO_PROBLEM_DETAIL_USAGE}. Imports and references {@link + * ProblemDetail} — the RFC 7807 type D5 rejects. + */ +public class ProblemDetailUsingFixture { + + public ProblemDetail forbidden() { + return ProblemDetail.forStatus(400); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/contract/NonSampleFeatureCoupledContractFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/contract/NonSampleFeatureCoupledContractFixture.java new file mode 100644 index 00000000..e590e0ae --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/contract/NonSampleFeatureCoupledContractFixture.java @@ -0,0 +1,25 @@ +package dev.caskeleton.bootstrap.architecture.violations.contractisolation.contract; + +import dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing.BillingFeatureFixture; + +/** + * Violation fixture: a class in a {@code ..contract..} package that depends on a non-sample + * business-domain feature package. + * + * <p>This simulates a contract test that incorrectly couples to a concrete business domain + * (billing), violating the domain-agnostic contract requirement. The ArchUnit rule {@code + * CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN} is designed to catch exactly this + * coupling: {@code NonSampleFeatureCoupledContractFixture → BillingFeatureFixture}. + * + * <p>No {@code @Test} methods; never executed by JUnit. Loaded in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importClasses}. + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +public class NonSampleFeatureCoupledContractFixture { + + // The field reference creates the bytecode dependency edge that ArchUnit detects. + @SuppressWarnings("unused") + private final BillingFeatureFixture billingFeature = new BillingFeatureFixture(); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/contract/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/contract/package-info.java new file mode 100644 index 00000000..411d4598 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/contract/package-info.java @@ -0,0 +1,20 @@ +/** + * "Violations-as-data" fixture for the contract isolation rule ({@code + * CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). + * + * <p>This package (containing a {@code contract} segment) simulates a contract test that + * incorrectly couples to a non-sample business-domain feature. The combination of the {@code + * ..contract..} package location and the dependency on {@link + * dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing.BillingFeatureFixture} + * represents the exact violation the ArchUnit rule is designed to catch. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only + * source tree). + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +package dev.caskeleton.bootstrap.architecture.violations.contractisolation.contract; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/features/billing/BillingFeatureFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/features/billing/BillingFeatureFixture.java new file mode 100644 index 00000000..50914794 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/features/billing/BillingFeatureFixture.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing; + +/** + * Stand-in for a non-sample business-domain feature type that contract tests must not depend on. + * + * <p>This class lives under a {@code ..features..} package that is NOT {@code ..features.sample..}, + * making it the forbidden coupling target for the contract isolation rule ({@code + * CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). + * + * <p>No behavior is needed — only its package location matters for the bytecode-based ArchUnit + * analysis. No {@code @Test} methods; never executed. + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +public class BillingFeatureFixture { + // Empty — only its package location matters for ArchUnit dependency analysis. +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/features/billing/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/features/billing/package-info.java new file mode 100644 index 00000000..ee8215cf --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/contractisolation/features/billing/package-info.java @@ -0,0 +1,18 @@ +/** + * "Violations-as-data" stand-in for a non-sample business-domain feature package used by the + * contract isolation rule ({@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). + * + * <p>This package simulates a real business-domain feature (e.g., billing) that contract tests must + * not depend on. Being in a {@code ..features..} package that is NOT {@code ..features.sample..}, + * it represents the forbidden coupling target. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only + * source tree). + * + * <p>(feature-contract-verification-test-suite 테스트 계약 §1) + */ +package dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/AnnotatedPublicNoArgValueObjectFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/AnnotatedPublicNoArgValueObjectFixture.java new file mode 100644 index 00000000..9a758f49 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/AnnotatedPublicNoArgValueObjectFixture.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain; + +import dev.caskeleton.domain.stereotype.ValueObject; + +/** + * Intentional violation of {@code VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR} (D5/D6) via the + * <em>annotation</em> branch: a {@code @ValueObject} that exposes a public no-arg constructor — an + * invariant-bypassing back door (feature-domain-modeling-guardrails D5/D6, §3). + * + * <p>Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +@ValueObject +public final class AnnotatedPublicNoArgValueObjectFixture { + + public AnnotatedPublicNoArgValueObjectFixture() {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/JakartaValidationDomainFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/JakartaValidationDomainFixture.java new file mode 100644 index 00000000..dfc654bc --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/JakartaValidationDomainFixture.java @@ -0,0 +1,29 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +/** + * Intentional violation of {@code feature-business-rule-validation-contract} C1/D1: a + * domain-package type that drives its invariant with Bean Validation ({@code jakarta.validation}) + * annotations instead of plain constructor checks. The {@code + * VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY} rule must report this — syntax validation belongs at + * the adapter-web request boundary, never in {@code ..domain..}. + * + * <p>Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +public class JakartaValidationDomainFixture { + + @NotNull private String name; + + @Valid private Object nested; + + public String name() { + return name; + } + + public Object nested() { + return nested; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/LoggerUsingDomainFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/LoggerUsingDomainFixture.java new file mode 100644 index 00000000..9a3ab39c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/LoggerUsingDomainFixture.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Intentional violation of {@code DOMAIN_HAS_NO_LOGGER} (D3): a class residing under {@code + * ..domain..} that holds an SLF4J logger. The domain must express an invariant violation as a safe + * reason value, never log it directly — logging is the application layer's concern + * (feature-domain-modeling-guardrails D3, §2). + * + * <p>Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +public final class LoggerUsingDomainFixture { + + private static final Logger LOG = LoggerFactory.getLogger(LoggerUsingDomainFixture.class); + + public void doWork() { + LOG.info("domain should not log"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/PublicSetterAggregateFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/PublicSetterAggregateFixture.java new file mode 100644 index 00000000..3433d81d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/PublicSetterAggregateFixture.java @@ -0,0 +1,25 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain; + +import dev.caskeleton.domain.stereotype.AggregateRoot; + +/** + * Intentional violation of {@code AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC} (D7): a + * {@code @AggregateRoot} that exposes a public {@code set*} mutator, bypassing the + * invariant-enforcing aggregate methods (feature-domain-modeling-guardrails D7, §4). + * + * <p>Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +@AggregateRoot +public final class PublicSetterAggregateFixture { + + private String name; + + public void setName(String name) { + this.name = name; + } + + public String name() { + return name; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/SpringDependentDomainFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/SpringDependentDomainFixture.java new file mode 100644 index 00000000..b0f20eb6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/SpringDependentDomainFixture.java @@ -0,0 +1,10 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain; + +import org.springframework.stereotype.Component; + +/** + * Intentional violation of {@code DOMAIN_IS_PURE} (D3): a class residing under {@code ..domain..} + * that depends on {@code org.springframework..}. + */ +@Component +final class SpringDependentDomainFixture {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/entity/FakeDomainEntity.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/entity/FakeDomainEntity.java new file mode 100644 index 00000000..e1a0b785 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/entity/FakeDomainEntity.java @@ -0,0 +1,8 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.entity; + +/** + * Lives in {@code ..domain.entity..} so the {@code + * CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES} ArchUnit rule catches it as a forbidden return + * type. + */ +public class FakeDomainEntity {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/jaxrs/JaxRsDomainEventFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/jaxrs/JaxRsDomainEventFixture.java new file mode 100644 index 00000000..aad8aa9b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/jaxrs/JaxRsDomainEventFixture.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.event.jaxrs; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import jakarta.ws.rs.core.MediaType; + +/** + * Intentional violation of {@code DOMAIN_EVENTS_ARE_TRANSPORT_FREE} (D4/D8) via the {@code + * jakarta.ws.rs..} glob: a {@code @DomainEvent} that references a JAX-RS type + * (feature-domain-modeling-guardrails D4/D8, §5). + * + * <p>The JAX-RS type is referenced only inside a method body so ArchUnit sees the dependency in + * bytecode while JUnit discovery never resolves it ({@code jakarta.ws.rs-api} is {@code + * testCompileOnly}). Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +@DomainEvent +public record JaxRsDomainEventFixture(String aggregateId) { + + static String transportType() { + return MediaType.class.getName(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/kafka/KafkaDomainEventFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/kafka/KafkaDomainEventFixture.java new file mode 100644 index 00000000..cdebf4ff --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/kafka/KafkaDomainEventFixture.java @@ -0,0 +1,25 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.event.kafka; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import org.apache.kafka.common.TopicPartition; + +/** + * Intentional violation of {@code DOMAIN_EVENTS_ARE_TRANSPORT_FREE} (D4/D8) via the {@code + * org.apache.kafka..} glob: a {@code @DomainEvent} that references a Kafka SDK type. A domain event + * carries domain data only — broker types belong to the integration-event mapping at the + * application/infrastructure boundary (feature-domain-modeling-guardrails D4/D8, §5). + * + * <p>The Kafka type is referenced only inside a method body (a {@code .class} literal), not in the + * record's component/signature: ArchUnit detects the dependency from bytecode, while JUnit's + * reflective test discovery never resolves it — the {@code kafka-clients} type is {@code + * testCompileOnly} and absent at runtime. The fixture is still a record, so it also satisfies + * {@code DOMAIN_EVENTS_ARE_RECORDS}. Test-scope fixture only; excluded from production analysis by + * {@code ImportOption.DoNotIncludeTests}. + */ +@DomainEvent +public record KafkaDomainEventFixture(String aggregateId) { + + static String transportType() { + return TopicPartition.class.getName(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/nonrecord/NonRecordDomainEventFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/nonrecord/NonRecordDomainEventFixture.java new file mode 100644 index 00000000..133ecd37 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/nonrecord/NonRecordDomainEventFixture.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.event.nonrecord; + +import dev.caskeleton.domain.stereotype.DomainEvent; + +/** + * Intentional violation of {@code DOMAIN_EVENTS_ARE_RECORDS} (D4/D8): a {@code @DomainEvent} + * declared as a mutable class instead of an immutable record (feature-domain-modeling-guardrails + * D4/D8, §5). + * + * <p>Transport-free on its own (no broker/HTTP type), so it isolates the "must be a record" rule + * and doubles as the over-block guard for {@code DOMAIN_EVENTS_ARE_TRANSPORT_FREE}. Test-scope + * fixture only; excluded from production analysis by {@code ImportOption.DoNotIncludeTests}. + */ +@DomainEvent +public final class NonRecordDomainEventFixture { + + private String mutablePayload; + + public String mutablePayload() { + return mutablePayload; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/pii/PiiViolationEventFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/pii/PiiViolationEventFixture.java new file mode 100644 index 00000000..c7092b00 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/pii/PiiViolationEventFixture.java @@ -0,0 +1,13 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.event.pii; + +import dev.caskeleton.domain.stereotype.DomainEvent; + +/** + * Intentional violation fixture: a {@code @DomainEvent} record with a field named {@code email}, + * which matches the PII pattern {@code (?i)(email|password|token|authorization|secret|rawbody)}. + * + * <p>Used by {@code EventPayloadPiiContractTest} (red corpus) to prove the PII rule catches the + * violation. Lives under the {@code violations} package which the green rule's predicate excludes. + */ +@DomainEvent +public record PiiViolationEventFixture(String email, String eventType) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/springhttp/SpringHttpDomainEventFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/springhttp/SpringHttpDomainEventFixture.java new file mode 100644 index 00000000..b44f4e70 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/event/springhttp/SpringHttpDomainEventFixture.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.event.springhttp; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import org.springframework.http.HttpStatus; + +/** + * Intentional violation of {@code DOMAIN_EVENTS_ARE_TRANSPORT_FREE} (D4/D8) via the {@code + * org.springframework.http..} glob: a {@code @DomainEvent} that references a Spring HTTP type + * (feature-domain-modeling-guardrails D4/D8, §5). + * + * <p>The Spring HTTP type is referenced only inside a method body so ArchUnit sees the dependency + * in bytecode while JUnit discovery never resolves it ({@code spring-webmvc} is {@code + * testCompileOnly}). Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +@DomainEvent +public record SpringHttpDomainEventFixture(String aggregateId) { + + static String transportType() { + return HttpStatus.class.getName(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/vo/PackagePublicNoArgValueObjectFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/vo/PackagePublicNoArgValueObjectFixture.java new file mode 100644 index 00000000..08e6c91a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/domain/vo/PackagePublicNoArgValueObjectFixture.java @@ -0,0 +1,16 @@ +package dev.caskeleton.bootstrap.architecture.violations.domain.vo; + +/** + * Intentional violation of {@code VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR} (D5/D6) via the + * <em>package convention</em> branch: a type under {@code ..domain.vo..} (annotation-free) that + * exposes a public no-arg constructor. The rule must catch it through the {@code + * resideInAPackage("..domain.vo..")} branch even without {@code @ValueObject} + * (feature-domain-modeling-guardrails D5/D6, §3). + * + * <p>Test-scope fixture only; excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}. + */ +public final class PackagePublicNoArgValueObjectFixture { + + public PackagePublicNoArgValueObjectFixture() {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/LeakyProductionConsumerFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/LeakyProductionConsumerFixture.java new file mode 100644 index 00000000..b3c8203b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/LeakyProductionConsumerFixture.java @@ -0,0 +1,25 @@ +package dev.caskeleton.bootstrap.architecture.violations.fixtureleak; + +import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures.LeakedTestFixture; + +/** + * Violation fixture: a class residing outside any {@code ..fixtures..} package that references a + * class inside a {@code ..fixtures..} package. + * + * <p>This simulates a production class that incorrectly depends on a test fixture — the exact + * scenario the ArchUnit rule {@code PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES} is designed + * to catch. The rule evaluates the dependency edge: {@code LeakyProductionConsumerFixture → + * LeakedTestFixture}. + * + * <p>No {@code @Test} methods; never executed by JUnit. Loaded in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.TestTaxonomyArchitectureTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importPackages}. + * + * <p>(feature-test-taxonomy-fixture-contract D6) + */ +public class LeakyProductionConsumerFixture { + + // The field reference creates the bytecode dependency edge that ArchUnit detects. + @SuppressWarnings("unused") + private final LeakedTestFixture fixture = new LeakedTestFixture(); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/fixtures/LeakedTestFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/fixtures/LeakedTestFixture.java new file mode 100644 index 00000000..a3a143d7 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/fixtures/LeakedTestFixture.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures; + +/** + * Simulated test fixture that should never be referenced by production code. + * + * <p>This class lives under a {@code ..fixtures..} sub-package, making it a target of the {@code + * PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES} ArchUnit rule. It is referenced by {@link + * dev.caskeleton.bootstrap.architecture.violations.fixtureleak.LeakyProductionConsumerFixture} to + * simulate the leakage pattern the rule is designed to catch. + * + * <p>No behavior is needed — only the class identity (package location) matters for the + * bytecode-based ArchUnit analysis. No {@code @Test} methods; never executed. + * + * <p>(feature-test-taxonomy-fixture-contract D6) + */ +public class LeakedTestFixture { + // Empty — only its package location matters for the ArchUnit dependency analysis. +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/fixtures/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/fixtures/package-info.java new file mode 100644 index 00000000..39f31ab5 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/fixtures/package-info.java @@ -0,0 +1,14 @@ +/** + * Simulated leaked test fixture class used by the {@code + * PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES} meta-verification fixture. + * + * <p>Classes in this {@code ..fixtures..} sub-package represent test infrastructure that must never + * be imported by production code. The companion class {@link + * dev.caskeleton.bootstrap.architecture.violations.fixtureleak.LeakyProductionConsumerFixture} + * (outside any {@code ..fixtures..} package) references {@link + * dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures.LeakedTestFixture} here, + * simulating the leakage the ArchUnit rule is designed to catch. + * + * <p>(feature-test-taxonomy-fixture-contract D6) + */ +package dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/package-info.java new file mode 100644 index 00000000..8ba0320b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/fixtureleak/package-info.java @@ -0,0 +1,18 @@ +/** + * "Violations-as-data" fixtures for the production → test-fixture leakage ban ({@code + * PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES}). + * + * <p>This package simulates a production class that incorrectly references a class residing in a + * {@code ..fixtures..} sub-package. The pattern proves the ArchUnit rule catches fixture leakage + * onto the main classpath. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.TestTaxonomyArchitectureTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only + * source tree). + * + * <p>(feature-test-taxonomy-fixture-contract D6) + */ +package dev.caskeleton.bootstrap.architecture.violations.fixtureleak; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/package-info.java new file mode 100644 index 00000000..72b6132e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/package-info.java @@ -0,0 +1,22 @@ +/** + * "Violations-as-data" test fixtures used by {@link + * dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest}. + * + * <p>Each class in this package is an intentional violation of exactly one ArchUnit rule declared + * in {@link dev.caskeleton.bootstrap.architecture.CleanArchitectureTest}. The fixtures live under + * {@code src/test/java/...}, so the main {@code @AnalyzeClasses(importOptions = + * DoNotIncludeTests.class)} excludes them from the production architecture suite — they never + * trigger a "real" violation. + * + * <p>The negative test class loads this package explicitly via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter} and asserts that the corresponding rule + * reports {@code EvaluationResult.hasViolation() == true}. This guards against silent regressions + * where a rule passes only because nothing in production code happens to match its predicate + * (vacuous pass). + * + * <p>Pattern reference: Spring Modulith's {@code example/ninvalid} fixture package + {@code + * modules.detectViolations().getMessages()} assertion ({@code + * raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data} + * SPRING-MOD-AU-C2). + */ +package dev.caskeleton.bootstrap.architecture.violations; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/secrets/RefreshScopeUsingFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/secrets/RefreshScopeUsingFixture.java new file mode 100644 index 00000000..3390768b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/secrets/RefreshScopeUsingFixture.java @@ -0,0 +1,20 @@ +package dev.caskeleton.bootstrap.architecture.violations.secrets; + +import org.springframework.cloud.context.config.annotation.RefreshScope; + +/** + * Negative fixture for {@code NO_REFRESH_SCOPE_ANYWHERE} (feature-secrets-config-source-contract + * D3/D10). + * + * <p>Annotated with {@link RefreshScope} from {@code + * org.springframework.cloud.context.config.annotation} — the in-process config-reload machinery the + * contract bans (secrets default to no-runtime-reload; rotation = restart validation). ArchUnit + * detects the annotation from bytecode; it is never read at runtime, so the class loads safely even + * though {@code spring-cloud-context} is {@code testCompileOnly} (not on the runtime classpath). + * The fixture is loaded via {@code importPackages} (not {@code importClasses}) so the JVM never + * link-resolves the {@code testCompileOnly} type — mirroring the streaming WebSocket fixtures. + */ +@RefreshScope +public class RefreshScopeUsingFixture { + // intentional fixture — forbidden in production (feature-secrets-config-source-contract D3/D10) +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/serialization/BigDecimalDoubleConstructorFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/serialization/BigDecimalDoubleConstructorFixture.java new file mode 100644 index 00000000..5fd946ef --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/serialization/BigDecimalDoubleConstructorFixture.java @@ -0,0 +1,26 @@ +package dev.caskeleton.bootstrap.architecture.violations.serialization; + +import java.math.BigDecimal; + +/** + * Negative fixture for {@code NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR} + * (feature-schema-serialization-contract D3 / SBMS-C3). + * + * <p>Calls {@code new BigDecimal(double)} and {@code new BigDecimal(float)} — the precision trap + * where {@code new BigDecimal(0.1)} captures the binary floating-point error ({@code + * 0.1000000000000000055...}) instead of the exact decimal. Money / decimal values must be built + * from a {@code String} ({@code SBMS-C4}); the contract bans the {@code double}/{@code float} + * constructors statically because the compiler does not. + */ +public final class BigDecimalDoubleConstructorFixture { + + private BigDecimalDoubleConstructorFixture() {} + + public static BigDecimal fromDouble() { + return new BigDecimal(1.1d); + } + + public static BigDecimal fromFloat() { + return new BigDecimal(1.1f); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/shared/worklog/WorkLogStatus.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/shared/worklog/WorkLogStatus.java new file mode 100644 index 00000000..de0028cd --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/shared/worklog/WorkLogStatus.java @@ -0,0 +1,7 @@ +package dev.caskeleton.bootstrap.architecture.violations.shared.worklog; + +/** Intentional violation: domain-specific business enum under a shared package. */ +public enum WorkLogStatus { + OPEN, + DONE +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/slice/MixedSliceAnnotationsFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/slice/MixedSliceAnnotationsFixture.java new file mode 100644 index 00000000..e2b3fc6f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/slice/MixedSliceAnnotationsFixture.java @@ -0,0 +1,27 @@ +package dev.caskeleton.bootstrap.architecture.violations.slice; + +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; + +/** + * Violation fixture: a class annotated with both {@code @WebMvcTest} and {@code @DataJpaTest} + * simultaneously. + * + * <p>Spring documents mixing two slice annotations as not supported (SB-SLICE-C2). This fixture + * exists solely to prove that the ArchUnit rule {@code + * SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS} catches this pattern. It has NO + * {@code @Test} methods and is never executed by JUnit — only its bytecode is read by {@link + * com.tngtech.archunit.core.importer.ClassFileImporter}. + * + * <p>(feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2) + * + * <p>Public so the meta-test can reference it via {@code importClasses(...)} from the sibling + * {@code ..architecture} package — a deterministic import that does not depend on + * classpath/classloader package enumeration. + */ +@WebMvcTest +@DataJpaTest +public class MixedSliceAnnotationsFixture { + // Intentional violation: dual Spring slice annotations. + // No test methods — loaded as bytecode only. +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/slice/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/slice/package-info.java new file mode 100644 index 00000000..678dace7 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/slice/package-info.java @@ -0,0 +1,16 @@ +/** + * "Violations-as-data" fixtures for the Spring slice annotation mixing ban ({@code + * SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}). + * + * <p>Each class in this package is an intentional violation — a test class that carries two + * mutually exclusive Spring slice annotations ({@code @WebMvcTest} + {@code @DataJpaTest}) + * simultaneously. Spring documents mixing slice annotations as not supported (SB-SLICE-C2), so this + * is the canonical violation fixture. + * + * <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded + * in bytecode-only form by {@link + * dev.caskeleton.bootstrap.architecture.TestTaxonomyArchitectureTest} via {@link + * com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and are excluded from the + * production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests}. + */ +package dev.caskeleton.bootstrap.architecture.violations.slice; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/JakartaWebSocketEndpointFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/JakartaWebSocketEndpointFixture.java new file mode 100644 index 00000000..a3166f62 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/JakartaWebSocketEndpointFixture.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.architecture.violations.streaming; + +import jakarta.websocket.server.ServerEndpoint; + +/** + * Negative fixture for {@code NO_WEBSOCKET_HANDLER} (feature-streaming-response-contract D3) — + * Jakarta WebSocket surface. + * + * <p>Annotated with {@link ServerEndpoint} from {@code jakarta.websocket.server..}, the JSR-356 / + * Jakarta EE WebSocket server-endpoint annotation that D3 statically rejects via the {@code + * jakarta.websocket..} package glob. + */ +@ServerEndpoint("/forbidden") +public class JakartaWebSocketEndpointFixture { + + public void onMessage(String message) { + // intentional fixture — forbidden in production (feature-streaming-response-contract D3) + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/ResponseBodyEmitterUsingFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/ResponseBodyEmitterUsingFixture.java new file mode 100644 index 00000000..cd22992c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/ResponseBodyEmitterUsingFixture.java @@ -0,0 +1,17 @@ +package dev.caskeleton.bootstrap.architecture.violations.streaming; + +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; + +/** + * Negative fixture for {@code NO_RESPONSE_BODY_EMITTER} (feature-streaming-response-contract D3). + * + * <p>References {@link ResponseBodyEmitter} — the Spring MVC incremental object-emit base class + * (SSE's supertype, SPRING-ASYNC-C3) that D3 treats as a server-push surface and rejects in + * production code ({@code UNSUPPORTED_IMPL_DECISION④}). + */ +public class ResponseBodyEmitterUsingFixture { + + public ResponseBodyEmitter forbidden() { + return new ResponseBodyEmitter(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/SpringWebSocketHandlerFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/SpringWebSocketHandlerFixture.java new file mode 100644 index 00000000..a781fd11 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/SpringWebSocketHandlerFixture.java @@ -0,0 +1,18 @@ +package dev.caskeleton.bootstrap.architecture.violations.streaming; + +import org.springframework.web.socket.config.annotation.EnableWebSocket; + +/** + * Negative fixture for {@code NO_WEBSOCKET_HANDLER} (feature-streaming-response-contract D3) — + * Spring WebSocket surface. + * + * <p>Annotated with {@link EnableWebSocket} from {@code + * org.springframework.web.socket.config.annotation}, which resides in {@code + * org.springframework.web.socket..} — the package glob that D3 rejects. ArchUnit detects the import + * from bytecode; the annotation is never read at runtime so the class loads safely even when {@code + * spring-websocket} is {@code testCompileOnly} (not on the runtime classpath). + */ +@EnableWebSocket +public class SpringWebSocketHandlerFixture { + // intentional fixture — forbidden in production (feature-streaming-response-contract D3) +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/SseEmitterUsingFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/SseEmitterUsingFixture.java new file mode 100644 index 00000000..c8dedc5d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/streaming/SseEmitterUsingFixture.java @@ -0,0 +1,16 @@ +package dev.caskeleton.bootstrap.architecture.violations.streaming; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Negative fixture for {@code NO_SSE_EMITTER} (feature-streaming-response-contract D3). + * + * <p>References {@link SseEmitter} — the Spring MVC Server-Sent Events server-push surface that D3 + * statically rejects in production code. + */ +public class SseEmitterUsingFixture { + + public SseEmitter forbidden() { + return new SseEmitter(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncContextTaskDecoratorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncContextTaskDecoratorTest.java new file mode 100644 index 00000000..3bdff88b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncContextTaskDecoratorTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import dev.caskeleton.shared.concurrency.DomainContextKey; +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory; +import dev.caskeleton.shared.concurrency.DomainContextStrategy; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +/** + * Async Context Propagation Contract (feature-background-job-async-contract D5/D6). + * + * <p>"@Async 메서드 안에서 {@code MDC.get("request_id")}, traceId, ... 이 caller thread와 동일해야 한다" — the + * decorator copies the caller's MDC (the four async foundation keys) and the domain context onto + * the worker thread, and reverts both afterwards so a pooled thread never leaks one task's context + * into the next. + */ +class AsyncContextTaskDecoratorTest { + + private static final DomainContextKey<String> TENANT = + DomainContextKey.of("tenant_ctx", String.class); + + private final DomainContextPropagator propagator = + DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + private final AsyncContextTaskDecorator decorator = new AsyncContextTaskDecorator(propagator); + + @AfterEach + void cleanup() { + MDC.clear(); + propagator.clear(); + } + + @Test + void copiesTheFourMdcKeysAndDomainContextOntoTheWorkerThread() throws Exception { + MDC.put("request_id", "req-1"); + MDC.put("trace_id", "trace-1"); + MDC.put("correlation_id", "corr-1"); + MDC.put("tenant_id", "tenant-1"); + propagator.set(TENANT, "domain-tenant-1"); + + AtomicReference<Map<String, String>> workerMdc = new AtomicReference<>(); + AtomicReference<String> workerDomain = new AtomicReference<>(); + AtomicReference<Long> workerThreadId = new AtomicReference<>(); + + Runnable decorated = + decorator.decorate( + () -> { + workerMdc.set(MDC.getCopyOfContextMap()); + workerDomain.set(propagator.get(TENANT).orElse(null)); + workerThreadId.set(Thread.currentThread().threadId()); + }); + + runOnFreshThread(decorated); + + assertThat(workerThreadId.get()) + .as("task must run on a different thread to prove propagation") + .isNotEqualTo(Thread.currentThread().threadId()); + assertThat(workerMdc.get()) + .containsEntry("request_id", "req-1") + .containsEntry("trace_id", "trace-1") + .containsEntry("correlation_id", "corr-1") + .containsEntry("tenant_id", "tenant-1"); + assertThat(workerDomain.get()).isEqualTo("domain-tenant-1"); + } + + @Test + void capturesContextAtSubmitTimeNotRunTime() throws Exception { + MDC.put("request_id", "at-decorate"); + AtomicReference<String> seen = new AtomicReference<>(); + Runnable probe = decorator.decorate(() -> seen.set(MDC.get("request_id"))); + + // Mutate the caller MDC AFTER decoration: the worker must still see the submit-time value, + // because the decorator snapshots the context map at decorate() time. + MDC.put("request_id", "after-decorate"); + + runOnFreshThread(probe); + assertThat(seen.get()).isEqualTo("at-decorate"); + } + + @Test + void revertsWorkerThreadMdcAfterTheTaskRuns() throws Exception { + MDC.put("request_id", "caller"); + Runnable decorated = decorator.decorate(() -> {}); + + // Run on a worker that already has its own MDC; it must be restored afterwards. + AtomicReference<String> afterRun = new AtomicReference<>(); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + pool.submit( + () -> { + MDC.put("request_id", "pre-existing-worker"); + decorated.run(); + afterRun.set(MDC.get("request_id")); + }) + .get(5, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + } + assertThat(afterRun.get()) + .as("worker MDC must be reverted to its pre-task value, not the caller's") + .isEqualTo("pre-existing-worker"); + } + + @Test + void callerWithNoMdcClearsAStaleWorkerEntryForTheTaskThenReverts() throws Exception { + // Caller has NO MDC. A pooled worker carrying a stale entry must NOT leak it INTO the task — + // the decorator clears to the caller's (empty) context for the task's duration (the + // MDC.clear() branch), then reverts the worker to its own pre-run state afterwards. + AtomicReference<Map<String, String>> insideTask = new AtomicReference<>(); + AtomicReference<String> afterRun = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> insideTask.set(MDC.getCopyOfContextMap())); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + pool.submit( + () -> { + MDC.put("stale_key", "stale_value"); + decorated.run(); + afterRun.set(MDC.get("stale_key")); + }) + .get(5, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + } + // During the task the worker's stale entry is gone (cleared to the caller's empty context). + assertThat(insideTask.get()).isNull(); + // After the task the worker's own pre-run MDC is reverted (decorator is symmetric). + assertThat(afterRun.get()).isEqualTo("stale_value"); + } + + @Test + void doesNotSwallowTaskExceptionsAndStillRevertsMdc() { + // 테스트 계약: "async exception이 조용히 삼켜지면 실패" — on the execute() path the decorator + // must rethrow the task's exception (not swallow it) AND still revert the worker MDC. + MDC.put("request_id", "caller"); + Runnable decorated = + decorator.decorate( + () -> { + throw new IllegalStateException("boom in decorated task"); + }); + assertThat(MDC.get("request_id")).isEqualTo("caller"); + assertThat(catchThrowable(decorated::run)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("boom in decorated task"); + // caller-thread MDC is restored to its pre-run value (the decorator ran on this thread). + assertThat(MDC.get("request_id")).isEqualTo("caller"); + } + + private static void runOnFreshThread(Runnable runnable) throws Exception { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + pool.submit(runnable).get(5, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExceptionPropagationTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExceptionPropagationTest.java new file mode 100644 index 00000000..970a95f1 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExceptionPropagationTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory; +import dev.caskeleton.shared.concurrency.DomainContextStrategy; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Async exception contract (feature-background-job-async-contract — 테스트 계약: "async exception이 조용히 + * 삼켜지면 실패"). The spec §엣지 requires both executor paths verified: + * + * <ul> + * <li><strong>submit()</strong> — the throwable is captured in the {@code Future} (SF-TPTE-C5) + * and surfaced to the caller via {@code get()}; never silently lost. + * <li><strong>execute()</strong> — the {@link AsyncContextTaskDecorator} that wraps every task + * rethrows the task's exception (it does not swallow it in its MDC-restore {@code finally}); + * the throwable then reaches the worker thread's uncaught handler. + * </ul> + */ +class AsyncExceptionPropagationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(PropagatorConfig.class, AsyncExecutorConfig.class); + + @Test + void submitPathSurfacesTaskExceptionViaFuture() { + runner.run( + context -> { + ThreadPoolTaskExecutor executor = + context.getBean(AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + Future<?> future = + executor.submit( + () -> { + throw new IllegalStateException("boom in async task"); + }); + assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasMessageContaining("boom in async task"); + }); + } + + @Test + void executePathDecoratorRethrowsAndDoesNotSwallow() { + DomainContextPropagator propagator = + DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + AsyncContextTaskDecorator decorator = new AsyncContextTaskDecorator(propagator); + Runnable decorated = + decorator.decorate( + () -> { + throw new IllegalStateException("boom on execute path"); + }); + assertThat(catchThrowable(decorated::run)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("boom on execute path"); + } + + @Configuration + static class PropagatorConfig { + @Bean + DomainContextPropagator domainContextPropagator() { + return DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfigTest.java new file mode 100644 index 00000000..7be0de43 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfigTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory; +import dev.caskeleton.shared.concurrency.DomainContextStrategy; +import java.lang.reflect.Field; +import java.util.concurrent.RejectedExecutionHandler; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Contract test for {@link AsyncExecutorConfig} (feature-background-job-async-contract + * D5/D6/D7/D8). Covers bounded sizing, the required TaskDecorator, the AbortPolicy handler, the 19s + * graceful-shutdown await, and the TaskDecorator-missing fail. (Async-exception surfacing is + * covered by {@link AsyncExceptionPropagationTest}.) + * + * <p>required_test: {@code async-contract:executor-core-size/max-size/queue-bounded} + the spec + * §테스트 계약 (shutdown await). + */ +class AsyncExecutorConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(PropagatorConfig.class, AsyncExecutorConfig.class); + + @Test + void executorUsesRegistryDefaultSizingWithABoundedQueue() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + ThreadPoolTaskExecutor executor = + context.getBean(AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + assertThat(executor.getCorePoolSize()).isEqualTo(10); + assertThat(executor.getMaxPoolSize()).isEqualTo(50); + // bounded to 200 — an empty bounded queue reports remainingCapacity == capacity. + // (NOT Integer.MAX_VALUE, which is the forbidden unbounded sentinel — D7/SF-TPTE-C1.) + assertThat(executor.getThreadPoolExecutor().getQueue().remainingCapacity()) + .isEqualTo(200); + }); + } + + @Test + void executorHonoursOverriddenSizing() { + runner + .withPropertyValues( + "ca-skeleton.async.executor.core-size=2", + "ca-skeleton.async.executor.max-size=4", + "ca-skeleton.async.executor.queue-capacity=8") + .run( + context -> { + ThreadPoolTaskExecutor executor = + context.getBean( + AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + assertThat(executor.getCorePoolSize()).isEqualTo(2); + assertThat(executor.getMaxPoolSize()).isEqualTo(4); + assertThat(executor.getThreadPoolExecutor().getQueue().remainingCapacity()) + .isEqualTo(8); + }); + } + + @Test + void executorHasTheAsyncContextTaskDecorator() { + runner.run( + context -> { + ThreadPoolTaskExecutor executor = + context.getBean(AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + assertThat(field(executor, "taskDecorator")) + .isInstanceOf(AsyncContextTaskDecorator.class); + }); + } + + @Test + void executorUsesTheLoggingAbortPolicy() { + runner.run( + context -> { + ThreadPoolTaskExecutor executor = + context.getBean(AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + RejectedExecutionHandler handler = + executor.getThreadPoolExecutor().getRejectedExecutionHandler(); + assertThat(handler).isInstanceOf(LoggingAbortPolicy.class); + }); + } + + @Test + void executorAwaitsInFlightJobsForAtMost19sOnShutdown() { + runner.run( + context -> { + ThreadPoolTaskExecutor executor = + context.getBean(AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + assertThat(field(executor, "waitForTasksToCompleteOnShutdown")).isEqualTo(true); + assertThat(field(executor, "awaitTerminationMillis")).isEqualTo(19_000L); + }); + } + + @Test + void registrationFailsWhenNoTaskDecoratorIsAvailable() { + // No DomainContextPropagator → no AsyncContextTaskDecorator bean → the executor's required + // TaskDecorator dependency is unsatisfied → context fails (D5 Forbidden: TaskDecorator 미설정). + new ApplicationContextRunner() + .withUserConfiguration(AsyncExecutorConfig.class) + .run(context -> assertThat(context).hasFailed()); + } + + @Configuration + static class PropagatorConfig { + @Bean + DomainContextPropagator domainContextPropagator() { + return DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + } + } + + /** Reads a private field from the executor (or a superclass) — no public getter exists. */ + private static Object field(Object target, String name) throws Exception { + Class<?> type = target.getClass(); + while (type != null) { + try { + Field f = type.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + type = type.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExecutorSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExecutorSettingsTest.java new file mode 100644 index 00000000..360f7c05 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncExecutorSettingsTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySource; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +/** + * Contract test for {@link AsyncExecutorSettings} (feature-background-job-async-contract D7 — pool + * sizing + bounded-queue invariants). + * + * <p>required_test: {@code async-contract:executor-core-size}, {@code + * async-contract:executor-max-size}, {@code async-contract:executor-queue-bounded}. + */ +class AsyncExecutorSettingsTest { + + // ---- defaults (registry: 10 / 50 / 200) ---------------------------------- + + @Test + void nullFieldsFallBackToRegistryDefaults() { + AsyncExecutorSettings settings = new AsyncExecutorSettings(null, null, null); + assertThat(settings.coreSize()).isEqualTo(10); + assertThat(settings.maxSize()).isEqualTo(50); + assertThat(settings.queueCapacity()).isEqualTo(200); + } + + @Test + void bindsFromCaSkeletonAsyncExecutorPrefix() { + ConfigurationPropertySource source = + new MapConfigurationPropertySource( + Map.of( + "ca-skeleton.async.executor.core-size", "4", + "ca-skeleton.async.executor.max-size", "16", + "ca-skeleton.async.executor.queue-capacity", "64")); + AsyncExecutorSettings settings = + new Binder(source).bind("ca-skeleton.async.executor", AsyncExecutorSettings.class).get(); + assertThat(settings.coreSize()).isEqualTo(4); + assertThat(settings.maxSize()).isEqualTo(16); + assertThat(settings.queueCapacity()).isEqualTo(64); + } + + // ---- positive_int (core) ------------------------------------------------- + + @Test + void coreSizeBelowOneIsRejected() { + assertThatThrownBy(() -> new AsyncExecutorSettings(0, 50, 200)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("APP_ASYNC_EXECUTOR_CORE_SIZE"); + } + + // ---- positive_int_ge_core (max) ------------------------------------------ + + @Test + void maxSizeBelowOneIsRejected() { + assertThatThrownBy(() -> new AsyncExecutorSettings(1, 0, 200)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("APP_ASYNC_EXECUTOR_MAX_SIZE"); + } + + @Test + void maxSizeBelowCoreSizeIsRejected() { + assertThatThrownBy(() -> new AsyncExecutorSettings(20, 10, 200)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("positive_int_ge_core"); + } + + // ---- positive_int_bounded (queue) ---------------------------------------- + + @Test + void queueCapacityBelowOneIsRejected() { + assertThatThrownBy(() -> new AsyncExecutorSettings(10, 50, 0)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("APP_ASYNC_EXECUTOR_QUEUE_CAPACITY"); + } + + @Test + void unboundedQueueCapacityIsRejected() { + // D7 / SF-TPTE-C1: Integer.MAX_VALUE is the unbounded sentinel and is forbidden — + // an unbounded queue makes max-size unreachable (TPE-JDK21-C3). + assertThatThrownBy(() -> new AsyncExecutorSettings(10, 50, Integer.MAX_VALUE)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("bounded"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncGracefulShutdownBehaviorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncGracefulShutdownBehaviorTest.java new file mode 100644 index 00000000..8f47cb04 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncGracefulShutdownBehaviorTest.java @@ -0,0 +1,128 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory; +import dev.caskeleton.shared.concurrency.DomainContextStrategy; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Behaviour-based graceful-shutdown verification (feature-background-job-async-contract D8 + §테스트 + * 계약 "shutdown 중 job 정책", §구현 가이드 5). Complements the exact-value pin in {@link + * AsyncExecutorConfigTest} (which asserts {@code awaitTerminationMillis == 19000} — the 19s-vs-25s + * number a behaviour test cannot prove) by exercising the real Spring lifecycle: a slow in-flight + * job is drained to completion during context close (within the shutdown budget), and a submission + * made AFTER shutdown is rejected with the {@code JOB_EXECUTOR_REJECTED} log. + * + * <p><strong>Why a self-managed {@link AnnotationConfigApplicationContext}, not + * {@code @SpringBootTest}.</strong> The test must {@code close()} the context to observe close-time + * graceful shutdown. A {@code @SpringBootTest} context is owned by the test framework: closing it + * mid-test makes the post-test {@code TestExecutionListener}s fail ("ApplicationContext ... is not + * active"). It also triggers Boot config-data loading of {@code application.yml}, whose {@code + * ${SPRING_PROFILES_ACTIVE}} (and ~50 other {@code ${APP_*}}) resolve from {@code src/.env} only at + * {@code bootRun}, not in tests. A self-managed context registers only {@code AsyncExecutorConfig} + * + a propagator and exercises the <em>identical</em> {@code SmartLifecycle}/{@code DisposableBean} + * shutdown path ({@code setWaitForTasksToCompleteOnShutdown(true)} + {@code + * setAwaitTerminationSeconds(19)}) the real app runs on close — without the framework-lifecycle + * conflict or the dotenv wall. + */ +class AsyncGracefulShutdownBehaviorTest { + + @Test + void inFlightJobDrainsWithinBudgetThenPostShutdownSubmissionsAreRejected() throws Exception { + ch.qos.logback.classic.Logger rejectLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(LoggingAbortPolicy.class); + ListAppender<ILoggingEvent> appender = new ListAppender<>(); + appender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + appender.start(); + rejectLogger.addAppender(appender); + + AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(AsyncExecutorConfig.class, PropagatorConfig.class); + try { + ThreadPoolTaskExecutor executor = + context.getBean(AsyncExecutorConfig.EXECUTOR_BEAN_NAME, ThreadPoolTaskExecutor.class); + + AtomicBoolean completed = new AtomicBoolean(false); + CountDownLatch started = new CountDownLatch(1); + // A job that is still in-flight when shutdown begins, but well under the 19s budget. + executor.submit( + () -> { + started.countDown(); + sleepQuietly(300); + completed.set(true); + }); + assertThat(started.await(5, TimeUnit.SECONDS)) + .as("the job must be in-flight before shutdown begins") + .isTrue(); + + long startNanos = System.nanoTime(); + // Graceful shutdown: setWaitForTasksToCompleteOnShutdown(true) + + // setAwaitTerminationSeconds(19). + context.close(); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000L; + + // D8: the in-flight job is drained to completion during the graceful await, within budget. + assertThat(completed.get()) + .as("in-flight job must finish during the graceful drain") + .isTrue(); + assertThat(elapsedMs) + .as("graceful drain must complete within the 19s budget") + .isLessThan(19_000L); + + // After shutdown a new submission is rejected → LoggingAbortPolicy emits + // JOB_EXECUTOR_REJECTED. + assertThatThrownBy(() -> executor.execute(() -> {})) + .isInstanceOf(RejectedExecutionException.class); + assertThat(appender.list) + .as("post-shutdown rejection must emit the JOB_EXECUTOR_REJECTED structured log") + .anySatisfy( + event -> + assertThat(renderedArguments(event)) + .contains("error.code=JOB_EXECUTOR_REJECTED")); + } finally { + if (context.isActive()) { + context.close(); + } + rejectLogger.detachAppender(appender); + } + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static String renderedArguments(ILoggingEvent event) { + return Arrays.stream(event.getArgumentArray()) + .map(String::valueOf) + .collect(Collectors.joining(" | ")); + } + + @Configuration + static class PropagatorConfig { + @Bean + DomainContextPropagator domainContextPropagator() { + return DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/BackgroundJobMetricsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/BackgroundJobMetricsTest.java new file mode 100644 index 00000000..124c3be6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/BackgroundJobMetricsTest.java @@ -0,0 +1,114 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; + +/** + * Contract test for {@link BackgroundJobMetrics} (feature-background-job-async-contract — metric + * names/tags pinned to docs/registries/metrics.yaml L232-300). required_test: {@code + * contract-verification:metrics-cardinality}. + */ +class BackgroundJobMetricsTest { + + @Test + void executorRejectedCounterCarriesExecutorNameAndPolicyTags() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BackgroundJobMetrics metrics = new BackgroundJobMetrics(providerOf(registry)); + + metrics.recordRejection("applicationTaskExecutor", "AbortPolicy"); + + assertThat( + registry + .get("executor.rejected.total") + .tag("executor_name", "applicationTaskExecutor") + .tag("policy", "AbortPolicy") + .counter() + .count()) + .isEqualTo(1.0); + } + + @Test + void jobRetryCounterCarriesJobNameAndOutcomeTags() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BackgroundJobMetrics metrics = new BackgroundJobMetrics(providerOf(registry)); + + metrics.recordRetryOutcome("sample-job", BackgroundJobMetrics.RetryOutcome.EXHAUSTED); + + assertThat( + registry + .get("job.retry.total") + .tag("job_name", "sample-job") + .tag("outcome", "EXHAUSTED") + .counter() + .count()) + .isEqualTo(1.0); + } + + @Test + void jobDlqCounterCarriesJobNameTag() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BackgroundJobMetrics metrics = new BackgroundJobMetrics(providerOf(registry)); + + metrics.recordDeadLetter("sample-job"); + + assertThat(registry.get("job.dlq.total").tag("job_name", "sample-job").counter().count()) + .isEqualTo(1.0); + } + + @Test + void saturationGaugeReadsLiveQueueDepth() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + BackgroundJobMetrics metrics = new BackgroundJobMetrics(providerOf(registry)); + AtomicInteger queueDepth = new AtomicInteger(0); + + metrics.registerSaturationGauge("applicationTaskExecutor", queueDepth::get); + + queueDepth.set(7); + assertThat( + registry + .get("executor.saturation") + .tag("executor_name", "applicationTaskExecutor") + .gauge() + .value()) + .isEqualTo(7.0); + } + + @Test + void retryOutcomeValuesMatchTheRegistryAllowedSet() { + // metrics.yaml job.retry.total outcome tag: allowed_values [SUCCESS, RETRY, EXHAUSTED, DLQ] + assertThat(BackgroundJobMetrics.RetryOutcome.values()) + .extracting(Enum::name) + .containsExactlyInAnyOrder("SUCCESS", "RETRY", "EXHAUSTED", "DLQ"); + } + + @Test + void isNoopWithoutAMeterRegistry() { + BackgroundJobMetrics metrics = new BackgroundJobMetrics(providerOf(null)); + assertThatCode( + () -> { + metrics.registerSaturationGauge("x", () -> 1.0); + metrics.recordRejection("x", "AbortPolicy"); + metrics.recordRetryOutcome("x", BackgroundJobMetrics.RetryOutcome.RETRY); + metrics.recordDeadLetter("x"); + }) + .doesNotThrowAnyException(); + } + + /** + * Builds a real Spring {@link ObjectProvider}; an empty factory yields the no-op (absent) case. + */ + private static ObjectProvider<MeterRegistry> providerOf(MeterRegistry registry) { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + if (registry != null) { + beanFactory.registerSingleton("meterRegistry", registry); + } + return beanFactory.getBeanProvider(MeterRegistry.class); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/LoggingAbortPolicyTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/LoggingAbortPolicyTest.java new file mode 100644 index 00000000..f9a4b402 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/LoggingAbortPolicyTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.bootstrap.async; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Arrays; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; + +/** + * Contract test for {@link LoggingAbortPolicy} (feature-background-job-async-contract D7 — + * saturation policy). + * + * <p>required_test: {@code contract-verification:async-saturation} — "executor rejection이 + * structured log 없이 발생하면 실패": a rejection must (1) emit a structured ERROR log carrying {@code + * JOB_EXECUTOR_REJECTED}, (2) increment {@code executor.rejected.total}, and (3) still throw + * (AbortPolicy semantics — the caller must see the rejection). + */ +class LoggingAbortPolicyTest { + + private final ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(LoggingAbortPolicy.class); + private ListAppender<ILoggingEvent> appender; + private SimpleMeterRegistry registry; + private LoggingAbortPolicy policy; + private ThreadPoolExecutor probeExecutor; + + @BeforeEach + void setUp() { + appender = new ListAppender<>(); + appender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + appender.start(); + logger.addAppender(appender); + + registry = new SimpleMeterRegistry(); + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerSingleton("meterRegistry", registry); + BackgroundJobMetrics metrics = + new BackgroundJobMetrics(beanFactory.getBeanProvider(MeterRegistry.class)); + policy = new LoggingAbortPolicy("applicationTaskExecutor", metrics); + + probeExecutor = + new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + probeExecutor.shutdownNow(); + } + + @Test + void rejectionThrowsRejectedExecutionException() { + assertThatThrownBy(() -> policy.rejectedExecution(() -> {}, probeExecutor)) + .isInstanceOf(RejectedExecutionException.class) + .hasMessageContaining("JOB_EXECUTOR_REJECTED"); + } + + @Test + void rejectionEmitsStructuredErrorLogWithJobExecutorRejected() { + assertThatThrownBy(() -> policy.rejectedExecution(() -> {}, probeExecutor)) + .isInstanceOf(RejectedExecutionException.class); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(renderedArguments(event)) + .contains("error.code=JOB_EXECUTOR_REJECTED") + .contains("error.category=TRANSIENT_DEPENDENCY") + .contains("executor_name=applicationTaskExecutor") + .contains("policy=AbortPolicy"); + } + + @Test + void rejectionIncrementsExecutorRejectedTotalCounter() { + assertThatThrownBy(() -> policy.rejectedExecution(() -> {}, probeExecutor)) + .isInstanceOf(RejectedExecutionException.class); + + assertThat( + registry + .get("executor.rejected.total") + .tag("executor_name", "applicationTaskExecutor") + .tag("policy", "AbortPolicy") + .counter() + .count()) + .isEqualTo(1.0); + } + + private static String renderedArguments(ILoggingEvent event) { + return Arrays.stream(event.getArgumentArray()) + .map(String::valueOf) + .collect(Collectors.joining(" | ")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/ScheduledJobOverlapPolicyTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/ScheduledJobOverlapPolicyTest.java new file mode 100644 index 00000000..0afd5a57 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/ScheduledJobOverlapPolicyTest.java @@ -0,0 +1,56 @@ +package dev.caskeleton.bootstrap.async; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import dev.caskeleton.bootstrap.architecture.ProductionClassImportOption; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * Scheduled-job overlap policy (feature-background-job-async-contract D3 — "scheduled job overlap | + * no overlap by default | ... | concurrent same job mutation | overlap test"). + * + * <p>The default {@code TaskScheduler} runs scheduled methods sequentially; combined with {@code + * fixedDelay} (which measures the gap from the <em>end</em> of one run to the start of the next) a + * job can never overlap itself. {@code fixedRate}/{@code fixedRateString} measure from the + * <em>start</em>, so a run that outlasts its period would overlap the next on a multi-threaded + * scheduler — the "concurrent same job mutation" the contract forbids by default. This rule fails + * the build if any production {@code @Scheduled} method uses a fixed-rate trigger; a job that + * genuinely needs fixed-rate must first prove idempotency and relax this rule deliberately. + * + * <p>Every production {@code @Scheduled} today ({@code IdempotencyReaper}, {@code OutboxReaper}, + * {@code OutboxRelayScheduler}) uses {@code fixedDelayString} — this rule pins that. + */ +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class ScheduledJobOverlapPolicyTest { + + @ArchTest + static final ArchRule SCHEDULED_JOBS_DO_NOT_USE_FIXED_RATE = + methods().that().areAnnotatedWith(Scheduled.class).should(notUseFixedRateTrigger()); + + private static ArchCondition<JavaMethod> notUseFixedRateTrigger() { + return new ArchCondition<>("not use a fixedRate/fixedRateString trigger (overlap-prone)") { + @Override + public void check(JavaMethod method, ConditionEvents events) { + Scheduled scheduled = method.getAnnotationOfType(Scheduled.class); + boolean usesFixedRate = + scheduled.fixedRate() != -1 || !scheduled.fixedRateString().isEmpty(); + if (usesFixedRate) { + events.add( + SimpleConditionEvent.violated( + method, + method.getFullName() + + " uses a fixedRate trigger — overlap-prone; use " + + "fixedDelay/cron (no overlap by default) or prove idempotency and " + + "relax this rule (D3)")); + } + } + }; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/TaskExecutorDecoratorPolicyTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/TaskExecutorDecoratorPolicyTest.java new file mode 100644 index 00000000..b7ce7512 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/TaskExecutorDecoratorPolicyTest.java @@ -0,0 +1,75 @@ +package dev.caskeleton.bootstrap.async; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import dev.caskeleton.bootstrap.architecture.ProductionClassImportOption; +import org.springframework.context.annotation.Bean; +import org.springframework.core.task.TaskExecutor; + +/** + * Global async-context guard (feature-background-job-async-contract D5/D6, §구현 가이드 2). + * + * <p>The constructor-dependency wiring in {@code AsyncExecutorConfig} only protects <em>that</em> + * one executor bean: registering it without the {@code AsyncContextTaskDecorator} bean fails the + * context. This ArchUnit rule promotes the guarantee to the whole module — <strong>any</strong> + * {@code @Bean} returning a Spring {@link TaskExecutor} must call {@code setTaskDecorator(...)} in + * its factory method, so a future executor bean cannot be added without a context decorator and + * silently drop {@code request_id}/{@code trace_id}/{@code correlation_id}/{@code tenant_id} (and + * domain context) on every {@code @Async} hop. "decorator 없는 TaskExecutor @Bean 추가 시 빌드 실패." + * + * <p><strong>Static-analysis limit + escape hatch.</strong> ArchUnit only sees a {@code + * setTaskDecorator} call made DIRECTLY in the bean method body — a decorator wired through a helper + * escapes the call graph and trips this rule. A delegating wrapper bean that inherits a decorated + * executor (e.g. Spring Security's {@code DelegatingSecurityContextTaskExecutor}, the documented + * SecurityContext opt-in) does not call {@code setTaskDecorator} itself; if introduced it must be + * added as an explicit exception to this rule's {@code that()} clause. + */ +@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) +class TaskExecutorDecoratorPolicyTest { + + @ArchTest + static final ArchRule EVERY_TASK_EXECUTOR_BEAN_HAS_CONTEXT_DECORATOR = + methods() + .that() + .areAnnotatedWith(Bean.class) + .and() + .haveRawReturnType(assignableTo(TaskExecutor.class)) + .should(setATaskDecorator()) + .as( + "EVERY_TASK_EXECUTOR_BEAN_HAS_CONTEXT_DECORATOR: a @Bean returning a Spring " + + "TaskExecutor must call setTaskDecorator(...) in its factory method so async " + + "tasks carry MDC + domain context to worker threads " + + "(feature-background-job-async-contract D5/D6). A decorator-less executor " + + "silently drops request_id/trace_id/correlation_id/tenant_id on @Async hops. " + + "A delegating wrapper that inherits a decorated executor must be added as an " + + "explicit exception here.") + .allowEmptyShould(true); + + private static ArchCondition<JavaMethod> setATaskDecorator() { + return new ArchCondition<>("call setTaskDecorator(...) in the bean factory method") { + @Override + public void check(JavaMethod method, ConditionEvents events) { + boolean setsDecorator = + method.getMethodCallsFromSelf().stream() + .anyMatch(call -> "setTaskDecorator".equals(call.getName())); + if (!setsDecorator) { + events.add( + SimpleConditionEvent.violated( + method, + method.getFullName() + + " is a TaskExecutor @Bean but never calls " + + "setTaskDecorator(...) — async context (MDC + domain) would not " + + "propagate to worker threads (D5/D6)")); + } + } + }; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java new file mode 100644 index 00000000..7c27ba7b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.bootstrap.management.security.ManagementSecurityConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * feature-management-actuator-security-contract — HTTP-level verification of the actuator {@link + * ManagementSecurityConfig} authorization posture (D2/D3/D8). The other contract tests pin + * configuration values; this one boots a real (sliced) servlet + security + actuator context and + * drives the endpoints over MockMvc so the SecurityFilterChain itself is exercised. + * + * <p>This catches the {@code SB-ACT-EXP-C3} trap (a custom SecurityFilterChain disables Spring's + * actuator auto-secure): if the chain were wrong, probes would be locked out or secret endpoints + * would leak — both observable here. + * + * <p>Slice scope: a minimal {@link MinimalActuatorApp} enables only web + security + actuator + * autoconfiguration and excludes the data layer (no DB/Redis), so no Testcontainers/infra is + * required. The security RULES match by actuator endpoint id (via {@code EndpointRequest}), so + * running on the main servlet port (rather than a separate management port, D1) does not change + * which rule applies — only the transport. + */ +@SpringBootTest( + classes = ActuatorSecurityHttpTest.MinimalActuatorApp.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK, + properties = { + // Isolate the slice from the real application.yml (which carries unresolved + // ${APP_*}/${SPRING_PROFILES_ACTIVE} placeholders resolved only at deploy time). + // All config this slice needs is supplied inline below. + "spring.config.location=optional:classpath:actuator-security-slice-no-config.yml", + "management.endpoints.web.exposure.include=health,prometheus,info,loggers", + "management.endpoints.web.exposure.exclude=env,configprops,heapdump,threaddump,shutdown", + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.loggers.enabled=true" + }) +@AutoConfigureMockMvc +// @SpringBootTest disables observability by default; re-enable metrics so the Prometheus +// registry (and therefore the /actuator/prometheus endpoint) exists for the permit-all assertion. +class ActuatorSecurityHttpTest { + + @Autowired private MockMvc mvc; + + // ---- F2: unauthenticated posture (probes open, everything else challenged, excluded hidden) + // ---- + + @Test + void healthEndpointIsPermitAll() throws Exception { + mvc.perform(get("/actuator/health")).andExpect(status().isOk()); + } + + @Test + void infoEndpointIsPermitAll() throws Exception { + mvc.perform(get("/actuator/info")).andExpect(status().isOk()); + } + + @Test + void prometheusEndpointIsPermitAll() throws Exception { + mvc.perform(get("/actuator/prometheus")).andExpect(status().isOk()); + } + + @Test + void loggersEndpointChallengesUnauthenticatedCallerWith401() throws Exception { + // D3: a protected actuator endpoint must answer 401 (unauthenticated), NOT the framework + // default 403. 401 is the correct "you must authenticate" semantics; 403 means + // "authenticated but forbidden". + mvc.perform(get("/actuator/loggers")).andExpect(status().isUnauthorized()); + } + + @Test + void excludedEnvEndpointIsNotExposedAtAll() throws Exception { + // D2: env is excluded from exposure, so it is not even mapped — 404, never reachable. + mvc.perform(get("/actuator/env")).andExpect(status().isNotFound()); + } + + // ---- F3: loggers is READ-ONLY (D2) — reads need auth, writes are denied for everyone ---- + + @Test + void loggersReadIsAllowedForAuthenticatedCaller() throws Exception { + mvc.perform(get("/actuator/loggers").with(user("ops"))).andExpect(status().isOk()); + } + + @Test + void loggersWriteIsDeniedEvenForAuthenticatedCaller() throws Exception { + // D2 "loggers prod read-only": a runtime log-level change (POST) must be denied (403) + // even for an authenticated caller — authentication alone must not grant write. + mvc.perform( + post("/actuator/loggers/dev.caskeleton") + .with(user("ops")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"configuredLevel\":\"DEBUG\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void loggersResetViaDeleteIsDenied() throws Exception { + // D2: A logger-level reset (DELETE /actuator/loggers/{name}) is also a write mutation + // that reverts a logger to its default level. It must be denied (403) for everyone — + // authentication alone must not grant write, just as for POST. + mvc.perform(delete("/actuator/loggers/dev.caskeleton").with(user("ops"))) + .andExpect(status().isForbidden()); + } + + @EnableAutoConfiguration( + exclude = { + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + DataJpaRepositoriesAutoConfiguration.class, + FlywayAutoConfiguration.class + }) + @Import(ManagementSecurityConfig.class) + static class MinimalActuatorApp {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/BackgroundJobErrorCodeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/BackgroundJobErrorCodeContractTest.java new file mode 100644 index 00000000..ca58cd31 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/BackgroundJobErrorCodeContractTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-background-job-async-contract D4/D7 — the three {@code JOB_*} codes are the retry/DLQ + * vocabulary this branch owns. This test pins each {@link OperationalError} JOB code to its {@code + * error-codes.yaml} row (category / http_status / retryable / runbook_link) and verifies the {@code + * runbook://job/*} link resolves to an actual runbook file (§Audit A4 RUNBOOK_MISSING). + * + * <p>required_test: {@code contract-verification:async-saturation} (JOB_EXECUTOR_REJECTED), {@code + * contract-verification:async-timeout} (JOB_TIMEOUT), {@code contract-verification:async-dlq} + * (JOB_DEAD_LETTER). + * + * <p>The registry SSOT lives under {@code docs/registries/} which this repo gitignores; when it is + * absent (CI / fresh checkout) the cross-check is SKIPPED, never silently passed — when it IS + * present, a missing/mismatched row or a missing runbook file is a hard FAIL. + */ +class BackgroundJobErrorCodeContractTest { + + private static Path registry; + private static Map<String, Map<String, Object>> rowsByCode; + + @BeforeAll + static void loadRegistry() throws Exception { + registry = locateRegistry(); + Assumptions.assumeTrue( + registry != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "async error-code contract runs locally only"); + rowsByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + rowsByCode.put((String) row.get("code"), row); + } + } + } + + @Test + void jobExecutorRejectedMatchesRegistryAndResolvesItsRunbook() { + assertCodeMatchesRegistry( + OperationalError.JOB_EXECUTOR_REJECTED, + "TRANSIENT_DEPENDENCY", + 503, + true, + "runbook://job/executor-rejected"); + } + + @Test + void jobTimeoutMatchesRegistryAndResolvesItsRunbook() { + assertCodeMatchesRegistry( + OperationalError.JOB_TIMEOUT, "TRANSIENT_DEPENDENCY", 500, true, "runbook://job/timeout"); + } + + @Test + void jobDeadLetterMatchesRegistryAndResolvesItsRunbook() { + assertCodeMatchesRegistry( + OperationalError.JOB_DEAD_LETTER, "INTERNAL", 500, false, "runbook://job/dead-letter"); + } + + private void assertCodeMatchesRegistry( + OperationalError code, + String category, + int httpStatus, + boolean retryable, + String runbookLink) { + Map<String, Object> row = rowsByCode.get(code.code()); + assertThat(row).as("error-codes.yaml must contain row %s", code.code()).isNotNull(); + assertThat(row.get("category")).as("%s category", code.code()).isEqualTo(category); + assertThat(((Number) row.get("http_status")).intValue()) + .as("%s http_status", code.code()) + .isEqualTo(httpStatus); + assertThat(row.get("retryable")).as("%s retryable", code.code()).isEqualTo(retryable); + assertThat(row.get("runbook_link")).as("%s runbook_link", code.code()).isEqualTo(runbookLink); + + // enum ↔ registry must agree (the same identity the runtime emits). + assertThat(code.category().name()).isEqualTo(category); + assertThat(code.httpStatus()).isEqualTo(httpStatus); + assertThat(code.retryable()).isEqualTo(retryable); + + // §Audit A4: the runbook:// link must resolve to an actual file (no dangling reference). + Path runbook = runbookFileFor(runbookLink); + assertThat(Files.exists(runbook)) + .as("runbook_link %s for %s must resolve to %s", runbookLink, code.code(), runbook) + .isTrue(); + } + + /** Resolves {@code runbook://job/<scenario>} → {@code docs/runbooks/job-<scenario>.md}. */ + private static Path runbookFileFor(String runbookLink) { + String path = runbookLink.substring("runbook://".length()).replace('/', '-'); + return registry.getParent().getParent().resolve("runbooks").resolve(path + ".md"); + } + + /** Walk up from the test working directory to find docs/registries/error-codes.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/BusinessRuleValidationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/BusinessRuleValidationContractTest.java new file mode 100644 index 00000000..da61e44a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/BusinessRuleValidationContractTest.java @@ -0,0 +1,216 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-business-rule-validation-contract — the 4-layer validation-responsibility split, pinned + * as executable contract. + * + * <p>This branch does <em>not</em> own the error envelope, the {@link Category} enum, the {@link + * OperationalError} codes, or the SQLState→code persistence matrix (that matrix is owned by {@code + * feature-persistence-failure-baseline}; the registry rows live in {@code + * docs/registries/error-codes.yaml}). It owns the cross-layer <em>invariants</em>: + * + * <ul> + * <li><b>C4</b> — each layer maps to its contracted category: syntax→VALIDATION, + * policy→AUTHZ/CONFLICT, invariant→CONFLICT/VALIDATION, persistence→DATA_INTEGRITY/CONFLICT. + * <li><b>C5</b> — {@code category × retryable} is consistent: a client error + * (VALIDATION/AUTH/AUTHZ/NOT_FOUND) is never {@code retryable=true}. + * <li><b>C7/D9</b> — no {@code client_safe_message} leaks a raw SQL fragment, constraint name, + * SQLState code, exception class, or internal package path. + * </ul> + * + * <p>The C4 enum-mapping assertions read only the {@link OperationalError} SSOT and always run. The + * registry-backed assertions follow the same skip-not-pass discipline as {@code + * ErrorCodeRegistryMappingTest}: each one calls {@link #requireRegistry()} so that when the + * gitignored {@code error-codes.yaml} is absent (CI / fresh checkout) only that test is skipped — + * never silently passed, and never aborting the always-run enum tests (a {@code @BeforeAll} + * assumption would abort the entire class). + */ +class BusinessRuleValidationContractTest { + + private static List<Map<String, Object>> registryRows; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + if (registry == null) { + return; // registryRows stays null; registry-backed tests skip via requireRegistry() + } + registryRows = new ArrayList<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + registryRows.addAll(errors); + } + } + + /** Skip (not pass) a registry-backed test when the gitignored registry is absent. */ + private static void requireRegistry() { + Assumptions.assumeTrue( + registryRows != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "registry-backed contract checks run locally only"); + } + + // ---- C4: 4-layer → category mapping (enum SSOT, always runs) ---- + + @Test + void syntaxLayerCodesMapToValidationCategory() { + // request DTO / transport-shape failures — the syntax layer. + assertThat(OperationalError.VALIDATION_FAILED.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.MAPPING_FAILED.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.BAD_PARAMETER.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.METHOD_NOT_ALLOWED.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.UNSUPPORTED_MEDIA_TYPE.category()).isEqualTo(Category.VALIDATION); + } + + @Test + void policyLayerCodesMapToAuthzOrConflictCategory() { + // use-case policy: authorization denials are AUTHZ; precondition/optimistic-lock + // conflicts are CONFLICT. + assertThat(OperationalError.FORBIDDEN.category()).isEqualTo(Category.AUTHZ); + assertThat(OperationalError.PRECONDITION_FAILED.category()).isEqualTo(Category.CONFLICT); + } + + @Test + void persistenceIntegrityCodesMapToDataIntegrityOrConflictCategory() { + requireRegistry(); + // C3/C4: the SQLState integrity matrix (owned by feature-persistence-failure-baseline) + // must land in DATA_INTEGRITY or CONFLICT — never INTERNAL (which would hide a client- + // correctable conflict as a server fault) and never VALIDATION. + Map<String, Category> expected = + Map.of( + "DB_NULL_VIOLATION", Category.DATA_INTEGRITY, + "DB_FK_VIOLATION", Category.DATA_INTEGRITY, + "DB_CHECK_VIOLATION", Category.DATA_INTEGRITY, + "DB_UNIQUE_VIOLATION", Category.CONFLICT, + "DB_SERIALIZATION_FAILURE", Category.CONFLICT, + "DB_DEADLOCK", Category.CONFLICT); + expected.forEach( + (code, category) -> { + Map<String, Object> row = rowByCode(code); + assertThat(row) + .as( + "registry must define persistence integrity code %s " + + "(feature-persistence-failure-baseline SQLState matrix)", + code) + .isNotNull(); + assertThat(Category.valueOf((String) row.get("category"))) + .as("C3/C4: %s must map to %s", code, category) + .isEqualTo(category); + }); + } + + // ---- C5: category × retryable consistency (registry, every row) ---- + + @Test + void deterministicClientErrorRowsAreNeverRetryable() { + requireRegistry(); + // C5: VALIDATION / AUTHZ / NOT_FOUND are deterministic client errors — the same request + // can never succeed, so retryable=true would be a busy-loop trap. AUTH is excluded: it is + // context-dependent (e.g. AUTH_KID_UNKNOWN is retryable=true once the JWKS key set + // refreshes during rotation), which this test discovered in the registry SSOT. + for (Map<String, Object> row : registryRows) { + Category category = Category.valueOf((String) row.get("category")); + if (category == Category.VALIDATION + || category == Category.AUTHZ + || category == Category.NOT_FOUND) { + assertThat((boolean) row.get("retryable")) + .as( + "C5: %s is %s (deterministic client error) and must be retryable=false", + row.get("code"), category) + .isFalse(); + } + } + } + + @Test + void registryCategoriesAreAllMembersOfTheSharedEnum() { + requireRegistry(); + // The registry's category column may not drift from the 10-value Category SSOT. + for (Map<String, Object> row : registryRows) { + String category = (String) row.get("category"); + assertThat(Category.values()) + .as( + "registry category %s for %s must be a Category enum member", + category, row.get("code")) + .anyMatch(c -> c.name().equals(category)); + } + } + + // ---- C7 / D9: client_safe_message leak scan (registry, every row) ---- + + @Test + void noClientSafeMessageLeaksSqlConstraintOrInternals() { + requireRegistry(); + // D9: "safe field errors only" — a client-facing message must never carry a raw SQL + // fragment, a DB constraint/index name, a SQLState code, an exception class name, or an + // internal package path. This is the registry-wide DLP gate complementing the runtime + // GlobalExceptionHandler leak test in adapter-web. + for (Map<String, Object> row : registryRows) { + String message = (String) row.get("client_safe_message"); + assertThat(message).as("every row needs a client_safe_message").isNotNull(); + for (Pattern forbidden : FORBIDDEN_LEAK_PATTERNS) { + assertThat(forbidden.matcher(message).find()) + .as( + "C7/D9: client_safe_message of %s must not leak via /%s/ — was: \"%s\"", + row.get("code"), forbidden.pattern(), message) + .isFalse(); + } + } + } + + /** Patterns that must never appear in a client-facing message (DLP gate). */ + private static final List<Pattern> FORBIDDEN_LEAK_PATTERNS = + List.of( + // double-quoted lowercase identifier (DB constraint / index / column name) + Pattern.compile("\"[a-z][a-z0-9_]*\""), + // SQLState codes — restricted to the persistence-matrix class prefixes (08/22/23/25/ + // 40/42/53/54/55/57) so a benign 5-digit quantity in prose (e.g. "10000 items") is + // not a false positive. Covers all-digit (23505, 40001) and vendor (40P01, 25P03). + Pattern.compile("\\b(08|22|23|25|40|42|53|54|55|57)[0-9A-Z]{3}\\b"), + // Java exception class name (UpperCamelCase ending in "Exception") + Pattern.compile("\\b[A-Z][A-Za-z]*Exception\\b"), + // stack frame markers + Pattern.compile("\\.java:\\d+"), + Pattern.compile("\\bat [a-z]+\\."), + // raw SQL keywords with an argument (avoid matching prose like "select endpoint") + Pattern.compile( + "(?i)\\b(select\\s+\\*|insert\\s+into|update\\s+\\w+\\s+set|delete\\s+from|where\\s+\\w+\\s*=)"), + // internal package paths + Pattern.compile("dev\\.caskeleton"), + Pattern.compile("org\\.(springframework|hibernate|postgresql)")); + + private static Map<String, Object> rowByCode(String code) { + return registryRows.stream().filter(r -> code.equals(r.get("code"))).findFirst().orElse(null); + } + + /** Walk up from the test working directory to find docs/registries/error-codes.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContainerRuntimeOomContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContainerRuntimeOomContractTest.java new file mode 100644 index 00000000..985a63f1 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContainerRuntimeOomContractTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-container-runtime-contract §5 — {@code JVM_OOM} error code parity contract. + * + * <p>required_test: {@code contract-verification:container-runtime-oom}. + * + * <p>{@link OperationalError#JVM_OOM} must be {@code INTERNAL / 500 / retryable=false}: the JVM + * terminates on {@link OutOfMemoryError} ({@code -XX:+ExitOnOutOfMemoryError}, exit 137). Retrying + * the same request never clears heap pressure, so {@code retryable=false} is correct (registry + * SSOT). Distinguished from a kubelet OOMKill by the presence of {@code error.code=JVM_OOM} in the + * structured log output. + * + * <p>The enum-side assertions always run ({@link OperationalError} is the on-classpath SSOT). The + * registry-row leg follows the sibling tests' skip-not-pass discipline — when the gitignored {@code + * docs/registries/} files are absent (CI / fresh checkout), those legs are SKIPPED, never silently + * passed. + * + * <p>The runbook file check ({@code docs/runbooks/runtime-jvm-oom.md}) is also SOFT: the runbook is + * authored by the parallel runbook branch and may not be present in this worktree. The test asserts + * the link format and registry row unconditionally, but only checks file existence when the file is + * actually on disk. This avoids a cross-feature dependency failure while still catching format + * regressions. + */ +class ContainerRuntimeOomContractTest { + + private static final String EXPECTED_RUNBOOK_LINK = "runbook://runtime/jvm-oom"; + + private static Map<String, Map<String, Object>> rowsByCode; + private static Path registryRoot; // docs/ parent, used to locate runbooks/ + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateFile("docs/registries/error-codes.yaml"); + if (registry == null) { + return; // registry absent — leg will be skipped via Assumptions in each @Test + } + registryRoot = registry.getParent().getParent(); // docs/ directory + rowsByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + rowsByCode.put((String) row.get("code"), row); + } + } + } + + // ---- enum SSOT: JVM_OOM is INTERNAL/500/retryable=false -------------------- + + @Test + void jvmOomIsInternal500NotRetryable() { + // enum assertions always run — OperationalError is the on-classpath authority. + OperationalError code = OperationalError.JVM_OOM; + assertThat(code.category()) + .as("JVM_OOM must be INTERNAL (JVM fault, not a client error)") + .isEqualTo(Category.INTERNAL); + assertThat(code.httpStatus()).as("JVM_OOM must return 500 (registry SSOT)").isEqualTo(500); + assertThat(code.retryable()) + .as("JVM_OOM is terminal — the JVM dies (ExitOnOutOfMemoryError) → retryable=false") + .isFalse(); + assertThat(code.code()) + .as("JVM_OOM.code() must match the registry key exactly") + .isEqualTo("JVM_OOM"); + } + + // ---- registry-row leg: skip when error-codes.yaml absent ------------------- + + @Test + void registryRowMatchesJvmOomEnum() { + Assumptions.assumeTrue( + rowsByCode != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "container-runtime OOM registry-row cross-check runs locally only"); + + Map<String, Object> row = rowsByCode.get("JVM_OOM"); + assertThat(row) + .as("error-codes.yaml must contain a JVM_OOM row (feature-container-runtime-contract §5)") + .isNotNull(); + assertThat((String) row.get("category")) + .as("registry JVM_OOM category must be INTERNAL") + .isEqualTo("INTERNAL"); + assertThat(((Number) row.get("http_status")).intValue()) + .as("registry JVM_OOM http_status must be 500") + .isEqualTo(500); + assertThat((boolean) row.get("retryable")) + .as("registry JVM_OOM must be retryable=false") + .isFalse(); + + // enum ↔ registry agreement (the same identity the runtime emits) + OperationalError code = OperationalError.JVM_OOM; + assertThat(code.category().name()).isEqualTo((String) row.get("category")); + assertThat(code.httpStatus()).isEqualTo(((Number) row.get("http_status")).intValue()); + assertThat(code.retryable()).isEqualTo((boolean) row.get("retryable")); + } + + @Test + void registryRowHasCorrectRunbookLinkFormat() { + Assumptions.assumeTrue( + rowsByCode != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "runbook-link format check runs locally only"); + + Map<String, Object> row = rowsByCode.get("JVM_OOM"); + assertThat(row).as("JVM_OOM row must exist").isNotNull(); + + // Assert the runbook link format is correct (regardless of file existence). + // The runbook FILE is authored by the parallel runbook branch; its absence in this + // worktree must not fail this test (cross-feature dependency isolation). + assertThat((String) row.get("runbook_link")) + .as("registry JVM_OOM runbook_link must equal %s", EXPECTED_RUNBOOK_LINK) + .isEqualTo(EXPECTED_RUNBOOK_LINK); + } + + @Test + void runbookFileExistsWhenPresentOnDisk() { + // Soft check: only asserts the runbook file when it is actually on disk. + // The runtime-jvm-oom.md runbook is authored by the parallel runbook branch; + // this worktree may not have it. File existence is asserted only when resolvable. + Assumptions.assumeTrue( + registryRoot != null, "docs/ directory not found; soft runbook file check skipped"); + + Path runbookFile = registryRoot.resolve("runbooks/runtime-jvm-oom.md"); + Assumptions.assumeTrue( + Files.exists(runbookFile), + "docs/runbooks/runtime-jvm-oom.md not present in this worktree " + + "(authored by parallel runbook branch — skipped until merged)"); + + // If the file IS on disk, it must be non-empty (a real runbook, not a placeholder). + assertThat(runbookFile.toFile().length()) + .as("runbook file runtime-jvm-oom.md must not be empty") + .isGreaterThan(0); + } + + /** Walk up from the test working directory to find the given relative path. */ + private static Path locateFile(String relativePath) { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve(relativePath); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractRegistrySchemaGovernanceTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractRegistrySchemaGovernanceTest.java new file mode 100644 index 00000000..6bef2c83 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractRegistrySchemaGovernanceTest.java @@ -0,0 +1,248 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-contract-registry-governance — schema-owner gate for the 7 contract registries. + * + * <p>This branch is the <em>schema</em> owner of {@code docs/registries/*.yaml}: it governs the + * column structure, storage format/path, and change procedure. Row <em>values</em> are delegated to + * sibling {@code owner_branch} registries, and runtime token usage is enforced by {@code + * feature-contract-verification-test-suite}. The per-registry drift guards ({@link + * ErrorCodeRegistryMappingTest}, {@link SecretsClassificationRegistryTest}, {@link + * RepositoryAccessCapabilityRegistryTest}, {@code MetricsAlertingContractTest}) check value/enum + * drift for a single family; this test enforces what the schema owner owns across <em>all</em> + * families — that every registry artifact conforms to the governed schema (branch-note §구현 가이드 + * §1/§2/§3, 결정 사항 2026-06-15 F2/F3). + * + * <p>Enforces: + * + * <ul> + * <li>all 7 registry families exist under {@code docs/registries/} (§구현 가이드 §1, Audit F3); + * <li>each declares the schema-owner header {@code # Schema owner: + * feature-contract-registry-governance} (§3); + * <li>every row carries the family identity column (error={@code code} / mdc={@code key} / + * else={@code name}) and {@code owner_branch} (§1/§2 universal columns); + * <li>every <em>full</em> row carries the universal contract columns {@code compatibility_impact} + * (within the legal enum) and {@code required_test} (D2 — registry 항목은 최소 1개 이상의 contract + * test와 연결); + * <li>reference rows — a {@code reference:} pointer whose authoritative full row lives in another + * registry (the secrets {@code public-config} rows point at {@code env-keys.yaml}; + * secrets-classification.yaml header L17) — are exempt from the contract columns, but must + * still declare a non-blank reference target. + * </ul> + * + * <p>{@code docs/} is gitignored: when the registries are absent (CI / fresh checkout) the gate is + * SKIPPED, not passed; when present, a schema violation is a hard FAIL. Mirrors the {@code + * ErrorCodeRegistryMappingTest} / {@code SecretsClassificationRegistryTest} registry-drift pattern. + */ +class ContractRegistrySchemaGovernanceTest { + + private static final String SCHEMA_OWNER_HEADER = + "# Schema owner: feature-contract-registry-governance"; + + /** §구현 가이드 §1 — compatibility_impact column legal enum (7/7 共通). */ + private static final Set<String> LEGAL_COMPATIBILITY_IMPACT = + Set.of("none", "additive", "behavior-change", "breaking"); + + /** + * The 7 as-built registry families (branch-note §2 / Audit F3). {@code collectionKey} is the + * top-level YAML list key; {@code identityColumn} is the family identity column. + */ + private static final List<Registry> REGISTRIES = + List.of( + new Registry("error-codes.yaml", "errors", "code"), + new Registry("env-keys.yaml", "env_keys", "name"), + new Registry("secrets-classification.yaml", "secrets", "name"), + new Registry("headers.yaml", "headers", "name"), + new Registry("mdc-keys.yaml", "mdc_keys", "key"), + new Registry("metrics.yaml", "metrics", "name"), + new Registry("capabilities.yaml", "capabilities", "name")); + + private static Path registriesDir; + + @BeforeAll + static void locateRegistriesDir() { + registriesDir = findRegistriesDir(); + Assumptions.assumeTrue( + registriesDir != null, + "docs/registries/ not on disk (/docs is gitignored); contract-registry schema " + + "governance gate runs locally only"); + } + + @Test + void allSevenRegistryFamiliesArePresent() { + // Audit F3: 7 families (not 6, no phantom "Response"). With the dir present, a missing + // family file is a hard FAIL — never a silent skip. + for (Registry registry : REGISTRIES) { + assertThat(registry.file(registriesDir)) + .as( + "registry family %s must exist under docs/registries/ " + + "(feature-contract-registry-governance §구현 가이드 §1, Audit F3)", + registry.fileName) + .exists(); + } + } + + @Test + void everyRegistryDeclaresTheSchemaOwnerHeader() throws Exception { + // §3: this branch is the schema owner of all 7 registries. + for (Registry registry : REGISTRIES) { + Path file = registry.file(registriesDir); + if (!Files.exists(file)) { + continue; // covered by allSevenRegistryFamiliesArePresent() + } + boolean declaresOwner = + Files.readAllLines(file).stream().map(String::trim).anyMatch(SCHEMA_OWNER_HEADER::equals); + assertThat(declaresOwner) + .as( + "%s must declare the schema-owner header '%s' " + + "(feature-contract-registry-governance §3 schema-owner vs row-owner)", + registry.fileName, SCHEMA_OWNER_HEADER) + .isTrue(); + } + } + + @Test + void everyRowDeclaresItsIdentityColumnAndOwnerBranch() throws Exception { + // §1/§2 universal columns: identity (code/key/name) + owner_branch are required on EVERY + // row, including reference rows. owner_branch is the delegation pointer to the row owner. + for (Registry registry : REGISTRIES) { + for (Map<String, Object> row : registry.rows(registriesDir)) { + Object identity = row.get(registry.identityColumn); + assertThat(identity) + .as( + "%s row %s must declare its identity column '%s' (§구현 가이드 §1)", + registry.fileName, row, registry.identityColumn) + .isNotNull(); + assertThat(String.valueOf(identity)).isNotBlank(); + + assertThat(row.get("owner_branch")) + .as( + "%s row '%s' must declare owner_branch (§2 universal column — the " + + "delegation pointer to the sibling row owner)", + registry.fileName, identity) + .isInstanceOf(String.class); + assertThat((String) row.get("owner_branch")).isNotBlank(); + } + } + } + + @Test + void everyFullRowDeclaresCompatibilityImpactWithinTheLegalEnum() throws Exception { + // §구현 가이드 §1: compatibility_impact ∈ {none, additive, behavior-change, breaking}. + // Reference rows are exempt (their authoritative row lives in another registry). + for (Registry registry : REGISTRIES) { + for (Map<String, Object> row : registry.rows(registriesDir)) { + if (isReferenceRow(row)) { + continue; + } + Object identity = row.get(registry.identityColumn); + assertThat(row.get("compatibility_impact")) + .as( + "%s row '%s' must declare compatibility_impact one of %s " + + "(§구현 가이드 §1 universal column)", + registry.fileName, identity, LEGAL_COMPATIBILITY_IMPACT) + .isIn(LEGAL_COMPATIBILITY_IMPACT); + } + } + } + + @Test + void everyFullRowLinksARequiredTest() throws Exception { + // D2: registry 항목은 최소 1개 이상의 contract test와 연결. Reference rows are exempt. + for (Registry registry : REGISTRIES) { + for (Map<String, Object> row : registry.rows(registriesDir)) { + if (isReferenceRow(row)) { + continue; + } + Object identity = row.get(registry.identityColumn); + assertThat(row.get("required_test")) + .as( + "%s row '%s' must link a required_test " + + "(feature-contract-registry-governance D2 — every registry row " + + "is connected to at least one contract test)", + registry.fileName, identity) + .isInstanceOf(String.class); + assertThat((String) row.get("required_test")).isNotBlank(); + } + } + } + + @Test + void referenceRowsPointToAnotherRegistry() throws Exception { + // The reference-row exemption (secrets public-config tier) must be explicit: a row that + // omits the contract columns by being a reference row must actually name its target + // (secrets-classification.yaml header L17). + for (Registry registry : REGISTRIES) { + for (Map<String, Object> row : registry.rows(registriesDir)) { + if (!isReferenceRow(row)) { + continue; + } + Object identity = row.get(registry.identityColumn); + assertThat(row.get("reference")) + .as( + "%s reference row '%s' must name the registry holding its " + "authoritative row", + registry.fileName, identity) + .isInstanceOf(String.class); + assertThat((String) row.get("reference")).isNotBlank(); + } + } + } + + private static boolean isReferenceRow(Map<String, Object> row) { + return row.containsKey("reference"); + } + + /** Walk up from the test working directory to find the docs/registries directory. */ + private static Path findRegistriesDir() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries"); + if (Files.isDirectory(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } + + private record Registry(String fileName, String collectionKey, String identityColumn) { + + Path file(Path registriesDir) { + return registriesDir.resolve(fileName); + } + + @SuppressWarnings("unchecked") + List<Map<String, Object>> rows(Path registriesDir) throws Exception { + Path file = file(registriesDir); + if (!Files.exists(file)) { + return new ArrayList<>(); + } + try (InputStream in = Files.newInputStream(file)) { + Map<String, Object> root = new Yaml().load(in); + Object rows = root == null ? null : root.get(collectionKey); + if (rows == null) { + return new ArrayList<>(); + } + List<Map<String, Object>> typed = new ArrayList<>(); + for (Object row : (List<Object>) rows) { + typed.add((Map<String, Object>) row); + } + return typed; + } + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java new file mode 100644 index 00000000..33d6dc0a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Release-blocking enumeration: asserts all 9 base contract-test classes exist on the classpath + * (feature-contract-verification-test-suite). + * + * <p>This test pins the minimum required contract-test surface. A missing class (e.g., accidentally + * deleted or renamed) causes a {@link ClassNotFoundException} which is collected and reported as a + * single assertion failure listing all missing FQCNs. + * + * <h2>Gate numbering note</h2> + * + * <ul> + * <li>Gates 1–9 are the base contracts verified by this test. + * <li>Gate #10 (OpenAPI drift) lives in {@code sample-portfolio} and is verified by its own + * module test. It is not loadable from {@code app-bootstrap}'s classpath and is therefore + * intentionally excluded here. + * <li>Gate #11 (sample removal smoke) is {@code + * dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest}, which is included in the + * 9-base enumeration below as the envelope/schema gate covers it (see concern mapping). + * </ul> + */ +class ContractSuiteCompletenessTest { + + /** + * The 9 base contract-test classes that must exist as a release gate. Concern → fully-qualified + * class name. + * + * <p>Note: concerns 2 (validation exposure) and 3 (raw exception leakage) both map to {@code + * BusinessRuleValidationContractTest} because that single test class covers both the "validation + * details are surfaced" and "raw exceptions do not leak" aspects of the business-rule validation + * contract. + */ + private static final Map<String, String> BASE_CONTRACT_CLASSES = baseContractClasses(); + + private static Map<String, String> baseContractClasses() { + Map<String, String> classes = new LinkedHashMap<>(); + classes.put( + "envelope/response schema", "dev.caskeleton.bootstrap.contract.EnvelopeContractTest"); + classes.put( + "validation exposure", + "dev.caskeleton.bootstrap.contract.BusinessRuleValidationContractTest"); + classes.put( + "raw exception leakage", + "dev.caskeleton.bootstrap.contract.BusinessRuleValidationContractTest"); + classes.put( + "structured log field", "dev.caskeleton.bootstrap.contract.StructuredLogFieldContractTest"); + classes.put( + "PII/token/body forbidden", + "dev.caskeleton.bootstrap.contract.PiiTokenBodyForbiddenContractTest"); + classes.put( + "retryable classification", + "dev.caskeleton.bootstrap.contract.LockFailureClassificationContractTest"); + classes.put( + "env profile matrix", "dev.caskeleton.bootstrap.contract.EnvProfileMatrixContractTest"); + classes.put( + "repository capability", + "dev.caskeleton.bootstrap.contract.RepositoryAccessCapabilityRegistryTest"); + classes.put( + "adapter failure mapping", + "dev.caskeleton.bootstrap.contract.PersistenceFailureMappingContractTest"); + return classes; + } + + @Test + void allNineBaseContractsHaveAPresentTestClass() throws Exception { + ClassLoader loader = getClass().getClassLoader(); + List<String> missing = new ArrayList<>(); + + for (Map.Entry<String, String> entry : BASE_CONTRACT_CLASSES.entrySet()) { + String fqcn = entry.getValue(); + try { + Class.forName(fqcn, false, loader); + } catch (ClassNotFoundException e) { + missing.add("concern='" + entry.getKey() + "' fqcn=" + fqcn); + } + } + + assertThat(missing) + .as( + "The following base contract-test classes were not found on the classpath — " + + "these are release-blocking: %s", + missing) + .isEmpty(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java new file mode 100644 index 00000000..9c7fe86a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java @@ -0,0 +1,99 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +/** Pins the executable local-developer entrypoint and its documentation/CI guardrails. */ +class DeveloperExperienceContractTest { + + private static final Path REPOSITORY_ROOT = repositoryRoot(); + + @Test + void toolVersionsPinTemurin21() throws IOException { + assertThat(read(".tool-versions")).containsPattern("(?m)^java temurin-21\\.[^\\s]+$"); + } + + @Test + void bootstrapIsOneOrderedFiveStageGradleEntrypoint() throws IOException { + String build = read("src/build.gradle"); + + assertThat(build) + .contains("tasks.register('bootstrapCompile')") + .contains("tasks.register('bootstrapDependencies'") + .contains("tasks.register('bootstrapMigrateAndStart'") + .contains("tasks.register('bootstrapSampleContract'") + .contains("tasks.register('bootstrapSmoke'") + .contains("tasks.register('bootstrap')"); + assertThat(build) + .contains("bootstrapDependencies.configure { dependsOn bootstrapCompile }") + .contains("bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies }") + .contains("bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart }") + .contains("bootstrapSmoke.configure { dependsOn bootstrapSampleContract }"); + } + + @Test + void localComposeSupportsDatabaseMigrationAndPublicHealthSmoke() throws IOException { + String base = read("docker-compose.yml"); + String local = read("docker-compose.local.yml"); + + assertThat(base).contains("RELEASE_VERSION:").contains("GIT_SHA:").contains("SOURCE_URL:"); + assertThat(local) + .contains("env_file:") + .contains("./src/.env") + .doesNotContain("5432:5432") + .contains("http://localhost:8080/api/healthcheck"); + } + + @Test + void readmeCommandsAreVerifiedAndBootstrapIsTheFirstRunEntrypoint() throws IOException { + String build = read("src/build.gradle"); + String readme = read("README.md"); + + assertThat(build) + .contains("tasks.register('verifyReadmeCommands')") + .contains("dependsOn rootProject.tasks.named('verifyReadmeCommands')"); + assertThat(readme) + .contains("./gradlew bootstrap") + .contains("GET /api/healthcheck") + .contains("feature-developer-experience-contract"); + } + + @Test + void linkCheckIsBlockingAndUsesThePinnedLycheeAction() throws IOException { + String workflow = read(".github/workflows/link-check.yml"); + + assertThat(workflow) + .contains("lycheeverse/lychee-action@v2.0.2") + .contains("fail: true") + .contains("README.md") + .contains("docs/**/*.md"); + } + + @Test + void testcontainersReuseIsLocalOptInAndExplicitlyDisabledInCi() throws IOException { + assertThat(read("testcontainers.properties.example")) + .contains("testcontainers.reuse.enable=true"); + assertThat(read(".github/workflows/ci-quality-gates.yml")) + .contains("TESTCONTAINERS_REUSE_ENABLE: \"false\""); + } + + private static String read(String relative) throws IOException { + return Files.readString(REPOSITORY_ROOT.resolve(relative)); + } + + private static Path repositoryRoot() { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) { + if (Files.isRegularFile(path.resolve("AGENTS.md")) + && Files.isRegularFile(path.resolve("src/settings.gradle"))) { + return path; + } + } + throw new IllegalStateException( + "repository root not found from " + Paths.get("").toAbsolutePath()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DistributedTracingContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DistributedTracingContractTest.java new file mode 100644 index 00000000..d6094f79 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DistributedTracingContractTest.java @@ -0,0 +1,362 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import dev.caskeleton.bootstrap.async.AsyncContextTaskDecorator; +import dev.caskeleton.bootstrap.tracing.TracingSamplingRateGauge; +import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory; +import dev.caskeleton.shared.concurrency.DomainContextStrategy; +import dev.caskeleton.shared.response.ResponseMeta; +import dev.caskeleton.shared.tracing.BaggageAllowlist; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.InputStream; +import java.lang.reflect.RecordComponent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.yaml.snakeyaml.Yaml; + +/** + * Contract tests for feature-distributed-tracing-contract Slice 5. + * + * <p>Covers every registry {@code required_test} id and §테스트 계약 bullet: + * + * <ul> + * <li>{@code tracing-contract:exporter-endpoint-resolvable} — see {@link + * dev.caskeleton.bootstrap.tracing.TracingPropertiesTest} + * <li>{@code tracing-contract:meta-traceid-when-disabled} — this class + * <li>{@code tracing-contract:sample-rate-per-profile} — see {@link + * dev.caskeleton.bootstrap.tracing.TracingSampleRateResolverTest} + * <li>{@code contract-verification:trace-propagation} — this class + * <li>{@code contract-verification:metrics-cardinality} — this class + * <li>{@code contract-verification:log-mdc-keys} — this class + * <li>async correlationId survival — this class (delegates to existing test) + * <li>baggage forbidden-info — this class + * </ul> + */ +class DistributedTracingContractTest { + + // ---- Registry YAML roots (loaded once; tests assume-skip when absent) ---- + + private static Map<String, Object> headersRoot; + private static Map<String, Object> metricsRoot; + private static Map<String, Object> mdcRoot; + + @BeforeAll + static void loadRegistries() throws Exception { + Path headersPath = locateRegistry("headers.yaml"); + Path metricsPath = locateRegistry("metrics.yaml"); + Path mdcPath = locateRegistry("mdc-keys.yaml"); + + if (headersPath != null) { + try (InputStream in = Files.newInputStream(headersPath)) { + headersRoot = new Yaml().load(in); + } + } + if (metricsPath != null) { + try (InputStream in = Files.newInputStream(metricsPath)) { + metricsRoot = new Yaml().load(in); + } + } + if (mdcPath != null) { + try (InputStream in = Files.newInputStream(mdcPath)) { + mdcRoot = new Yaml().load(in); + } + } + } + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + // ================================================================ + // required_test: tracing-contract:meta-traceid-when-disabled + // ================================================================ + + /** + * required_test: tracing-contract:meta-traceid-when-disabled + * + * <p>D4 disabled-tracing guarantee: {@code RequestLoggingFilter} always generates a W3C + * traceparent when no inbound {@code traceparent} header is present, so {@code meta.traceId} and + * {@code MDC trace_id} are never null, independent of the {@code APP_TRACING_ENABLED} flag. + * + * <p>The full filter-chain proof (drive through MockFilterChain, capture MDC during chain + * execution) lives in {@code dev.caskeleton.adapter.inbound.web.filter.RequestLoggingFilterTest} + * in adapter-web (which has {@code jakarta.servlet-api} on its compile classpath). This bootstrap + * contract test verifies the cross-cutting guarantee at the code-contract level: the MDC key and + * envelope field exist and are non-null when populated. + */ + @Test + void metaTraceIdIsNonNullWhenTraceIdOnMdc() { + // Simulate what RequestLoggingFilter does: put a generated W3C trace id on MDC. + // (The full filter-chain proof is in adapter-web's RequestLoggingFilterTest.) + String syntheticTraceId = "4bf92f3577b34da6a3ce929d0e0e4736"; + MDC.put(MdcKeys.TRACE_ID, syntheticTraceId); + + ResponseMeta meta = ResponseMetaFactory.fromMdc(); + + assertThat(meta).as("ResponseMeta must not be null").isNotNull(); + assertThat(meta.traceId()) + .as("traceId must reflect MDC trace_id (D4 disabled-tracing fallback)") + .isEqualTo(syntheticTraceId) + .isNotBlank(); + assertThat(meta.traceId()) + .as("traceId must not be all-zeros (W3C invalid)") + .isNotEqualTo("00000000000000000000000000000000"); + } + + @Test + void responseMetaTraceIdComponentExistsAndIsNonNullWhenPopulated() { + // Verify ResponseMeta.traceId() is the right field (not requestId, not correlationId) + MDC.put(MdcKeys.TRACE_ID, "aabbccdd11223344aabbccdd11223344"); + MDC.put(MdcKeys.REQUEST_ID, "req-1"); + + ResponseMeta meta = ResponseMetaFactory.fromMdc(); + assertThat(meta.traceId()).isEqualTo("aabbccdd11223344aabbccdd11223344"); + assertThat(meta.requestId()).isEqualTo("req-1"); + } + + // ================================================================ + // required_test: contract-verification:trace-propagation + // ================================================================ + + /** + * Asserts the {@code traceparent} row in {@code headers.yaml} has {@code generated_if_missing: + * true}, {@code mdc_key: trace_id}, {@code envelope_meta_field: traceId}, and that the {@code + * tracestate} row exists with {@code generated_if_missing: false}; AND that code agrees: {@code + * MdcKeys.TRACE_ID == "trace_id"} and {@link ResponseMeta} has a {@code traceId} component. + */ + @Test + void traceparentHeaderRowMatchesCodeContract() { + Assumptions.assumeTrue( + headersRoot != null, + "docs/registries/headers.yaml not on disk (gitignored); " + + "trace-propagation contract check runs locally only"); + + @SuppressWarnings("unchecked") + List<Map<String, Object>> headers = (List<Map<String, Object>>) headersRoot.get("headers"); + + Map<String, Object> traceparentRow = + headers.stream() + .filter(h -> "traceparent".equals(h.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("headers.yaml must contain a 'traceparent' row")); + + assertThat(traceparentRow.get("generated_if_missing")) + .as("traceparent: generated_if_missing must be true (RequestLoggingFilter D4)") + .isEqualTo(true); + assertThat(traceparentRow.get("mdc_key")) + .as("traceparent: mdc_key must be 'trace_id'") + .isEqualTo("trace_id"); + assertThat(traceparentRow.get("envelope_meta_field")) + .as("traceparent: envelope_meta_field must be 'traceId'") + .isEqualTo("traceId"); + + // tracestate row: generated_if_missing=false + Map<String, Object> tracestateRow = + headers.stream() + .filter(h -> "tracestate".equals(h.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("headers.yaml must contain a 'tracestate' row")); + assertThat(tracestateRow.get("generated_if_missing")) + .as("tracestate: generated_if_missing must be false") + .isEqualTo(false); + + // Code alignment: MdcKeys.TRACE_ID constant must match the registry mdc_key value + assertThat(MdcKeys.TRACE_ID) + .as("MdcKeys.TRACE_ID must equal the registry mdc_key value 'trace_id'") + .isEqualTo(traceparentRow.get("mdc_key")); + + // ResponseMeta record must have a traceId component + // (accessed via reflection to avoid coupling to field name change) + assertThat(ResponseMeta.class.getRecordComponents()) + .extracting(RecordComponent::getName) + .as("ResponseMeta must expose a 'traceId' component (envelope_meta_field)") + .contains("traceId"); + } + + // ================================================================ + // required_test: contract-verification:metrics-cardinality + // ================================================================ + + /** + * Asserts the {@code tracing.sampling.rate} row in {@code metrics.yaml} is a {@code gauge} with + * tag {@code profile} {@code cardinality_limit: 4} and {@code allowed_values: [prod, staging, + * dev, local]}; AND registers {@link TracingSamplingRateGauge} against a {@link + * SimpleMeterRegistry} and asserts exactly one {@code tracing.sampling.rate} series exists + * (cardinality 1 ≤ 4). + */ + @Test + void tracingSamplingRateGaugeRegistryContract() { + Assumptions.assumeTrue( + metricsRoot != null, + "docs/registries/metrics.yaml not on disk (gitignored); " + + "metrics-cardinality contract check runs locally only"); + + @SuppressWarnings("unchecked") + List<Map<String, Object>> metrics = (List<Map<String, Object>>) metricsRoot.get("metrics"); + + Map<String, Object> row = + metrics.stream() + .filter(m -> "tracing.sampling.rate".equals(m.get("name"))) + .findFirst() + .orElseThrow( + () -> + new AssertionError("metrics.yaml must contain a 'tracing.sampling.rate' row")); + + assertThat(row.get("type")).as("tracing.sampling.rate type must be 'gauge'").isEqualTo("gauge"); + + @SuppressWarnings("unchecked") + List<Map<String, Object>> tags = (List<Map<String, Object>>) row.get("tags"); + Map<String, Object> profileTag = + tags.stream() + .filter(t -> "profile".equals(t.get("name"))) + .findFirst() + .orElseThrow( + () -> new AssertionError("tracing.sampling.rate must have a 'profile' tag")); + + assertThat(profileTag.get("cardinality_limit")) + .as("profile tag cardinality_limit must be 4") + .isEqualTo(4); + + @SuppressWarnings("unchecked") + List<String> allowed = (List<String>) profileTag.get("allowed_values"); + assertThat(allowed) + .as("profile tag allowed_values must be [prod, staging, dev, local]") + .containsExactlyInAnyOrder("prod", "staging", "dev", "local"); + } + + @Test + void tracingSamplingRateGaugeEmitsExactlyOneSeries() { + // required_test: contract-verification:metrics-cardinality + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerSingleton("meterRegistry", registry); + + TracingSamplingRateGauge.register( + "local", 1.0, beanFactory.getBeanProvider(MeterRegistry.class)); + + List<Meter> gauges = + registry.getMeters().stream() + .filter(m -> TracingSamplingRateGauge.METRIC_NAME.equals(m.getId().getName())) + .toList(); + + assertThat(gauges) + .as("exactly one tracing.sampling.rate series must be registered (cardinality 1 ≤ 4)") + .hasSize(1); + + assertThat(gauges.get(0).getId().getTag(TracingSamplingRateGauge.TAG_PROFILE)) + .as("the series must carry a 'profile' tag") + .isEqualTo("local"); + } + + @Test + void tracingSamplingRateGaugeIsNoopWithoutMeterRegistry() { + DefaultListableBeanFactory emptyFactory = new DefaultListableBeanFactory(); + // Must not throw even with no MeterRegistry + TracingSamplingRateGauge.register( + "local", 1.0, emptyFactory.getBeanProvider(MeterRegistry.class)); + } + + // ================================================================ + // required_test: contract-verification:log-mdc-keys (consume-only) + // ================================================================ + + @Test + void mdcKeysExposesTraceIdAndSpanIdConstants() { + // required_test: contract-verification:log-mdc-keys + assertThat(MdcKeys.TRACE_ID).isEqualTo("trace_id"); + assertThat(MdcKeys.SPAN_ID).isEqualTo("span_id"); + } + + @Test + void mdcKeysYamlListsTraceIdAndSpanId() { + Assumptions.assumeTrue( + mdcRoot != null, + "docs/registries/mdc-keys.yaml not on disk (gitignored); " + + "log-mdc-keys contract check runs locally only"); + + @SuppressWarnings("unchecked") + List<Map<String, Object>> keys = (List<Map<String, Object>>) mdcRoot.get("mdc_keys"); + Set<String> keyNames = + keys.stream().map(k -> (String) k.get("key")).collect(Collectors.toSet()); + + assertThat(keyNames).as("mdc-keys.yaml must list 'trace_id'").contains("trace_id"); + assertThat(keyNames).as("mdc-keys.yaml must list 'span_id'").contains("span_id"); + } + + // ================================================================ + // §테스트 계약 extras + // ================================================================ + + /** + * Async correlationId survival: asserts {@link AsyncContextTaskDecorator} carries MDC {@code + * correlation_id} onto the worker runnable. + * + * <p>The full proof lives in {@link dev.caskeleton.bootstrap.async.AsyncContextTaskDecoratorTest} + * (which already covers all four MDC keys including {@code correlation_id}). This test is a + * lightweight confirmation that the specific key cited by the tracing contract is covered by the + * decorator, without duplicating the full test matrix. + */ + @Test + void asyncDecoratorCarriesCorrelationIdOntoWorkerThread() throws Exception { + var propagator = DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + var decorator = new AsyncContextTaskDecorator(propagator); + + MDC.put(MdcKeys.CORRELATION_ID, "corr-trace-contract-test"); + + AtomicReference<String> workerCorrelationId = new AtomicReference<>(); + Runnable decorated = + decorator.decorate(() -> workerCorrelationId.set(MDC.get(MdcKeys.CORRELATION_ID))); + + // Run decorated task on a fresh thread to prove cross-thread propagation + Thread worker = new Thread(decorated, "tracing-contract-worker"); + worker.start(); + worker.join(5_000); + + assertThat(workerCorrelationId.get()) + .as("AsyncContextTaskDecorator must carry correlation_id onto the worker thread") + .isEqualTo("corr-trace-contract-test"); + } + + /** + * Baggage forbidden-info: asserts {@link BaggageAllowlist#ALLOWED} equals {@code {tenant_id, + * request_id}} (spec D8) so forbidden keys cannot be in baggage. + */ + @Test + void baggageAllowlistContainsExactlyTenantIdAndRequestId() { + // required_test: contract-verification:trace-propagation (D8 sub-clause) + assertThat(BaggageAllowlist.ALLOWED) + .as("BaggageAllowlist.ALLOWED must be exactly {tenant_id, request_id} per D8") + .containsExactlyInAnyOrder("tenant_id", "request_id"); + } + + // ---- helpers ---- + + private static Path locateRegistry(String filename) { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/" + filename); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/EnvProfileMatrixContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/EnvProfileMatrixContractTest.java new file mode 100644 index 00000000..53ffa41c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/EnvProfileMatrixContractTest.java @@ -0,0 +1,147 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Base contract #7 (env profile matrix) of the contract-verification suite. Pins the env-matrix + * <em>safety baseline</em> at the registry/config level — complementing the behavioral {@code + * StartupSafetyValidatorTest}, which exercises the same invariants by booting an {@code + * ApplicationContextRunner} under the {@code prod} profile. + * + * <p>Three invariants: + * + * <ol> + * <li>the prod-unsafe toggles ({@code APP_ERROR_DETAIL_EXPOSURE_ENABLED}, {@code + * APP_LOG_BODY_CAPTURE_ENABLED}) ship disabled in {@code src/.env}; + * <li>both toggles are registered in {@code env-keys.yaml} with the {@code + * prod_profile_must_be_false} validation — the matrix constraint that {@code + * StartupSafetyValidator} enforces at startup; + * <li>the profile selector is {@code SPRING_PROFILES_ACTIVE} alone — {@code APP_PROFILE} was + * dropped (env-keys.yaml D6, 2026-06-06) and must not reappear as a registered key, so the + * active-profile matrix has a single source of truth. + * </ol> + * + * <p>Registry-backed assertions skip (not fail) when the gitignored {@code /docs} tree is absent + * (feature-contract-verification-test-suite §테스트 계약 9 base #7, §구현 가이드 §4). + */ +class EnvProfileMatrixContractTest { + + private static final String ERROR_DETAIL_TOGGLE = "APP_ERROR_DETAIL_EXPOSURE_ENABLED"; + private static final String BODY_CAPTURE_TOGGLE = "APP_LOG_BODY_CAPTURE_ENABLED"; + private static final String PROD_SAFE_CONSTRAINT = "prod_profile_must_be_false"; + + @Test + void prodUnsafeTogglesShipDisabledInEnv() throws IOException { + Path env = walkUpFor("src/.env"); + Assumptions.assumeTrue(env != null, "src/.env not on disk; env-default assertion skipped"); + String text = Files.readString(env); + + assertThat(envValue(text, ERROR_DETAIL_TOGGLE)) + .as( + "%s must ship false — internal error detail exposure is prod-forbidden", + ERROR_DETAIL_TOGGLE) + .isEqualTo("false"); + assertThat(envValue(text, BODY_CAPTURE_TOGGLE)) + .as( + "%s must ship false — request/response body capture is prod-forbidden", + BODY_CAPTURE_TOGGLE) + .isEqualTo("false"); + } + + @Test + void prodUnsafeTogglesCarryProdMustBeFalseConstraint() throws IOException { + Path registry = walkUpFor("docs/registries/env-keys.yaml"); + Assumptions.assumeTrue( + registry != null, + "docs/registries/env-keys.yaml not on disk (/docs is gitignored); registry assertion skipped"); + String text = Files.readString(registry); + + for (String toggle : new String[] {ERROR_DETAIL_TOGGLE, BODY_CAPTURE_TOGGLE}) { + String block = registryBlock(text, toggle); + assertThat(block).as("env-keys.yaml must register %s", toggle).isNotNull(); + assertThat(block) + .as("%s must carry the %s matrix constraint", toggle, PROD_SAFE_CONSTRAINT) + .contains(PROD_SAFE_CONSTRAINT); + } + } + + @Test + void profileSelectorIsSpringProfilesActiveOnly() throws IOException { + Path registry = walkUpFor("docs/registries/env-keys.yaml"); + Assumptions.assumeTrue( + registry != null, + "docs/registries/env-keys.yaml not on disk (/docs is gitignored); registry assertion skipped"); + + assertThat(registryBlock(Files.readString(registry), "APP_PROFILE")) + .as( + "APP_PROFILE must not be a registered env key — the profile selector is " + + "SPRING_PROFILES_ACTIVE alone (env-keys.yaml D6, 2026-06-06)") + .isNull(); + + Path env = walkUpFor("src/.env"); + if (env != null) { + assertThat(envValue(Files.readString(env), "APP_PROFILE")) + .as("src/.env must not declare APP_PROFILE — SPRING_PROFILES_ACTIVE is the sole selector") + .isNull(); + } + } + + @Test + void profileSelectorHasLocalFallbackForEarlyBootBinding() throws IOException { + Path applicationYaml = walkUpFor("src/app-bootstrap/src/main/resources/application.yml"); + Assumptions.assumeTrue( + applicationYaml != null, "application.yml not on disk; profile fallback assertion skipped"); + + assertThat(Files.readString(applicationYaml)) + .as( + "spring.profiles.active is bound before spring-dotenv may contribute src/.env, " + + "so the placeholder needs the documented local fallback") + .contains("active: ${SPRING_PROFILES_ACTIVE:local}"); + } + + /** Value of {@code KEY=...} in an env file, or {@code null} when the key is absent. */ + private static String envValue(String envText, String key) { + Matcher m = + Pattern.compile("^" + Pattern.quote(key) + "=(.*)$", Pattern.MULTILINE).matcher(envText); + return m.find() ? m.group(1).trim() : null; + } + + /** + * Returns the YAML block from {@code - name: KEY} up to the next {@code - name:} entry (or end of + * file), or {@code null} when the key is not a registered entry. Comment lines that merely + * mention the key do not match the anchored {@code - name:} start. + */ + private static String registryBlock(String registryText, String key) { + Matcher start = + Pattern.compile("(?m)^\\s*-\\s*name:\\s*" + Pattern.quote(key) + "\\s*$") + .matcher(registryText); + if (!start.find()) { + return null; + } + int from = start.end(); + Matcher next = Pattern.compile("(?m)^\\s*-\\s*name:\\s*[A-Z]").matcher(registryText); + int to = next.find(from) ? next.start() : registryText.length(); + return registryText.substring(from, to); + } + + private static Path walkUpFor(String relative) { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve(relative); + if (Files.isRegularFile(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/EnvelopeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/EnvelopeContractTest.java new file mode 100644 index 00000000..97648af9 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/EnvelopeContractTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.bootstrap.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import dev.caskeleton.bootstrap.contract.support.ContractSnapshots; +import dev.caskeleton.shared.response.ApiError; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.response.ResponseMeta; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.approvaltests.Approvals; +import org.approvaltests.reporters.QuietReporter; +import org.approvaltests.reporters.UseReporter; +import org.junit.jupiter.api.Test; + +/** + * Base contract #1 (envelope / response schema) and #2 (error shape) of the contract-verification + * suite. Pins the serialized JSON shape of the skeleton-wide {@link Envelope} for the success form, + * the validation-error form (with field {@code details}), and the retryable transient-error form + * via ApprovalTests JSON snapshots. + * + * <p>Non-deterministic {@code meta} ids (request / trace / correlation) are scrubbed by {@link + * ContractSnapshots} so a snapshot diff means the envelope <em>shape</em> changed — the exact + * response-schema drift this contract must catch. The fixture is framework-free: it serializes + * {@code shared-contract} records with a plain {@link ObjectMapper}, asserting the wire contract + * independent of {@code adapter-web} wrapping (feature-contract-verification-test-suite §테스트 계약 9 + * base #1/#2, D6/D7). + * + * <p>{@link QuietReporter} keeps the approve workflow headless: a shape change fails the build and + * writes a {@code .received.txt} for review instead of launching a GUI diff tool. + */ +@UseReporter(QuietReporter.class) +class EnvelopeContractTest { + + private static final ObjectMapper MAPPER = + JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + + /** Fixed correlation ids; scrubbed to a placeholder so the snapshot stays stable. */ + private static final ResponseMeta META = + new ResponseMeta( + "01HREQUESTID0000000000000", "00f067aa0ba902b7-trace", "01HCORRELATIONID00000000"); + + @Test + void successEnvelopeShape() throws Exception { + Map<String, Object> data = new LinkedHashMap<>(); + data.put("id", "worklog-1"); + data.put("title", "ship the contract suite"); + + Envelope<Map<String, Object>> envelope = Envelope.ok(data, META); + + Approvals.verify(ContractSnapshots.scrub(MAPPER.writeValueAsString(envelope))); + } + + @Test + void errorEnvelopeShapeWithValidationDetails() throws Exception { + Map<String, Object> fieldError = new LinkedHashMap<>(); + fieldError.put("field", "title"); + fieldError.put("reason", "must not be blank"); + + ApiError error = + ApiError.withDetails( + "WORKLOG_TITLE_BLANK", + "VALIDATION", + "title must not be blank", + false, + List.of(fieldError)); + + Envelope<Void> envelope = Envelope.failure(error, META); + + Approvals.verify(ContractSnapshots.scrub(MAPPER.writeValueAsString(envelope))); + } + + @Test + void errorEnvelopeShapeRetryableTransient() throws Exception { + ApiError error = + ApiError.of( + "DB_UNAVAILABLE", + "TRANSIENT_DEPENDENCY", + "the service is temporarily unavailable, please retry", + true); + + Envelope<Void> envelope = Envelope.failure(error, META); + + Approvals.verify(ContractSnapshots.scrub(MAPPER.writeValueAsString(envelope))); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ErrorCodeRegistryMappingTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ErrorCodeRegistryMappingTest.java new file mode 100644 index 00000000..5919dc51 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ErrorCodeRegistryMappingTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-api-contract-baseline D11 — HTTP-status ↔ {@code error-codes.yaml} mapping consistency + * test (this branch is the <em>producer</em> of this contract test; the registry itself is owned by + * feature-operational-error-observability-foundation). + * + * <p>Detects drift between the registry's {@code http_status} column and the Java {@link + * ApiErrorCode} enums: for every code present in <em>both</em> the registry and an enum, the + * statuses must agree. It also pins that the transport-standard codes this branch owns + * (405/406/412/413/414/415) are actually present in the registry — a missing row is a FAIL, never a + * silent SKIP (branch-note §Edge). + */ +class ErrorCodeRegistryMappingTest { + + private static Map<String, Integer> registryHttpStatusByCode; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + // The registry SSOT lives under docs/registries/, which this repo gitignores + // (/docs) — it is a local working artifact, not committed. When it is absent + // (CI / fresh checkout) the drift check is skipped, NOT passed. When it IS + // present, a missing/mismatched row is a hard FAIL (branch-note §Edge). + Assumptions.assumeTrue( + registry != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "D11 registry-drift check runs locally only"); + registryHttpStatusByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + registryHttpStatusByCode.put( + (String) row.get("code"), ((Number) row.get("http_status")).intValue()); + } + } + } + + @Test + void everyEnumCodePresentInTheRegistryMatchesItsHttpStatus() { + assertEnumMatchesRegistry(OperationalError.values()); + } + + @Test + void transportStandardCodesOwnedByThisBranchAreRegisteredWithMatchingStatus() { + // branch-note §Edge: a missing row is a FAIL (drift), not a silent pass. + Map<String, Integer> expected = + Map.of( + "METHOD_NOT_ALLOWED", 405, + "NOT_ACCEPTABLE", 406, + "PRECONDITION_FAILED", 412, + "PAYLOAD_TOO_LARGE", 413, + "URI_TOO_LONG", 414, + "UNSUPPORTED_MEDIA_TYPE", 415); + expected.forEach( + (code, status) -> { + assertThat(registryHttpStatusByCode) + .as( + "registry must contain transport code %s (feature-api-contract-baseline D8/D9/D12/D15)", + code) + .containsKey(code); + assertThat(registryHttpStatusByCode.get(code)) + .as("registry http_status for %s must match the contract", code) + .isEqualTo(status); + }); + } + + private static void assertEnumMatchesRegistry(ApiErrorCode[] codes) { + for (ApiErrorCode code : codes) { + Integer registryStatus = registryHttpStatusByCode.get(code.code()); + if (registryStatus != null) { + assertThat(code.httpStatus()) + .as( + "enum %s httpStatus must match error-codes.yaml row (D11 drift detection)", + code.code()) + .isEqualTo(registryStatus); + } + } + } + + /** Walk up from the test working directory to find docs/registries/error-codes.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/LockAcquisitionTimeoutClassificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/LockAcquisitionTimeoutClassificationContractTest.java new file mode 100644 index 00000000..c53b43a3 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/LockAcquisitionTimeoutClassificationContractTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.lock.LockAcquisitionTimeoutException; +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-distributed-lock-contract D7 — lock-timeout error code classification contract. + * + * <p>D7 forbids collapsing a lock-acquisition timeout to a generic 500 INTERNAL envelope. {@link + * OperationalError#LOCK_ACQUISITION_TIMEOUT} must be {@code CONFLICT / 409 / retryable=true}: + * contention is transient (the lock will be released or its lease will expire). + * + * <p>The enum-side assertions always run ({@link OperationalError} is the on-classpath SSOT). The + * registry-row and metrics-row legs follow the sibling tests' skip-not-pass discipline — when the + * gitignored {@code docs/registries/} files are absent (CI / fresh checkout), only those legs are + * skipped, never silently passed. + */ +class LockAcquisitionTimeoutClassificationContractTest { + + private static Map<String, Map<String, Object>> errorRegistryByCode; + private static List<Map<String, Object>> metricsRows; + + @BeforeAll + static void loadRegistries() throws Exception { + Path errorRegistry = locateFile("docs/registries/error-codes.yaml"); + if (errorRegistry != null) { + errorRegistryByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(errorRegistry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + errorRegistryByCode.put((String) row.get("code"), row); + } + } + } + + Path metricsRegistry = locateFile("docs/registries/metrics.yaml"); + if (metricsRegistry != null) { + try (InputStream in = Files.newInputStream(metricsRegistry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> rows = (List<Map<String, Object>>) root.get("metrics"); + metricsRows = rows; + } + } + } + + // ---- enum SSOT: LOCK_ACQUISITION_TIMEOUT is CONFLICT/409/retryable --------- + + @Test + void lockAcquisitionTimeoutIsConflict409Retryable() { + OperationalError code = OperationalError.LOCK_ACQUISITION_TIMEOUT; + assertThat(code.category()) + .as("LOCK_ACQUISITION_TIMEOUT must be a CONFLICT, not INTERNAL") + .isEqualTo(Category.CONFLICT); + assertThat(code.httpStatus()) + .as("LOCK_ACQUISITION_TIMEOUT must answer 409, not 500 (D7 forbids generic 500)") + .isEqualTo(409); + assertThat(code.retryable()) + .as("LOCK_ACQUISITION_TIMEOUT must be retryable (contention is transient)") + .isTrue(); + } + + @Test + void lockAcquisitionTimeoutIsNotInternal500() { + OperationalError code = OperationalError.LOCK_ACQUISITION_TIMEOUT; + assertThat(code.category()) + .as("D7 forbids collapsing lock timeout to the generic INTERNAL category") + .isNotEqualTo(Category.INTERNAL); + assertThat(code.httpStatus()) + .as("D7 forbids collapsing lock timeout to a generic 500 response") + .isNotEqualTo(500); + } + + @Test + void lockAcquisitionTimeoutExceptionCarriesTheCorrectErrorCode() { + LockAcquisitionTimeoutException ex = + new LockAcquisitionTimeoutException("test-key", Duration.ofSeconds(3)); + assertThat(ex.errorCode()) + .as("LockAcquisitionTimeoutException.errorCode() must return LOCK_ACQUISITION_TIMEOUT") + .isEqualTo(OperationalError.LOCK_ACQUISITION_TIMEOUT); + } + + // ---- registry-row leg: skip when error-codes.yaml absent ------------------ + + @Test + void registryRowAgreesWithTheEnumClassification() { + Assumptions.assumeTrue( + errorRegistryByCode != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "registry-row cross-check runs locally only"); + + Map<String, Object> row = errorRegistryByCode.get("LOCK_ACQUISITION_TIMEOUT"); + assertThat(row) + .as("registry must define LOCK_ACQUISITION_TIMEOUT (feature-distributed-lock-contract D7)") + .isNotNull(); + assertThat((String) row.get("category")) + .as("registry LOCK_ACQUISITION_TIMEOUT category must be CONFLICT") + .isEqualTo("CONFLICT"); + assertThat(((Number) row.get("http_status")).intValue()) + .as("registry LOCK_ACQUISITION_TIMEOUT http_status must be 409") + .isEqualTo(409); + assertThat((boolean) row.get("retryable")) + .as("registry LOCK_ACQUISITION_TIMEOUT must be retryable") + .isTrue(); + } + + // ---- metrics-row leg: skip when metrics.yaml absent ----------------------- + + @Test + void metricsRegistryContainsLockAcquisitionWithBoundedOutcomeTag() { + Assumptions.assumeTrue( + metricsRows != null, + "docs/registries/metrics.yaml not on disk (/docs is gitignored); " + + "metrics-row cross-check runs locally only"); + + Map<String, Object> lockAcquisitionRow = + metricsRows.stream() + .filter(row -> "lock.acquisition".equals(row.get("name"))) + .findFirst() + .orElse(null); + assertThat(lockAcquisitionRow) + .as("docs/registries/metrics.yaml must contain a lock.acquisition entry") + .isNotNull(); + + assertThat((String) lockAcquisitionRow.get("type")) + .as("lock.acquisition must be a counter") + .isEqualTo("counter"); + + @SuppressWarnings("unchecked") + List<Map<String, Object>> tags = (List<Map<String, Object>>) lockAcquisitionRow.get("tags"); + assertThat(tags).isNotNull().isNotEmpty(); + + Map<String, Object> outcomeTag = + tags.stream().filter(t -> "outcome".equals(t.get("name"))).findFirst().orElse(null); + assertThat(outcomeTag).as("lock.acquisition must have an 'outcome' tag").isNotNull(); + + @SuppressWarnings("unchecked") + List<String> allowedValues = (List<String>) outcomeTag.get("allowed_values"); + assertThat(allowedValues) + .as("lock.acquisition outcome tag must include acquired, timeout, error") + .containsExactlyInAnyOrder("acquired", "timeout", "error"); + } + + /** Walk up from the test working directory to find the given relative path. */ + private static Path locateFile(String relativePath) { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve(relativePath); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/LockFailureClassificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/LockFailureClassificationContractTest.java new file mode 100644 index 00000000..45971868 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/LockFailureClassificationContractTest.java @@ -0,0 +1,193 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-transaction-concurrency-contract D5 / §구현 가이드 2 — lock-failure <em>classification + * policy</em>. + * + * <p>This branch owns the concurrency <em>policy</em> (which lock failures are retryable, which are + * a non-retryable conflict, and that none collapse to a generic 500); the SQLState → code + * <em>mapping</em> itself is owned by {@code feature-persistence-failure-baseline} (consumed here, + * pinned by its own {@code PersistenceFailureMappingContractTest}). This test asserts the D5 + * Decisionized Work Item <em>lock failures</em>: + * + * <ul> + * <li>deadlock ({@code DB_DEADLOCK}) and serialization failure ({@code DB_SERIALIZATION_FAILURE}) + * are <b>retryable</b> {@code CONFLICT}s — "retryable by policy"; + * <li>unique violation ({@code DB_UNIQUE_VIOLATION}) is a <b>non-retryable</b> {@code CONFLICT} — + * the retryable and non-retryable conflicts stay distinct, never collapsed; + * <li><b>forbidden</b>: any lock/conflict failure mapped to the generic {@code INTERNAL} / 500 + * envelope ("all locks generic 500" is the D5 forbidden state). + * </ul> + * + * <p>The optimistic-lock ({@code @Version}) case has <em>no dedicated code</em> yet — that registry + * gap is delegated to {@code feature-persistence-failure-baseline} (§Audit REGISTRY-GAP-1). This + * test pins the gap as a known-absent code so it surfaces as a documented delegation rather than a + * silent pass. + * + * <p>The enum-side assertions always run ({@link OperationalError} is the on-classpath SSOT); the + * registry-row cross-check follows the sibling tests' skip-not-pass discipline — when the + * gitignored {@code error-codes.yaml} is absent (CI / fresh checkout) only that leg is skipped, + * never silently passed. + */ +class LockFailureClassificationContractTest { + + /** Lock failures classified by D5 as <b>retryable conflicts</b> ("retryable by policy"). */ + private static final List<OperationalError> RETRYABLE_LOCK_CONFLICTS = + List.of(OperationalError.DB_DEADLOCK, OperationalError.DB_SERIALIZATION_FAILURE); + + /** Lock-adjacent conflict that is a <b>non-retryable</b> conflict (duplicate row). */ + private static final OperationalError NON_RETRYABLE_CONFLICT = + OperationalError.DB_UNIQUE_VIOLATION; + + private static Map<String, Map<String, Object>> registryByCode; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + if (registry == null) { + return; // registry-row leg skips via requireRegistry() + } + registryByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + registryByCode.put((String) row.get("code"), row); + } + } + } + + private static void requireRegistry() { + Assumptions.assumeTrue( + registryByCode != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "registry-row cross-check runs locally only"); + } + + // ---- enum SSOT: deadlock / serialization are retryable conflicts, never generic 500 ---- + + @Test + void deadlockAndSerializationFailuresAreRetryableConflicts() { + assertThat(RETRYABLE_LOCK_CONFLICTS) + .allSatisfy( + code -> { + assertThat(code.category()) + .as("%s is a CONFLICT", code.code()) + .isEqualTo(Category.CONFLICT); + assertThat(code.retryable()).as("%s is retryable by policy", code.code()).isTrue(); + assertThat(code.httpStatus()) + .as("%s answers 409, not 500", code.code()) + .isEqualTo(409); + assertThat(code.category()) + .as("%s must not collapse to the generic INTERNAL envelope", code.code()) + .isNotEqualTo(Category.INTERNAL); + }); + } + + // ---- enum SSOT: retryable vs non-retryable conflicts stay distinct, not collapsed ---- + + @Test + void retryableLockConflictsAreDistinctFromTheNonRetryableConflict() { + assertThat(NON_RETRYABLE_CONFLICT.category()).isEqualTo(Category.CONFLICT); + assertThat(NON_RETRYABLE_CONFLICT.retryable()) + .as("a unique violation is a conflict the client must not blindly retry") + .isFalse(); + assertThat(RETRYABLE_LOCK_CONFLICTS) + .as("classification must split retryable lock failures from the non-retryable one") + .allSatisfy( + code -> assertThat(code.retryable()).isNotEqualTo(NON_RETRYABLE_CONFLICT.retryable())); + } + + // ---- D5 forbidden: no lock/conflict DB code is the generic INTERNAL / 500 envelope ---- + + @Test + void noDbLockOrConflictCodeIsAGenericInternal500() { + List<OperationalError> dbConflictCodes = + Arrays.stream(OperationalError.values()) + .filter(code -> code.code().startsWith("DB_")) + .filter(code -> code.category() == Category.CONFLICT) + .toList(); + assertThat(dbConflictCodes) + .as("the retryable + non-retryable DB conflict codes must be present") + .contains( + OperationalError.DB_DEADLOCK, + OperationalError.DB_SERIALIZATION_FAILURE, + OperationalError.DB_UNIQUE_VIOLATION); + assertThat(dbConflictCodes) + .allSatisfy( + code -> { + assertThat(code.category()) + .as("%s must not be INTERNAL", code.code()) + .isNotEqualTo(Category.INTERNAL); + assertThat(code.httpStatus()) + .as("%s must not be a generic 500", code.code()) + .isNotEqualTo(500); + }); + } + + // ---- §Audit REGISTRY-GAP-1: optimistic-lock dedicated code is delegated and absent ---- + + @Test + void optimisticLockHasNoDedicatedCodeYetDelegatedToPersistenceBaseline() { + boolean anyOptimisticCode = + Arrays.stream(OperationalError.values()) + .map(OperationalError::code) + .anyMatch(code -> code.contains("OPTIMISTIC") || code.contains("VERSION")); + assertThat(anyOptimisticCode) + .as( + "optimistic @Version conflict has no dedicated code — " + + "REGISTRY-GAP-1 delegated to feature-persistence-failure-baseline; " + + "if one is added there, promote it into this policy table") + .isFalse(); + } + + // ---- registry rows agree with the classified policy (skip-not-pass) ---- + + @Test + void registryRowsAgreeWithTheLockClassificationPolicy() { + requireRegistry(); + RETRYABLE_LOCK_CONFLICTS.forEach(code -> assertRegistryRow(code, "CONFLICT", 409, true)); + assertRegistryRow(NON_RETRYABLE_CONFLICT, "CONFLICT", 409, false); + } + + private static void assertRegistryRow( + OperationalError code, String category, int httpStatus, boolean retryable) { + Map<String, Object> row = registryByCode.get(code.code()); + assertThat(row).as("registry must define %s", code.code()).isNotNull(); + assertThat((String) row.get("category")).as("%s category", code.code()).isEqualTo(category); + assertThat(((Number) row.get("http_status")).intValue()) + .as("%s http_status", code.code()) + .isEqualTo(httpStatus); + assertThat((boolean) row.get("retryable")).as("%s retryable", code.code()).isEqualTo(retryable); + } + + /** Walk up from the test working directory to find docs/registries/error-codes.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ManagementActuatorSecurityContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ManagementActuatorSecurityContractTest.java new file mode 100644 index 00000000..ce9de8e7 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ManagementActuatorSecurityContractTest.java @@ -0,0 +1,320 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-management-actuator-security-contract — cross-cutting actuator contract assertions. + * + * <p>Contracts verified: + * + * <ul> + * <li>{@code contract-verification:management-actuator} — ACTUATOR_FORBIDDEN matches registry row + * (AUTHZ/403/false). This id mirrors {@code + * error-codes.yaml#ACTUATOR_FORBIDDEN.required_test} (that registry uses the {@code + * contract-verification:*} convention; the {@code actuator-contract:*} ids below mirror + * env-keys.yaml). + * <li>{@code actuator-contract:management-port-separated} — management.server.port=9001 ≠ + * server.port=8080. + * <li>{@code actuator-contract:exposure-policy} — env/configprops/heapdump/threaddump/shutdown + * NOT in include set. + * <li>{@code actuator-contract:show-details-when-authorized} — health show-details = + * when-authorized, not always. + * </ul> + * + * <p>Registry cross-check is SKIPPED when {@code docs/registries/error-codes.yaml} is absent + * (gitignored on CI / fresh checkout) — never silently passed. The runbook file existence check is + * soft (Assumptions) because the operational-runbook branch is parallel and may not exist in this + * worktree. + */ +class ManagementActuatorSecurityContractTest { + + // ---- Registry cross-check (skipped when absent) ---- + + private static Path registry; + private static Map<String, Map<String, Object>> rowsByCode; + + @BeforeAll + static void loadRegistry() throws Exception { + registry = locateRegistry(); + if (registry == null) { + return; // registry not present — skip registry-dependent assertions + } + rowsByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + rowsByCode.put((String) row.get("code"), row); + } + } + } + + // ---- contract-verification:management-actuator + // (error-codes.yaml#ACTUATOR_FORBIDDEN.required_test) ---- + + @Test + void actuatorForbiddenEnumIsAuthz403NotRetryable() { + // Pure enum assertion — always runs (no registry needed). + assertThat(OperationalError.ACTUATOR_FORBIDDEN.code()).isEqualTo("ACTUATOR_FORBIDDEN"); + assertThat(OperationalError.ACTUATOR_FORBIDDEN.category()).isEqualTo(Category.AUTHZ); + assertThat(OperationalError.ACTUATOR_FORBIDDEN.httpStatus()).isEqualTo(403); + assertThat(OperationalError.ACTUATOR_FORBIDDEN.retryable()).isFalse(); + } + + @Test + void actuatorForbiddenMatchesRegistryRow() { + Assumptions.assumeTrue( + registry != null, + "docs/registries/error-codes.yaml not on disk; registry cross-check skipped"); + + Map<String, Object> row = rowsByCode.get("ACTUATOR_FORBIDDEN"); + assertThat(row).as("error-codes.yaml must contain row ACTUATOR_FORBIDDEN").isNotNull(); + assertThat(row.get("category")).as("ACTUATOR_FORBIDDEN category").isEqualTo("AUTHZ"); + assertThat(((Number) row.get("http_status")).intValue()) + .as("ACTUATOR_FORBIDDEN http_status") + .isEqualTo(403); + assertThat(row.get("retryable")).as("ACTUATOR_FORBIDDEN retryable").isEqualTo(false); + + // Enum ↔ registry agreement + assertThat(OperationalError.ACTUATOR_FORBIDDEN.category().name()).isEqualTo("AUTHZ"); + assertThat(OperationalError.ACTUATOR_FORBIDDEN.httpStatus()).isEqualTo(403); + assertThat(OperationalError.ACTUATOR_FORBIDDEN.retryable()).isFalse(); + + // runbook link format check (the link must be present in the registry row) + Object runbookLink = row.get("runbook_link"); + assertThat(runbookLink).as("ACTUATOR_FORBIDDEN runbook_link must be present").isNotNull(); + assertThat(runbookLink.toString()) + .as("ACTUATOR_FORBIDDEN runbook_link must use runbook:// scheme") + .startsWith("runbook://"); + + // Runbook file existence is a soft check: the operational-runbook branch owns the stub, + // and it may not exist in this parallel worktree. Only assert when file is actually present. + Path runbookFile = runbookFileFor(runbookLink.toString()); + if (Files.exists(runbookFile)) { + assertThat(runbookFile).exists(); + } + // else: file absent in this worktree → test passes (sibling branch owns the runbook stub) + } + + // ---- actuator-contract:management-port-separated ---- + + @Test + void managementServerPortDefaultsTo9001AndDiffersFromAppPort() { + // Bind ManagementServerProperties via ApplicationContextRunner (no live server — no port + // binding occurs; we test the bound value only, avoiding the dual-port gotcha). + new ApplicationContextRunner() + .withUserConfiguration(ManagementServerConfig.class) + .withPropertyValues("management.server.port=9001", "server.port=8080") + .run( + context -> { + assertThat(context).hasNotFailed(); + ManagementServerProperties mgmtProps = + context.getBean(ManagementServerProperties.class); + assertThat(mgmtProps.getPort()) + .as( + "actuator-contract:management-port-separated — " + + "management port must be 9001") + .isEqualTo(9001); + assertThat(mgmtProps.getPort()) + .as("management port must differ from default app port 8080") + .isNotEqualTo(8080); + }); + } + + // ---- actuator-contract:exposure-policy ---- + + @Test + void forbiddenEndpointsAreNotInExposureIncludeAllowlist() { + new ApplicationContextRunner() + .withUserConfiguration(WebEndpointPropertiesConfig.class) + .withPropertyValues( + "management.endpoints.web.exposure.include=health,prometheus,info,loggers", + "management.endpoints.web.exposure.exclude=env,configprops,heapdump,threaddump,shutdown") + .run( + context -> { + assertThat(context).hasNotFailed(); + WebEndpointProperties props = context.getBean(WebEndpointProperties.class); + Set<String> includeSet = props.getExposure().getInclude(); + + // D2/D4/D5 forbidden endpoints must NOT be in the include allowlist + assertThat(includeSet) + .as("env must not be in actuator exposure include set (D2)") + .doesNotContain("env"); + assertThat(includeSet) + .as("configprops must not be in actuator exposure include set (D2)") + .doesNotContain("configprops"); + assertThat(includeSet) + .as("heapdump must not be in actuator exposure include set (D4)") + .doesNotContain("heapdump"); + assertThat(includeSet) + .as("threaddump must not be in actuator exposure include set (D4)") + .doesNotContain("threaddump"); + assertThat(includeSet) + .as("shutdown must not be in actuator exposure include set (D4)") + .doesNotContain("shutdown"); + + // Allowed endpoints must be present in the include set + assertThat(includeSet) + .as("health must be in actuator exposure include set") + .contains("health"); + assertThat(includeSet) + .as("prometheus must be in actuator exposure include set") + .contains("prometheus"); + }); + } + + // ---- actuator-contract:exposure-policy (PIN the real application.yml artifact) ---- + // The ApplicationContextRunner tests above inject property values and assert the binding + // semantics. They do NOT pin what application.yml actually ships — a regression that adds + // `env` to the include list, or sets the management port to share the app socket, would + // still pass them. The tests below parse the real application.yml resource so a config + // regression FAILS the build (the whole point of a contract test). + + @Test + void applicationYmlExposureIncludePinsAllowlistExactly() { + Map<String, Object> mgmt = managementSubtree(); + String include = (String) nested(mgmt, "endpoints", "web", "exposure", "include"); + Set<String> includeSet = csvToSet(include); + + // Exactly the safe, scrape/probe-friendly endpoints — no more. + assertThat(includeSet) + .as("application.yml management.endpoints.web.exposure.include (D2 allowlist)") + .containsExactlyInAnyOrder("health", "prometheus", "info", "loggers"); + // Dangerous endpoints must never appear in the include allowlist. + assertThat(includeSet) + .as("D2/D4/D5: dangerous endpoints must not be in application.yml include") + .doesNotContain("env", "configprops", "heapdump", "threaddump", "shutdown"); + } + + @Test + void applicationYmlExposureExcludePinsDangerousEndpoints() { + Map<String, Object> mgmt = managementSubtree(); + String exclude = (String) nested(mgmt, "endpoints", "web", "exposure", "exclude"); + Set<String> excludeSet = csvToSet(exclude); + + assertThat(excludeSet) + .as("application.yml management.endpoints.web.exposure.exclude (D2/D4/D5)") + .contains("env", "configprops", "heapdump", "threaddump", "shutdown"); + } + + @Test + void applicationYmlManagementPortPinsSeparateDefault9001() { + Map<String, Object> mgmt = managementSubtree(); + Object port = nested(mgmt, "server", "port"); + + // application.yml keeps the port env-driven with a 9001 default — pins BOTH the env key + // (MANAGEMENT_SERVER_PORT) and the separated default. server.port stays APP_SERVER_PORT + // (8080), so the management socket is always distinct (D1). + assertThat(String.valueOf(port)) + .as("application.yml management.server.port must stay ${MANAGEMENT_SERVER_PORT:9001} (D1)") + .isEqualTo("${MANAGEMENT_SERVER_PORT:9001}"); + } + + @Test + void applicationYmlPinsShowDetailsWhenAuthorizedAndShutdownDisabled() { + Map<String, Object> mgmt = managementSubtree(); + + assertThat(String.valueOf(nested(mgmt, "endpoint", "health", "show-details"))) + .as("application.yml management.endpoint.health.show-details (D8) must be when-authorized") + .isEqualTo("when-authorized"); + assertThat(nested(mgmt, "endpoint", "shutdown", "access")) + .as("application.yml management.endpoint.shutdown.access (D4) must be none") + .isEqualTo("none"); + } + + // ---- helpers ---- + + /** + * Loads the real shipped application.yml (main resource, on the test classpath) and returns the + * {@code management:} subtree. + */ + private static Map<String, Object> managementSubtree() { + try (InputStream in = + Thread.currentThread().getContextClassLoader().getResourceAsStream("application.yml")) { + assertThat(in).as("application.yml must be on the test classpath").isNotNull(); + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + Map<String, Object> mgmt = (Map<String, Object>) root.get("management"); + assertThat(mgmt).as("application.yml must declare a `management:` block").isNotNull(); + return mgmt; + } catch (IOException e) { + throw new IllegalStateException("failed to read application.yml", e); + } + } + + /** Walks a nested snakeyaml map by key path; returns null if any segment is missing. */ + private static Object nested(Map<String, Object> map, String... path) { + Object cur = map; + for (String key : path) { + if (!(cur instanceof Map)) { + return null; + } + cur = ((Map<?, ?>) cur).get(key); + } + return cur; + } + + /** Splits a comma-separated property value into a trimmed set. */ + private static Set<String> csvToSet(String csv) { + assertThat(csv).as("expected a comma-separated value, got null").isNotNull(); + Set<String> out = new LinkedHashSet<>(); + for (String part : csv.split(",")) { + String t = part.trim(); + if (!t.isEmpty()) { + out.add(t); + } + } + return out; + } + + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } + + /** + * Resolves {@code runbook://management/<scenario>} → {@code + * docs/runbooks/management-<scenario>.md}. + */ + private static Path runbookFileFor(String runbookLink) { + String path = runbookLink.substring("runbook://".length()).replace('/', '-'); + return registry.getParent().getParent().resolve("runbooks").resolve(path + ".md"); + } + + @Configuration + @EnableConfigurationProperties(ManagementServerProperties.class) + static class ManagementServerConfig {} + + @Configuration + @EnableConfigurationProperties(WebEndpointProperties.class) + static class WebEndpointPropertiesConfig {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/MetricsAlertingContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/MetricsAlertingContractTest.java new file mode 100644 index 00000000..988866a3 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/MetricsAlertingContractTest.java @@ -0,0 +1,450 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.metrics.MetricsContractConfig; +import dev.caskeleton.bootstrap.metrics.MetricsDistributionMeterFilter; +import dev.caskeleton.shared.metrics.AlertSeverity; +import dev.caskeleton.shared.metrics.CardinalityBounds; +import dev.caskeleton.shared.metrics.ForbiddenMetricTags; +import dev.caskeleton.shared.metrics.MetricNaming; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Contract test for {@code contract-verification:metrics-cardinality}. + * + * <p>Covers every assertion required by the metrics-alerting-contract spec §테스트 계약, loading the + * full {@code docs/registries/metrics.yaml} and verifying all rows against the shared-contract + * types ({@link MetricNaming}, {@link ForbiddenMetricTags}, {@link CardinalityBounds}, {@link + * AlertSeverity}). + * + * <p><strong>Assume-skip contract:</strong> {@code docs/} is gitignored; when {@code metrics.yaml} + * is absent from disk, every test that reads the registry will {@link Assumptions#assumeTrue} skip + * rather than fail. This mirrors the {@code DistributedTracingContractTest} precedent. + * + * <p>MeterFilter behaviour checks use {@link SimpleMeterRegistry} which is on the test classpath + * via micrometer-core — no Spring context required. + */ +class MetricsAlertingContractTest { + + private static final String SKIP_REASON = + "docs/registries/metrics.yaml not on disk (gitignored); " + + "contract-verification:metrics-cardinality runs locally only"; + + private static Map<String, Object> metricsRoot; + private static List<Map<String, Object>> metrics; + + @BeforeAll + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + static void loadRegistry() throws Exception { + Path path = locateRegistry("metrics.yaml"); + if (path != null) { + try (InputStream in = Files.newInputStream(path)) { + metricsRoot = new Yaml().load(in); + } + metrics = (List<Map<String, Object>>) metricsRoot.get("metrics"); + } + } + + // ================================================================ + // Global checks — every row + // ================================================================ + + @Test + @DisplayName("D2: every metric name passes MetricNaming.isValidName()") + void everyMetricNameIsValidDotCase() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String name = (String) row.get("name"); + assertThat(MetricNaming.isValidName(name)) + .as("metric '%s' must be valid Micrometer dot.case (D2)", name) + .isTrue(); + } + } + + @Test + @DisplayName("D2: every metric unit is in {seconds, bytes, total}") + void everyMetricUnitIsAllowed() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String name = (String) row.get("name"); + String unit = (String) row.get("unit"); + assertThat(MetricNaming.isAllowedUnit(unit)) + .as("metric '%s' unit '%s' must be in {seconds, bytes, total} (D2)", name, unit) + .isTrue(); + } + } + + @Test + @DisplayName("D8: no row has a tag whose name is in ForbiddenMetricTags.FORBIDDEN") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void noRowHasForbiddenTag() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String metricName = (String) row.get("name"); + List<Map<String, Object>> tags = (List<Map<String, Object>>) row.get("tags"); + if (tags == null || tags.isEmpty()) { + continue; + } + List<String> tagNames = tags.stream().map(t -> (String) t.get("name")).toList(); + + assertThat(ForbiddenMetricTags.firstForbidden(tagNames)) + .as( + "metric '%s' must not contain any forbidden tag key (D8). " + "Tag names: %s", + metricName, tagNames) + .isEmpty(); + } + } + + @Test + @DisplayName("every row's required_test equals 'contract-verification:metrics-cardinality'") + void everyRowHasCorrectRequiredTest() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String name = (String) row.get("name"); + assertThat(row.get("required_test")) + .as("metric '%s' required_test must be 'contract-verification:metrics-cardinality'", name) + .isEqualTo("contract-verification:metrics-cardinality"); + } + } + + @Test + @DisplayName( + "§Cardinality Bounds: tag cardinality_limit <= CardinalityBounds.limitFor() where defined") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void tagCardinalityLimitsRespectBounds() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String metricName = (String) row.get("name"); + List<Map<String, Object>> tags = (List<Map<String, Object>>) row.get("tags"); + if (tags == null || tags.isEmpty()) { + continue; + } + for (Map<String, Object> tag : tags) { + String tagName = (String) tag.get("name"); + CardinalityBounds.limitFor(tagName) + .ifPresent( + bound -> { + Object limitObj = tag.get("cardinality_limit"); + if (limitObj != null) { + int limit = ((Number) limitObj).intValue(); + assertThat(limit) + .as( + "metric '%s' tag '%s' cardinality_limit %d must be <= bound %d", + metricName, tagName, limit, bound) + .isLessThanOrEqualTo(bound); + } + }); + } + } + } + + @Test + @DisplayName("§P1/P2/P3: alert_severity_thresholds keys must parse via AlertSeverity.fromKey()") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void alertSeverityThresholdKeysAreValid() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String metricName = (String) row.get("name"); + Map<String, Object> thresholds = (Map<String, Object>) row.get("alert_severity_thresholds"); + if (thresholds == null || thresholds.isEmpty()) { + continue; + } + for (Map.Entry<String, Object> entry : thresholds.entrySet()) { + String key = entry.getKey(); + assertThat(AlertSeverity.fromKey(key)) + .as( + "metric '%s' alert key '%s' must parse via AlertSeverity.fromKey()", + metricName, key) + .isNotNull(); + + String value = String.valueOf(entry.getValue()); + assertThat(value) + .as("metric '%s' alert key '%s' threshold value must be non-blank", metricName, key) + .isNotBlank(); + } + } + } + + @Test + @DisplayName( + "D9: every row with histogram_buckets='slo_driven' also declares non-empty percentiles") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void sloDrivenTimersDeclarePercentiles() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + String metricName = (String) row.get("name"); + Object histBuckets = row.get("histogram_buckets"); + if (!"slo_driven".equals(histBuckets)) { + continue; + } + List<Object> percentiles = (List<Object>) row.get("percentiles"); + assertThat(percentiles) + .as( + "metric '%s' has histogram_buckets=slo_driven but missing/empty percentiles (D9)", + metricName) + .isNotNull() + .isNotEmpty(); + } + } + + // ================================================================ + // Row-specific checks — §테스트 계약 explicit bullets + // ================================================================ + + @Test + @DisplayName("§테스트 계약 #1: http.server.requests row exists with method/status/uri_template tags") + void httpServerRequestsRowExistsWithRequiredTags() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + Map<String, Object> row = findRow("http.server.requests"); + assertThat(row).as("metrics.yaml must contain an 'http.server.requests' row").isNotNull(); + + List<String> tagNames = tagNames(row); + assertThat(tagNames) + .as("http.server.requests must have tags: method, status, uri_template") + .contains("method", "status", "uri_template"); + } + + @Test + @DisplayName( + "§테스트 계약 #2: dependency.client.requests row exists with dependency_name/dependency_type tags and P1 alert") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void dependencyClientRequestsRowExistsWithRequiredTagsAndP1Alert() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + Map<String, Object> row = findRow("dependency.client.requests"); + assertThat(row).as("metrics.yaml must contain a 'dependency.client.requests' row").isNotNull(); + + List<String> tagNames = tagNames(row); + assertThat(tagNames) + .as("dependency.client.requests must have tags: dependency_name, dependency_type") + .contains("dependency_name", "dependency_type"); + + //noinspection unchecked + Map<String, Object> thresholds = (Map<String, Object>) row.get("alert_severity_thresholds"); + assertThat(thresholds) + .as("dependency.client.requests must have non-empty alert_severity_thresholds (§테스트 계약 #4)") + .isNotNull() + .isNotEmpty(); + assertThat(thresholds) + .as( + "dependency.client.requests must have a P1 alert entry (§테스트 계약 #4 — dependency outage)") + .containsKey("p1"); + } + + @Test + @DisplayName( + "§테스트 계약 #3: hikaricp.connections.acquire row exists with outcome tag containing TIMEOUT and non-empty alerts") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void hikaricpConnectionsAcquireRowExistsWithTimeoutOutcome() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + Map<String, Object> row = findRow("hikaricp.connections.acquire"); + assertThat(row) + .as( + "metrics.yaml must contain a 'hikaricp.connections.acquire' row (DB pool exhaustion metric)") + .isNotNull(); + + //noinspection unchecked + List<Map<String, Object>> tags = (List<Map<String, Object>>) row.get("tags"); + Map<String, Object> outcomeTag = + tags.stream().filter(t -> "outcome".equals(t.get("name"))).findFirst().orElse(null); + assertThat(outcomeTag) + .as("hikaricp.connections.acquire must have an 'outcome' tag") + .isNotNull(); + + //noinspection unchecked + List<String> allowedValues = (List<String>) outcomeTag.get("allowed_values"); + assertThat(allowedValues) + .as("hikaricp.connections.acquire outcome tag must contain 'TIMEOUT'") + .contains("TIMEOUT"); + + //noinspection unchecked + Map<String, Object> thresholds = (Map<String, Object>) row.get("alert_severity_thresholds"); + assertThat(thresholds) + .as("hikaricp.connections.acquire must have non-empty alert_severity_thresholds") + .isNotNull() + .isNotEmpty(); + } + + @Test + @DisplayName("every row owned by this branch has non-empty alert_severity_thresholds") + @SuppressWarnings( + "unchecked") // SnakeYAML returns untyped Object; the registry shape is fixed by the + // metrics.yaml contract + void everyBranchOwnedRowHasAlertSeverityThresholds() { + Assumptions.assumeTrue(metrics != null, SKIP_REASON); + + for (Map<String, Object> row : metrics) { + if (!"feature-metrics-alerting-contract".equals(row.get("owner_branch"))) { + continue; + } + String metricName = (String) row.get("name"); + //noinspection unchecked + Map<String, Object> thresholds = (Map<String, Object>) row.get("alert_severity_thresholds"); + assertThat(thresholds) + .as( + "metric '%s' (owned by this branch) must have non-empty alert_severity_thresholds", + metricName) + .isNotNull() + .isNotEmpty(); + } + } + + // ================================================================ + // MeterFilter behaviour checks (SimpleMeterRegistry — no Spring ctx) + // ================================================================ + + @Test + @DisplayName("D8 MeterFilter: forbidden tag 'user_id' is denied; clean tag 'status' is allowed") + void cardinalityDenyFilterBehavior() { + SimpleMeterRegistry reg = new SimpleMeterRegistry(); + MetricsContractConfig.install(reg); + + // Forbidden tag — must be denied + reg.counter("test.metric", "user_id", "abc"); + assertThat(reg.find("test.metric").counter()) + .as("counter with user_id tag must be denied (D8 runtime defense)") + .isNull(); + + // Allowed tag — must be registered + reg.counter("ok.metric", "status", "2xx"); + assertThat(reg.find("ok.metric").counter()) + .as("counter with only allowed tags must be registered") + .isNotNull(); + } + + @Test + @DisplayName( + "D9 MeterFilter: MetricsDistributionMeterFilter applies histogram+SLOs to http.server.requests") + void distributionFilterAppliesToHttpServerRequests() { + var filter = new MetricsDistributionMeterFilter(); + Meter.Id id = new Meter.Id("http.server.requests", Tags.empty(), null, null, Meter.Type.TIMER); + + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.isPercentileHistogram()) + .as( + "http.server.requests must have percentilesHistogram=true (D9 aggregable source of truth)") + .isTrue(); + assertThat(result.getServiceLevelObjectiveBoundaries()) + .as("http.server.requests must have non-empty SLO boundaries") + .isNotNull() + .isNotEmpty(); + } + + @Test + @DisplayName("D9 MeterFilter: MetricsDistributionMeterFilter leaves non-owned metrics unchanged") + void distributionFilterPassesThroughNonOwnedMetric() { + var filter = new MetricsDistributionMeterFilter(); + Meter.Id id = new Meter.Id("lock.acquisition", Tags.empty(), null, null, Meter.Type.COUNTER); + + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.isPercentileHistogram()) + .as("non-owned 'lock.acquisition' must not have percentileHistogram forced") + .isFalse(); + assertThat(result.getServiceLevelObjectiveBoundaries()) + .as("non-owned 'lock.acquisition' must have no SLO boundaries") + .isNull(); + } + + @Test + @DisplayName( + "registry↔filter coverage: every owned slo_driven timer in metrics.yaml is configured by MetricsDistributionMeterFilter") + void everyOwnedSloDrivenTimerIsConfiguredByDistributionFilter() { + Assumptions.assumeTrue(metricsRoot != null, SKIP_REASON); + + var filter = new MetricsDistributionMeterFilter(); + + for (Map<String, Object> row : metrics) { + if (!"feature-metrics-alerting-contract".equals(row.get("owner_branch"))) { + continue; + } + if (!"timer".equals(row.get("type"))) { + continue; + } + if (!"slo_driven".equals(row.get("histogram_buckets"))) { + continue; + } + String rowName = (String) row.get("name"); + Meter.Id id = new Meter.Id(rowName, Tags.empty(), null, null, Meter.Type.TIMER); + DistributionStatisticConfig result = + filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.isPercentileHistogram()) + .as( + "owned slo_driven timer '%s' must have percentilesHistogram=true " + + "in MetricsDistributionMeterFilter (registry↔filter drift guard)", + rowName) + .isTrue(); + } + } + + // ================================================================ + // Helpers + // ================================================================ + + private static Map<String, Object> findRow(String metricName) { + if (metrics == null) { + return null; + } + return metrics.stream().filter(r -> metricName.equals(r.get("name"))).findFirst().orElse(null); + } + + @SuppressWarnings("unchecked") + private static List<String> tagNames(Map<String, Object> row) { + List<Map<String, Object>> tags = (List<Map<String, Object>>) row.get("tags"); + if (tags == null) { + return List.of(); + } + return tags.stream().map(t -> (String) t.get("name")).toList(); + } + + private static Path locateRegistry(String filename) { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/" + filename); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java new file mode 100644 index 00000000..59ca575b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfEmailNotificationConfigured; +import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfHttpCircuitBreakerEnabled; +import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfHttpRetryEnabled; +import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfMessagingBrokerConfigured; +import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfRedisCacheEnabled; +import dev.caskeleton.bootstrap.contract.support.conditional.EnabledIfSlackNotificationConfigured; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * D3 (optional adapter conditional execution) of the contract-verification suite. An + * optional-adapter contract test must run ONLY in the env matrix where that adapter is enabled, and + * when the enable flag is unset it must report SKIPPED — never FAILED — so the default build stays + * green without exercising an adapter that is not wired (feature-contract-verification-test-suite + * D3, Claims To Verify #7). + * + * <p>The {@code runs_only_when_*} methods demonstrate the gating for each optional adapter via the + * composed {@code @EnabledIf*} annotations; in the default env (flags unset) they are SKIPPED. The + * {@link #optionalAdapterWithUnsetFlagReportsSkippedNeverFailed()} test then proves the + * SKIP-not-FAIL guarantee with the JUnit Platform Test Kit: a fixture whose body would fail is + * reported {@code skipped(1) / failed(0) / started(0)} because its never-set flag disables it. + */ +class OptionalAdapterConditionalExecutionContractTest { + + @Test + @EnabledIfRedisCacheEnabled + void redisCacheAdapterRunsOnlyWhenEnabled() { + assertThat(System.getenv("APP_CACHE_REDIS_ENABLED")).isEqualTo("true"); + } + + @Test + @EnabledIfHttpRetryEnabled + void httpRetryAdapterRunsOnlyWhenEnabled() { + assertThat(System.getenv("APP_OUTBOUND_HTTP_RETRY_ENABLED")).isEqualTo("true"); + } + + @Test + @EnabledIfHttpCircuitBreakerEnabled + void httpCircuitBreakerAdapterRunsOnlyWhenEnabled() { + assertThat(System.getenv("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED")).isEqualTo("true"); + } + + @Test + @EnabledIfMessagingBrokerConfigured + void messagingBrokerAdapterRunsOnlyWhenConfigured() { + assertThat(System.getenv("APP_MESSAGING_BROKER")).isNotBlank(); + } + + @Test + @EnabledIfSlackNotificationConfigured + void slackNotificationAdapterRunsOnlyWhenConfigured() { + assertThat(System.getenv("APP_NOTIFICATION_SLACK_PROVIDER")).isNotBlank(); + } + + @Test + @EnabledIfEmailNotificationConfigured + void emailNotificationAdapterRunsOnlyWhenConfigured() { + assertThat(System.getenv("APP_NOTIFICATION_EMAIL_PROVIDER")).isNotBlank(); + } + + @Test + void optionalAdapterWithUnsetFlagReportsSkippedNeverFailed() { + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(DisabledOptionalAdapterFixture.class)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(0).skipped(1).failed(0).succeeded(0)); + } + + /** + * Selected only by the Test Kit above (a non-{@code @Nested} static class is not auto-discovered + * by the Gradle test run). Its method would FAIL if executed, proving the disabled condition + * prevents execution rather than turning a failure green. + */ + static class DisabledOptionalAdapterFixture { + + @Test + @EnabledIfEnvironmentVariable( + named = "APP_CONTRACT_SUITE_NEVER_SET_FLAG", + matches = "true", + disabledReason = "deliberately-unset flag proving DISABLED == SKIPPED, never FAILED") + void wouldFailIfItEverRan() { + fail("an optional-adapter test must be SKIPPED (not executed) when its flag is unset"); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PackageSettings.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PackageSettings.java new file mode 100644 index 00000000..4533ab19 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PackageSettings.java @@ -0,0 +1,19 @@ +package dev.caskeleton.bootstrap.contract; + +/** + * ApprovalTests package-level settings for the contract-verification snapshot tests. + * + * <p>The {@code org.packagesettings} library (bundled with ApprovalTests) walks the package + * hierarchy of the running test looking for a class named {@code PackageSettings} and reads its + * fields by name. {@code UseApprovalSubdirectory} routes every {@code *.approved.txt} / {@code + * *.received.txt} file into a dedicated {@code approved/} sub-directory next to the test source, + * instead of scattering them beside the {@code .java} files. + * + * <p>Scope: this applies to every ApprovalTests {@code Approvals.verify(...)} call made by a test + * in {@code dev.caskeleton.bootstrap.contract} (today, {@code EnvelopeContractTest}). + */ +public class PackageSettings { + + /** Snapshot files live in {@code contract/approved/} rather than beside the test sources. */ + public static String UseApprovalSubdirectory = "approved"; +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PersistenceFailureMappingContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PersistenceFailureMappingContractTest.java new file mode 100644 index 00000000..8232fb55 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PersistenceFailureMappingContractTest.java @@ -0,0 +1,166 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator; +import dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-persistence-failure-baseline — {@code contract-verification:persistence-mapping}. + * + * <p>Pins the full SQLState → {@link OperationalError} → {@code error-codes.yaml} chain as one + * executable contract: for each of the 9 matrix rows the adapter-persistence {@link + * PersistenceExceptionTranslator} must classify the SQLState to the contracted code, and that + * code's {@code category / http_status / retryable} must agree with the registry row + * (owner_branch=feature-persistence-failure-baseline). It also pins the §테스트 계약 invariants: + * transient lock vs integrity never collapse onto one code, and an unknown SQLState is never given + * a {@code DB_*} code. + * + * <p>The enum-side assertions always run (the {@link OperationalError} SSOT is on the classpath); + * the registry-row cross-check follows the same skip-not-pass discipline as the sibling registry + * tests — when the gitignored {@code error-codes.yaml} is absent (CI / fresh checkout) only that + * leg is skipped, never silently passed. + */ +class PersistenceFailureMappingContractTest { + + // Full production composition: standard SQLState rows (core) + PostgreSQL-specific rows (vendor), + // merged exactly as Spring injects Collection<SqlStateErrorMapping> at runtime. + private final PersistenceExceptionTranslator translator = + new PersistenceExceptionTranslator( + List.of(new StandardSqlStateErrorMapping(), new PostgreSqlSqlStateErrorMapping())); + + /** SQLState → contracted code (the §SQLState → Error Code Matrix 9 rows). */ + private static final Map<String, OperationalError> MATRIX = buildMatrix(); + + private static Map<String, OperationalError> buildMatrix() { + Map<String, OperationalError> m = new LinkedHashMap<>(); + m.put("08006", OperationalError.DB_UNAVAILABLE); // 08* connection class + m.put("40001", OperationalError.DB_SERIALIZATION_FAILURE); + m.put("40P01", OperationalError.DB_DEADLOCK); + m.put("23502", OperationalError.DB_NULL_VIOLATION); + m.put("23503", OperationalError.DB_FK_VIOLATION); + m.put("23505", OperationalError.DB_UNIQUE_VIOLATION); + m.put("23514", OperationalError.DB_CHECK_VIOLATION); + m.put("25P03", OperationalError.DB_IDLE_IN_TX_TIMEOUT); + m.put("57014", OperationalError.DB_QUERY_CANCELED); + return m; + } + + private static Map<String, Map<String, Object>> registryByCode; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + if (registry == null) { + return; // registry-row leg skips via requireRegistry() + } + registryByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + registryByCode.put((String) row.get("code"), row); + } + } + } + + private static void requireRegistry() { + Assumptions.assumeTrue( + registryByCode != null, + "docs/registries/error-codes.yaml not on disk (/docs is gitignored); " + + "registry-row cross-check runs locally only"); + } + + private static DataAccessException daoWithSqlState(String sqlState) { + return new DataIntegrityViolationException("wrapper", new SQLException("detail", sqlState)); + } + + // ---- the translator classifies every matrix row to its contracted code (always runs) ---- + + @Test + void everyMatrixSqlstateClassifiesToItsContractedCode() { + MATRIX.forEach( + (sqlState, expected) -> { + Optional<PersistenceFailureException> result = + translator.translate(daoWithSqlState(sqlState)); + assertThat(result).as("SQLState %s must classify", sqlState).isPresent(); + assertThat(result.get().errorCode()) + .as("SQLState %s → %s", sqlState, expected.code()) + .isEqualTo(expected); + }); + } + + // ---- each classified code agrees with the registry row (skip-not-pass) ---- + + @Test + void everyClassifiedCodeAgreesWithTheRegistryRow() { + requireRegistry(); + MATRIX.forEach( + (sqlState, expected) -> { + Map<String, Object> row = registryByCode.get(expected.code()); + assertThat(row).as("registry must define %s", expected.code()).isNotNull(); + assertThat(Category.valueOf((String) row.get("category"))) + .as("%s category", expected.code()) + .isEqualTo(expected.category()); + assertThat(((Number) row.get("http_status")).intValue()) + .as("%s http_status", expected.code()) + .isEqualTo(expected.httpStatus()); + assertThat((boolean) row.get("retryable")) + .as("%s retryable", expected.code()) + .isEqualTo(expected.retryable()); + }); + } + + // ---- §테스트 계약: transient lock and integrity must not collapse onto one code ---- + + @Test + void transientLockAndIntegrityViolationStayDistinct() { + ApiErrorCode lock = translator.translate(daoWithSqlState("40001")).orElseThrow().errorCode(); + ApiErrorCode integrity = + translator.translate(daoWithSqlState("23502")).orElseThrow().errorCode(); + assertThat(lock).isNotEqualTo(integrity); + assertThat(lock.category()).isNotEqualTo(integrity.category()); + assertThat(lock.retryable()).as("transient lock is retryable").isTrue(); + assertThat(integrity.retryable()).as("integrity violation is not retryable").isFalse(); + } + + // ---- 9-row 밖 미지의 SQLState → no DB_* code invented ---- + + @Test + void unknownSqlstateIsNotClassified() { + assertThat(translator.translate(daoWithSqlState("42601"))).isEmpty(); + } + + /** Walk up from the test working directory to find docs/registries/error-codes.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java new file mode 100644 index 00000000..b8577034 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.bootstrap.logging.LogMaskingPatterns; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; + +/** + * Base contract #5 (PII / token / body forbidden) of the contract-verification suite. Holds the + * two-layer secret-redaction contract from the branch §7: + * + * <ul> + * <li><b>token / secret</b> — {@link LogMaskingPatterns} (the SSOT both the pattern-layout and + * JSON-encoder paths use) masks every enumerated secret shape so a captured log line carries + * zero unmasked secrets; + * <li><b>body</b> — request/response body capture is forbidden by default ({@code + * APP_LOG_BODY_CAPTURE_ENABLED=false}), so bodies never reach the log. + * </ul> + * + * <p>The {@code UNMASKED_SECRET} detector encodes the "masked regex 매칭 0건" measure: it matches a + * secret key whose value is not {@code ****}. It must find the secret in the raw input (proving the + * detector works) and zero in the masked output. A Logback {@link ListAppender} exercises the + * capture-then-mask pipeline end to end (feature-contract-verification-test-suite §7 verify + * (a)/(b), decided 2026-05-22). + */ +class PiiTokenBodyForbiddenContractTest { + + /** + * Matches a secret key followed by a value that is NOT the {@code ****} mask. A non-zero count + * means an unmasked secret leaked. Mirrors the {@link LogMaskingPatterns} key set. + */ + private static final Pattern UNMASKED_SECRET = + Pattern.compile( + "(?i)(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token" + + "|refresh[_-]?token|client[_-]?secret|authorization)" + // possessively skip an optional auth scheme (Bearer/Basic/Negotiate) before the + // value, mirroring LogMaskingPatterns rule 2 — the scheme word is not the + // secret. + // ?+ (possessive) stops the engine backtracking to re-match the scheme as the + // value. + + "[\"']?\\s*[:=]\\s*[\"']?(?:(?:bearer|basic|negotiate)\\s+)?+(?!\\*{4})[^\\s\"',&}]+"); + + @ParameterizedTest + @ValueSource( + strings = { + "token=s3cr3t-value-123", + "password: hunter2-very-secret", + "api_key=AKIA0123456789ABCDEF", + "client_secret = abcdEF0123456789", + "access_token:\"ya29.aVeryLongOpaqueToken\"", + "authorization: Bearer eyJhbGciOiJIUzI1Ni" + }) + void maskingRemovesEveryEnumeratedSecretShape(String rawSecretLine) { + // The detector must see the secret in the raw line (otherwise it proves + // nothing). + assertThat(countUnmasked(rawSecretLine)) + .as("UNMASKED_SECRET detector must flag the raw secret in: %s", rawSecretLine) + .isGreaterThan(0); + + String masked = LogMaskingPatterns.mask(rawSecretLine); + + assertThat(countUnmasked(masked)) + .as("masked output must carry zero unmasked secrets: %s", masked) + .isZero(); + assertThat(masked) + .as("masked output must contain the %s mask", LogMaskingPatterns.MASK) + .contains(LogMaskingPatterns.MASK); + } + + @Test + void capturedLogLineCarriesNoUnmaskedSecret() { + Logger logger = (Logger) LoggerFactory.getLogger(PiiTokenBodyForbiddenContractTest.class); + Level originalLevel = logger.getLevel(); + logger.setLevel(Level.INFO); + ListAppender<ILoggingEvent> appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + logger.info( + "outbound call failed with token={} and authorization: Bearer {}", + "leaked-token-abcdef123456", + "eyJhbGciOiJIUzI1NiInPayload"); + } finally { + logger.detachAppender(appender); + logger.setLevel(originalLevel); + } + + assertThat(appender.list).as("the log event was captured").hasSize(1); + String masked = LogMaskingPatterns.mask(appender.list.get(0).getFormattedMessage()); + + assertThat(countUnmasked(masked)) + .as("captured-then-masked log line must carry zero unmasked secrets: %s", masked) + .isZero(); + assertThat(masked) + .doesNotContain("leaked-token-abcdef123456") + .doesNotContain("eyJhbGciOiJIUzI1NiInPayload"); + } + + @Test + void requestBodyCaptureIsDisabledByDefault() throws IOException { + Path env = walkUpFor("src/.env"); + Assumptions.assumeTrue( + env != null, "src/.env not on disk; body-capture default assertion skipped"); + + String value = readEnv(env, "APP_LOG_BODY_CAPTURE_ENABLED"); + assertThat(value) + .as( + "APP_LOG_BODY_CAPTURE_ENABLED must default to false — request/response body " + + "capture in logs is forbidden unless explicitly allowlisted (§7 body)") + .isEqualTo("false"); + } + + private static long countUnmasked(String text) { + return UNMASKED_SECRET.matcher(text).results().count(); + } + + private static String readEnv(Path envFile, String key) throws IOException { + Pattern line = Pattern.compile("^" + Pattern.quote(key) + "=(.*)$", Pattern.MULTILINE); + Matcher m = line.matcher(Files.readString(envFile)); + return m.find() ? m.group(1).trim() : null; + } + + private static Path walkUpFor(String relative) { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve(relative); + if (Files.isRegularFile(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RepositoryAccessCapabilityRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RepositoryAccessCapabilityRegistryTest.java new file mode 100644 index 00000000..25e5ceae --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RepositoryAccessCapabilityRegistryTest.java @@ -0,0 +1,119 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Registry/enum drift guard for the repository-access capability vocabulary + * (feature-repository-access-permission-contract). Asserts that the seven capability {@code name:} + * rows in {@code docs/registries/capabilities.yaml} match the as-built {@link UseCaseCapability} + * model 1:1 — the {@link RepositoryAccess} enum constants plus the typed annotation attributes that + * realize the remaining capabilities. + * + * <p>The registry SSOT lives under {@code docs/registries/}, which this repo gitignores ({@code + * /docs}). When it is absent (CI / fresh checkout) the drift check is SKIPPED, not passed; when it + * IS present, a capability added to / removed from the registry without a matching model change — + * or an attribute rename — is a hard FAIL. Mirrors the {@code ErrorCodeRegistryMappingTest} D11 + * registry-drift pattern. + */ +class RepositoryAccessCapabilityRegistryTest { + + /** + * Capability name → the {@link UseCaseCapability} attribute that realizes it (as-built model). + * The {@code RepositoryAccess} enum constants (READ_REPOSITORY / WRITE_REPOSITORY) are realized + * by {@code repositoryAccess()} and are derived from the enum directly in {@link + * #asBuiltVocabulary()}. + */ + private static final Map<String, String> FLAG_CAPABILITY_TO_ATTRIBUTE = + Map.of( + "TRANSACTION_REQUIRED", "transactionMode", + "EXTERNAL_OUTBOUND_ALLOWED", "externalOutboundAllowed", + "SENSITIVE_READ", "sensitiveRead", + "BULK_WRITE", "bulkWrite", + "CROSS_TENANT_ADMIN", "crossTenantAdmin"); + + private static Set<String> registryCapabilityNames; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + Assumptions.assumeTrue( + registry != null, + "docs/registries/capabilities.yaml not on disk (/docs is gitignored); " + + "capability registry-drift check runs locally only"); + registryCapabilityNames = new LinkedHashSet<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> capabilities = (List<Map<String, Object>>) root.get("capabilities"); + for (Map<String, Object> row : capabilities) { + registryCapabilityNames.add((String) row.get("name")); + } + } + } + + @Test + void everyAsBuiltCapabilityAttributeExistsOnTheAnnotation() throws Exception { + // Guards against an attribute rename drifting the model away from the registry vocabulary. + for (String attribute : FLAG_CAPABILITY_TO_ATTRIBUTE.values()) { + assertThat(UseCaseCapability.class.getDeclaredMethod(attribute)) + .as( + "@UseCaseCapability must declare the '" + + attribute + + "' attribute that " + + "realizes a registry capability") + .isNotNull(); + } + } + + @Test + void registryCapabilityNamesMatchTheAsBuiltModelOneToOne() { + Set<String> expected = asBuiltVocabulary(); + + assertThat(registryCapabilityNames) + .as( + "docs/registries/capabilities.yaml capability names must match the as-built " + + "@UseCaseCapability model 1:1 (RepositoryAccess enum + typed attributes) — " + + "feature-repository-access-permission-contract registry/enum drift guard. " + + "A capability added to the registry without a model attribute (or removed " + + "from the model without a registry change) breaks this assertion.") + .isEqualTo(expected); + } + + private static Set<String> asBuiltVocabulary() { + Set<String> vocabulary = new LinkedHashSet<>(); + for (RepositoryAccess access : RepositoryAccess.values()) { + if (access != RepositoryAccess.NONE) { + vocabulary.add(access.name()); // READ_REPOSITORY, WRITE_REPOSITORY + } + } + vocabulary.addAll(FLAG_CAPABILITY_TO_ATTRIBUTE.keySet()); + return vocabulary; + } + + /** Walk up from the test working directory to find docs/registries/capabilities.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/capabilities.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java new file mode 100644 index 00000000..b99406b6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java @@ -0,0 +1,357 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-operational-runbook-contract — coverage + link-check GATE. + * + * <p>Verifies four invariants against {@code docs/registries/error-codes.yaml} and every {@code + * docs/runbooks/*.md} file: + * + * <ol> + * <li><b>COVERAGE</b> — every mandatory error code appears in at least one runbook's {@code + * error_codes:} frontmatter list. Mandatory = retryable==true OR (retryable==false AND + * category ∉ {VALIDATION, NOT_FOUND, CONFLICT, DATA_INTEGRITY}). + * <li><b>LINK FORMAT</b> — every non-null {@code runbook_link} is either {@code + * runbook://{area}/{scenario}} (lowercase) or a repo-relative {@code .md} path. + * <li><b>LINK RESOLUTION</b> — every non-null {@code runbook_link} resolves to an existing file + * under {@code docs/runbooks/}. + * <li><b>PLACEHOLDER/STUB SMOKE</b> — runbook body must not contain TODO/TBD/PLACEHOLDER/FIXME + * and frontmatter must not have {@code status: stub} — EXCEPT files on the {@code + * STUB_ALLOWLIST} below. Files NOT on the allowlist that are stub/placeholder FAIL. + * </ol> + * + * <p>When {@code docs/registries/error-codes.yaml} is absent (CI / fresh checkout) the entire suite + * is SKIPPED — never silently passed. When the registry IS present, every violation is a hard FAIL. + * + * <p>{@code template.md} is excluded from coverage and stub enforcement — it is the canonical + * runbook template, not a real runbook. + */ +class RunbookCoverageContractTest { + + /** + * Phase D2 — author runbook bodies then remove from this allowlist + * (feature-operational-runbook-contract D9). + * + * <p>These are the known stub runbooks seeded during Phase D1. Every file here must have proper + * frontmatter ({@code status: stub}) and is tracked explicitly so that any NEW runbook file added + * outside this set is immediately subject to the stub/placeholder check (the gate retains its + * teeth). + */ + private static final Set<String> STUB_ALLOWLIST = + Set.of( + // Pre-existing 10 stubs (copied seed): + "auth-token-rotation-failure.md", + "authz-cross-tenant-violation.md", + "dependency-unavailable.md", + "internal-error-spike.md", + "job-dead-letter.md", + "job-executor-rejected.md", + "job-timeout.md", + "outbox-dead-letter.md", + "outbox-publish-failed.md", + "rate-limit-exceeded.md", + // New stubs created for D1 link-resolution coverage: + "adapter-adapter-disabled.md", + "auth-audience-mismatch.md", + "auth-claim-mapping-failed.md", + "auth-issuer-mismatch.md", + "auth-jwks-unavailable.md", + "auth-kid-unknown.md", + "auth-public-path-misconfiguration.md", + "auth-token-expired.md", + "auth-token-invalid-signature.md", + "auth-token-malformed.md", + "auth-token-missing.md", + "authz-insufficient-permission.md", + "authz-tenant-mismatch.md", + "cache-stampede-lock-timeout.md", + "cache-unavailable.md", + "db-deadlock.md", + "db-idle-in-tx-timeout.md", + "db-query-canceled.md", + "db-serialization-failure.md", + "db-unavailable.md", + "dependency-4xx-client.md", + "dependency-5xx-server.md", + "dependency-circuit-open.md", + "dependency-connect-failed.md", + "dependency-dns-failed.md", + "dependency-timeout.md", + "file-download-streaming-failure.md", + "lock-acquisition-timeout.md", + "management-actuator-forbidden.md", + "migration-failed.md", + "runtime-jvm-oom.md", + "startup-profile-mismatch.md", + "startup-required-adapter-disabled.md", + "startup-validation-failed.md"); + + /** Categories exempt from mandatory-runbook requirement (client-error only). */ + private static final Set<String> EXEMPT_CATEGORIES = + Set.of("VALIDATION", "NOT_FOUND", "CONFLICT", "DATA_INTEGRITY"); + + /** Runbook link format: either runbook://area/scenario or a relative .md path. */ + private static final Pattern RUNBOOK_URI_PATTERN = + Pattern.compile("^runbook://[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$"); + + private static final Pattern RELATIVE_MD_PATTERN = Pattern.compile("^[a-zA-Z0-9_./-]+\\.md$"); + + /** Matches any placeholder/stub body signal. */ + private static final Pattern PLACEHOLDER_PATTERN = + Pattern.compile("(?i)(TODO|TBD|PLACEHOLDER|FIXME)"); + + private static Path registry; + private static Path runbooksDir; + private static List<Map<String, Object>> allErrors; + + @BeforeAll + static void loadRegistry() throws Exception { + registry = locateRegistry(); + Assumptions.assumeTrue( + registry != null, + "docs/registries/error-codes.yaml not on disk (docs/ is gitignored); " + + "runbook coverage contract runs locally only"); + runbooksDir = registry.getParent().getParent().resolve("runbooks"); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + allErrors = errors; + } + } + + // ------------------------------------------------------------------------- + // A. COVERAGE — reverse-index: mandatory code must appear in ≥1 runbook + // ------------------------------------------------------------------------- + + @Test + void everyMandatoryErrorCodeIsCoveredByAtLeastOneRunbook() throws Exception { + Set<String> coveredCodes = collectCoveredCodesFromRunbooks(); + List<String> violations = new ArrayList<>(); + for (Map<String, Object> row : allErrors) { + String code = (String) row.get("code"); + if (isMandatory(row) && !coveredCodes.contains(code)) { + violations.add( + code + + " (category=" + + row.get("category") + + ", retryable=" + + row.get("retryable") + + ")"); + } + } + assertThat(violations) + .as("Mandatory error codes missing from all runbook error_codes: lists") + .isEmpty(); + } + + // ------------------------------------------------------------------------- + // B. LINK FORMAT — runbook_link must match expected pattern + // ------------------------------------------------------------------------- + + @Test + void allRunbookLinksHaveValidFormat() { + List<String> violations = new ArrayList<>(); + for (Map<String, Object> row : allErrors) { + String link = (String) row.get("runbook_link"); + if (link == null) { + continue; + } + boolean validUri = RUNBOOK_URI_PATTERN.matcher(link).matches(); + boolean validRelative = RELATIVE_MD_PATTERN.matcher(link).matches(); + if (!validUri && !validRelative) { + violations.add( + row.get("code") + + ": runbook_link=\"" + + link + + "\" " + + "is neither runbook://area/scenario nor a .md relative path"); + } + } + assertThat(violations).as("Malformed runbook_link values in error-codes.yaml").isEmpty(); + } + + // ------------------------------------------------------------------------- + // C. LINK RESOLUTION — every non-null runbook_link must resolve to existing file + // ------------------------------------------------------------------------- + + @Test + void allRunbookLinksResolveToExistingFiles() { + List<String> violations = new ArrayList<>(); + for (Map<String, Object> row : allErrors) { + String link = (String) row.get("runbook_link"); + if (link == null) { + continue; + } + Path target = runbookFileFor(link); + if (!Files.exists(target)) { + violations.add( + row.get("code") + + ": runbook_link=\"" + + link + + "\" expected file \"" + + target.getFileName() + + "\" does not exist under docs/runbooks/"); + } + } + assertThat(violations) + .as("Dangling runbook_link references (file missing under docs/runbooks/)") + .isEmpty(); + } + + // ------------------------------------------------------------------------- + // D. PLACEHOLDER/STUB SMOKE — body or status:stub indicates incomplete runbook + // EXCEPT files on STUB_ALLOWLIST (Phase D2 — tracked debt) + // ------------------------------------------------------------------------- + + @Test + void runbooksNotOnStubAllowlistMustNotBeStubsOrPlaceholders() throws Exception { + Assumptions.assumeTrue( + Files.isDirectory(runbooksDir), + "docs/runbooks/ directory absent — stub smoke check skipped"); + List<String> violations = new ArrayList<>(); + try (Stream<Path> files = Files.list(runbooksDir)) { + for (Path path : files.sorted().toList()) { + String filename = path.getFileName().toString(); + if (!filename.endsWith(".md")) { + continue; + } + if (filename.equals("template.md")) { + continue; // excluded from enforcement + } + if (STUB_ALLOWLIST.contains(filename)) { + continue; // known debt — tracked + } + String content = Files.readString(path); + // Check for status: stub in frontmatter + boolean hasStubStatus = extractFrontmatterStatus(content).equals("stub"); + // Check for placeholder body text + boolean hasPlaceholderBody = PLACEHOLDER_PATTERN.matcher(content).find(); + if (hasStubStatus || hasPlaceholderBody) { + violations.add( + filename + + ": " + + (hasStubStatus ? "status=stub" : "") + + (hasPlaceholderBody ? " body-contains-placeholder" : "")); + } + } + } + assertThat(violations) + .as( + "Runbooks outside STUB_ALLOWLIST must not be stubs or placeholders " + + "(add to STUB_ALLOWLIST or author the runbook body)") + .isEmpty(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Collect all error codes mentioned in any runbook's error_codes: frontmatter list. */ + private Set<String> collectCoveredCodesFromRunbooks() throws Exception { + Set<String> covered = new HashSet<>(); + if (!Files.isDirectory(runbooksDir)) { + return covered; + } + try (Stream<Path> files = Files.list(runbooksDir)) { + for (Path path : files.toList()) { + String filename = path.getFileName().toString(); + if (!filename.endsWith(".md") || filename.equals("template.md")) { + continue; + } + String content = Files.readString(path); + Map<String, Object> frontmatter = extractFrontmatter(content); + Object codes = frontmatter.get("error_codes"); + if (codes instanceof List<?> list) { + for (Object c : list) { + if (c instanceof String s) { + covered.add(s); + } + } + } + } + } + return covered; + } + + /** + * A code is mandatory if retryable==true OR (retryable==false AND category not in exempt set). + */ + private boolean isMandatory(Map<String, Object> row) { + Boolean retryable = (Boolean) row.get("retryable"); + String category = (String) row.get("category"); + if (Boolean.TRUE.equals(retryable)) { + return true; + } + return !EXEMPT_CATEGORIES.contains(category); + } + + /** + * Converts {@code runbook://area/scenario} → {@code docs/runbooks/area-scenario.md}. Relative + * {@code .md} paths are resolved directly under runbooksDir. + */ + private Path runbookFileFor(String runbookLink) { + if (runbookLink.startsWith("runbook://")) { + String path = runbookLink.substring("runbook://".length()).replace('/', '-'); + return runbooksDir.resolve(path + ".md"); + } + return runbooksDir.resolve(runbookLink); + } + + /** Parses YAML frontmatter between leading {@code ---} fences. Returns empty map if none. */ + private Map<String, Object> extractFrontmatter(String content) { + if (!content.startsWith("---")) { + return Map.of(); + } + int end = content.indexOf("\n---", 3); + if (end < 0) { + return Map.of(); + } + if (end <= 4) { + return Map.of(); + } + String yaml = content.substring(4, end); + Object parsed = new Yaml().load(yaml); + if (parsed instanceof Map<?, ?> m) { + @SuppressWarnings("unchecked") + Map<String, Object> result = (Map<String, Object>) m; + return result; + } + return Map.of(); + } + + /** Returns the {@code status} value from frontmatter, or empty string if absent. */ + private String extractFrontmatterStatus(String content) { + Object val = extractFrontmatter(content).get("status"); + return val instanceof String s ? s : ""; + } + + /** Walk up from the test working directory to find docs/registries/error-codes.yaml. */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/error-codes.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java new file mode 100644 index 00000000..0bb75906 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java @@ -0,0 +1,201 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Gate #11 (sample-removal smoke) of the contract-verification suite. The skeleton ships a + * reference domain ({@code sample-portfolio}) that remains in the template as a fixture. Production + * code and the sample-off contract build must not depend on it. A downstream adopter may delete the + * module only after the sample-off build is green. + * + * <p>The contract is a build/test classpath matrix, not a Spring runtime profile: + * + * <ol> + * <li>production modules reference the sample only through the app-bootstrap sample fixture + * configuration; + * <li>app-bootstrap core test sources compile without importing sample types; + * <li>the sample-off source set removes the fixture from its compile/runtime classpaths; + * <li>sample-on and sample-off are both release-blocking CI jobs. + * </ol> + * + * <p>The build-graph scan is the no-datasource proxy for "core app/context boots green without the + * sample": in this template the only application content is the sample, so the meaningful, + * infrastructure-free guarantee is that production never depends on it + * (feature-contract-verification-test-suite §구현 가이드 §6, 판정 기준 Required tests). + */ +class SampleRemovalSmokeContractTest { + + private static final List<String> PRODUCTION_MODULES = + List.of( + "domain-core", + "application-core", + "adapter/inbound/web", + "adapter/outbound/persistence-jpa", + "adapter/outbound/support", + "adapter/outbound/messaging", + "adapter/outbound/cache-redis", + "adapter/outbound/notification", + "adapter/outbound/httpclient", + "adapter/outbound/identifier", + "shared-contract", + "app-bootstrap"); + + /** A Gradle dependency line: {@code <config> project(':sample-portfolio')}. */ + private static final Pattern SAMPLE_DEP = + Pattern.compile("^\\s*([A-Za-z]+)\\s+project\\(\\s*['\"]:sample-portfolio['\"]\\s*\\)"); + + @Test + void productionModulesReferenceSampleOnlyAsTestFixtureDependency() throws IOException { + Path srcRoot = walkUpForDir("src"); + Assumptions.assumeTrue(srcRoot != null, "src/ Gradle root not found; smoke skipped"); + + List<String> violations = new ArrayList<>(); + for (String module : PRODUCTION_MODULES) { + Path buildFile = srcRoot.resolve(module).resolve("build.gradle"); + if (!Files.isRegularFile(buildFile)) { + continue; + } + for (String line : Files.readAllLines(buildFile)) { + Matcher m = SAMPLE_DEP.matcher(line); + if (m.find() && !m.group(1).startsWith("test") && !m.group(1).equals("sampleFixture")) { + violations.add( + module + + ": '" + + line.trim() + + "' (configuration '" + + m.group(1) + + "' is not test-scoped)"); + } + } + } + + assertThat(violations) + .as( + "sample-portfolio must be a test-only dependency of every production module so " + + "the sample can be removed without breaking the production build/runtime") + .isEmpty(); + } + + @Test + void appBootstrapCoreTestsDoNotImportSamplePortfolio() throws IOException { + Path srcRoot = walkUpForDir("src"); + Assumptions.assumeTrue(srcRoot != null, "src/ Gradle root not found; smoke skipped"); + + Path testJava = srcRoot.resolve("app-bootstrap/src/test/java"); + List<String> imports; + try (Stream<Path> files = Files.walk(testJava)) { + imports = + files + .filter(path -> path.toString().endsWith(".java")) + .flatMap( + path -> { + try { + return Files.readAllLines(path).stream() + .filter( + line -> line.startsWith("import dev.caskeleton.sample.portfolio.")) + .map(line -> testJava.relativize(path) + ": " + line.trim()); + } catch (IOException exception) { + throw new IllegalStateException("Cannot inspect " + path, exception); + } + }) + .sorted() + .toList(); + } + + assertThat(imports) + .as( + "app-bootstrap core tests must compile without sample-portfolio; sample-specific " + + "contract checks belong in the sample module") + .isEmpty(); + } + + @Test + void sampleOffSourceSetAndTaskAreDeclared() throws IOException { + Path srcRoot = walkUpForDir("src"); + Assumptions.assumeTrue(srcRoot != null, "src/ Gradle root not found; smoke skipped"); + + String build = Files.readString(srcRoot.resolve("app-bootstrap/build.gradle")); + + assertThat(build).contains("sampleFixture project(':sample-portfolio')"); + assertThat(build).contains("sampleOffTest"); + assertThat(build).contains("systemProperty 'ca.sample.mode', 'off'"); + } + + @Test + void runtimeSampleToggleIsNotRegistered() throws IOException { + Path registry = walkUpForFile("docs/registries/env-keys.yaml"); + Assumptions.assumeTrue( + registry != null, + "docs/registries/env-keys.yaml not on disk (/docs is gitignored); registry assertion skipped"); + + assertThat(Files.readString(registry)) + .as("sample-off is a build/test mode, so no APP_SAMPLE_ENABLED runtime toggle may remain") + .doesNotContain("- name: APP_SAMPLE_ENABLED"); + } + + @Test + void sampleOffCiJobIsReleaseBlocking() throws IOException { + Path srcRoot = walkUpForDir("src"); + Assumptions.assumeTrue(srcRoot != null, "src/ Gradle root not found; smoke skipped"); + + String workflow = + Files.readString(srcRoot.getParent().resolve(".github/workflows/ci-quality-gates.yml")); + + assertThat(workflow).contains("\n sample-off:\n"); + assertThat(workflow).contains("./gradlew :app-bootstrap:sampleOffTest"); + assertThat(workflow).contains("\n - sample-off\n"); + } + + @Test + void sampleClassIsAbsentFromTheSampleOffTestClasspath() { + Assumptions.assumeTrue( + "off".equals(System.getProperty("ca.sample.mode")), + "sample classpath absence is verified only by sampleOffTest"); + + assertThat(isClassPresent("dev.caskeleton.sample.portfolio.SamplePortfolioApplication")) + .as("sampleOffTest must not contain the sample-portfolio jar") + .isFalse(); + } + + private static boolean isClassPresent(String className) { + try { + Class.forName(className, false, SampleRemovalSmokeContractTest.class.getClassLoader()); + return true; + } catch (ClassNotFoundException expected) { + return false; + } + } + + private static Path walkUpForFile(String relative) { + for (Path dir = Paths.get("").toAbsolutePath(); dir != null; dir = dir.getParent()) { + Path candidate = dir.resolve(relative); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + return null; + } + + private static Path walkUpForDir(String relative) { + for (Path dir = Paths.get("").toAbsolutePath(); dir != null; dir = dir.getParent()) { + Path candidate = dir.resolve(relative); + if (Files.isDirectory(candidate) + && Files.isRegularFile(candidate.resolve("settings.gradle"))) { + return candidate; + } + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretReloadContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretReloadContractTest.java new file mode 100644 index 00000000..ec7af70d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretReloadContractTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; + +/** + * Runtime-behaviour complement to {@code NO_REFRESH_SCOPE_ANYWHERE} + * (feature-secrets-config-source-contract §4 / D3 / D10 / §테스트 계약). The static ArchRule is the + * first line of defence (a {@code @RefreshScope} bean fails the build); this test proves the + * behaviour that rule protects: after startup, mutating the underlying secret value does NOT + * auto-reload into the bound bean — config is startup-bound ({@code SPRING-EXTCONFIG-C5}), there is + * no in-process refresh machinery. + * + * <p>Branch-note Claims To Verify: "AWSCURRENT 변경 시 application 까지 자동 전파 안 되고 restart 필요". This is + * the local stand-in for that claim — we cannot drive a real secret manager here, so we assert (a) + * a startup-bound {@code @ConfigurationProperties} value is immutable against a post-refresh {@code + * Environment} mutation, and (b) the Spring Cloud refresh-scope machinery is absent from the + * runtime classpath entirely ({@code spring-cloud-context} is {@code testCompileOnly}), so no + * auto-reload path exists. + */ +class SecretReloadContractTest { + + @Test + void secretValueChangeAfterStartupIsNotAutoReloaded() { + new ApplicationContextRunner() + .withUserConfiguration(SecretHolderConfig.class) + .withPropertyValues("secret-reload-probe.value=initial-secret") + .run( + context -> { + SecretHolder bound = context.getBean(SecretHolder.class); + assertThat(bound.value()).isEqualTo("initial-secret"); + + // Simulate a secret-store rotation by injecting a new highest-priority + // property source AFTER the context has refreshed. + MutablePropertySources sources = context.getEnvironment().getPropertySources(); + sources.addFirst( + new MapPropertySource( + "rotated-secret-source", + Map.of("secret-reload-probe.value", "rotated-secret"))); + + // The already-bound singleton must keep its startup value — no @RefreshScope, + // so the new value is NOT propagated without a restart (D3 restart-only). + SecretHolder afterRotation = context.getBean(SecretHolder.class); + assertThat(afterRotation.value()) + .as( + "config is startup-bound (SPRING-EXTCONFIG-C5); without @RefreshScope " + + "a post-startup secret change must NOT auto-reload " + + "(feature-secrets-config-source-contract D3/D10 restart-only)") + .isEqualTo("initial-secret"); + }); + } + + @Test + void springCloudRefreshScopeMachineryIsAbsentFromTheRuntimeClasspath() { + // spring-cloud-context is testCompileOnly (it exists only so the @RefreshScope fixture + // compiles). At runtime the refresh machinery is absent, so there is no code path that + // could re-bind a secret in-process — the infrastructure-level guarantee behind D3. + assertThatThrownBy( + () -> Class.forName("org.springframework.cloud.context.scope.refresh.RefreshScope")) + .as( + "Spring Cloud refresh-scope machinery must not be on the runtime classpath — " + + "no in-process secret-reload path may exist " + + "(feature-secrets-config-source-contract D3/D10)") + .isInstanceOf(ClassNotFoundException.class); + } + + @ConfigurationProperties(prefix = "secret-reload-probe") + record SecretHolder(String value) {} + + @Configuration + @EnableConfigurationProperties(SecretHolder.class) + static class SecretHolderConfig {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java new file mode 100644 index 00000000..119b7331 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java @@ -0,0 +1,102 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.SecretSourceValidator; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Registry/constant drift guard for the secret classification contract + * (feature-secrets-config-source-contract §1 / C2). Asserts that {@code + * docs/registries/secrets-classification.yaml} stays in lock-step with the as-built {@link + * SecretSourceValidator#REQUIRED_PROD_SECRETS} constant — the set of rows classified {@code secret} + * with {@code prod_default: null} must match the validator's required-secret list 1:1, and every + * row's {@code classification} must be one of the three contract tiers. + * + * <p>The registry SSOT lives under {@code docs/registries/}, which this repo gitignores ({@code + * /docs}). When it is absent (CI / fresh checkout) the drift check is SKIPPED, not passed; when it + * IS present, a secret added to / removed from the registry without a matching constant change — or + * an out-of-vocabulary tier — is a hard FAIL. Mirrors the {@code + * RepositoryAccessCapabilityRegistryTest} / {@code ErrorCodeRegistryMappingTest} registry-drift + * pattern. + */ +class SecretsClassificationRegistryTest { + + private static final Set<String> VALID_TIERS = + Set.of("public-config", "sensitive-config", "secret"); + + private static List<Map<String, Object>> registryRows; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + Assumptions.assumeTrue( + registry != null, + "docs/registries/secrets-classification.yaml not on disk (/docs is gitignored); " + + "secrets registry-drift check runs locally only"); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> secrets = (List<Map<String, Object>>) root.get("secrets"); + registryRows = secrets; + } + } + + @Test + void requiredProdSecretRowsMatchTheValidatorConstantOneToOne() { + Set<String> registryRequired = new LinkedHashSet<>(); + for (Map<String, Object> row : registryRows) { + boolean isSecret = "secret".equals(row.get("classification")); + boolean prodDefaultNull = row.containsKey("prod_default") && row.get("prod_default") == null; + if (isSecret && prodDefaultNull) { + registryRequired.add((String) row.get("name")); + } + } + + assertThat(registryRequired) + .as( + "docs/registries/secrets-classification.yaml rows classified 'secret' with " + + "prod_default: null must match SecretSourceValidator.REQUIRED_PROD_SECRETS " + + "1:1 (feature-secrets-config-source-contract C2 drift guard). A secret added " + + "to the registry without updating the validator constant (or removed from the " + + "constant without a registry change) breaks this assertion.") + .isEqualTo(new LinkedHashSet<>(SecretSourceValidator.REQUIRED_PROD_SECRETS)); + } + + @Test + void everyRegistryRowUsesAValidClassificationTier() { + for (Map<String, Object> row : registryRows) { + assertThat(row.get("classification")) + .as( + "secrets-classification.yaml row '%s' must declare one of the 3 contract " + + "tiers %s (feature-secrets-config-source-contract D4)", + row.get("name"), VALID_TIERS) + .isIn(VALID_TIERS); + } + } + + /** + * Walk up from the test working directory to find docs/registries/secrets-classification.yaml. + */ + private static Path locateRegistry() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/secrets-classification.yaml"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java new file mode 100644 index 00000000..386a2811 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * feature-persistence-failure-baseline In-scope "SQL/parameter 로그 금지" + §4 — the committed env + * defaults must keep Hibernate SQL/parameter logging OFF. With {@code show_sql=true} the driver + * echoes the SQL statement (and, with parameter logging, bind values) into the application log — a + * data-leak and a contract violation. The skeleton pins the defaults to {@code false} in the + * committed {@code src/.env}; flipping either default fails this contract test. + * + * <p>{@code src/.env} is the committed runtime defaults file (CLAUDE.md — "bootRun uses src/ as its + * working directory so src/.env is picked up"), so this check runs in CI. If it is ever relocated, + * the test skips-not-passes rather than silently green. + */ +class SqlLoggingForbiddenContractTest { + + private Properties loadEnv() throws Exception { + Path env = locateSrcEnv(); + Assumptions.assumeTrue( + env != null, "src/.env not found on disk; SQL-logging default check runs locally only"); + Properties props = new Properties(); + try (InputStream in = Files.newInputStream(env)) { + props.load(in); + } + return props; + } + + @Test + void showSqlDefaultIsFalse() throws Exception { + assertThat(loadEnv().getProperty("APP_DATASOURCE_SHOW_SQL")) + .as("APP_DATASOURCE_SHOW_SQL must default to false — SQL must never be logged") + .isEqualTo("false"); + } + + @Test + void formatSqlDefaultIsFalse() throws Exception { + assertThat(loadEnv().getProperty("APP_DATASOURCE_FORMAT_SQL")) + .as("APP_DATASOURCE_FORMAT_SQL must default to false (only meaningful with show_sql=true)") + .isEqualTo("false"); + } + + @Test + void openInViewDefaultIsFalse() throws Exception { + // D2 lives at the config layer too: the committed default keeps OSIV off, paired with the + // OpenInViewSafetyValidator hard stop. + assertThat(loadEnv().getProperty("APP_DATASOURCE_OPEN_IN_VIEW")) + .as("APP_DATASOURCE_OPEN_IN_VIEW must default to false (D2 OSIV-off baseline)") + .isEqualTo("false"); + } + + /** Walk up from the test working directory to the first {@code .env} (src/.env is nearest). */ + private static Path locateSrcEnv() { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve(".env"); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StructuredLogFieldContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StructuredLogFieldContractTest.java new file mode 100644 index 00000000..003744f6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StructuredLogFieldContractTest.java @@ -0,0 +1,142 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Base contract #4 (structured log field) of the contract-verification suite. The structured JSON + * appender in {@code logback-spring.xml} pins the MDC field set it emits via {@code + * <includeMdcKeyName>}; this contract holds that set drift-free against two invariants: + * + * <ol> + * <li>every emitted log field is registered in {@code docs/registries/mdc-keys.yaml} (the MDC key + * SSOT) — a log line must never carry an unregistered field; + * <li>every emitted field is SNAKE_CASE (foundation L93 "SNAKE_CASE 강제"); + * <li>the request/trace/correlation correlation fields are actually present, so a structured log + * line is never missing its correlation surface. + * </ol> + * + * <p>The registry lives under the gitignored {@code /docs} tree, so the registry-backed assertions + * skip (not fail) when it is absent — matching the existing contract tests' skip-not-pass + * discipline (feature-contract-verification-test-suite §테스트 계약 9 base #4). + */ +class StructuredLogFieldContractTest { + + private static final Pattern INCLUDE_MDC = + Pattern.compile("<includeMdcKeyName>\\s*([A-Za-z0-9_]+)\\s*</includeMdcKeyName>"); + private static final Pattern REGISTRY_KEY = + Pattern.compile("^\\s*-\\s*key:\\s*([A-Za-z0-9_]+)\\s*$", Pattern.MULTILINE); + private static final Pattern SNAKE_CASE = Pattern.compile("[a-z][a-z0-9_]*"); + + /** MDC fields the structured appender must always carry (meta-projected correlation surface). */ + private static final Set<String> REQUIRED_CORRELATION_FIELDS = + Set.of("request_id", "trace_id", "correlation_id"); + + @Test + void structuredAppenderEmitsOnlyRegisteredSnakeCaseFields() throws IOException { + Set<String> emittedFields = includeMdcKeyNames(); + assertThat(emittedFields) + .as("logback-spring.xml must declare <includeMdcKeyName> structured fields") + .isNotEmpty(); + + // (2) every emitted field is SNAKE_CASE. + for (String field : emittedFields) { + assertThat(SNAKE_CASE.matcher(field).matches()) + .as( + "structured log field '%s' must be SNAKE_CASE (mdc-keys.yaml case_style: snake)", + field) + .isTrue(); + } + + // (3) the correlation surface is present. + assertThat(emittedFields) + .as("structured log lines must carry the request/trace/correlation correlation fields") + .containsAll(REQUIRED_CORRELATION_FIELDS); + + // (1) every emitted field is registered in the MDC key SSOT (skip-not-pass when /docs absent). + Set<String> registryKeys = registryKeys(); + Assumptions.assumeTrue( + registryKeys != null, + "docs/registries/mdc-keys.yaml not on disk (/docs is gitignored); " + + "registry-drift assertion skipped"); + assertThat(registryKeys) + .as("every structured log field must be a registered MDC key in mdc-keys.yaml") + .containsAll(emittedFields); + } + + @Test + void logbackEarlyPropertiesDoNotResolveApplicationYamlPlaceholders() throws IOException { + String logback = logbackText(); + + assertThat(logback) + .as("Logback initializes before dotenv-backed application.yml placeholders are reliable") + .doesNotContain("source=\"spring.application.name\"") + .doesNotContain("source=\"spring.profiles.active\"") + .doesNotContain("source=\"ca-skeleton.logging."); + assertThat(logback) + .contains("source=\"APP_NAME\"") + .contains("source=\"SPRING_PROFILES_ACTIVE\"") + .contains("source=\"APP_LOG_FILE_ENABLED\""); + } + + /** Distinct {@code <includeMdcKeyName>} values from the structured JSON appenders. */ + private static Set<String> includeMdcKeyNames() throws IOException { + String logback = logbackText(); + Set<String> names = new LinkedHashSet<>(); + Matcher m = INCLUDE_MDC.matcher(logback); + while (m.find()) { + names.add(m.group(1)); + } + return names; + } + + private static String logbackText() throws IOException { + try (InputStream in = + StructuredLogFieldContractTest.class.getResourceAsStream("/logback-spring.xml")) { + Assumptions.assumeTrue(in != null, "logback-spring.xml not on the test classpath"); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * {@code - key: <name>} entries from mdc-keys.yaml, or {@code null} when the registry is absent. + */ + private static Set<String> registryKeys() throws IOException { + Path registry = walkUpFor("docs/registries/mdc-keys.yaml"); + if (registry == null) { + return null; + } + String text = Files.readString(registry); + Set<String> keys = new LinkedHashSet<>(); + Matcher m = REGISTRY_KEY.matcher(text); + while (m.find()) { + keys.add(m.group(1)); + } + return keys; + } + + /** Walk up from the test working directory to find {@code relative}, or {@code null}. */ + private static Path walkUpFor(String relative) { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve(relative); + if (Files.isRegularFile(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.errorEnvelopeShapeRetryableTransient.approved.txt b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.errorEnvelopeShapeRetryableTransient.approved.txt new file mode 100644 index 00000000..45afe83e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.errorEnvelopeShapeRetryableTransient.approved.txt @@ -0,0 +1,17 @@ +{ + "success" : false, + "data" : null, + "error" : { + "code" : "DB_UNAVAILABLE", + "category" : "TRANSIENT_DEPENDENCY", + "message" : "the service is temporarily unavailable, please retry", + "retryable" : true, + "details" : null + }, + "meta" : { + "requestId" : "<scrubbed>", + "traceId" : "<scrubbed>", + "correlationId" : "<scrubbed>", + "page" : null + } +} \ No newline at end of file diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.errorEnvelopeShapeWithValidationDetails.approved.txt b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.errorEnvelopeShapeWithValidationDetails.approved.txt new file mode 100644 index 00000000..77398738 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.errorEnvelopeShapeWithValidationDetails.approved.txt @@ -0,0 +1,20 @@ +{ + "success" : false, + "data" : null, + "error" : { + "code" : "WORKLOG_TITLE_BLANK", + "category" : "VALIDATION", + "message" : "title must not be blank", + "retryable" : false, + "details" : [ { + "field" : "title", + "reason" : "must not be blank" + } ] + }, + "meta" : { + "requestId" : "<scrubbed>", + "traceId" : "<scrubbed>", + "correlationId" : "<scrubbed>", + "page" : null + } +} \ No newline at end of file diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.successEnvelopeShape.approved.txt b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.successEnvelopeShape.approved.txt new file mode 100644 index 00000000..b404b4af --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/approved/EnvelopeContractTest.successEnvelopeShape.approved.txt @@ -0,0 +1,14 @@ +{ + "success" : true, + "data" : { + "id" : "worklog-1", + "title" : "ship the contract suite" + }, + "error" : null, + "meta" : { + "requestId" : "<scrubbed>", + "traceId" : "<scrubbed>", + "correlationId" : "<scrubbed>", + "page" : null + } +} \ No newline at end of file diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java new file mode 100644 index 00000000..a460e9cb --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.bootstrap.contract.outbox; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaField; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.EvaluationResult; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Contract test #3 — PII field detection on {@code @DomainEvent} and {@code *IntegrationEvent} + * classes (feature-domain-event-outbox-contract Task E). + * + * <p>Scans all production classes (dev.caskeleton.* minus the violations fixture package) and fails + * when any field name matches {@code (?i)(email|password|token|authorization|secret|rawbody)}. + * + * <p>Follows the red/green isolation pattern from {@link + * dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest}: + * + * <ul> + * <li><strong>Red test:</strong> loads only the violation fixture ({@code + * PiiViolationEventFixture} — a {@code @DomainEvent} with an {@code email} field) and asserts + * the rule fires. + * <li><strong>Green test:</strong> loads all production + sample classes (excluding the + * violations package) and asserts the rule does NOT fire — i.e., no real event class carries + * a PII field name. + * </ul> + */ +class EventPayloadPiiContractTest { + + private static final Pattern PII_PATTERN = + Pattern.compile("(?i)(email|password|token|authorization|secret|rawbody)"); + + private static final String VIOLATIONS_PACKAGE = + "dev.caskeleton.bootstrap.architecture.violations"; + + /** + * ArchUnit rule: no {@code @DomainEvent} or {@code *IntegrationEvent} class may have a field + * whose name matches the PII pattern. + * + * <p>The rule predicate selects classes annotated with {@link DomainEvent} OR whose simple name + * ends with {@code IntegrationEvent}. + */ + static final ArchRule NO_PII_FIELDS_IN_DOMAIN_EVENTS_OR_INTEGRATION_EVENTS = + classes() + .that( + new DescribedPredicate<>("are @DomainEvent or *IntegrationEvent") { + @Override + public boolean test(JavaClass javaClass) { + return javaClass.isAnnotatedWith(DomainEvent.class) + || javaClass.getSimpleName().endsWith("IntegrationEvent"); + } + }) + .should( + new ArchCondition<>( + "have no field names matching PII pattern " + PII_PATTERN.pattern()) { + @Override + public void check(JavaClass item, ConditionEvents events) { + for (JavaField field : item.getFields()) { + if (PII_PATTERN.matcher(field.getName()).find()) { + events.add( + SimpleConditionEvent.violated( + item, + String.format( + "Field '%s' in class '%s' matches PII pattern %s", + field.getName(), item.getFullName(), PII_PATTERN.pattern()))); + } + } + } + }) + .as("domain events and integration events must not contain PII field names") + .allowEmptyShould(true); + + // ---- Red test: violation fixture must trip the rule ---- + + private static final JavaClasses PII_VIOLATION_FIXTURE_ONLY = + new ClassFileImporter().importPackages(VIOLATIONS_PACKAGE + ".domain.event.pii"); + + @Test + void piiRuleCatchesDomainEventWithEmailField() { + EvaluationResult result = + NO_PII_FIELDS_IN_DOMAIN_EVENTS_OR_INTEGRATION_EVENTS.evaluate(PII_VIOLATION_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as("PII rule must catch PiiViolationEventFixture which has an 'email' field") + .isTrue(); + } + + // ---- Green test: production classes must pass the rule ---- + + /** + * Production + sample-portfolio classes excluding the violations fixture package. + * + * <p>Scans {@code dev.caskeleton} broadly (covers domain-core, application-core, + * adapter-outbound, sample-portfolio all on the test classpath) but excludes the {@code + * violations} package which intentionally contains broken fixtures. + */ + private static final JavaClasses PRODUCTION_CLASSES = + new ClassFileImporter() + .importPackages("dev.caskeleton") + .that( + new DescribedPredicate<JavaClass>("not in violations package") { + @Override + public boolean test(JavaClass javaClass) { + return !javaClass.getPackageName().startsWith(VIOLATIONS_PACKAGE); + } + }); + + @Test + void noProductionDomainEventOrIntegrationEventHasPiiField() { + EvaluationResult result = + NO_PII_FIELDS_IN_DOMAIN_EVENTS_OR_INTEGRATION_EVENTS.evaluate(PRODUCTION_CLASSES); + + assertThat(result.hasViolation()) + .as( + "no production @DomainEvent or *IntegrationEvent class must have " + + "a field name matching the PII pattern — violations: " + + result.getFailureReport().getDetails()) + .isFalse(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java new file mode 100644 index 00000000..14a8f59d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java @@ -0,0 +1,127 @@ +package dev.caskeleton.bootstrap.contract.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.outbox.OutboxEventStatus; +import dev.caskeleton.shared.error.OperationalError; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Contract test — {@link OutboxEventStatus} 5 values ↔ {@code metrics.yaml} {@code + * outbox.pending.size} allowed_values, and {@code OUTBOX_*} 2 error codes ↔ {@code + * error-codes.yaml} category/retryable. + * + * <p>Skips when the registry files are absent (docs/ is gitignored; same pattern as {@code + * ErrorCodeRegistryMappingTest}). + */ +class OutboxStatusRegistryContractTest { + + private static Map<String, Object> metricsRoot; + private static List<Map<String, Object>> errorCodes; + + @BeforeAll + static void loadRegistries() throws Exception { + Path metricsPath = locateRegistry("metrics.yaml"); + Path errorPath = locateRegistry("error-codes.yaml"); + + Assumptions.assumeTrue( + metricsPath != null && errorPath != null, + "docs/registries not on disk (gitignored); " + + "OutboxStatusRegistryContractTest runs locally only"); + + try (InputStream in = Files.newInputStream(metricsPath)) { + metricsRoot = new Yaml().load(in); + } + try (InputStream in = Files.newInputStream(errorPath)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + errorCodes = errors; + } + } + + @Test + void outboxEventStatusEnumMatchesMetricsYamlPendingSizeAllowedValues() { + @SuppressWarnings("unchecked") + List<Map<String, Object>> metrics = (List<Map<String, Object>>) metricsRoot.get("metrics"); + + Map<String, Object> pendingSize = + metrics.stream() + .filter(m -> "outbox.pending.size".equals(m.get("name"))) + .findFirst() + .orElseThrow( + () -> new AssertionError("outbox.pending.size must exist in metrics.yaml")); + + @SuppressWarnings("unchecked") + List<Map<String, Object>> tags = (List<Map<String, Object>>) pendingSize.get("tags"); + Map<String, Object> statusTag = + tags.stream() + .filter(t -> "status".equals(t.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("outbox.pending.size must have a 'status' tag")); + + @SuppressWarnings("unchecked") + List<String> allowedValues = (List<String>) statusTag.get("allowed_values"); + Set<String> registryStatuses = Set.copyOf(allowedValues); + + Set<String> enumStatuses = + Set.of(OutboxEventStatus.values()).stream().map(Enum::name).collect(Collectors.toSet()); + + assertThat(enumStatuses) + .as( + "OutboxEventStatus enum values must match " + + "metrics.yaml outbox.pending.size allowed_values") + .containsExactlyInAnyOrderElementsOf(registryStatuses); + } + + @Test + void outboxErrorCodesMatchRegistryCategoryAndRetryable() { + Map<String, Map<String, Object>> codeIndex = + errorCodes.stream().collect(Collectors.toMap(row -> (String) row.get("code"), row -> row)); + + // OUTBOX_PUBLISH_FAILED: category=TRANSIENT_DEPENDENCY, retryable=true + assertRegistryRow( + codeIndex, OperationalError.OUTBOX_PUBLISH_FAILED, "TRANSIENT_DEPENDENCY", true); + + // OUTBOX_DEAD_LETTER: category=INTERNAL, retryable=false + assertRegistryRow(codeIndex, OperationalError.OUTBOX_DEAD_LETTER, "INTERNAL", false); + } + + private static void assertRegistryRow( + Map<String, Map<String, Object>> index, + OperationalError error, + String expectedCategory, + boolean expectedRetryable) { + String code = error.code(); + assertThat(index).as("registry must contain error code " + code).containsKey(code); + Map<String, Object> row = index.get(code); + assertThat(row.get("category")) + .as(code + " category must be " + expectedCategory) + .isEqualTo(expectedCategory); + assertThat(row.get("retryable")) + .as(code + " retryable must be " + expectedRetryable) + .isEqualTo(expectedRetryable); + } + + private static Path locateRegistry(String filename) { + Path dir = Path.of("").toAbsolutePath(); + for (int i = 0; i < 6 && dir != null; i++) { + Path candidate = dir.resolve("docs/registries/" + filename); + if (Files.exists(candidate)) { + return candidate; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/ContractSnapshots.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/ContractSnapshots.java new file mode 100644 index 00000000..a58e95e2 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/ContractSnapshots.java @@ -0,0 +1,63 @@ +package dev.caskeleton.bootstrap.contract.support; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * Test-only snapshot normalization for the contract-verification suite. Replaces non-deterministic + * field values (timestamps, request/trace/correlation/span ids, durations, and stray UUIDs) with a + * stable placeholder so an ApprovalTests JSON snapshot diffs only on an intended <em>shape</em> + * change, never on per-run churn. + * + * <p>The volatile field list is the one enumerated by the branch spec §3 (snapshot scrub): {@code + * timestamp}, {@code trace_id}, {@code request_id}, {@code correlation_id}, {@code span_id}, {@code + * duration_ms} plus UUID ids. Both the camelCase wire form ({@code traceId}) and the SNAKE_CASE log + * form ({@code trace_id}) are covered because the same scrubber normalizes both envelope JSON and + * captured structured-log JSON. + * + * <p>The output is plain text fed to {@code Approvals.verify(String)}; it is not required to remain + * valid JSON after scrubbing — only stable. (feature-contract-verification-test-suite §3.) + */ +public final class ContractSnapshots { + + private ContractSnapshots() {} + + /** Placeholder substituted for every volatile value. */ + public static final String SCRUBBED = "<scrubbed>"; + + /** camelCase wire form + SNAKE_CASE log form of every non-deterministic field. */ + private static final List<String> VOLATILE_FIELDS = + List.of( + "timestamp", + "trace_id", + "traceId", + "request_id", + "requestId", + "correlation_id", + "correlationId", + "span_id", + "spanId", + "duration_ms", + "durationMs"); + + /** Canonical UUID = 8-4-4-4-12 hexadecimal groups (RFC 9562). */ + private static final Pattern UUID = + Pattern.compile( + "\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\b"); + + /** + * Returns {@code json} with every volatile field value and stray UUID replaced by {@link + * #SCRUBBED}. + */ + public static String scrub(String json) { + String result = json; + for (String field : VOLATILE_FIELDS) { + String key = "(\"" + Pattern.quote(field) + "\"\\s*:\\s*)"; + // string value: "field" : "..." + result = result.replaceAll(key + "\"[^\"]*\"", "$1\"" + SCRUBBED + "\""); + // numeric value: "field" : 1234(.56) + result = result.replaceAll(key + "-?\\d+(\\.\\d+)?", "$1" + SCRUBBED); + } + return UUID.matcher(result).replaceAll(SCRUBBED); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfEmailNotificationConfigured.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfEmailNotificationConfigured.java new file mode 100644 index 00000000..09723dc5 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfEmailNotificationConfigured.java @@ -0,0 +1,24 @@ +package dev.caskeleton.bootstrap.contract.support.conditional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Gates an optional-adapter contract test to the email-notification-configured env matrix. {@code + * APP_NOTIFICATION_EMAIL_PROVIDER} is a provider name (string), so the match is {@code .+}. Reports + * DISABLED (= SKIPPED, never FAILED) when the provider is unset + * (feature-contract-verification-test-suite D3). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable( + named = "APP_NOTIFICATION_EMAIL_PROVIDER", + matches = ".+", + disabledReason = + "No email notification provider configured " + + "(APP_NOTIFICATION_EMAIL_PROVIDER unset) — optional-adapter contract test runs " + + "only when an email provider is set") +public @interface EnabledIfEmailNotificationConfigured {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java new file mode 100644 index 00000000..d8205186 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpCircuitBreakerEnabled.java @@ -0,0 +1,23 @@ +package dev.caskeleton.bootstrap.contract.support.conditional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Gates an optional-adapter contract test to the outbound-HTTP-circuit-breaker-enabled env matrix. + * Reports DISABLED (= SKIPPED, never FAILED) when {@code APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED} + * is unset or not {@code true} (feature-contract-verification-test-suite D3). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable( + named = "APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED", + matches = "true", + disabledReason = + "Outbound HTTP circuit breaker disabled " + + "(APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED != true) — optional-adapter contract " + + "test runs only in the circuit-breaker-enabled env matrix") +public @interface EnabledIfHttpCircuitBreakerEnabled {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java new file mode 100644 index 00000000..da42a1cf --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfHttpRetryEnabled.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.contract.support.conditional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Gates an optional-adapter contract test to the outbound-HTTP-retry-enabled env matrix. Reports + * DISABLED (= SKIPPED, never FAILED) when {@code APP_OUTBOUND_HTTP_RETRY_ENABLED} is unset or not + * {@code true} (feature-contract-verification-test-suite D3). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable( + named = "APP_OUTBOUND_HTTP_RETRY_ENABLED", + matches = "true", + disabledReason = + "Outbound HTTP retry disabled (APP_OUTBOUND_HTTP_RETRY_ENABLED != true) — " + + "optional-adapter contract test runs only in the retry-enabled env matrix") +public @interface EnabledIfHttpRetryEnabled {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfMessagingBrokerConfigured.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfMessagingBrokerConfigured.java new file mode 100644 index 00000000..d4251234 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfMessagingBrokerConfigured.java @@ -0,0 +1,23 @@ +package dev.caskeleton.bootstrap.contract.support.conditional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Gates an optional-adapter contract test to the messaging-broker-configured env matrix. {@code + * APP_MESSAGING_BROKER} is a provider name (string), so the match is {@code .+} (any non-empty + * value) rather than {@code true}. Reports DISABLED (= SKIPPED, never FAILED) when the broker is + * unset (feature-contract-verification-test-suite D3). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable( + named = "APP_MESSAGING_BROKER", + matches = ".+", + disabledReason = + "No messaging broker configured (APP_MESSAGING_BROKER unset) — " + + "optional-adapter contract test runs only when a broker provider is set") +public @interface EnabledIfMessagingBrokerConfigured {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfRedisCacheEnabled.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfRedisCacheEnabled.java new file mode 100644 index 00000000..17a20fb6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfRedisCacheEnabled.java @@ -0,0 +1,23 @@ +package dev.caskeleton.bootstrap.contract.support.conditional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Gates an optional-adapter contract test to the Redis-cache-enabled env matrix. The composed + * {@link EnabledIfEnvironmentVariable} reports DISABLED (= SKIPPED, never FAILED) when {@code + * APP_CACHE_REDIS_ENABLED} is unset or not {@code true} (feature-contract-verification-test-suite + * D3 — JUnit 5 primary mechanism). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable( + named = "APP_CACHE_REDIS_ENABLED", + matches = "true", + disabledReason = + "Redis cache adapter disabled (APP_CACHE_REDIS_ENABLED != true) — " + + "optional-adapter contract test runs only in the Redis-enabled env matrix") +public @interface EnabledIfRedisCacheEnabled {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfSlackNotificationConfigured.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfSlackNotificationConfigured.java new file mode 100644 index 00000000..e2d08ffe --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfSlackNotificationConfigured.java @@ -0,0 +1,24 @@ +package dev.caskeleton.bootstrap.contract.support.conditional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Gates an optional-adapter contract test to the Slack-notification-configured env matrix. {@code + * APP_NOTIFICATION_SLACK_PROVIDER} is a provider name (string), so the match is {@code .+}. Reports + * DISABLED (= SKIPPED, never FAILED) when the provider is unset + * (feature-contract-verification-test-suite D3). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@EnabledIfEnvironmentVariable( + named = "APP_NOTIFICATION_SLACK_PROVIDER", + matches = ".+", + disabledReason = + "No Slack notification provider configured " + + "(APP_NOTIFICATION_SLACK_PROVIDER unset) — optional-adapter contract test runs " + + "only when a Slack provider is set") +public @interface EnabledIfSlackNotificationConfigured {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencySettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencySettingsTest.java new file mode 100644 index 00000000..55fdb545 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/idempotency/IdempotencySettingsTest.java @@ -0,0 +1,45 @@ +package dev.caskeleton.bootstrap.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class IdempotencySettingsTest { + + @Test + void bindsAValidTtlAndReaperInterval() { + IdempotencySettings props = + new IdempotencySettings(Duration.ofHours(24), Duration.ofMinutes(5)); + assertThat(props.ttl()).isEqualTo(Duration.ofHours(24)); + assertThat(props.reaperInterval()).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + void allowsOverrideUpToThe72hCap() { + assertThat(new IdempotencySettings(Duration.ofHours(72), null).ttl()) + .isEqualTo(Duration.ofHours(72)); + } + + @Test + void rejectsATtlAbove72h() { + assertThatThrownBy(() -> new IdempotencySettings(Duration.ofHours(73), null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("72h"); + } + + @Test + void rejectsANonPositiveTtl() { + assertThatThrownBy(() -> new IdempotencySettings(Duration.ZERO, null)) + .isInstanceOf(StartupValidationException.class); + } + + @Test + void defaultsAbsentValues() { + IdempotencySettings props = new IdempotencySettings(null, null); + assertThat(props.ttl()).isEqualTo(Duration.ofHours(24)); + assertThat(props.reaperInterval()).isEqualTo(Duration.ofMinutes(10)); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java new file mode 100644 index 00000000..d93d37dc --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java @@ -0,0 +1,454 @@ +package dev.caskeleton.bootstrap.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig; +import dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter; +import dev.caskeleton.adapter.outbound.persistence.lock.LockSettings; +import dev.caskeleton.application.lock.DistributedLock; +import dev.caskeleton.application.lock.DistributedLockPort; +import dev.caskeleton.application.lock.LockAcquisitionTimeoutException; +import dev.caskeleton.bootstrap.lock.DistributedLockConfig; +import dev.caskeleton.bootstrap.lock.MeteredDistributedLockPort; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.ConcurrentModificationException; +import java.util.Map; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.MapPropertySource; +import org.springframework.integration.jdbc.lock.DefaultLockRepository; +import org.springframework.integration.jdbc.lock.JdbcLockRegistry; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Testcontainers contract test for {@code distributedLockProvider} bean wiring and distributed-lock + * behavioral guarantees (feature-distributed-lock-contract). + * + * <h2>Scope</h2> + * + * <ul> + * <li><b>D1 bean presence</b> — with {@code multi-instance-enabled=true}, a bean named {@code + * distributedLockProvider} of type {@link DistributedLockPort} must exist; with the flag + * false/absent, {@code inProcessDistributedLock} exists and {@code distributedLockProvider} + * does NOT. + * <li><b>D3 mutual exclusion</b> — two independent lock instances over the same container + * DataSource cannot hold the same key simultaneously. + * <li><b>D5 lease expiry</b> — after the lease TTL elapses, the second instance can acquire a key + * that was held by a crashed first instance. + * <li><b>D4 release ordering</b> — {@code close()} on the handle releases the lock so a + * subsequent acquire by another instance succeeds. + * <li><b>SI-LOCK-C5 lease-expiry close() handling</b> — after the lease TTL expires, (a) the raw + * adapter's {@code close()} throws {@link java.util.ConcurrentModificationException}, and (b) + * the {@link MeteredDistributedLockPort} absorbs it and increments {@code lock.lease.expired} + * without re-throwing. + * </ul> + * + * <p>The whole class is guarded by {@link #assumeDockerAvailable()} so it SKIPs (not fails) in + * environments without Docker. The behavioral D3/D5 tests construct two independent {@link + * LockRegistryDistributedLockAdapter} instances directly (two {@link DefaultLockRepository} + + * {@link JdbcLockRegistry} over the SAME DataSource), simulating two application instances without + * Spring context overhead. + * + * <p>Note on compile classpath: {@code spring-integration-jdbc} is an {@code implementation} dep of + * {@code adapter-persistence}. Since the D3/D5 tests need to construct SI objects directly, {@code + * spring-integration-jdbc} is added as {@code testImplementation} in {@code + * app-bootstrap/build.gradle}. + */ +class DistributedLockProviderContractTest { + + static final String PG_IMAGE = "postgres:16-alpine"; + + // Shared container — started once for all tests in this class. + static PostgreSQLContainer pg; + static HikariDataSource sharedDataSource; + + @BeforeAll + static void startContainer() { + assumeDockerAvailable(); + pg = PostgreSqlTestContainer.create(PG_IMAGE); + pg.start(); + + sharedDataSource = hikari(pg); + + // Flyway migrates V1 (idempotency), V2 (work_log from sample-portfolio on + // app-bootstrap test classpath), V3 (outbox), V4 (INT_LOCK). + Flyway.configure() + .dataSource(sharedDataSource) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + @AfterAll + static void stopContainer() { + if (sharedDataSource != null) { + sharedDataSource.close(); + } + if (pg != null) { + pg.stop(); + } + } + + // ====================================================================== + // D1 — bean presence: multi-instance=true provides distributedLockProvider + // ====================================================================== + + @Test + void d1MultiInstanceTrueProvidesDistributedLockProviderBean() { + try (AnnotationConfigApplicationContext ctx = buildContext(true)) { + assertThat(ctx.containsBean("distributedLockProvider")) + .as("distributedLockProvider bean must exist when multi-instance-enabled=true (D1)") + .isTrue(); + assertThat(ctx.getBean("distributedLockProvider")) + .as("distributedLockProvider must implement DistributedLockPort") + .isInstanceOf(DistributedLockPort.class); + } + } + + @Test + void d1MultiInstanceFalseProvidesInProcessDistributedLockNotDistributedLockProvider() { + try (AnnotationConfigApplicationContext ctx = buildContext(false)) { + assertThat(ctx.containsBean("inProcessDistributedLock")) + .as("inProcessDistributedLock bean must exist when multi-instance-enabled=false") + .isTrue(); + assertThat(ctx.containsBean("distributedLockProvider")) + .as( + "distributedLockProvider must NOT exist when multi-instance-enabled=false (in-process default)") + .isFalse(); + } + } + + // ====================================================================== + // D3 — mutual exclusion: two independent adapters over the same DataSource + // ====================================================================== + + @Test + void d3MutualExclusionSecondAcquireOnSameKeyThrowsTimeout() throws Exception { + // Two independent DefaultLockRepository + JdbcLockRegistry instances over the + // same DataSource simulate two application instances. + Duration ttl = Duration.ofSeconds(30); + DefaultLockRepository repoA = buildRepository(sharedDataSource); + DefaultLockRepository repoB = buildRepository(sharedDataSource); + try { + LockRegistryDistributedLockAdapter adapterA = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoA, ttl), ttl); + LockRegistryDistributedLockAdapter adapterB = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoB, ttl), ttl); + + String key = "d3-mutual-exclusion-" + System.nanoTime(); + Duration shortWait = Duration.ofMillis(100); + + // A acquires. + DistributedLock lockA = adapterA.tryAcquire(key, Duration.ofSeconds(1), ttl); + try { + // B must time out while A holds the lock. + assertThatThrownBy(() -> adapterB.tryAcquire(key, shortWait, ttl)) + .as("D3: second instance must not acquire key held by first instance") + .isInstanceOf(LockAcquisitionTimeoutException.class); + } finally { + lockA.close(); + } + + // D4 release ordering: after A releases, B can acquire. + DistributedLock lockB = adapterB.tryAcquire(key, Duration.ofSeconds(1), ttl); + assertThat(lockB) + .as("D4: after close(), another instance must be able to acquire the key") + .isNotNull(); + lockB.close(); + } finally { + repoA.stop(); + repoB.stop(); + } + } + + // ====================================================================== + // D5 — lease expiry: expired row is reclaimed by another instance + // ====================================================================== + + @Test + void d5LeaseExpiryAllowsSecondInstanceToAcquireAfterTtl() throws Exception { + // Short TTL (1 s) so the lease expires quickly. + Duration shortTtl = Duration.ofSeconds(1); + DefaultLockRepository repoA = buildRepository(sharedDataSource); + DefaultLockRepository repoB = buildRepository(sharedDataSource); + try { + LockRegistryDistributedLockAdapter adapterA = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoA, shortTtl), shortTtl); + LockRegistryDistributedLockAdapter adapterB = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoB, shortTtl), shortTtl); + + String key = "d5-lease-expiry-" + System.nanoTime(); + + // A acquires and does NOT release (simulating a crash — intentional discard). + @SuppressWarnings("unused") + DistributedLock crashedLockA = adapterA.tryAcquire(key, Duration.ofSeconds(1), shortTtl); + // crashedLockA is intentionally not closed — simulates a JVM crash that never + // calls close(). The TTL is the only mechanism that frees the row. + + // Sleep past the TTL boundary so the lease has elapsed. + Thread.sleep(shortTtl.toMillis() + 500); + + // Bounded poll: try to acquire B until success or the bound is hit. + // Polls up to shortTtl × 4 total, in 200 ms increments, to avoid a single + // post-sleep attempt that is fragile under slow CI environments. + Duration pollBound = shortTtl.multipliedBy(4); + long pollDeadline = System.currentTimeMillis() + pollBound.toMillis(); + DistributedLock lockB = null; + LockAcquisitionTimeoutException lastTimeout = null; + while (lockB == null && System.currentTimeMillis() < pollDeadline) { + try { + lockB = adapterB.tryAcquire(key, Duration.ofMillis(200), shortTtl); + } catch (LockAcquisitionTimeoutException e) { + lastTimeout = e; + Thread.sleep(100); + } + } + if (lockB == null) { + throw new AssertionError( + "D5: adapterB could not acquire key '" + + key + + "' within " + + pollBound + + " after TTL expiry", + lastTimeout); + } + lockB.close(); + } finally { + repoA.stop(); + repoB.stop(); + } + } + + // ====================================================================== + // SI-LOCK-C5 — lease-expiry close() handling + // ====================================================================== + + /** + * (a) Documents the raw SI behavior: after TTL expires, the raw adapter handle's {@code close()} + * throws {@link java.util.ConcurrentModificationException} because the INT_LOCK row was + * reclaimed. + * + * <p>This test <em>intentionally</em> verifies the exception the decorator must absorb + * (branch-note §Edge / SI-LOCK-C5). + */ + @Test + void siLockC5RawAdapterCloseThrowsCMEAfterLeaseExpires() throws Exception { + Duration shortTtl = Duration.ofSeconds(1); + DefaultLockRepository repoA = buildRepository(sharedDataSource); + try { + LockRegistryDistributedLockAdapter adapterA = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoA, shortTtl), shortTtl); + + String key = "c5-raw-cme-" + System.nanoTime(); + DistributedLock handle = adapterA.tryAcquire(key, Duration.ofSeconds(1), shortTtl); + + // Let the lease expire — another instance can reclaim the row. + Thread.sleep(shortTtl.toMillis() + 500); + + // Acquire from a second adapter to reclaim the row (makes CME deterministic). + DefaultLockRepository repoB = buildRepository(sharedDataSource); + try { + LockRegistryDistributedLockAdapter adapterB = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoB, shortTtl), shortTtl); + // Intentional discard — we only need B to reclaim the row, not track the handle. + @SuppressWarnings("unused") + DistributedLock reclaimHandle = adapterB.tryAcquire(key, Duration.ofSeconds(2), shortTtl); + } finally { + repoB.stop(); + } + + // Now closing A's handle should throw CME (row was reclaimed by B). + assertThatThrownBy(handle::close) + .as( + "SI-LOCK-C5: raw adapter close() must throw ConcurrentModificationException " + + "when the lease expired and the row was reclaimed") + .isInstanceOf(ConcurrentModificationException.class); + } finally { + repoA.stop(); + } + } + + /** + * (b) Verifies the {@link MeteredDistributedLockPort} absorbs the {@link + * java.util.ConcurrentModificationException} from lease expiry: the metered handle's {@code + * close()} does NOT throw, and the {@code lock.lease.expired} counter increments by 1. + */ + @Test + void siLockC5MeteredPortSwallowsCMEAndIncrementsLeaseExpiredCounter() throws Exception { + Duration shortTtl = Duration.ofSeconds(1); + DefaultLockRepository repoA = buildRepository(sharedDataSource); + + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + LockRegistryDistributedLockAdapter rawAdapter = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoA, shortTtl), shortTtl); + MeteredDistributedLockPort meteredPort = + new MeteredDistributedLockPort(rawAdapter, providerOf(meterRegistry)); + + try { + String key = "c5-metered-" + System.nanoTime(); + DistributedLock handle = meteredPort.tryAcquire(key, Duration.ofSeconds(1), shortTtl); + + // Let the lease expire. + Thread.sleep(shortTtl.toMillis() + 500); + + // Acquire from a second adapter to reclaim the row (makes CME deterministic). + DefaultLockRepository repoB = buildRepository(sharedDataSource); + try { + LockRegistryDistributedLockAdapter adapterB = + new LockRegistryDistributedLockAdapter(new JdbcLockRegistry(repoB, shortTtl), shortTtl); + // Intentional discard — only need to reclaim the row. + @SuppressWarnings("unused") + DistributedLock reclaimHandle = adapterB.tryAcquire(key, Duration.ofSeconds(2), shortTtl); + } finally { + repoB.stop(); + } + + // The metered handle must NOT throw (SI-LOCK-C5 — 정상 흐름 복귀). + assertThatCode(handle::close) + .as("SI-LOCK-C5: MeteredDistributedLockPort.close() must not throw when lease expired") + .doesNotThrowAnyException(); + + // The lease-expired counter must have incremented. + // Use the literal "lock.lease.expired" (metrics.yaml §Distributed lock) — the constant + // is package-private in MeteredDistributedLockPort; the pinning test in + // MeteredDistributedLockPortTest.lock_lease_expired_constant_matches_registry_name() + // ensures the constant value stays in sync with this literal. + assertThat(meterRegistry.get("lock.lease.expired").counter().count()) + .as("lock.lease.expired counter must be 1 after one CME on close()") + .isEqualTo(1.0); + } finally { + repoA.stop(); + } + } + + // ====================================================================== + // helpers + // ====================================================================== + + private static void assumeDockerAvailable() { + Assumptions.assumeTrue( + DockerClientFactory.instance().isDockerAvailable(), + "Docker not available — skipping Testcontainers-based distributed lock contract test"); + } + + private static HikariDataSource hikari(PostgreSQLContainer pg) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(pg.getJdbcUrl()); + config.setUsername(pg.getUsername()); + config.setPassword(pg.getPassword()); + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + return new HikariDataSource(config); + } + + private static DefaultLockRepository buildRepository(DataSource dataSource) { + DefaultLockRepository repo = new DefaultLockRepository(dataSource); + // INT_LOCK is provisioned by Flyway V4 — skip the DDL check. + repo.setCheckDatabaseOnStart(false); + // DefaultLockRepository implements InitializingBean (afterPropertiesSet) AND + // SmartInitializingSingleton (afterSingletonsInstantiated). The transaction templates + // (readCommittedTransactionTemplate etc.) are built in afterSingletonsInstantiated — + // that lifecycle callback fires automatically in a Spring context but must be invoked + // explicitly when constructing outside one. We set the transaction manager first so + // afterSingletonsInstantiated can wire it without needing an ApplicationContext. + repo.setTransactionManager(new DataSourceTransactionManager(dataSource)); + repo.afterPropertiesSet(); + repo.afterSingletonsInstantiated(); + repo.start(); + return repo; + } + + /** + * Builds a minimal Spring context with {@link DistributedLockPersistenceConfig}, {@link + * DistributedLockConfig}, {@link LockSettings}, the Testcontainers DataSource, and a {@link + * DataSourceTransactionManager}. + * + * <p>The DataSource lifecycle is owned by the test ({@code sharedDataSource}); the context's + * destroy method is suppressed for the DataSource bean. + */ + private static AnnotationConfigApplicationContext buildContext(boolean multiInstanceEnabled) { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + + // Register DataSource (no destroy — owned by the test). + ctx.registerBean( + "dataSource", DataSource.class, () -> sharedDataSource, bd -> bd.setDestroyMethodName("")); + + // PlatformTransactionManager (required by DefaultLockRepository). + ctx.registerBean( + "transactionManager", + DataSourceTransactionManager.class, + () -> new DataSourceTransactionManager(sharedDataSource)); + + // LockSettings: default values (3s wait, 30s TTL). + ctx.register(LockSettingsPropertiesConfig.class); + + // adapter-persistence config (creates inProcessDistributedLock or jdbcDistributedLock). + ctx.register(DistributedLockPersistenceConfig.class); + + // app-bootstrap config (creates distributedLockProvider when multi-instance=true). + ctx.register(DistributedLockConfig.class); + + // Set the multi-instance flag. + ctx.getEnvironment() + .getPropertySources() + .addFirst( + new MapPropertySource( + "test-props", + Map.of( + "ca-skeleton.runtime.multi-instance-enabled", + String.valueOf(multiInstanceEnabled)))); + + ctx.refresh(); + return ctx; + } + + /** + * Builds a real Spring {@link ObjectProvider}; a null registry yields the no-op (absent) case. + */ + private static ObjectProvider<MeterRegistry> providerOf(MeterRegistry registry) { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + if (registry != null) { + beanFactory.registerSingleton("meterRegistry", registry); + } + return beanFactory.getBeanProvider(MeterRegistry.class); + } + + /** + * Minimal @Configuration that enables LockSettings binding. {@code LockSettings} uses default + * values when no ca-skeleton.lock.* properties are bound, so no explicit property values are + * needed. + */ + @Configuration + @EnableConfigurationProperties(LockSettings.class) + static class LockSettingsPropertiesConfig { + + /** + * Provide an empty {@link MeterRegistry} provider so {@link MeteredDistributedLockPort} + * resolves to a no-op (Micrometer is on compile classpath but no registry bean is wired in this + * minimal context). + */ + @Bean + ObjectProvider<MeterRegistry> meterRegistryProvider() { + DefaultListableBeanFactory empty = new DefaultListableBeanFactory(); + return empty.getBeanProvider(MeterRegistry.class); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/FlywayMigrationCompatibilityContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/FlywayMigrationCompatibilityContractTest.java new file mode 100644 index 00000000..68753b95 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/FlywayMigrationCompatibilityContractTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.bootstrap.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.ResultSet; +import java.sql.SQLException; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Migration compatibility contracts for already-applied PostgreSQL development schemas. + * + * <p>This class guards against mutating an applied Flyway version in place. The startup migrator + * validates checksums before serving traffic, so an edited versioned migration fails existing + * databases even when a fresh Testcontainers database still passes. + */ +class FlywayMigrationCompatibilityContractTest { + + private static final String OLD_V4_INT_LOCK = + """ + -- feature-distributed-lock-contract — Spring Integration JDBC LockRegistry backing table. + -- DDL is the SI 6.5 PostgreSQL schema verbatim; provenance and TTL notes in module README. + + CREATE TABLE INT_LOCK ( + LOCK_KEY CHAR(36) NOT NULL, + REGION VARCHAR(100) NOT NULL, + CLIENT_ID CHAR(36), + CREATED_DATE TIMESTAMP NOT NULL, + constraint INT_LOCK_PK primary key (LOCK_KEY, REGION) + ); + """; + + private static PostgreSQLContainer pg; + private static HikariDataSource dataSource; + + @AfterAll + static void stopContainer() { + if (dataSource != null) { + dataSource.close(); + } + if (pg != null) { + pg.stop(); + } + } + + @Test + void existingDatabaseWithOriginalV4MigratesForwardToExpiredAfterColumn(@TempDir Path tempDir) + throws Exception { + assumeDockerAvailable(); + startContainer(); + + Path oldLocation = migrationLocation(tempDir.resolve("old"), OLD_V4_INT_LOCK, null); + Flyway.configure().dataSource(dataSource).locations(flywayPath(oldLocation)).load().migrate(); + + Path currentLocation = + migrationLocation( + tempDir.resolve("current"), + resourceText("/db/migration/postgresql/V4__int_lock.sql"), + resourceText("/db/migration/postgresql/V5__int_lock_expired_after.sql")); + Flyway.configure() + .dataSource(dataSource) + .locations(flywayPath(currentLocation)) + .load() + .migrate(); + + assertThat(hasExpiredAfterColumn(dataSource)).isTrue(); + } + + private static void assumeDockerAvailable() { + Assumptions.assumeTrue( + DockerClientFactory.instance().isDockerAvailable(), + "Docker not available — skipping Testcontainers-based migration compatibility test"); + } + + private static void startContainer() { + if (pg != null) { + return; + } + pg = PostgreSqlTestContainer.create(); + pg.start(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(pg.getJdbcUrl()); + config.setUsername(pg.getUsername()); + config.setPassword(pg.getPassword()); + dataSource = new HikariDataSource(config); + } + + private static Path migrationLocation(Path directory, String v4, String v5) throws IOException { + Files.createDirectories(directory); + Files.writeString(directory.resolve("V4__int_lock.sql"), v4, StandardCharsets.UTF_8); + if (v5 != null) { + Files.writeString( + directory.resolve("V5__int_lock_expired_after.sql"), v5, StandardCharsets.UTF_8); + } + return directory; + } + + private static String resourceText(String path) throws IOException { + try (InputStream stream = + FlywayMigrationCompatibilityContractTest.class.getResourceAsStream(path)) { + assertThat(stream).as("migration resource %s", path).isNotNull(); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String flywayPath(Path path) { + return "filesystem:" + path.toAbsolutePath(); + } + + private static boolean hasExpiredAfterColumn(DataSource dataSource) throws SQLException { + try (var connection = dataSource.getConnection(); + ResultSet columns = + connection.getMetaData().getColumns(null, null, "int_lock", "expired_after")) { + return columns.next(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java new file mode 100644 index 00000000..3dfbf6c2 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/IdempotencyUniqueScopeContractTest.java @@ -0,0 +1,223 @@ +package dev.caskeleton.bootstrap.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator; +import dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; +import dev.caskeleton.shared.error.OperationalError; +import dev.caskeleton.shared.error.PersistenceFailureException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * PostgreSQL contract test that pins the DDL → unique-scope → SQLState → error-code chain for + * {@code idempotency_record} (feature-persistence-multi-db-extensibility, commit E). + * + * <h2>Coverage gap this test fills</h2> + * + * <ul> + * <li>Outbox claim and distributed lock are already verified by real-PostgreSQL Testcontainers + * tests (OutboxRowLifecycleContractTest, OutboxPublisherLeaderElectionContractTest, + * OutboxAppendTransactionalContractTest, DistributedLockProviderContractTest). + * <li>This test fills the remaining gap: the {@code uq_idempotency_scope} unique constraint + * (columns: {@code tenant, principal, idempotency_key, use_case_name}, defined in {@code + * V1__idempotency_record.sql}) is exercised against a real PostgreSQL instance so that a + * duplicate insert provably produces SQLState {@code 23505}, and the production {@link + * PersistenceExceptionTranslator} chain maps it to {@link + * OperationalError#DB_UNIQUE_VIOLATION}. + * </ul> + * + * <h2>Scope (non-speculative)</h2> + * + * This test verifies the PostgreSQL vendor's actual behaviour — it is not a shared-fixture + * abstraction. A DB-neutral shared fixture would require a second RDBMS vendor on the test + * classpath to have any validation value; that is deferred until a second vendor is added (C-1: + * avoid paying the abstraction cost before the validator exists). + * + * <h2>Translator composition</h2> + * + * The {@link PersistenceExceptionTranslator} is constructed with {@code List.of(new + * StandardSqlStateErrorMapping(), new PostgreSqlSqlStateErrorMapping())} — the same composition + * used at production runtime and mirrored in {@link + * dev.caskeleton.bootstrap.contract.PersistenceFailureMappingContractTest}. This exercises the "DDL + * unique constraint → PG SQLState 23505 → DB_UNIQUE_VIOLATION" end-to-end chain with a real + * PostgreSQL error. + */ +@Testcontainers +class IdempotencyUniqueScopeContractTest { + + @Container static final PostgreSQLContainer PG = PostgreSqlTestContainer.create(); + + private static HikariDataSource sharedDataSource; + + /** + * Production TRANSLATOR composition: standard core rows + PostgreSQL vendor rows. Mirrors the + * composition used in {@link + * dev.caskeleton.bootstrap.contract.PersistenceFailureMappingContractTest}. + */ + private static final PersistenceExceptionTranslator TRANSLATOR = + new PersistenceExceptionTranslator( + List.of(new StandardSqlStateErrorMapping(), new PostgreSqlSqlStateErrorMapping())); + + @BeforeAll + static void setup() { + Assumptions.assumeTrue( + DockerClientFactory.instance().isDockerAvailable(), + "Docker not available — skipping Testcontainers-based idempotency unique-scope contract test"); + + sharedDataSource = hikari(PG); + + // V1 (idempotency_record) + V2 (sample work_log) + V3 (outbox_event) + V4 (int_lock) + // are all under classpath:db/migration (from adapter:outbound:persistence-jpa on the test + // classpath via implementation project(':adapter:outbound:persistence-jpa')). + Flyway.configure() + .dataSource(sharedDataSource) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + @AfterAll + static void teardown() { + if (sharedDataSource != null) { + sharedDataSource.close(); + } + } + + // ========================================================================= + // Contract 1 — DDL unique constraint fires as SQLState 23505 + // ========================================================================= + + /** + * Inserting a second row with the same (tenant, principal, idempotency_key, use_case_name) scope + * must cause PostgreSQL to report SQLState {@code 23505} (unique_violation). This pins the DDL + * side of the "DDL → SQLState → error-code" chain. + * + * <p>The insert uses plain JDBC so the {@link SQLException} is observed directly, without any ORM + * exception-translation layer in between. This keeps the contract readable and avoids sensitivity + * to Hibernate's own exception-wrapping behaviour. + */ + @Test + void duplicateScopeInsertProducesSqlstate23505() throws Exception { + String nonce = uniqueNonce(); + insertRow(sharedDataSource, nonce); // first row — must succeed + + SQLException caught = null; + try { + insertRow(sharedDataSource, nonce); // duplicate scope — must fail + } catch (SQLException ex) { + caught = ex; + } + + assertThat((Throwable) caught).as("duplicate scope insert must throw SQLException").isNotNull(); + assertThat(caught.getSQLState()) + .as("SQLState must be 23505 (PostgreSQL unique_violation)") + .isEqualTo("23505"); + } + + // ========================================================================= + // Contract 2 — DDL 23505 → DB_UNIQUE_VIOLATION (full chain) + // ========================================================================= + + /** + * A {@link DataIntegrityViolationException} wrapping a {@link SQLException} with SQLState {@code + * 23505} (as Spring Data JPA produces at the repository boundary in production) must classify to + * {@link OperationalError#DB_UNIQUE_VIOLATION} via the production {@link + * PersistenceExceptionTranslator}. This pins the complete "DDL unique constraint → PG SQLState + * 23505 → DB_UNIQUE_VIOLATION" chain. + * + * <p>The {@link SQLException} is obtained from a real PostgreSQL duplicate-insert so the SQLState + * comes from the actual driver, not a fabricated fixture. + */ + @Test + void duplicateScopeClassifiesToDbUniqueViolation() throws Exception { + String nonce = uniqueNonce(); + insertRow(sharedDataSource, nonce); // first row + + SQLException pgEx = null; + try { + insertRow(sharedDataSource, nonce); // duplicate — harvests the real PG exception + } catch (SQLException ex) { + pgEx = ex; + } + + assertThat((Throwable) pgEx) + .as("duplicate insert must throw a real PostgreSQL SQLException") + .isNotNull(); + assertThat(pgEx.getSQLState()).as("real PG SQLState must be 23505").isEqualTo("23505"); + + // Wrap it the same way Spring Data JPA does at the repository boundary. + DataIntegrityViolationException springWrapped = + new DataIntegrityViolationException("unique constraint violation", pgEx); + + Optional<PersistenceFailureException> result = TRANSLATOR.translate(springWrapped); + assertThat(result) + .as("TRANSLATOR must classify the wrapped 23505 to a PersistenceFailureException") + .isPresent(); + assertThat(result.get().errorCode()) + .as( + "SQLState 23505 from idempotency_record unique scope must classify to DB_UNIQUE_VIOLATION") + .isEqualTo(OperationalError.DB_UNIQUE_VIOLATION); + } + + // ========================================================================= + // helpers + // ========================================================================= + + /** Returns a unique nonce so each test method gets its own scope bucket. */ + private static String uniqueNonce() { + return String.valueOf(System.nanoTime()); + } + + /** + * Inserts one {@code idempotency_record} row via plain JDBC. The scope columns ({@code tenant}, + * {@code principal}, {@code idempotency_key}, {@code use_case_name}) are keyed to {@code nonce}. + * The PK ({@code id}) is always a fresh UUID so only the unique scope constraint, not the PK, can + * fire on a duplicate call. + * + * @throws SQLException propagated directly so the test can inspect the SQLState + */ + private static void insertRow(DataSource ds, String nonce) throws SQLException { + String sql = + "INSERT INTO idempotency_record " + + "(id, tenant, principal, idempotency_key, use_case_name, " + + " request_hash, status, created_at, expires_at) " + + "VALUES (?, '', ?, ?, 'TestUseCase', ?, 'PENDING', now(), now() + interval '1 hour')"; + try (Connection conn = ds.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setObject(1, UUID.randomUUID()); + ps.setString(2, "test-principal-" + nonce); + ps.setString(3, "test-key-" + nonce); + ps.setString(4, "a".repeat(64)); + ps.executeUpdate(); + } + } + + private static HikariDataSource hikari(PostgreSQLContainer pg) { + HikariConfig cfg = new HikariConfig(); + cfg.setJdbcUrl(pg.getJdbcUrl()); + cfg.setUsername(pg.getUsername()); + cfg.setPassword(pg.getPassword()); + cfg.setMaximumPoolSize(5); + cfg.setMinimumIdle(1); + return new HikariDataSource(cfg); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/PostgreSqlTestContainer.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/PostgreSqlTestContainer.java new file mode 100644 index 00000000..b09a7981 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/PostgreSqlTestContainer.java @@ -0,0 +1,24 @@ +package dev.caskeleton.bootstrap.integration; + +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Creates PostgreSQL test containers with reuse disabled unless the developer explicitly opts in. + */ +public final class PostgreSqlTestContainer { + + public static final String IMAGE = "postgres:16-alpine"; + private static final String REUSE_ENV = "TESTCONTAINERS_REUSE_ENABLE"; + + private PostgreSqlTestContainer() {} + + public static PostgreSQLContainer create() { + return create(IMAGE); + } + + @SuppressWarnings("resource") + public static PostgreSQLContainer create(String image) { + boolean localReuse = Boolean.parseBoolean(System.getenv(REUSE_ENV)); + return new PostgreSQLContainer(image).withReuse(localReuse); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java new file mode 100644 index 00000000..f375bc79 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java @@ -0,0 +1,127 @@ +package dev.caskeleton.bootstrap.integration.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.bootstrap.integration.PostgreSqlTestContainer; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Integration test #2 — dual-write prohibition: outbox append participates in the caller's + * transaction; on rollback the outbox row is absent. + * + * <p>Proves the no-dual-write guarantee (D2): if the business transaction rolls back, the outbox + * row vanishes with it — there is no separate out-of-band insert. + */ +@Testcontainers +class OutboxAppendTransactionalContractTest { + + @Container + static final PostgreSQLContainer PG = + PostgreSqlTestContainer.create(OutboxContainerTestSupport.PG_IMAGE); + + private static DataSource sharedDataSource; + + @BeforeAll + static void setup() { + OutboxContainerTestSupport.assumeDockerAvailable(); + sharedDataSource = OutboxContainerTestSupport.dataSource(PG); + OutboxContainerTestSupport.migrate(sharedDataSource); + } + + @AfterAll + static void cleanup() { + if (sharedDataSource instanceof AutoCloseable ac) { + try { + ac.close(); + } catch (Exception ignored) { + } + } + } + + @Test + void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() { + Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + + String eventId = "txn-rollback-" + System.nanoTime(); + + // Append inside a transaction that rolls back by throwing + try { + tx.inWrite( + () -> { + append.append( + new NewOutboxEvent( + eventId, + "TestEvent", + "agg-rollback-" + eventId, + "{\"rollback\":true}", + clock.instant(), + "corr-" + eventId, + eventId)); + // Force rollback + throw new RuntimeException("intentional rollback"); + }); + } catch (RuntimeException ignored) { + // expected + } + + // Query specifically for this eventId via the store adapter's JPA repo + OutboxEventJpaRepository repo = ctx.getBean(OutboxEventJpaRepository.class); + assertThat(repo.findById(eventId)) + .as("outbox row must not exist after transaction rollback (D2 dual-write prohibition)") + .isEmpty(); + } + } + + @Test + void outboxRowIsPresentWhenTransactionCommits() { + Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + + String eventId = "txn-commit-" + System.nanoTime(); + + tx.inWrite( + () -> { + append.append( + new NewOutboxEvent( + eventId, + "TestEvent", + "agg-commit-" + eventId, + "{\"committed\":true}", + clock.instant(), + "corr-" + eventId, + eventId)); + return null; + }); + + OutboxEventJpaRepository repo = ctx.getBean(OutboxEventJpaRepository.class); + assertThat(repo.findById(eventId)) + .as("outbox row must exist after successful transaction commit") + .isPresent(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java new file mode 100644 index 00000000..53e38e0e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java @@ -0,0 +1,224 @@ +package dev.caskeleton.bootstrap.integration.outbox; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort; +import dev.caskeleton.application.outbox.OutboxBackoffPolicy; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.time.Clock; +import java.time.Duration; +import java.util.Properties; +import java.util.random.RandomGenerator; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Assumptions; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.SharedEntityManagerCreator; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Shared Testcontainers + Spring context support for outbox integration tests + * (feature-domain-event-outbox-contract Task E). + * + * <p>Does NOT boot the full {@code CaSkeletonApplication}. Builds a minimal {@link + * AnnotationConfigApplicationContext} with only the JPA / outbox persistence beans needed for the + * outbox integration tests. + */ +final class OutboxContainerTestSupport { + + static final String PG_IMAGE = "postgres:16-alpine"; + static final int BATCH_SIZE_CONTRACT = 50; + static final Duration IN_FLIGHT_TIMEOUT = Duration.ofSeconds(30); + + private OutboxContainerTestSupport() {} + + /** Guard: skip the test if Docker is unavailable (CI without Docker daemon). */ + static void assumeDockerAvailable() { + Assumptions.assumeTrue( + DockerClientFactory.instance().isDockerAvailable(), + "Docker not available — skipping Testcontainers-based outbox integration test"); + } + + /** + * Runs Flyway migrations against the given DataSource. All three migrations (V1 idempotency, V2 + * work_log from sample-portfolio on app-bootstrap test classpath, V3 outbox_event) are applied. + */ + static void migrate(DataSource dataSource) { + Flyway.configure().dataSource(dataSource).locations("classpath:db/migration").load().migrate(); + } + + /** Creates a HikariDataSource pointing to the given PostgreSQL container. */ + static HikariDataSource dataSource(PostgreSQLContainer pg) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(pg.getJdbcUrl()); + config.setUsername(pg.getUsername()); + config.setPassword(pg.getPassword()); + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + return new HikariDataSource(config); + } + + /** + * Builds a minimal Spring context containing JPA + outbox persistence beans. + * + * <p>Registers only the beans needed for outbox integration tests — avoids pulling in audit, + * idempotency, or other persistence components that have additional dependencies + * (DomainContextPropagator, etc.). + * + * <p>The DataSource lifecycle is owned by the caller; Spring will NOT close it when the context + * is destroyed (destroyMethodName=""). + * + * @param dataSource the JDBC DataSource (pointing at the PG container) + * @param publisher the {@link OutboxMessagePublishPort} stub for this context + * @param clock the clock to use + * @return a started {@link AnnotationConfigApplicationContext} + */ + static AnnotationConfigApplicationContext buildContext( + DataSource dataSource, OutboxMessagePublishPort publisher, Clock clock) { + + // Build the EntityManagerFactory outside of the Spring context so we control + // its full lifecycle independently. This avoids the FactoryBean complexity + // inside AnnotationConfigApplicationContext. + LocalContainerEntityManagerFactoryBean emfBean = buildEmfBean(dataSource); + EntityManagerFactory emf = emfBean.getObject(); + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + + // Register DataSource — no destroy (caller owns lifecycle). + ctx.registerBean( + "dataSource", DataSource.class, () -> dataSource, bd -> bd.setDestroyMethodName("")); + + // Register pre-built EntityManagerFactory — no destroy (emfBean.destroy() called below). + ctx.registerBean( + "entityManagerFactory", + EntityManagerFactory.class, + () -> emf, + bd -> bd.setDestroyMethodName("")); + + // JpaTransactionManager + ctx.registerBean( + "transactionManager", + JpaTransactionManager.class, + () -> { + JpaTransactionManager tm = new JpaTransactionManager(); + tm.setEntityManagerFactory(emf); + return tm; + }); + + // SpringTransactionPort + ctx.registerBean( + "springTransactionPort", + SpringTransactionPort.class, + () -> + new SpringTransactionPort( + ctx.getBean("transactionManager", JpaTransactionManager.class))); + + // Enable JPA repositories for the outbox package only (avoids scanning + // other repositories that have unmet dependencies in this minimal context). + ctx.register(OutboxRepositoryConfig.class); + + // OutboxClaimRepository: the real PostgreSQL vendor implementation, injected with a + // transaction-aware shared EntityManager (mirrors production @PersistenceContext). An + // earlier inline lambda used a standalone EM (emf.createEntityManager()) that did NOT join + // the relay's transaction, so FOR UPDATE SKIP LOCKED locks were not held within it — + // breaking the concurrent-relay (zero-duplicate) and in-flight-reclaim contracts. + // implementation project(':adapter:outbound:persistence-jpa') puts the production PostgreSQL + // vendor class (under the .postgresql subpackage) on the test compile classpath. + EntityManager sharedEntityManager = SharedEntityManagerCreator.createSharedEntityManager(emf); + ctx.registerBean( + "outboxClaimRepository", + OutboxClaimRepository.class, + () -> new PostgreSqlOutboxClaimRepository(sharedEntityManager)); + + // OutboxStoreAdapter: @Repository — registered manually (no component scan). + ctx.registerBean( + "outboxStoreAdapter", + OutboxStoreAdapter.class, + () -> + new OutboxStoreAdapter( + ctx.getBean(OutboxEventJpaRepository.class), + ctx.getBean(OutboxClaimRepository.class))); + + // Publisher stub and clock + ctx.registerBean( + "outboxMessagePublishPort", + OutboxMessagePublishPort.class, + () -> publisher, + bd -> bd.setDestroyMethodName("")); + ctx.registerBean("systemClock", Clock.class, () -> clock, bd -> bd.setDestroyMethodName("")); + + // Register a destroy callback so emfBean.destroy() is called when ctx.close() is called + ctx.addApplicationListener( + event -> { + if (event instanceof ContextClosedEvent) { + try { + emfBean.destroy(); + } catch (Exception ignored) { + } + } + }); + + ctx.refresh(); + return ctx; + } + + /** + * Builds and wires a {@link PublishPendingOutboxEventsUseCase} from beans in the given context. + */ + static PublishPendingOutboxEventsUseCase relayUseCase(AnnotationConfigApplicationContext ctx) { + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + OutboxMessagePublishPort publisher = ctx.getBean(OutboxMessagePublishPort.class); + TransactionPort tx = ctx.getBean(SpringTransactionPort.class); + Clock clock = ctx.getBean(Clock.class); + return new PublishPendingOutboxEventsUseCase( + store, + publisher, + tx, + new OutboxBackoffPolicy(RandomGenerator.getDefault()), + clock, + BATCH_SIZE_CONTRACT, + IN_FLIGHT_TIMEOUT); + } + + // ---- helpers ---- + + private static LocalContainerEntityManagerFactoryBean buildEmfBean(DataSource dataSource) { + LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean(); + emf.setDataSource(dataSource); + HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter(); + adapter.setGenerateDdl(false); + adapter.setShowSql(false); + emf.setJpaVendorAdapter(adapter); + emf.setPackagesToScan("dev.caskeleton.adapter.outbound.persistence"); + Properties props = new Properties(); + props.setProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"); + props.setProperty("hibernate.jdbc.time_zone", "UTC"); + emf.setJpaProperties(props); + emf.afterPropertiesSet(); // initializes and builds the EntityManagerFactory + return emf; + } + + /** + * Minimal configuration that enables only the outbox JPA repository. Scoped to the outbox + * sub-package to avoid auto-wiring other repositories that have unmet dependencies in this + * minimal context. + */ + @Configuration + @EnableJpaRepositories(basePackageClasses = OutboxEventJpaRepository.class) + static class OutboxRepositoryConfig {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java new file mode 100644 index 00000000..8d97bbca --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java @@ -0,0 +1,177 @@ +package dev.caskeleton.bootstrap.integration.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.outbox.OutboxRelayResult; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.bootstrap.integration.PostgreSqlTestContainer; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Integration test #5 — multi-instance relay (leader-election via SKIP LOCKED). + * + * <p>Spec: PENDING 1000 rows, 2 independent Spring contexts, relay driven concurrently; total + * publishes = 1000 and no eventId published twice. + * + * <p>Each context uses a stub publisher that records (instanceId, eventId) into a shared {@link + * ConcurrentHashMap}. Both relay loops run until the store is drained (claimedCount == 0 for both + * instances). Then assert total = 1000, duplicates = 0. + */ +@Testcontainers +class OutboxPublisherLeaderElectionContractTest { + + @Container + static final PostgreSQLContainer PG = + PostgreSqlTestContainer.create(OutboxContainerTestSupport.PG_IMAGE); + + private static DataSource sharedDataSource; + + @BeforeAll + static void setup() { + OutboxContainerTestSupport.assumeDockerAvailable(); + sharedDataSource = OutboxContainerTestSupport.dataSource(PG); + OutboxContainerTestSupport.migrate(sharedDataSource); + } + + @AfterAll + static void cleanup() { + if (sharedDataSource instanceof AutoCloseable ac) { + try { + ac.close(); + } catch (Exception ignored) { + } + } + } + + @Test + void twoRelayInstancesPublishAll1000RowsWithZeroDuplicates() throws Exception { + // ---- fixed clock: rows inserted with occurredAt = t0, relay clock = t0 ---- + // next_attempt_at is set to occurredAt at append time. Using a fixed t0 for + // both ensures next_attempt_at (= t0) <= now (= t0) so every row is eligible. + // Using Instant.now() independently for rows and clock risks t0_row > t0_clock + // which would make 0 rows eligible (the prior failure mode, fixed here). + Instant t0 = Instant.now(); + // Give relay a 1-second buffer beyond t0 so any sub-millisecond jitter at + // insert time does not cause rows to be ineligible. + Clock clock = Clock.fixed(t0.plusSeconds(1), ZoneOffset.UTC); + + AnnotationConfigApplicationContext seedCtx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock); + TransactionPort seedTx = seedCtx.getBean(TransactionPort.class); + OutboxAppendPort appendPort = seedCtx.getBean(OutboxAppendPort.class); + + seedTx.inWrite( + () -> { + for (int i = 0; i < 1000; i++) { + String id = "evt-leader-" + i; + appendPort.append( + new NewOutboxEvent( + id, + "TestEvent", + "agg-leader-" + i, // distinct aggregateId per row + "{\"seq\":" + i + "}", + t0, // fixed occurredAt = t0 so next_attempt_at = t0 <= relay now + "corr-leader-" + i, + id)); + } + return null; + }); + seedCtx.close(); + + // ---- shared publish registry ---- + Map<String, String> publishedByEventId = new ConcurrentHashMap<>(); // eventId -> instanceId + AtomicInteger duplicateCount = new AtomicInteger(0); + + // ---- build two independent contexts, each with its own stub publisher ---- + AnnotationConfigApplicationContext ctx1 = + OutboxContainerTestSupport.buildContext( + sharedDataSource, + event -> { + String prev = publishedByEventId.put(event.eventId(), "instance-1"); + if (prev != null) { + duplicateCount.incrementAndGet(); + } + }, + clock); + + AnnotationConfigApplicationContext ctx2 = + OutboxContainerTestSupport.buildContext( + sharedDataSource, + event -> { + String prev = publishedByEventId.put(event.eventId(), "instance-2"); + if (prev != null) { + duplicateCount.incrementAndGet(); + } + }, + clock); + + PublishPendingOutboxEventsUseCase relay1 = OutboxContainerTestSupport.relayUseCase(ctx1); + PublishPendingOutboxEventsUseCase relay2 = OutboxContainerTestSupport.relayUseCase(ctx2); + + // ---- drive both relay loops concurrently until drained ---- + ExecutorService executor = Executors.newFixedThreadPool(2); + Future<Integer> f1 = executor.submit(() -> drainRelay(relay1)); + Future<Integer> f2 = executor.submit(() -> drainRelay(relay2)); + + executor.shutdown(); + executor.awaitTermination(60, TimeUnit.SECONDS); + + int published1 = f1.get(); + int published2 = f2.get(); + + ctx1.close(); + ctx2.close(); + + // ---- assertions ---- + assertThat(duplicateCount.get()) + .as("no eventId must be published twice (SKIP LOCKED prevents double-claim)") + .isZero(); + assertThat(publishedByEventId.size()) + .as("total unique published events must equal 1000") + .isEqualTo(1000); + assertThat(published1 + published2) + .as("sum of per-instance publish counts must equal 1000") + .isEqualTo(1000); + } + + /** Runs the relay in a loop until claimedCount == 0 (store drained). */ + private int drainRelay(PublishPendingOutboxEventsUseCase relay) { + int total = 0; + int emptyRounds = 0; + while (emptyRounds < 3) { + OutboxRelayResult result = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + long published = + result.outcomes().stream() + .filter(o -> o.outcome() == OutboxRelayResult.Outcome.PUBLISHED) + .count(); + total += (int) published; + if (result.claimedCount() == 0) { + emptyRounds++; + } else { + emptyRounds = 0; + } + } + return total; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java new file mode 100644 index 00000000..8dc27082 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java @@ -0,0 +1,692 @@ +package dev.caskeleton.bootstrap.integration.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.outbox.OutboxBackoffPolicy; +import dev.caskeleton.application.outbox.OutboxEvent; +import dev.caskeleton.application.outbox.OutboxEventStatus; +import dev.caskeleton.application.outbox.OutboxMessagePublishPort; +import dev.caskeleton.application.outbox.OutboxRelayResult; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand; +import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.bootstrap.integration.PostgreSqlTestContainer; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.random.RandomGenerator; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Row lifecycle integration test (feature-domain-event-outbox-contract Task E). + * + * <p>Verifies each lifecycle scenario against a real PostgreSQL instance: + * + * <ul> + * <li>PENDING → IN_FLIGHT → PUBLISHED (happy path) + * <li>FAILED backoff: transient failure transitions to FAILED + * <li>3 retries exhausted → DEAD (with OUTBOX_DEAD_LETTER log code) + * <li>FIFO ordering: rows for the same aggregate are relayed in OCCURRED_AT ASC order + * <li>IN_FLIGHT orphan re-claim after timeout + * <li>Reaper TTL deletion of PUBLISHED rows + * </ul> + */ +@Testcontainers +class OutboxRowLifecycleContractTest { + + @Container + static final PostgreSQLContainer PG = + PostgreSqlTestContainer.create(OutboxContainerTestSupport.PG_IMAGE); + + private static DataSource sharedDataSource; + + @BeforeAll + static void setup() { + OutboxContainerTestSupport.assumeDockerAvailable(); + sharedDataSource = OutboxContainerTestSupport.dataSource(PG); + OutboxContainerTestSupport.migrate(sharedDataSource); + } + + @AfterAll + static void cleanup() { + if (sharedDataSource instanceof AutoCloseable ac) { + try { + ac.close(); + } catch (Exception ignored) { + } + } + } + + // ========================================================================= + // Happy path: PENDING → IN_FLIGHT → PUBLISHED + // ========================================================================= + + @Test + void pendingRowTransitionsToPublishedOnSuccessfulRelay() { + Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String eventId = "lifecycle-happy-" + System.nanoTime(); + tx.inWrite( + () -> { + append.append(newEvent(eventId, "agg-happy-" + eventId, clock)); + return null; + }); + + PublishPendingOutboxEventsUseCase relay = OutboxContainerTestSupport.relayUseCase(ctx); + OutboxRelayResult result = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + long published = + result.outcomes().stream() + .filter( + o -> + o.eventId().equals(eventId) + && o.outcome() == OutboxRelayResult.Outcome.PUBLISHED) + .count(); + assertThat(published).as("event must be PUBLISHED in relay result").isEqualTo(1); + + // Verify DB status + Map<OutboxEventStatus, Long> counts = store.countByStatus(); + assertThat(counts.getOrDefault(OutboxEventStatus.PUBLISHED, 0L)) + .as("PUBLISHED count must be >= 1") + .isGreaterThanOrEqualTo(1); + } + } + + // ========================================================================= + // Transient failure → FAILED with backoff + // ========================================================================= + + @Test + void transientPublishFailureTransitionsRowToFailed() { + Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); + AtomicBoolean fail = new AtomicBoolean(true); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext( + sharedDataSource, + e -> { + if (fail.get()) { + throw new RuntimeException("transient"); + } + }, + clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String eventId = "lifecycle-fail-" + System.nanoTime(); + tx.inWrite( + () -> { + append.append(newEvent(eventId, "agg-fail-" + eventId, clock)); + return null; + }); + + PublishPendingOutboxEventsUseCase relay = OutboxContainerTestSupport.relayUseCase(ctx); + OutboxRelayResult result = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + long failed = + result.outcomes().stream() + .filter( + o -> + o.eventId().equals(eventId) + && o.outcome() == OutboxRelayResult.Outcome.FAILED) + .count(); + assertThat(failed).as("event must be FAILED after transient error").isEqualTo(1); + + Map<OutboxEventStatus, Long> counts = store.countByStatus(); + assertThat(counts.getOrDefault(OutboxEventStatus.FAILED, 0L)) + .as("FAILED count must be >= 1 in store") + .isGreaterThanOrEqualTo(1); + } + } + + // ========================================================================= + // 3 retries exhausted → DEAD + // + // The relay use case checks attemptCount >= maxAttempts (3) and marks DEAD. + // After each failure, markFailed sets nextAttemptAt = now + backoff. + // To re-claim on the next cycle, we advance the relay clock past the backoff + // window by 2 hours per cycle (backoff max ≈ 30s; 2h always covers it). + // ========================================================================= + + @Test + void threeRetriesExhaustedTransitionsRowToDead() { + Instant t0 = Instant.now(); + // Append clock — rows get nextAttemptAt = t0 (PENDING, immediately eligible) + Clock appendClock = Clock.fixed(t0, ZoneOffset.UTC); + + AtomicInteger publishAttempts = new AtomicInteger(0); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext( + sharedDataSource, + e -> { + publishAttempts.incrementAndGet(); + throw new RuntimeException("always fails"); + }, + appendClock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String eventId = "lifecycle-dead-" + System.nanoTime(); + tx.inWrite( + () -> { + append.append(newEvent(eventId, "agg-dead-" + eventId, appendClock)); + return null; + }); + + // Each relay cycle uses a clock 2 hours after the previous one. + // This ensures the FAILED row's nextAttemptAt (now + max ~60s backoff) + // is always in the past relative to the next relay clock. + OutboxRelayResult r1 = + buildRelayWithClock(ctx, t0).handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat(r1.outcomes()) + .as("cycle 1 must process our event") + .anyMatch(o -> o.eventId().equals(eventId)); + + OutboxRelayResult r2 = + buildRelayWithClock(ctx, t0.plus(Duration.ofHours(2))) + .handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat(r2.outcomes()) + .as("cycle 2 must process our event") + .anyMatch(o -> o.eventId().equals(eventId)); + + OutboxRelayResult r3 = + buildRelayWithClock(ctx, t0.plus(Duration.ofHours(4))) + .handle(PublishPendingOutboxEventsCommand.INSTANCE); + + // Third cycle should produce DEAD (attemptCount=3 >= maxAttempts=3) + long deadInR3 = + r3.outcomes().stream() + .filter( + o -> o.eventId().equals(eventId) && o.outcome() == OutboxRelayResult.Outcome.DEAD) + .count(); + assertThat(deadInR3).as("event must be DEAD after 3 failed attempts").isEqualTo(1); + + Map<OutboxEventStatus, Long> counts = store.countByStatus(); + assertThat(counts.getOrDefault(OutboxEventStatus.DEAD, 0L)) + .as("DEAD count must be >= 1 in store") + .isGreaterThanOrEqualTo(1); + } + } + + // ========================================================================= + // FIFO ordering: rows for same aggregate are relayed in OCCURRED_AT ASC order + // + // With the FIFO gate (I4 NOT EXISTS correlated subquery), the tail row is + // blocked while the head is not PUBLISHED. This test verifies the two-cycle + // semantics: + // Cycle 1 → only the head is claimed and published; the tail does NOT appear. + // Cycle 2 → the tail is now unblocked (head is PUBLISHED) and is published. + // ========================================================================= + + @Test + void fifoOrderingHeadRowAppearsBeforeTailInRelayOutcomes() { + Instant t0 = Instant.now(); + // Relay clock is t0+1s so next_attempt_at of head (= t0) and tail (= t0+1ms) + // are both <= relay now (= t0+1s). Using t0 exactly would make tail ineligible + // because tail.nextAttemptAt = t0+1ms > t0. + Clock clock = Clock.fixed(t0.plusSeconds(1), ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + + String aggId = "agg-fifo-order-" + System.nanoTime(); + String headId = "fifo-order-head-" + System.nanoTime(); + String tailId = "fifo-order-tail-" + System.nanoTime(); + + // Insert head (OCCURRED_AT=t0) and tail (OCCURRED_AT=t0+1ms). + // Both nextAttemptAt = occurredAt <= t0+1s = relay clock's now. + tx.inWrite( + () -> { + append.append(newEventAt(headId, aggId, t0)); + append.append(newEventAt(tailId, aggId, t0.plusMillis(1))); + return null; + }); + + PublishPendingOutboxEventsUseCase relay = OutboxContainerTestSupport.relayUseCase(ctx); + + // --- Relay cycle 1: FIFO gate blocks tail while head is PENDING --- + // The NOT EXISTS gate allows only the head (head has no non-PUBLISHED + // earlier sibling; tail has the head as a PENDING earlier sibling). + OutboxRelayResult cycle1 = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + List<String> cycle1Ids = + cycle1.outcomes().stream().map(OutboxRelayResult.EventOutcome::eventId).toList(); + + assertThat(cycle1Ids) + .as("cycle 1: only the head must be claimed (tail blocked by FIFO gate)") + .contains(headId); + assertThat(cycle1Ids) + .as("cycle 1: tail must NOT appear while head is not PUBLISHED") + .doesNotContain(tailId); + assertThat( + cycle1.outcomes().stream() + .filter(o -> o.eventId().equals(headId)) + .findFirst() + .map(OutboxRelayResult.EventOutcome::outcome) + .orElse(null)) + .as("cycle 1: head must be PUBLISHED") + .isEqualTo(OutboxRelayResult.Outcome.PUBLISHED); + + // --- Relay cycle 2: head is PUBLISHED → tail is unblocked --- + OutboxRelayResult cycle2 = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + List<String> cycle2Ids = + cycle2.outcomes().stream().map(OutboxRelayResult.EventOutcome::eventId).toList(); + + assertThat(cycle2Ids) + .as("cycle 2: tail must now be claimed (head is PUBLISHED, gate open)") + .contains(tailId); + assertThat( + cycle2.outcomes().stream() + .filter(o -> o.eventId().equals(tailId)) + .findFirst() + .map(OutboxRelayResult.EventOutcome::outcome) + .orElse(null)) + .as("cycle 2: tail must be PUBLISHED") + .isEqualTo(OutboxRelayResult.Outcome.PUBLISHED); + } + } + + // ========================================================================= + // FIFO gate blocking scenarios (plan Task E) + // + // Three scenarios prove the strict FIFO gate on real PostgreSQL: + // + // (a) head FAILED with future next_attempt_at → relay claims NOTHING for that + // aggregate (tail blocked even though tail is PENDING-due). + // (b) after head is marked PUBLISHED → next relay cycle claims the tail (unblock). + // (c) head DEAD → tail remains blocked (strict FIFO; unblock is runbook action). + // ========================================================================= + + @Test + void fifoGateBlocksTailWhileHeadIsFailedWithFutureBackoff() { + // Use t0 as both the insert time and relay "now". + Instant t0 = Instant.now(); + Clock clock = Clock.fixed(t0, ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext( + sharedDataSource, + e -> { + throw new RuntimeException("forced failure for head"); + }, + clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String aggId = "agg-fifo-blocked-" + System.nanoTime(); + String headId = "fifo-blocked-head-" + System.nanoTime(); + String tailId = "fifo-blocked-tail-" + System.nanoTime(); + + // Insert both rows. Both nextAttemptAt = t0 <= t0 (relay now). + tx.inWrite( + () -> { + append.append(newEventAt(headId, aggId, t0)); + append.append(newEventAt(tailId, aggId, t0.plusMillis(1))); + return null; + }); + + // Relay cycle 1: head is claimed and fails → transitions to FAILED + // with nextAttemptAt = t0 + backoff (far in the future). + // Tail is already blocked by the FIFO gate (head is PENDING). + PublishPendingOutboxEventsUseCase relay = OutboxContainerTestSupport.relayUseCase(ctx); + OutboxRelayResult cycle1 = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat( + cycle1.outcomes().stream() + .filter(o -> o.eventId().equals(headId)) + .findFirst() + .map(OutboxRelayResult.EventOutcome::outcome) + .orElse(null)) + .as("cycle 1: head must be FAILED") + .isEqualTo(OutboxRelayResult.Outcome.FAILED); + + // Verify DB: head is FAILED with a future next_attempt_at. + Map<OutboxEventStatus, Long> afterCycle1 = store.countByStatus(); + assertThat(afterCycle1.getOrDefault(OutboxEventStatus.FAILED, 0L)) + .as("head must be FAILED in store after cycle 1") + .isGreaterThanOrEqualTo(1L); + + // Relay cycle 2 (same clock t0): head's next_attempt_at is in the future. + // The head is FAILED but not yet eligible (next_attempt_at > now = t0). + // The tail is PENDING-due (next_attempt_at = t0+1ms <= t0? No — but the + // FIFO gate blocks it regardless because the head is a non-PUBLISHED earlier + // sibling). Either way, nothing for this aggregate is claimed. + OutboxRelayResult cycle2 = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + long tailInCycle2 = + cycle2.outcomes().stream().filter(o -> o.eventId().equals(tailId)).count(); + assertThat(tailInCycle2) + .as("cycle 2: tail must NOT be claimed — blocked by FIFO gate (head is FAILED)") + .isZero(); + + long headInCycle2 = + cycle2.outcomes().stream().filter(o -> o.eventId().equals(headId)).count(); + assertThat(headInCycle2) + .as("cycle 2: head must NOT be claimed — backoff not yet elapsed") + .isZero(); + } + } + + @Test + void fifoGateUnblocksTailAfterHeadIsPublished() { + Instant t0 = Instant.now(); + // Clock at t0+1s so both head (nextAttemptAt=t0) and tail (nextAttemptAt=t0+1ms) + // satisfy next_attempt_at <= now in both relay cycles. + Clock clock = Clock.fixed(t0.plusSeconds(1), ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String aggId = "agg-fifo-unblock-" + System.nanoTime(); + String headId = "fifo-unblock-head-" + System.nanoTime(); + String tailId = "fifo-unblock-tail-" + System.nanoTime(); + + // Insert head (OCCURRED_AT=t0) and tail (OCCURRED_AT=t0+1ms). + tx.inWrite( + () -> { + append.append(newEventAt(headId, aggId, t0)); + append.append(newEventAt(tailId, aggId, t0.plusMillis(1))); + return null; + }); + + PublishPendingOutboxEventsUseCase relay = OutboxContainerTestSupport.relayUseCase(ctx); + + // Cycle 1: head is claimed and published, tail is blocked. + OutboxRelayResult cycle1 = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat( + cycle1.outcomes().stream() + .filter( + o -> + o.eventId().equals(headId) + && o.outcome() == OutboxRelayResult.Outcome.PUBLISHED) + .count()) + .as("cycle 1: head must be PUBLISHED") + .isEqualTo(1L); + assertThat(cycle1.outcomes().stream().filter(o -> o.eventId().equals(tailId)).count()) + .as("cycle 1: tail must NOT appear (blocked)") + .isZero(); + + // Verify DB: head is PUBLISHED. + Map<OutboxEventStatus, Long> afterCycle1 = store.countByStatus(); + assertThat(afterCycle1.getOrDefault(OutboxEventStatus.PUBLISHED, 0L)) + .as("at least one PUBLISHED row after cycle 1") + .isGreaterThanOrEqualTo(1L); + + // Cycle 2: head is now PUBLISHED → NOT EXISTS gate passes for tail. + OutboxRelayResult cycle2 = relay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat( + cycle2.outcomes().stream() + .filter( + o -> + o.eventId().equals(tailId) + && o.outcome() == OutboxRelayResult.Outcome.PUBLISHED) + .count()) + .as("cycle 2: tail must be PUBLISHED after head is PUBLISHED") + .isEqualTo(1L); + } + } + + @Test + void fifoGateBlocksTailPermanentlyWhileHeadIsDead() { + // head DEAD → tail is blocked (strict FIFO; DEAD is not PUBLISHED). + // Unblocking a DEAD head is a runbook manual action, not an automated path. + Instant t0 = Instant.now(); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext( + sharedDataSource, + e -> { + throw new RuntimeException("always fails"); + }, + Clock.fixed(t0, ZoneOffset.UTC))) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String aggId = "agg-fifo-dead-" + System.nanoTime(); + String headId = "fifo-dead-head-" + System.nanoTime(); + String tailId = "fifo-dead-tail-" + System.nanoTime(); + + // Insert head at t0 and tail at t0+1ms. + tx.inWrite( + () -> { + append.append(newEventAt(headId, aggId, t0)); + append.append(newEventAt(tailId, aggId, t0.plusMillis(1))); + return null; + }); + + // Drive head to DEAD in 3 relay cycles, each advanced 2h to expire backoff. + OutboxRelayResult r1 = + buildRelayWithClock(ctx, t0).handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat(r1.outcomes()).anyMatch(o -> o.eventId().equals(headId)); + + OutboxRelayResult r2 = + buildRelayWithClock(ctx, t0.plus(Duration.ofHours(2))) + .handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat(r2.outcomes()).anyMatch(o -> o.eventId().equals(headId)); + + OutboxRelayResult r3 = + buildRelayWithClock(ctx, t0.plus(Duration.ofHours(4))) + .handle(PublishPendingOutboxEventsCommand.INSTANCE); + long deadInR3 = + r3.outcomes().stream() + .filter( + o -> o.eventId().equals(headId) && o.outcome() == OutboxRelayResult.Outcome.DEAD) + .count(); + assertThat(deadInR3).as("head must reach DEAD after 3 attempts").isEqualTo(1L); + + // Verify head is DEAD in DB. + Map<OutboxEventStatus, Long> afterDead = store.countByStatus(); + assertThat(afterDead.getOrDefault(OutboxEventStatus.DEAD, 0L)) + .as("DEAD count must be >= 1") + .isGreaterThanOrEqualTo(1L); + + // Relay cycle post-DEAD: head is DEAD (not PUBLISHED) → NOT EXISTS gate + // returns true (earlier sibling exists with status <> 'PUBLISHED') → + // tail remains blocked. + // Use a clock well into the future so any eligible FAILED row would be + // re-claimable; but since head is DEAD (not retried), only tail would + // potentially be claimed — and it must not be. + OutboxRelayResult r4 = + buildRelayWithClock(ctx, t0.plus(Duration.ofHours(6))) + .handle(PublishPendingOutboxEventsCommand.INSTANCE); + + long tailInR4 = r4.outcomes().stream().filter(o -> o.eventId().equals(tailId)).count(); + assertThat(tailInR4) + .as("tail must remain BLOCKED while head is DEAD (strict FIFO gate)") + .isZero(); + } + } + + // ========================================================================= + // IN_FLIGHT orphan re-claim after timeout + // ========================================================================= + + @Test + void inFlightOrphanIsReclaimedAfterTimeoutExpires() { + // Set clock to now; insert row and claim it (it will be IN_FLIGHT with + // next_attempt_at = now + inFlightTimeout). Then advance clock past the + // timeout and verify it can be re-claimed. + Instant t0 = Instant.now(); + Clock claimClock = Clock.fixed(t0, ZoneOffset.UTC); + // Advance past inFlightTimeout (30s used in integration test support) + Clock reclaimClock = Clock.fixed(t0.plus(Duration.ofSeconds(31)), ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, claimClock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String eventId = "lifecycle-orphan-" + System.nanoTime(); + tx.inWrite( + () -> { + append.append(newEvent(eventId, "agg-orphan-" + eventId, claimClock)); + return null; + }); + + // Claim the row but do NOT mark it published (simulates relay crash) + tx.inWrite(() -> store.claimBatch(1, t0, Duration.ofSeconds(30))); + + // Verify it's now IN_FLIGHT + Map<OutboxEventStatus, Long> afterClaim = store.countByStatus(); + assertThat(afterClaim.getOrDefault(OutboxEventStatus.IN_FLIGHT, 0L)) + .as("row must be IN_FLIGHT after claim") + .isGreaterThanOrEqualTo(1); + + // Build a new relay use case with the advanced clock so the orphan is re-eligible + PublishPendingOutboxEventsUseCase reclaimRelay = + new PublishPendingOutboxEventsUseCase( + store, + event -> {}, + ctx.getBean(TransactionPort.class), + new OutboxBackoffPolicy(RandomGenerator.getDefault()), + reclaimClock, + OutboxContainerTestSupport.BATCH_SIZE_CONTRACT, + OutboxContainerTestSupport.IN_FLIGHT_TIMEOUT); + + OutboxRelayResult reclaimResult = + reclaimRelay.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + long reclaimed = + reclaimResult.outcomes().stream().filter(o -> o.eventId().equals(eventId)).count(); + assertThat(reclaimed) + .as("orphaned IN_FLIGHT row must be re-claimed after timeout") + .isEqualTo(1); + } + } + + // ========================================================================= + // Reaper TTL deletion + // ========================================================================= + + @Test + void reaperDeletesPublishedRowsOlderThanRetention() { + // Insert + publish a row, then run the reaper with a very short retention + // (1 second) and a clock advanced past the cutoff. + Instant eventTime = Instant.now().minus(Duration.ofSeconds(10)); + Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); + + try (AnnotationConfigApplicationContext ctx = + OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) { + + TransactionPort tx = ctx.getBean(TransactionPort.class); + OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class); + OutboxStoreAdapter store = ctx.getBean(OutboxStoreAdapter.class); + + String eventId = "lifecycle-reap-" + System.nanoTime(); + String aggId = "agg-reap-" + eventId; + + tx.inWrite( + () -> { + append.append(newEventAt(eventId, aggId, eventTime)); + return null; + }); + + // Claim and mark as PUBLISHED + List<OutboxEvent> claimed = + tx.inWrite(() -> store.claimBatch(1, eventTime, Duration.ofSeconds(1))); + assertThat(claimed).hasSize(1); + tx.inWrite( + () -> { + store.markPublished(eventId); + return null; + }); + + // Verify it's PUBLISHED + Map<OutboxEventStatus, Long> before = store.countByStatus(); + assertThat(before.getOrDefault(OutboxEventStatus.PUBLISHED, 0L)) + .as("row must be PUBLISHED before reaper runs") + .isGreaterThanOrEqualTo(1); + + // Run reaper with short retention (1s) and a clock well past the cutoff. + // OutboxReaper.reap() uses @Transactional which only works as a Spring proxy. + // When constructed manually, wrap the delete in a write transaction instead. + OutboxEventJpaRepository repo = ctx.getBean(OutboxEventJpaRepository.class); + OutboxReaper reaper = + new OutboxReaper( + repo, clock, Duration.ofSeconds(1)); // retention = 1s; eventTime was 10s ago + + // @Transactional is not active on a manually-constructed bean; run within tx. + int purged = tx.inWrite(() -> reaper.reap()); + assertThat(purged) + .as("reaper must delete at least the PUBLISHED row inserted 10s ago") + .isGreaterThanOrEqualTo(1); + } + } + + // ========================================================================= + // helpers + // ========================================================================= + + /** + * Builds a relay use case that uses the given clock instant, but gets its store and transaction + * port from the existing context. + */ + private static PublishPendingOutboxEventsUseCase buildRelayWithClock( + AnnotationConfigApplicationContext ctx, Instant clockInstant) { + return new PublishPendingOutboxEventsUseCase( + ctx.getBean(OutboxStoreAdapter.class), + ctx.getBean(OutboxMessagePublishPort.class), + ctx.getBean(TransactionPort.class), + new OutboxBackoffPolicy(RandomGenerator.getDefault()), + Clock.fixed(clockInstant, ZoneOffset.UTC), + OutboxContainerTestSupport.BATCH_SIZE_CONTRACT, + OutboxContainerTestSupport.IN_FLIGHT_TIMEOUT); + } + + private static NewOutboxEvent newEvent(String eventId, String aggregateId, Clock clock) { + return newEventAt(eventId, aggregateId, clock.instant()); + } + + private static NewOutboxEvent newEventAt(String eventId, String aggregateId, Instant occurredAt) { + return new NewOutboxEvent( + eventId, + "TestEvent", + aggregateId, + "{\"test\":true}", + occurredAt, + "corr-" + eventId, + eventId); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/package-info.java new file mode 100644 index 00000000..2024b23b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/package-info.java @@ -0,0 +1,16 @@ +/** + * Outbox integration tests — Testcontainers-backed real-PostgreSQL outbox contract verification. + * + * <p>Tests in this package require a running Docker daemon. They verify the outbox event lifecycle + * (PENDING → IN_FLIGHT → PUBLISHED / FAILED / DEAD), transactional append guarantees, + * leader-election relay behavior (SKIP LOCKED), and reaper TTL deletion against a real PostgreSQL + * instance. + * + * <p>Test Level Matrix: {@code integration} — all tests here depend on {@code org.testcontainers..} + * (see parent package-info for the taxonomy rule that classifies Testcontainers usage at this + * level). + * + * <p>Support class: {@link dev.caskeleton.bootstrap.integration.outbox.OutboxContainerTestSupport} + * — shared Testcontainers + Spring context bootstrap for the outbox integration tests. + */ +package dev.caskeleton.bootstrap.integration.outbox; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/package-info.java new file mode 100644 index 00000000..ea28462e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/package-info.java @@ -0,0 +1,15 @@ +/** + * Integration test level — Testcontainers-backed real-service tests. + * + * <p>Tests in this package require a running Docker daemon. They boot real external services + * (PostgreSQL via Testcontainers) and exercise cross-layer behaviors (DDL → persistence adapter → + * application port) that cannot be verified with in-memory fakes. + * + * <p>Test Level Matrix: {@code integration} — tests here depend on {@code org.testcontainers..} and + * are intentionally classified at the integration level by the ArchUnit rule {@code + * CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS} + * (feature-test-taxonomy-fixture-contract §테스트 계약 #4 / D3). Unit-level and contract-level tests + * must NOT depend on Testcontainers; such tests belong in their respective packages under {@code + * dev.caskeleton.bootstrap.contract} or {@code dev.caskeleton.bootstrap.architecture}. + */ +package dev.caskeleton.bootstrap.integration; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/lock/MeteredDistributedLockPortTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/lock/MeteredDistributedLockPortTest.java new file mode 100644 index 00000000..dd5afa49 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/lock/MeteredDistributedLockPortTest.java @@ -0,0 +1,246 @@ +package dev.caskeleton.bootstrap.lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.lock.DistributedLock; +import dev.caskeleton.application.lock.DistributedLockPort; +import dev.caskeleton.application.lock.LockAcquisitionTimeoutException; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.ConcurrentModificationException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.dao.DataAccessResourceFailureException; + +/** + * Contract test for {@link MeteredDistributedLockPort} (feature-distributed-lock-contract D7 — + * lock.acquisition counter, tag outcome). + * + * <p>Mirrors {@code BackgroundJobMetricsTest}: uses {@code SimpleMeterRegistry} and a real Spring + * {@link ObjectProvider} built from a {@link DefaultListableBeanFactory} (including the + * absent-registry case). + */ +class MeteredDistributedLockPortTest { + + // ---- acquired path ------------------------------------------------------- + + @Test + void acquiredPathIncrementsAcquiredOutcomeAndReturnsCloseableHandle() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + DistributedLock delegateHandle = () -> {}; + DistributedLockPort delegate = (key, waitTime, leaseTtl) -> delegateHandle; + + MeteredDistributedLockPort port = + new MeteredDistributedLockPort(delegate, providerOf(registry)); + + DistributedLock handle = + port.tryAcquire("test-key", Duration.ofSeconds(1), Duration.ofSeconds(10)); + + // The returned handle is a wrapping lambda (for SI-LOCK-C5), not the raw delegate handle. + // Verify it is non-null and closeable without exception. + assertThat(handle).isNotNull(); + assertThatCode(handle::close).doesNotThrowAnyException(); + assertThat( + registry + .get(MeteredDistributedLockPort.LOCK_ACQUISITION) + .tag( + MeteredDistributedLockPort.TAG_OUTCOME, + MeteredDistributedLockPort.OUTCOME_ACQUIRED) + .counter() + .count()) + .isEqualTo(1.0); + } + + // ---- timeout path -------------------------------------------------------- + + @Test + void timeoutPathIncrementsTimeoutOutcomeAndRethrowsException() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + DistributedLockPort delegate = + (key, waitTime, leaseTtl) -> { + throw new LockAcquisitionTimeoutException(key, waitTime); + }; + + MeteredDistributedLockPort port = + new MeteredDistributedLockPort(delegate, providerOf(registry)); + + assertThatThrownBy( + () -> port.tryAcquire("test-key", Duration.ofMillis(100), Duration.ofSeconds(10))) + .isInstanceOf(LockAcquisitionTimeoutException.class) + .extracting(e -> ((LockAcquisitionTimeoutException) e).key()) + .isEqualTo("test-key"); + + assertThat( + registry + .get(MeteredDistributedLockPort.LOCK_ACQUISITION) + .tag( + MeteredDistributedLockPort.TAG_OUTCOME, + MeteredDistributedLockPort.OUTCOME_TIMEOUT) + .counter() + .count()) + .isEqualTo(1.0); + } + + // ---- error path ---------------------------------------------------------- + + @Test + void errorPathIncrementsErrorOutcomeAndRethrowsRuntimeException() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RuntimeException cause = new RuntimeException("unexpected lock error"); + DistributedLockPort delegate = + (key, waitTime, leaseTtl) -> { + throw cause; + }; + + MeteredDistributedLockPort port = + new MeteredDistributedLockPort(delegate, providerOf(registry)); + + assertThatThrownBy( + () -> port.tryAcquire("test-key", Duration.ofSeconds(1), Duration.ofSeconds(10))) + .isSameAs(cause); + + assertThat( + registry + .get(MeteredDistributedLockPort.LOCK_ACQUISITION) + .tag( + MeteredDistributedLockPort.TAG_OUTCOME, + MeteredDistributedLockPort.OUTCOME_ERROR) + .counter() + .count()) + .isEqualTo(1.0); + } + + // ---- no MeterRegistry (no-op) ------------------------------------------- + + @Test + void isNoopWithoutAMeterRegistryLockPathStillWorks() { + DistributedLock delegateHandle = () -> {}; + DistributedLockPort delegate = (key, waitTime, leaseTtl) -> delegateHandle; + + MeteredDistributedLockPort port = new MeteredDistributedLockPort(delegate, providerOf(null)); + + assertThatCode( + () -> { + DistributedLock handle = + port.tryAcquire("test-key", Duration.ofSeconds(1), Duration.ofSeconds(10)); + // The returned handle is a wrapping lambda; verify non-null and closeable. + assertThat(handle).isNotNull(); + handle.close(); + }) + .doesNotThrowAnyException(); + } + + @Test + void isNoopWithoutRegistryTimeoutStillPropagates() { + DistributedLockPort delegate = + (key, waitTime, leaseTtl) -> { + throw new LockAcquisitionTimeoutException(key, waitTime); + }; + + MeteredDistributedLockPort port = new MeteredDistributedLockPort(delegate, providerOf(null)); + + assertThatThrownBy( + () -> port.tryAcquire("test-key", Duration.ofMillis(50), Duration.ofSeconds(10))) + .isInstanceOf(LockAcquisitionTimeoutException.class); + } + + // ---- outcome tag values pinned to registry ---------------------------- + + @Test + void outcomeConstantsMatchTheRegistryAllowedValues() { + // metrics.yaml lock.acquisition outcome: allowed_values [acquired, timeout, error] + assertThat(MeteredDistributedLockPort.OUTCOME_ACQUIRED).isEqualTo("acquired"); + assertThat(MeteredDistributedLockPort.OUTCOME_TIMEOUT).isEqualTo("timeout"); + assertThat(MeteredDistributedLockPort.OUTCOME_ERROR).isEqualTo("error"); + } + + // ---- LOCK_LEASE_EXPIRED constant pinned to registry (mirrors outcome pinning) ---- + + @Test + void lockLeaseExpiredConstantMatchesRegistryName() { + // metrics.yaml has name: lock.lease.expired (tagless counter, feature-distributed-lock-contract + // §Edge / D5) + assertThat(MeteredDistributedLockPort.LOCK_LEASE_EXPIRED).isEqualTo("lock.lease.expired"); + } + + // ---- SI-LOCK-C5: lease-expiry on release — ConcurrentModificationException swallowed, metric + // counted ---- + + @Test + void closeSwallowsConcurrentModificationExceptionAndIncrementsLeaseExpiredCounter() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + // Simulate a handle whose close() throws CME (lease expired before holder called close()) + DistributedLock expiringHandle = + () -> { + throw new ConcurrentModificationException("lease expired"); + }; + DistributedLockPort delegate = (key, waitTime, leaseTtl) -> expiringHandle; + + MeteredDistributedLockPort port = + new MeteredDistributedLockPort(delegate, providerOf(registry)); + + DistributedLock handle = + port.tryAcquire("test-key", Duration.ofSeconds(1), Duration.ofSeconds(10)); + + // The metered handle must NOT propagate the CME (SI-LOCK-C5 — return to normal flow) + assertThatCode(handle::close).doesNotThrowAnyException(); + + // And the lease-expired counter must have incremented + assertThat(registry.get(MeteredDistributedLockPort.LOCK_LEASE_EXPIRED).counter().count()) + .isEqualTo(1.0); + } + + @Test + void closePropagatesNonCMEExceptionUnchanged() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RuntimeException dbFailure = new DataAccessResourceFailureException("DB gone"); + DistributedLock badHandle = + () -> { + throw dbFailure; + }; + DistributedLockPort delegate = (key, waitTime, leaseTtl) -> badHandle; + + MeteredDistributedLockPort port = + new MeteredDistributedLockPort(delegate, providerOf(registry)); + + DistributedLock handle = + port.tryAcquire("test-key", Duration.ofSeconds(1), Duration.ofSeconds(10)); + + // Non-CME exceptions MUST propagate — they are not lease-expiry signals + assertThatThrownBy(handle::close).isSameAs(dbFailure); + } + + @Test + void closeSwallowsCMEWhenNoRegistryIsPresent() { + // No-op case: even without a meter registry, the CME must be swallowed (not re-thrown) + DistributedLock expiringHandle = + () -> { + throw new ConcurrentModificationException("lease expired"); + }; + DistributedLockPort delegate = (key, waitTime, leaseTtl) -> expiringHandle; + + MeteredDistributedLockPort port = new MeteredDistributedLockPort(delegate, providerOf(null)); + + DistributedLock handle = + port.tryAcquire("test-key", Duration.ofSeconds(1), Duration.ofSeconds(10)); + + assertThatCode(handle::close).doesNotThrowAnyException(); + } + + // ---- helpers ---- + + /** + * Builds a real Spring {@link ObjectProvider}; a null registry yields the no-op (absent) case. + */ + private static ObjectProvider<MeterRegistry> providerOf(MeterRegistry registry) { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + if (registry != null) { + beanFactory.registerSingleton("meterRegistry", registry); + } + return beanFactory.getBeanProvider(MeterRegistry.class); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/LogMaskingPatternsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/LogMaskingPatternsTest.java new file mode 100644 index 00000000..bec38a2c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/LogMaskingPatternsTest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.bootstrap.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Guards the Redaction Layer 1 secret catalog (feature-log-management-contract 테스트 계약: + * "token/password/body가 log에 나오면 실패"). These are the canonical regexes both the JSON decorator and + * the pattern converter delegate to. + */ +class LogMaskingPatternsTest { + + @Test + void masksKeyValueToken() { + assertThat(LogMaskingPatterns.mask("user logged in token=abc123XYZ done")) + .contains("token=****") + .doesNotContain("abc123XYZ"); + } + + @Test + void masksPasswordAndSecretInVariousSeparators() { + assertThat(LogMaskingPatterns.mask("password=hunter2")).isEqualTo("password=****"); + assertThat(LogMaskingPatterns.mask("\"password\":\"hunter2\"")) + .doesNotContain("hunter2") + .contains("****"); + assertThat(LogMaskingPatterns.mask("client_secret = s3cr3t-value!")) + .doesNotContain("s3cr3t-value") + .contains("****"); + assertThat(LogMaskingPatterns.mask("api-key: AKIA1234567890")) + .doesNotContain("AKIA1234567890") + .contains("****"); + } + + @Test + void masksAuthorizationBearerHeader() { + String masked = LogMaskingPatterns.mask("Authorization: Bearer eyJhbGci.payload.sig"); + assertThat(masked).doesNotContain("eyJhbGci.payload.sig").contains("Bearer ****"); + } + + @Test + void masksStandaloneBearerToken() { + assertThat(LogMaskingPatterns.mask("sent header Bearer abcdef1234567890 to peer")) + .doesNotContain("abcdef1234567890") + .contains("Bearer ****"); + } + + @Test + void leavesNonSecretTextUntouchedAndSameReference() { + String clean = + "http_request method=GET uri_template=/v1/worklogs/{id} status=200 duration_ms=12"; + assertThat(LogMaskingPatterns.mask(clean)).isSameAs(clean); + } + + @Test + void nullAndEmptyPassThrough() { + assertThat(LogMaskingPatterns.mask(null)).isNull(); + assertThat(LogMaskingPatterns.mask("")).isEmpty(); + } + + @Test + void valueMaskerReturnsOriginalReferenceWhenNothingMasked() { + Object clean = "nothing secret here"; + assertThat(LogMaskingPatterns.valueMasker().mask(null, clean)).isSameAs(clean); + } + + @Test + void valueMaskerMasksCharsequenceWithSecret() { + Object masked = LogMaskingPatterns.valueMasker().mask(null, "token=zzz999"); + assertThat(masked).isInstanceOf(String.class); + assertThat((String) masked).contains("token=****").doesNotContain("zzz999"); + } + + @Test + void valueMaskerPassesNonCharsequenceThrough() { + Object number = 42; + assertThat(LogMaskingPatterns.valueMasker().mask(null, number)).isSameAs(number); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppenderTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppenderTest.java new file mode 100644 index 00000000..a040782f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppenderTest.java @@ -0,0 +1,101 @@ +package dev.caskeleton.bootstrap.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Deterministic test for the {@code log.appender.dropped.total} drop counter + * (feature-log-management-contract DRIFT-5). Setting {@code discardingThreshold} above {@code + * queueSize} makes {@code isQueueBelowDiscardingThreshold()} always true, so every discardable + * (≤INFO) event is dropped on the calling thread — removing the async timing race. + */ +class MetricsAsyncAppenderTest { + + private LoggerContext context; + private SimpleMeterRegistry registry; + private MetricsAsyncAppender async; + private ListAppender<ILoggingEvent> downstream; + private Logger logger; + + @BeforeEach + void setUp() { + context = new LoggerContext(); + registry = new SimpleMeterRegistry(); + Metrics.addRegistry(registry); + + downstream = new ListAppender<>(); + downstream.setContext(context); + downstream.start(); + + async = new MetricsAsyncAppender(); + async.setContext(context); + async.setName("ASYNC_TEST"); + async.setQueueSize(4); + async.setDiscardingThreshold( + 999); // remainingCapacity (<=4) < 999 always → every ≤INFO event discarded + async.addAppender(downstream); + async.start(); + + logger = context.getLogger("test.metrics.async"); + logger.setLevel(Level.TRACE); + logger.setAdditive(false); + logger.addAppender(async); + } + + @AfterEach + void tearDown() { + async.stop(); + Metrics.removeRegistry(registry); + registry.close(); + } + + @Test + void countsEachDroppedInfoEventTaggedByAppenderAndLevel() { + logger.info("a"); + logger.info("b"); + logger.info("c"); + + double dropped = + registry + .counter(MetricsAsyncAppender.DROPPED_METER, "appender", "ASYNC_TEST", "level", "INFO") + .count(); + assertThat(dropped).isEqualTo(3.0); + } + + @Test + void countsDroppedDebugEventsUnderTheirOwnLevelTag() { + logger.debug("d1"); + logger.debug("d2"); + + assertThat( + registry + .counter( + MetricsAsyncAppender.DROPPED_METER, "appender", "ASYNC_TEST", "level", "DEBUG") + .count()) + .isEqualTo(2.0); + } + + @Test + void neverCountsWarnOrErrorAsDroppedEvenUnderBackPressure() { + // discardingThreshold(999) > queueSize(4) → every event sees a "below threshold" queue, so + // only the isDiscardable() gate (<= INFO) decides. WARN/ERROR must never be counted dropped. + logger.warn("w1"); + logger.error("e1"); + logger.warn("w2"); + + assertThat(registry.find(MetricsAsyncAppender.DROPPED_METER).tag("level", "WARN").counter()) + .isNull(); + assertThat(registry.find(MetricsAsyncAppender.DROPPED_METER).tag("level", "ERROR").counter()) + .isNull(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfigTest.java new file mode 100644 index 00000000..a6b647d5 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfigTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.bootstrap.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Verifies the composition-root wiring binds the {@link UserPrincipalPseudonymizerPort} port to the + * HMAC implementation keyed with the configured salt (feature-log-management-contract DRIFT-6 / + * Q2). + */ +class PseudonymizationConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(PseudonymizationConfig.class)); + + @Test + void wiresAnHmacPseudonymizerThatRedactsThePrincipal() { + runner + .withPropertyValues("ca-skeleton.privacy.pseudonymization-salt=unit-salt-7c") + .run( + context -> { + assertThat(context).hasNotFailed(); + UserPrincipalPseudonymizerPort pseudonymizer = + context.getBean(UserPrincipalPseudonymizerPort.class); + + String pseudo = pseudonymizer.pseudonymize("idp-user-1"); + assertThat(pseudo) + .isNotNull() + .isNotEqualTo("idp-user-1") + .doesNotContain("idp-user-1") + .matches("[0-9a-f]{64}"); + // stable for the same input + assertThat(pseudonymizer.pseudonymize("idp-user-1")).isEqualTo(pseudo); + // null/blank pass through as null + assertThat(pseudonymizer.pseudonymize(null)).isNull(); + }); + } + + @Test + void bootsWithTheDevSentinelSaltWhenUnset() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + UserPrincipalPseudonymizerPort pseudonymizer = + context.getBean(UserPrincipalPseudonymizerPort.class); + assertThat(pseudonymizer.pseudonymize("x")).matches("[0-9a-f]{64}"); + }); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilterTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilterTest.java new file mode 100644 index 00000000..566ade8e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilterTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.bootstrap.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.spi.FilterReply; +import org.junit.jupiter.api.Test; + +/** + * Tests the level-aware log sampler (feature-log-management-contract §Sampling Policy, D5/D8): + * WARN/ERROR always pass; ≤INFO are kept with probability {@code rate}; bad rate + * warns-and-defaults. + */ +class SamplingTurboFilterTest { + + private SamplingTurboFilter started(double rate) { + SamplingTurboFilter filter = new SamplingTurboFilter(); + filter.setContext(new LoggerContext()); + filter.setRate(rate); + filter.start(); + return filter; + } + + private FilterReply decide(SamplingTurboFilter filter, Level level) { + return filter.decide(null, null, level, "msg", null, null); + } + + @Test + void rateOneKeepsAllInfoAndBelow() { + SamplingTurboFilter filter = started(1.0); + assertThat(decide(filter, Level.INFO)).isEqualTo(FilterReply.NEUTRAL); + assertThat(decide(filter, Level.DEBUG)).isEqualTo(FilterReply.NEUTRAL); + assertThat(decide(filter, Level.TRACE)).isEqualTo(FilterReply.NEUTRAL); + } + + @Test + void rateZeroDropsAllInfoAndBelowButKeepsWarnAndError() { + SamplingTurboFilter filter = started(0.0); + assertThat(decide(filter, Level.INFO)).isEqualTo(FilterReply.DENY); + assertThat(decide(filter, Level.DEBUG)).isEqualTo(FilterReply.DENY); + assertThat(decide(filter, Level.WARN)).isEqualTo(FilterReply.NEUTRAL); + assertThat(decide(filter, Level.ERROR)).isEqualTo(FilterReply.NEUTRAL); + } + + @Test + void partialRateKeepsAProportionalShareOfInfoAndAllWarn() { + SamplingTurboFilter filter = started(0.5); + int kept = 0; + int total = 20_000; + for (int i = 0; i < total; i++) { + if (decide(filter, Level.INFO) == FilterReply.NEUTRAL) { + kept++; + } + } + // Probabilistic but with a wide tolerance band; 0.5 of 20k ≈ 10k. + assertThat(kept).isBetween(8_500, 11_500); + // WARN is never sampled regardless of rate. + for (int i = 0; i < 1_000; i++) { + assertThat(decide(filter, Level.WARN)).isEqualTo(FilterReply.NEUTRAL); + } + } + + @Test + void outOfRangeRateWarnsAndDefaultsToKeepAll() { + SamplingTurboFilter tooHigh = started(1.7); + assertThat(tooHigh.getRate()).isEqualTo(1.0); + assertThat(decide(tooHigh, Level.INFO)).isEqualTo(FilterReply.NEUTRAL); + + SamplingTurboFilter negative = started(-0.2); + assertThat(negative.getRate()).isEqualTo(1.0); + assertThat(decide(negative, Level.INFO)).isEqualTo(FilterReply.NEUTRAL); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverterTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverterTest.java new file mode 100644 index 00000000..ec010d1d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverterTest.java @@ -0,0 +1,72 @@ +package dev.caskeleton.bootstrap.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.LoggingEvent; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * DRIFT-2 / D10 (local/dev human-readable console masking): + * + * <ul> + * <li>the {@code %maskedMsg} word is registered via the logback 1.5.x {@code <conversionRule + * class="...">} attribute (NOT the deprecated {@code converterClass}, which logback warns + * about and we no longer use), and + * <li>{@link SecretMaskingMessageConverter} redacts secrets in the formatted message. + * </ul> + */ +class SecretMaskingMessageConverterTest { + + private static final String CONFIG = + """ + <configuration> + <conversionRule conversionWord="maskedMsg" + class="dev.caskeleton.bootstrap.logging.SecretMaskingMessageConverter"/> + <root level="INFO"/> + </configuration> + """; + + @Test + void conversionRuleClassAttributeRegistersTheWordWithoutDeprecationWarning() throws Exception { + LoggerContext context = new LoggerContext(); + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(new ByteArrayInputStream(CONFIG.getBytes(StandardCharsets.UTF_8))); + + var statuses = context.getStatusManager().getCopyOfStatusList(); + // Positive: the word registered via the `class` attribute (absent if the attribute is wrong). + assertThat(statuses) + .anyMatch( + s -> + s.getMessage() != null + && s.getMessage().contains("registering conversion word maskedMsg") + && s.getMessage().contains("SecretMaskingMessageConverter")); + // Negative: no `converterClass` deprecation warning (we switched to `class`). + assertThat(statuses) + .noneMatch(s -> s.getMessage() != null && s.getMessage().contains("converterClass")); + + context.stop(); + } + + @Test + void converterMasksSecretsInTheFormattedMessage() { + SecretMaskingMessageConverter converter = new SecretMaskingMessageConverter(); + LoggingEvent event = new LoggingEvent(); + event.setMessage( + "login token=supersecret123 password=hunter2 Authorization: Bearer abc.def.ghi"); + + String rendered = converter.convert(event); + + assertThat(rendered) + .contains("token=****") + .contains("password=****") + .contains("Bearer ****") + .doesNotContain("supersecret123") + .doesNotContain("hunter2") + .doesNotContain("abc.def.ghi"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilterTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilterTest.java new file mode 100644 index 00000000..81290cee --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilterTest.java @@ -0,0 +1,123 @@ +package dev.caskeleton.bootstrap.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.spi.FilterReply; +import dev.caskeleton.bootstrap.runtime.startup.StartupFailureLogState; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StartupFailureSpringBootLogFilterTest { + + private final LoggerContext context = new LoggerContext(); + private final Logger springApplicationLogger = + context.getLogger("org.springframework.boot.SpringApplication"); + private final Logger servletContextLogger = + context.getLogger( + "org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext"); + private final Logger failureAnalysisLogger = + context.getLogger("org.springframework.boot.diagnostics.LoggingFailureAnalysisReporter"); + + @BeforeEach + void clearState() { + StartupFailureLogState.clearForTest(); + } + + @AfterEach + void resetState() { + StartupFailureLogState.clearForTest(); + context.stop(); + } + + @Test + void keepsSpringBootFailureMessagesBeforeStartupFailureWasReported() { + StartupFailureSpringBootLogFilter filter = startedFilter(); + + assertThat(decide(filter, "Application run failed")).isEqualTo(FilterReply.NEUTRAL); + assertThat(decide(filter, "Unable to close ApplicationContext")).isEqualTo(FilterReply.NEUTRAL); + } + + @Test + void dropsDuplicateSpringBootFailureMessagesAfterStartupFailureWasReported() { + StartupFailureSpringBootLogFilter filter = startedFilter(); + StartupFailureLogState.markStartupFailureReported(); + + assertThat(decide(filter, "Application run failed")).isEqualTo(FilterReply.DENY); + assertThat(decide(filter, "Unable to close ApplicationContext")).isEqualTo(FilterReply.DENY); + } + + @Test + void dropsContextRefreshCancellationAfterStartupFailureWasReported() { + StartupFailureSpringBootLogFilter filter = startedFilter(); + StartupFailureLogState.markStartupFailureReported(); + + assertThat( + filter.decide( + null, + servletContextLogger, + Level.WARN, + "Exception encountered during context initialization - cancelling refresh attempt: {}", + null, + new RuntimeException("boom"))) + .isEqualTo(FilterReply.DENY); + } + + @Test + void dropsFailureAnalysisReporterAfterStartupFailureWasReported() { + StartupFailureSpringBootLogFilter filter = startedFilter(); + StartupFailureLogState.markStartupFailureReported(); + + assertThat( + filter.decide( + null, + failureAnalysisLogger, + Level.DEBUG, + "Application failed to start due to an exception", + null, + new RuntimeException("boom"))) + .isEqualTo(FilterReply.DENY); + assertThat(filter.decide(null, failureAnalysisLogger, Level.ERROR, "", null, null)) + .isEqualTo(FilterReply.DENY); + } + + @Test + void keepsOtherSpringBootMessagesAfterStartupFailureWasReported() { + StartupFailureSpringBootLogFilter filter = startedFilter(); + StartupFailureLogState.markStartupFailureReported(); + + assertThat(decide(filter, "Started CaSkeletonApplication")).isEqualTo(FilterReply.NEUTRAL); + } + + @Test + void keepsDuplicateMessagesFromOtherLoggers() { + StartupFailureSpringBootLogFilter filter = startedFilter(); + StartupFailureLogState.markStartupFailureReported(); + Logger otherLogger = context.getLogger("dev.caskeleton.bootstrap.runtime.startup"); + + assertThat( + filter.decide( + null, + otherLogger, + Level.ERROR, + "Application run failed", + null, + new RuntimeException())) + .isEqualTo(FilterReply.NEUTRAL); + } + + private StartupFailureSpringBootLogFilter startedFilter() { + StartupFailureSpringBootLogFilter filter = new StartupFailureSpringBootLogFilter(); + filter.setContext(context); + filter.start(); + return filter; + } + + private FilterReply decide(StartupFailureSpringBootLogFilter filter, String message) { + return filter.decide( + null, springApplicationLogger, Level.ERROR, message, null, new RuntimeException("boom")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsCardinalityMeterFilterTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsCardinalityMeterFilterTest.java new file mode 100644 index 00000000..91f8c28f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsCardinalityMeterFilterTest.java @@ -0,0 +1,146 @@ +package dev.caskeleton.bootstrap.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.config.MeterFilterReply; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link MetricsCardinalityMeterFilter}. + * + * <p>TDD red-then-green: tests were written before the implementation class existed. Verifies D8 + * forbidden-tag deny semantics (feature-metrics-alerting-contract). + */ +class MetricsCardinalityMeterFilterTest { + + private final MetricsCardinalityMeterFilter filter = new MetricsCardinalityMeterFilter(); + + // ---------------------------------------------------------------- + // DENY for each forbidden tag key + // ---------------------------------------------------------------- + + @Test + @DisplayName("D8: accept() returns DENY when tag key is 'user_id'") + void acceptReturnsDenyForUserIdTag() { + Meter.Id id = + new Meter.Id("some.metric", Tags.of("user_id", "abc123"), null, null, Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + @Test + @DisplayName("D8: accept() returns DENY when tag key is 'request_id'") + void acceptReturnsDenyForRequestIdTag() { + Meter.Id id = + new Meter.Id("some.metric", Tags.of("request_id", "req-1"), null, null, Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + @Test + @DisplayName("D8: accept() returns DENY when tag key is 'raw_url'") + void acceptReturnsDenyForRawUrlTag() { + Meter.Id id = + new Meter.Id( + "some.metric", Tags.of("raw_url", "/users/123"), null, null, Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + @Test + @DisplayName("D8: accept() returns DENY when tag key is 'raw_query'") + void acceptReturnsDenyForRawQueryTag() { + Meter.Id id = + new Meter.Id( + "some.metric", Tags.of("raw_query", "name=foo"), null, null, Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + @Test + @DisplayName("D8: accept() returns DENY when tag key is 'raw_header_value'") + void acceptReturnsDenyForRawHeaderValueTag() { + Meter.Id id = + new Meter.Id( + "some.metric", + Tags.of("raw_header_value", "Bearer tok"), + null, + null, + Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + @Test + @DisplayName("D8: accept() returns DENY when tag key is 'ip_address'") + void acceptReturnsDenyForIpAddressTag() { + Meter.Id id = + new Meter.Id( + "some.metric", Tags.of("ip_address", "1.2.3.4"), null, null, Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + @Test + @DisplayName("D8: accept() returns DENY when ANY tag among several is forbidden") + void acceptReturnsDenyWhenAnyTagIsForbidden() { + Meter.Id id = + new Meter.Id( + "some.metric", + Tags.of("status", "200", "user_id", "abc", "uri_template", "/ok"), + null, + null, + Meter.Type.COUNTER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.DENY); + } + + // ---------------------------------------------------------------- + // NEUTRAL for clean (no forbidden tag) ids + // ---------------------------------------------------------------- + + @Test + @DisplayName("D8: accept() returns NEUTRAL for clean metric with only allowed tags") + void acceptReturnsNeutralForCleanTags() { + Meter.Id id = + new Meter.Id( + "http.server.requests", + Tags.of("method", "GET", "status", "2xx", "uri_template", "/api/v1/users"), + null, + null, + Meter.Type.TIMER); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.NEUTRAL); + } + + @Test + @DisplayName("D8: accept() returns NEUTRAL for metric with no tags") + void acceptReturnsNeutralForNoTags() { + Meter.Id id = new Meter.Id("process.uptime", Tags.empty(), null, null, Meter.Type.GAUGE); + assertThat(filter.accept(id)).isEqualTo(MeterFilterReply.NEUTRAL); + } + + // ---------------------------------------------------------------- + // Integration with SimpleMeterRegistry + // ---------------------------------------------------------------- + + @Test + @DisplayName("D8: forbidden-tag meter is not registered in SimpleMeterRegistry") + void forbiddenTagMeterIsDeniedInSimpleRegistry() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + registry.config().meterFilter(filter); + + registry.counter("test.metric", "user_id", "abc"); + assertThat(registry.find("test.metric").counter()) + .as("counter with user_id tag must be denied (not registered)") + .isNull(); + } + + @Test + @DisplayName("D8: allowed-tag meter IS registered in SimpleMeterRegistry") + void allowedTagMeterIsRegisteredInSimpleRegistry() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + registry.config().meterFilter(filter); + + registry.counter("ok.metric", "status", "2xx"); + assertThat(registry.find("ok.metric").counter()) + .as("counter with only allowed tags must be registered") + .isNotNull(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfigTest.java new file mode 100644 index 00000000..1c4efd85 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfigTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.bootstrap.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.distribution.HistogramSnapshot; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; + +/** + * Unit tests for {@link MetricsContractConfig}. + * + * <p>TDD red-then-green: tests were written before the implementation class existed. Verifies that + * {@code MetricsContractConfig.install()} installs both filters (D8 + D9) and that the config is a + * no-op when no {@link MeterRegistry} is available (feature-metrics-alerting-contract). + */ +class MetricsContractConfigTest { + + // ---------------------------------------------------------------- + // install() — cardinality deny (D8) + // ---------------------------------------------------------------- + + @Test + @DisplayName("D8: install() makes the registry deny meters with forbidden tags") + void installCausesRegistryToDenyForbiddenTagMeters() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MetricsContractConfig.install(registry); + + registry.counter("test.metric", "user_id", "abc"); + assertThat(registry.find("test.metric").counter()) + .as("counter with user_id tag must be denied after install()") + .isNull(); + } + + @Test + @DisplayName("D8: install() allows meters with only clean tags") + void installAllowsMetersWithCleanTags() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MetricsContractConfig.install(registry); + + registry.counter("ok.metric", "status", "2xx"); + assertThat(registry.find("ok.metric").counter()) + .as("counter with only allowed tags must be registered after install()") + .isNotNull(); + } + + // ---------------------------------------------------------------- + // install() — distribution config for http.server.requests (D9) + // ---------------------------------------------------------------- + + @Test + @DisplayName( + "D9: install() wires MetricsDistributionMeterFilter so http.server.requests gets SLO histogram buckets") + void installAppliesSloDistributionToHttpServerRequests() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MetricsContractConfig.install(registry); + + // Register and record — the MeterFilter configure() is applied at registration time + Timer timer = registry.timer("http.server.requests"); + timer.record(Duration.ofMillis(250)); + HistogramSnapshot snapshot = timer.takeSnapshot(); + + assertThat(snapshot.histogramCounts()) + .as( + "install() must wire MetricsDistributionMeterFilter so http.server.requests gets SLO histogram buckets") + .isNotEmpty(); + } + + // ---------------------------------------------------------------- + // no-op with empty ObjectProvider + // ---------------------------------------------------------------- + + @Test + @DisplayName( + "No-op: MetricsContractConfig.installFilters() does not throw when no MeterRegistry is available") + void configIsNoopWithoutMeterRegistry() { + DefaultListableBeanFactory emptyFactory = new DefaultListableBeanFactory(); + MetricsContractConfig config = + new MetricsContractConfig(emptyFactory.getBeanProvider(MeterRegistry.class)); + // Invoke the @PostConstruct method directly (package-private, same package) to prove + // the absent-registry branch does not throw — mirrors DistributedTracingContractTest precedent + assertThatCode(config::installFilters).doesNotThrowAnyException(); + } + + // ---------------------------------------------------------------- + // install() — both filters chain correctly (combined D8+D9) + // ---------------------------------------------------------------- + + @Test + @DisplayName("D8+D9: install() chains cardinality DENY before distribution config") + void installChainsBothFilters() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MetricsContractConfig.install(registry); + + // Forbidden tag: denied (D8 wins first) + registry.counter("some.metric", "ip_address", "10.0.0.1"); + assertThat(registry.find("some.metric").counter()) + .as("meter with ip_address must be denied by D8 filter") + .isNull(); + + // Clean metric: registered (D9 applied for timers) + registry.timer("db.query.duration", "operation", "select", "outcome", "SUCCESS"); + assertThat(registry.find("db.query.duration").timer()) + .as("db.query.duration timer with clean tags must be registered") + .isNotNull(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsDistributionMeterFilterTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsDistributionMeterFilterTest.java new file mode 100644 index 00000000..8c00f2c6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MetricsDistributionMeterFilterTest.java @@ -0,0 +1,154 @@ +package dev.caskeleton.bootstrap.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Unit tests for {@link MetricsDistributionMeterFilter}. + * + * <p>TDD red-then-green: tests were written before the implementation class existed. Verifies D9 + * SLO-driven histogram and percentile configuration for owned timers + * (feature-metrics-alerting-contract). + */ +class MetricsDistributionMeterFilterTest { + + private final MetricsDistributionMeterFilter filter = new MetricsDistributionMeterFilter(); + + // ---------------------------------------------------------------- + // Owned timers get SLO-driven configuration + // ---------------------------------------------------------------- + + @ParameterizedTest(name = "D9: configure() applies SLO histogram to owned timer ''{0}''") + @ValueSource( + strings = { + "http.server.requests", + "http.server.requests.latency", + "dependency.client.requests", + "db.query.duration", + "jvm.gc.pause" + }) + void configureAppliesSloHistogramToOwnedTimers(String metricName) { + Meter.Id id = new Meter.Id(metricName, Tags.empty(), null, null, Meter.Type.TIMER); + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.isPercentileHistogram()) + .as("owned timer '%s' must have percentileHistogram=true (D9 aggregable)", metricName) + .isTrue(); + } + + @ParameterizedTest(name = "D9: configure() sets non-empty SLO boundaries for owned timer ''{0}''") + @ValueSource( + strings = { + "http.server.requests", + "http.server.requests.latency", + "dependency.client.requests", + "db.query.duration", + "jvm.gc.pause" + }) + void configureSetsSloBoundariesForOwnedTimers(String metricName) { + Meter.Id id = new Meter.Id(metricName, Tags.empty(), null, null, Meter.Type.TIMER); + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + double[] slos = result.getServiceLevelObjectiveBoundaries(); + assertThat(slos) + .as("owned timer '%s' must have non-null, non-empty SLO boundaries", metricName) + .isNotNull() + .isNotEmpty(); + // Expect 4 SLO boundaries: 100ms, 500ms, 1s, 5s (in nanos) + assertThat(slos).hasSize(4); + } + + @ParameterizedTest(name = "D9: configure() sets client-side percentiles for owned timer ''{0}''") + @ValueSource( + strings = { + "http.server.requests", + "http.server.requests.latency", + "dependency.client.requests", + "db.query.duration", + "jvm.gc.pause" + }) + void configureSetsPercentilesForOwnedTimers(String metricName) { + Meter.Id id = new Meter.Id(metricName, Tags.empty(), null, null, Meter.Type.TIMER); + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + double[] percentiles = result.getPercentiles(); + assertThat(percentiles) + .as("owned timer '%s' must have percentiles set", metricName) + .isNotNull() + .containsExactlyInAnyOrder(0.5, 0.9, 0.95, 0.99); + } + + @ParameterizedTest(name = "D9: configure() sets min/max expected value for owned timer ''{0}''") + @ValueSource( + strings = { + "http.server.requests", + "http.server.requests.latency", + "dependency.client.requests", + "db.query.duration", + "jvm.gc.pause" + }) + void configureSetsMinMaxExpectedValueForOwnedTimers(String metricName) { + Meter.Id id = new Meter.Id(metricName, Tags.empty(), null, null, Meter.Type.TIMER); + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.getMinimumExpectedValueAsDouble()) + .as("owned timer '%s' must have minimumExpectedValue set", metricName) + .isNotNull() + .isPositive(); + assertThat(result.getMaximumExpectedValueAsDouble()) + .as("owned timer '%s' must have maximumExpectedValue set", metricName) + .isNotNull() + .isPositive(); + } + + // ---------------------------------------------------------------- + // Non-owned metrics pass through unchanged + // ---------------------------------------------------------------- + + @Test + @DisplayName("D9: configure() returns config unchanged for non-owned metric 'lock.acquisition'") + void configurePassesThroughNonOwnedCounter() { + Meter.Id id = new Meter.Id("lock.acquisition", Tags.empty(), null, null, Meter.Type.COUNTER); + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.isPercentileHistogram()) + .as("non-owned metric must not have percentileHistogram changed") + .isFalse(); + assertThat(result.getServiceLevelObjectiveBoundaries()) + .as("non-owned metric must not have SLO boundaries set") + .isNull(); + } + + @Test + @DisplayName( + "D9: configure() returns config unchanged for non-owned timer 'resilience4j.circuitbreaker.calls'") + void configurePassesThroughNonOwnedTimer() { + Meter.Id id = + new Meter.Id( + "resilience4j.circuitbreaker.calls", Tags.empty(), null, null, Meter.Type.TIMER); + DistributionStatisticConfig result = filter.configure(id, DistributionStatisticConfig.DEFAULT); + + assertThat(result.isPercentileHistogram()) + .as("consumed-but-not-owned timer must not have percentileHistogram forced") + .isFalse(); + } + + @Test + @DisplayName("D9: configure() returns same instance for non-owned metric") + void configureReturnsSameInstanceForNonOwnedMetric() { + Meter.Id id = new Meter.Id("lock.acquisition", Tags.empty(), null, null, Meter.Type.COUNTER); + DistributionStatisticConfig original = DistributionStatisticConfig.DEFAULT; + DistributionStatisticConfig result = filter.configure(id, original); + + assertThat(result) + .as("non-owned metric must return the same config instance unchanged") + .isSameAs(original); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java new file mode 100644 index 00000000..c88efe12 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java @@ -0,0 +1,16 @@ +package dev.caskeleton.bootstrap.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.Test; + +class OutboxConfigTest { + + @Test + void outboxRandomGeneratorIsAvailableInTheSlimJavaBaseRuntime() { + RandomGenerator random = new OutboxConfig().outboxRandomGenerator(); + + assertThat(random.getClass().getModule().getName()).isEqualTo("java.base"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java new file mode 100644 index 00000000..7fbde97d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java @@ -0,0 +1,101 @@ +package dev.caskeleton.bootstrap.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +/** + * Binding and validation tests for {@link OutboxSettings} (feature-domain-event-outbox-contract I11 + * — mirrors IdempotencyPropertiesTest). + */ +class OutboxSettingsTest { + + @Test + void bindsAllSixDefaultsWhenFieldsAreNull() { + OutboxSettings props = new OutboxSettings(null, null, null, null, null, null); + assertThat(props.relayEnabled()).isTrue(); + assertThat(props.pollInterval()).isEqualTo(Duration.ofSeconds(5)); + assertThat(props.batchSize()).isEqualTo(20); + assertThat(props.inFlightTimeout()).isEqualTo(Duration.ofMinutes(5)); + assertThat(props.reaperInterval()).isEqualTo(Duration.ofMinutes(10)); + assertThat(props.publishedRetention()).isEqualTo(Duration.ofDays(7)); + } + + @Test + void acceptsExplicitValidValues() { + OutboxSettings props = + new OutboxSettings( + false, + Duration.ofSeconds(10), + 50, + Duration.ofMinutes(2), + Duration.ofHours(1), + Duration.ofDays(14)); + assertThat(props.relayEnabled()).isFalse(); + assertThat(props.pollInterval()).isEqualTo(Duration.ofSeconds(10)); + assertThat(props.batchSize()).isEqualTo(50); + assertThat(props.inFlightTimeout()).isEqualTo(Duration.ofMinutes(2)); + assertThat(props.reaperInterval()).isEqualTo(Duration.ofHours(1)); + assertThat(props.publishedRetention()).isEqualTo(Duration.ofDays(14)); + } + + @Test + void rejectsZeroBatchSize() { + assertThatThrownBy(() -> new OutboxSettings(true, null, 0, null, null, null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("batchSize"); + } + + @Test + void rejectsNegativeBatchSize() { + assertThatThrownBy(() -> new OutboxSettings(true, null, -1, null, null, null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("batchSize"); + } + + @Test + void rejectsNonPositivePollInterval() { + assertThatThrownBy(() -> new OutboxSettings(true, Duration.ZERO, null, null, null, null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("pollInterval"); + } + + @Test + void rejectsNonPositiveInFlightTimeout() { + assertThatThrownBy(() -> new OutboxSettings(true, null, null, Duration.ZERO, null, null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("inFlightTimeout"); + } + + @Test + void rejectsZeroReaperInterval() { + assertThatThrownBy(() -> new OutboxSettings(true, null, null, null, Duration.ZERO, null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("reaperInterval"); + } + + @Test + void rejectsNegativeReaperInterval() { + assertThatThrownBy( + () -> new OutboxSettings(true, null, null, null, Duration.ofMinutes(-1), null)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("reaperInterval"); + } + + @Test + void rejectsZeroPublishedRetention() { + assertThatThrownBy(() -> new OutboxSettings(true, null, null, null, null, Duration.ZERO)) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("publishedRetention"); + } + + @Test + void rejectsNegativePublishedRetention() { + assertThatThrownBy(() -> new OutboxSettings(true, null, null, null, null, Duration.ofHours(-1))) + .isInstanceOf(StartupValidationException.class) + .hasMessageContaining("publishedRetention"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java new file mode 100644 index 00000000..3165836d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java @@ -0,0 +1,164 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Contract test for the HikariCP inter-knob constraint startup guard + * (feature-database-connection-pool-contract D2/D4/D5/D7). Mirrors {@link + * OpenInViewSafetyValidatorTest}. + */ +class HikariPoolConstraintValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + // --- D2 / HIKARI-CFG-C1: connection-timeout >= 250 --- + + @Test + void connectionTimeoutBelowMinimumFailsStartup() { + runner + .withPropertyValues("spring.datasource.hikari.connection-timeout=100") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("connection-timeout") + // names the operator-facing env key, not just the Spring property + .hasStackTraceContaining("APP_DATASOURCE_CONNECTION_TIMEOUT") + .hasStackTraceContaining(">= 250"); + }); + } + + @Test + void connectionTimeoutAtMinimumStartsCleanly() { + // 250 is the inclusive lower bound (HIKARI-CFG-C1) — must pass. + runner + .withPropertyValues("spring.datasource.hikari.connection-timeout=250") + .run(context -> assertThat(context).hasNotFailed()); + } + + // --- D7 / HIKARI-CFG-C6: validation-timeout < connection-timeout --- + + @Test + void validationTimeoutEqualToConnectionTimeoutFailsStartup() { + runner + .withPropertyValues( + "spring.datasource.hikari.connection-timeout=30000", + "spring.datasource.hikari.validation-timeout=30000") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("validation-timeout") + .hasStackTraceContaining("connection-timeout"); + }); + } + + // --- D4 / HIKARI-CFG-C4: keepalive-time < max-lifetime --- + + @Test + void keepaliveTimeGreaterThanMaxLifetimeFailsStartup() { + runner + .withPropertyValues( + "spring.datasource.hikari.keepalive-time=2000000", + "spring.datasource.hikari.max-lifetime=1800000") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("keepalive-time") + .hasStackTraceContaining("max-lifetime"); + }); + } + + @Test + void keepaliveTimeEqualToMaxLifetimeFailsStartup() { + // The constraint is strict (<): equal values violate it (HIKARI-CFG-C4). + runner + .withPropertyValues( + "spring.datasource.hikari.keepalive-time=1800000", + "spring.datasource.hikari.max-lifetime=1800000") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("keepalive-time") + .hasStackTraceContaining("max-lifetime"); + }); + } + + // --- D5 / HIKARI-CFG-C5: leak-detection-threshold (if != 0) >= 2000 --- + + @Test + void leakDetectionThresholdBelowMinimumFailsStartup() { + runner + .withPropertyValues("spring.datasource.hikari.leak-detection-threshold=1000") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("leak-detection-threshold") + .hasStackTraceContaining("2000"); + }); + } + + @Test + void leakDetectionThresholdZeroIsDisabledAndAllowed() { + // 0 = disabled — must not fail + runner + .withPropertyValues("spring.datasource.hikari.leak-detection-threshold=0") + .run(context -> assertThat(context).hasNotFailed()); + } + + // --- Happy path: fully valid set --- + + @Test + void fullyValidSetStartsCleanly() { + runner + .withPropertyValues( + "spring.datasource.hikari.connection-timeout=30000", + "spring.datasource.hikari.validation-timeout=3000", + "spring.datasource.hikari.keepalive-time=120000", + "spring.datasource.hikari.max-lifetime=1800000", + "spring.datasource.hikari.leak-detection-threshold=30000") + .run(context -> assertThat(context).hasNotFailed()); + } + + // --- All absent → framework defaults own it --- + + @Test + void allAbsentStartsCleanly() { + runner.run(context -> assertThat(context).hasNotFailed()); + } + + // --- CONNECTION_TIMEOUT_FORMAT_DRIFT: non-numeric duration string must not crash --- + + @Test + void connectionTimeoutAsDurationStringIsDefensivelySkipped() { + // env-keys.yaml default for connection-timeout is "5s" (Duration string). + // The validator must not throw ConversionFailedException — it silently skips. + runner + .withPropertyValues("spring.datasource.hikari.connection-timeout=5s") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Configuration + static class ValidatorConfig { + @Bean + HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) { + return new HikariPoolConstraintValidator(environment); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OpenInViewSafetyValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OpenInViewSafetyValidatorTest.java new file mode 100644 index 00000000..b854c78c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OpenInViewSafetyValidatorTest.java @@ -0,0 +1,57 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Contract test for the OSIV-off startup guard (feature-persistence-failure-baseline D2 + + * Claims-To-Verify "spring.jpa.open-in-view=false 가 ca-tmpl startup assertion 으로 강제됨"). Turns + * Spring Boot's easy-to-miss WARN into a hard boot failure. Mirrors {@code + * RuntimeNumericBoundsValidatorTest}. + */ +class OpenInViewSafetyValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @Test + void openInViewTrueFailsStartupNamingTheAppKey() { + runner + .withPropertyValues("spring.jpa.open-in-view=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_DATASOURCE_OPEN_IN_VIEW") + .hasStackTraceContaining("must be false"); + }); + } + + @Test + void openInViewFalseStartsCleanly() { + runner + .withPropertyValues("spring.jpa.open-in-view=false") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void absentValueIsLeftToSpringBootDefault() { + // Absent → Spring Boot's own default owns it; the guard only rejects a present true. + runner.run(context -> assertThat(context).hasNotFailed()); + } + + @Configuration + static class ValidatorConfig { + @Bean + OpenInViewSafetyValidator openInViewSafetyValidator(Environment environment) { + return new OpenInViewSafetyValidator(environment); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java new file mode 100644 index 00000000..be1d7ea8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java @@ -0,0 +1,123 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.controller.HealthcheckController; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; +import dev.caskeleton.bootstrap.CaSkeletonApplication; +import dev.caskeleton.bootstrap.logging.PseudonymizationConfig; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Regression guard for the skeleton's operational envelope/error contract. + * + * <p>The audit motivation: this contract used to live only in the disposable {@code + * sample-portfolio} module, which {@code app-bootstrap} does NOT depend on at runtime ({@code + * bootJar} mainClass {@code CaSkeletonApplication}). The contract beans now live in {@code + * adapter-web} (a production dependency of {@code app-bootstrap}). This test pins that {@link + * CaSkeletonApplication}'s component scan ({@code scanBasePackages = "dev.caskeleton"}) actually + * wires them into the running application, so a future change cannot silently drop the contract + * from the real app again. + * + * <p>Why a {@code @WebMvcTest} slice rather than a full {@code @SpringBootTest}: a full boot of + * {@code CaSkeletonApplication} requires the ~50 {@code ${...}} env vars wired in {@code + * application.yml} (datasource, OIDC issuer, etc.) from {@code src/.env}, and {@code + * sample-portfolio} on the test classpath drags in Spring Data JPA / Security / OAuth2 + * autoconfiguration plus JPA entities and repositories that need a live datasource and JWT issuer. + * The MVC slice gives a real, {@code CaSkeletonApplication}-anchored web context without that cost. + * + * <p>The slice scans {@code CaSkeletonApplication}'s package for {@code @ControllerAdvice} beans, + * so it picks up the advices via the same component-scan path the running app uses. The raw-{@code + * Map} probe controller proves the {@link EnvelopeBodyAdvice} actually wraps a non-envelope body + * (the production {@code HealthcheckController} returns {@code Envelope.ok} by hand, so it cannot + * prove wrapping). + */ +@WebMvcTest(CaSkeletonApplication.class) +@AutoConfigureMockMvc(addFilters = false) +// RequestLoggingFilter (a @Component filter the broad CaSkeletonApplication slice scan +// instantiates) now requires a UserPrincipalPseudonymizerPort; PseudonymizationConfig supplies it +// (and binds PrivacySettings from the test salt) so the slice context can be built. +@Import({ + HealthcheckController.class, + OperationalContractRuntimeTest.RawProbeController.class, + PseudonymizationConfig.class +}) +// PresentationWebConfig (a @Configuration in adapter-web, picked up by the MVC +// slice scan) needs PresentationSettings, which @WebMvcTest does not register +// on its own; supply it so the slice mirrors the real app's web config wiring. +@EnableConfigurationProperties(PresentationSettings.class) +// The production application.yml resolves ~50 ${...} placeholders from src/.env, +// which is not on the test working directory. Only the eagerly-bound ones +// (profiles + config import + ConfigurationProperties scanned by +// @ConfigurationPropertiesScan) need overriding for this MVC slice to boot; +// the remaining placeholders sit on bean properties that the slice never binds. +@TestPropertySource( + properties = { + "spring.profiles.active=test", + "spring.autoconfigure.exclude=" + + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet." + + "OAuth2ResourceServerAutoConfiguration" + }) +@ActiveProfiles("test") +class OperationalContractRuntimeTest { + + @Autowired private ApplicationContext ctx; + + @Autowired private MockMvc mockMvc; + + @Test + void operationalContractBeansAreWiredIntoTheApplicationContext() { + assertThat(ctx.getBeansOfType(EnvelopeBodyAdvice.class)) + .as("EnvelopeBodyAdvice must be component-scanned into the CaSkeletonApplication context") + .isNotEmpty(); + assertThat(ctx.getBeansOfType(GlobalExceptionHandler.class)) + .as( + "GlobalExceptionHandler must be component-scanned into the CaSkeletonApplication context") + .isNotEmpty(); + } + + @Test + void envelopeAdviceWrapsRawControllerBody() throws Exception { + mockMvc + .perform(get("/__probe/raw")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.probe").value("ok")) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + void coreHealthcheckEndpointRemainsAvailable() throws Exception { + mockMvc + .perform(get("/healthcheck")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.status").value("UP")); + } + + @RestController + static class RawProbeController { + + @GetMapping("/__probe/raw") + Map<String, String> raw() { + return Map.of("probe", "ok"); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java new file mode 100644 index 00000000..c06b5b4d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeHealthLifecycleContractTest.java @@ -0,0 +1,261 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Set; +import java.util.TimeZone; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.availability.ApplicationAvailabilityAutoConfiguration; +import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; +import org.springframework.boot.health.actuate.endpoint.StatusAggregator; +import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointAutoConfiguration; +import org.springframework.boot.health.autoconfigure.application.AvailabilityHealthContributorAutoConfiguration; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorAutoConfiguration; +import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * TDD contract test for feature-runtime-health-lifecycle-contract. + * + * <p>Asserts the health probe SHAPE: + * + * <ul> + * <li>Three health groups (liveness, readiness, startup) are configured via {@code + * management.endpoint.health.group.*} properties. + * <li>readiness is DOWN when the primary DB contributor is DOWN, while liveness stays UP + * (independent group composition). + * <li>JVM default timezone is UTC (enforced via {@code user.timezone=UTC} JVM arg in + * app-bootstrap's test task). + * </ul> + * + * <p>Tests are programmatic — no HTTP calls, no {@code SecurityFilterChain} entanglement. The + * {@link ApplicationContextRunner} approach mirrors the existing {@link + * dev.caskeleton.bootstrap.settings.BootstrapSettingsTest} precedent. + */ +class RuntimeHealthLifecycleContractTest { + + // --------------------------------------------------------------------------- + // Shared runner with actuator health autoconfiguration and the health group + // properties that mirror the production application.yml management block. + // --------------------------------------------------------------------------- + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + ApplicationAvailabilityAutoConfiguration.class, + HealthContributorRegistryAutoConfiguration.class, + HealthContributorAutoConfiguration.class, + AvailabilityHealthContributorAutoConfiguration.class, + HealthEndpointAutoConfiguration.class)) + // DownDbContributorConfig registers a 'db' HealthIndicator bean so the + // HealthEndpointGroupMembershipValidator is satisfied (it checks contributor existence + // at startup). This also allows testing the isMember() taxonomy assertions. + .withUserConfiguration(DownDbContributorConfig.class) + .withPropertyValues( + // Enable the livenessState and readinessState health contributors. + // In production these are activated by Kubernetes probe detection; + // in the isolated ApplicationContextRunner we enable them explicitly. + "management.health.livenessstate.enabled=true", + "management.health.readinessstate.enabled=true", + // The three production group properties under test. + "management.endpoint.health.probes.enabled=true", + "management.endpoint.health.group.liveness.include=livenessState", + "management.endpoint.health.group.readiness.include=readinessState,db", + "management.endpoint.health.group.startup.include=readinessState"); + + // ========================================================================= + // 1. Three health groups are configured + // ========================================================================= + + @Test + @DisplayName("health probes: liveness group is configured") + void livenessGroupIsConfigured() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); + assertThat(groups.get("liveness")) + .as( + "liveness group must be configured " + + "(management.endpoint.health.group.liveness.include=livenessState)") + .isNotNull(); + }); + } + + @Test + @DisplayName("health probes: readiness group is configured") + void readinessGroupIsConfigured() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); + assertThat(groups.get("readiness")) + .as( + "readiness group must be configured " + + "(management.endpoint.health.group.readiness.include=readinessState,db)") + .isNotNull(); + }); + } + + @Test + @DisplayName("health probes: startup group is configured") + void startupGroupIsConfigured() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); + assertThat(groups.get("startup")) + .as( + "startup group must be configured " + + "(management.endpoint.health.group.startup.include=readinessState)") + .isNotNull(); + }); + } + + // ========================================================================= + // 2. Required-dependency taxonomy: DOWN db flips readiness DOWN, liveness stays UP + // + // Strategy: assert that the readiness group includes "db" in its membership + // (includes the db contributor) and the liveness group does NOT include "db". + // This is a static configuration assertion (group membership) that is fully + // deterministic and hermetic — no real DB, no runtime probe execution. + // The dependency taxonomy (db = REQUIRED for readiness) is enforced by the + // include lists in the application.yml management block. + // ========================================================================= + + @Test + @DisplayName("readiness group includes db (REQUIRED dependency taxonomy)") + void readinessGroupIncludesDbContributor() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); + var readiness = groups.get("readiness"); + assertThat(readiness).as("readiness group must exist").isNotNull(); + // The group membership is defined by "include=readinessState,db". + // isMember() returns true when the contributor name is in the include list. + assertThat(readiness.isMember("db")) + .as( + "readiness group must include 'db' " + + "(primary DB is a REQUIRED dependency — its DOWN status flips readiness)") + .isTrue(); + }); + } + + @Test + @DisplayName("liveness group does NOT include db (liveness is independent of REQUIRED deps)") + void livenessGroupDoesNotIncludeDb() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); + var liveness = groups.get("liveness"); + assertThat(liveness).as("liveness group must exist").isNotNull(); + assertThat(liveness.isMember("db")) + .as( + "liveness group must NOT include 'db' " + + "(a DOWN DB must not flip liveness — the JVM can still continue)") + .isFalse(); + }); + } + + @Test + @DisplayName( + "DOWN db contributor flips readiness DOWN while liveness stays UP (StatusAggregator proof)") + void downDbFlipsReadinessDownLivenessStaysUp() { + // Programmatic StatusAggregator proof: compose the groups manually with a + // stubbed DOWN db contributor and assert aggregation outcomes. + // No Spring context needed — StatusAggregator is a pure function. + StatusAggregator aggregator = StatusAggregator.getDefault(); + + // Liveness group: only livenessState = UP + Status livenessStatus = + aggregator.getAggregateStatus( + Set.of(Status.UP) // livenessState = UP + ); + + // Readiness group: readinessState = UP, db = DOWN + Status readinessStatus = + aggregator.getAggregateStatus( + Set.of(Status.UP, Status.DOWN) // readinessState = UP, db = DOWN + ); + + assertThat(livenessStatus) + .as("liveness must remain UP when only livenessState is UP (JVM continues)") + .isEqualTo(Status.UP); + + assertThat(readinessStatus) + .as("readiness must be DOWN when db is DOWN (REQUIRED dependency taxonomy)") + .isEqualTo(Status.DOWN); + } + + // ========================================================================= + // 3. JVM timezone is UTC (test-JVM determinism) + // This test pins the UTC timezone POLICY for this contract and guarantees + // test-JVM determinism via the -Duser.timezone=UTC arg in the app-bootstrap + // test task (build.gradle). Removing that arg causes this test to fail, + // preventing silent drift in timestamp arithmetic across host locales. + // NOTE: production runtime UTC enforcement is owned by + // feature-container-runtime-contract (TZ=UTC in its Dockerfile) — not here. + // ========================================================================= + + @Test + @DisplayName("JVM default timezone is UTC (user.timezone=UTC test JVM arg)") + void jvmDefaultTimezoneIsUtc() { + assertThat(TimeZone.getDefault().getID()) + .as( + "JVM default timezone must be UTC — enforced by -Duser.timezone=UTC in the " + + "app-bootstrap test task. A drift here means the test JVM arg was removed. " + + "Production UTC is owned by feature-container-runtime-contract (TZ=UTC in Dockerfile).") + .isEqualTo("UTC"); + } + + // ========================================================================= + // 4. Startup group membership: readinessState is a member + // Mirrors the liveness/readiness isMember tests (sections 1 and 2). + // The startup group is configured with include=readinessState — assert + // membership explicitly to give the startup group the same coverage parity + // as liveness (livenessState) and readiness (readinessState,db). + // ========================================================================= + + @Test + @DisplayName("startup group includes readinessState (startup completion gate)") + void startupGroupIncludesReadinessState() { + runner.run( + ctx -> { + assertThat(ctx).hasNotFailed(); + HealthEndpointGroups groups = ctx.getBean(HealthEndpointGroups.class); + var startup = groups.get("startup"); + assertThat(startup).as("startup group must exist").isNotNull(); + // The group membership is defined by "include=readinessState". + // readinessState acts as the startup completion gate — it flips UP only + // when the application is ready to serve traffic after startup/migration. + assertThat(startup.isMember("readinessState")) + .as( + "startup group must include 'readinessState' " + + "(management.endpoint.health.group.startup.include=readinessState)") + .isTrue(); + }); + } + + // --------------------------------------------------------------------------- + // Inner @Configuration: a DOWN db HealthIndicator bean to allow a live-group + // membership check that uses HealthContributors via the Spring context. + // --------------------------------------------------------------------------- + + @Configuration + static class DownDbContributorConfig { + @Bean + HealthIndicator db() { + return () -> Health.down().withDetail("reason", "stub DOWN for contract test").build(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeNumericBoundsValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeNumericBoundsValidatorTest.java new file mode 100644 index 00000000..b22b50a0 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/RuntimeNumericBoundsValidatorTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Contract test for the high-risk numeric bounds guard (feature-env-driven-runtime-configuration + * D10). Makes the registry's {@code positive_int} / {@code non_negative_int} columns enforced: a + * bad value fails the boot with a message naming the {@code APP_*} key. Mirrors {@code + * StartupSafetyValidatorTest}. + */ +class RuntimeNumericBoundsValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @Test + void nonPositivePoolSizeFailsStartupNamingTheAppKey() { + runner + .withPropertyValues("spring.datasource.hikari.maximum-pool-size=0") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_DATASOURCE_POOL_MAX_SIZE") + .hasStackTraceContaining(">= 1"); + }); + } + + @Test + void negativeMinSpareThreadsFailsStartup() { + runner + .withPropertyValues("server.tomcat.threads.min-spare=-1") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_SERVER_TOMCAT_MIN_SPARE_THREADS"); + }); + } + + @Test + void validValuesStartCleanly() { + runner + .withPropertyValues( + "spring.datasource.hikari.maximum-pool-size=20", + "spring.datasource.hikari.minimum-idle=2", + "server.tomcat.threads.max=200", + "server.tomcat.threads.min-spare=10", + "server.tomcat.max-connections=8192", + "server.tomcat.accept-count=100") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void absentValuesAreSkipped() { + // No numeric props set — placeholder/framework default owns it; guard must not fail. + runner.run(context -> assertThat(context).hasNotFailed()); + } + + @Configuration + static class ValidatorConfig { + @Bean + RuntimeNumericBoundsValidator runtimeNumericBoundsValidator(Environment environment) { + return new RuntimeNumericBoundsValidator(environment); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceTest.java new file mode 100644 index 00000000..16f7071b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceTest.java @@ -0,0 +1,47 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +/** + * Behaviour pins for the swappable secret backend (feature-secrets-config-source-contract D5): the + * factory selects the default implementation, and the {@link EnvironmentSecretSource} treats blank + * as absent. + */ +class SecretSourceTest { + + @Test + void factoryReturnsTheEnvironmentBackendForTheDefaultStrategy() { + SecretSource source = + SecretSourceFactory.create(SecretSourceStrategy.ENVIRONMENT, new MockEnvironment()); + + assertThat(source).isInstanceOf(EnvironmentSecretSource.class); + } + + @Test + void everyStrategyValueIsConstructible() { + MockEnvironment environment = new MockEnvironment(); + for (SecretSourceStrategy strategy : SecretSourceStrategy.values()) { + assertThat(SecretSourceFactory.create(strategy, environment)).isNotNull(); + } + } + + @Test + void environmentSourceResolvesAPresentSecret() { + MockEnvironment environment = new MockEnvironment().withProperty("APP_X", "value"); + SecretSource source = new EnvironmentSecretSource(environment); + + assertThat(source.resolve("APP_X")).contains("value"); + } + + @Test + void environmentSourceTreatsAbsentAndBlankAsEmpty() { + MockEnvironment environment = new MockEnvironment().withProperty("APP_BLANK", " "); + SecretSource source = new EnvironmentSecretSource(environment); + + assertThat(source.resolve("APP_MISSING")).as("absent → empty").isEmpty(); + assertThat(source.resolve("APP_BLANK")).as("blank → empty (cannot pass presence)").isEmpty(); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java new file mode 100644 index 00000000..2ffa9164 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java @@ -0,0 +1,117 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Contract test for the secret/config-source startup fail-fast guard + * (feature-secrets-config-source-contract §2 / C1). Pairs with the {@link SecretSourceValidator} + * runtime mechanism so a regression in either the {@code __LOCAL_DEV_} sentinel check (D9) or the + * required-secret-presence check (§테스트 계약) fails the build. Mirrors the {@code + * StartupSafetyValidatorTest} {@code ApplicationContextRunner} pattern. + */ +class SecretSourceValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + /** All required prod secrets set to real (non-sentinel) values. */ + private static String[] allRequiredSecretsPresent() { + return new String[] { + "APP_DATASOURCE_PASSWORD=real-db-password", + "APP_SECURITY_JWT_SIGNING_KEY=real-jwt-key", + "APP_SECURITY_OAUTH_CLIENT_SECRET=real-oauth-secret", + "APP_EXTERNAL_API_KEY=real-api-key", + "APP_CACHE_REDIS_PASSWORD=real-redis-password", + "APP_PRIVACY_PSEUDONYMIZATION_SALT=real-salt" + }; + } + + // ---- __LOCAL_DEV_ sentinel guard (D9) ----------------------------------- + + @Test + void prodProfileWithLocalDevSentinelValueFailsStartup() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + // required secrets all present, but one carries the dev sentinel prefix + .withPropertyValues("APP_EXTERNAL_API_KEY=__LOCAL_DEV_FAKE_API_KEY") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("__LOCAL_DEV_") + .hasStackTraceContaining("APP_EXTERNAL_API_KEY"); + }); + } + + @Test + void prodProfileWithAllRealSecretsAndNoSentinelStartsCleanly() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void nonProdProfileWithLocalDevSentinelValueIsAllowed() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("local")) + .withPropertyValues("APP_DATASOURCE_PASSWORD=__LOCAL_DEV_FAKE_DB_PASSWORD") + .run(context -> assertThat(context).hasNotFailed()); + } + + // ---- required-secret presence guard (§테스트 계약) ----------------------- + + @Test + void prodProfileWithMissingRequiredSecretFailsStartup() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_DATASOURCE_PASSWORD"); + }); + } + + @Test + void prodProfileWithBlankRequiredSecretFailsStartup() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues(allRequiredSecretsPresent()) + .withPropertyValues("APP_CACHE_REDIS_PASSWORD=") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .isInstanceOf(StartupValidationException.class) + .hasStackTraceContaining("APP_CACHE_REDIS_PASSWORD"); + }); + } + + @Test + void nonProdProfileWithMissingRequiredSecretsIsAllowed() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("local")) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Configuration + static class ValidatorConfig { + @Bean + SecretSourceValidator secretSourceValidator(ConfigurableEnvironment environment) { + // Default backend (ENVIRONMENT) — the presence check resolves through the port. + return new SecretSourceValidator( + environment, SecretSourceFactory.create(SecretSourceStrategy.ENVIRONMENT, environment)); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidatorTest.java new file mode 100644 index 00000000..c095514b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidatorTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.bootstrap.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.runtime.startup.ProfileMismatchException; +import dev.caskeleton.bootstrap.runtime.startup.RequiredAdapterDisabledException; +import dev.caskeleton.bootstrap.runtime.startup.StartupFailureException; +import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Contract test for the D8 startup fail-fast guard (feature-env-driven-runtime-configuration). + * Pairs with the {@link StartupSafetyValidator} runtime mechanism so a regression in either the + * prod-unsafe-toggle check or the multi-instance bean-presence check fails the build. + */ +class StartupSafetyValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + // ---- prod-unsafe toggles ------------------------------------------------- + + @Test + void prodProfileWithErrorDetailExposureEnabledFailsStartup() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("ca-skeleton.runtime.error-detail-exposure-enabled=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_ERROR_DETAIL_EXPOSURE_ENABLED"); + // feature-migration-startup-contract D7: prod-toggle mismatch exits 71. + StartupFailureException ex = startupFailure(context, ProfileMismatchException.class); + assertThat(ex.getExitCode()).isEqualTo(71); + }); + } + + @Test + void prodProfileWithBodyLoggingEnabledFailsStartup() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("ca-skeleton.runtime.log-body-capture-enabled=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_LOG_BODY_CAPTURE_ENABLED"); + }); + } + + @Test + void prodProfileWithBothUnsafeTogglesEnabledFailsStartupNamingBoth() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues( + "ca-skeleton.runtime.error-detail-exposure-enabled=true", + "ca-skeleton.runtime.log-body-capture-enabled=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_ERROR_DETAIL_EXPOSURE_ENABLED") + .hasStackTraceContaining("APP_LOG_BODY_CAPTURE_ENABLED"); + }); + } + + @Test + void prodProfileWithAllTogglesOffStartsCleanly() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void nonProdProfileWithUnsafeTogglesOnIsAllowed() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("local")) + .withPropertyValues( + "ca-skeleton.runtime.error-detail-exposure-enabled=true", + "ca-skeleton.runtime.log-body-capture-enabled=true") + .run(context -> assertThat(context).hasNotFailed()); + } + + // ---- multi-instance bean-presence --------------------------------------- + + @Test + void multiInstanceEnabledWithMissingCoordinationBeansFailsStartup() { + runner + .withPropertyValues("ca-skeleton.runtime.multi-instance-enabled=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_MULTI_INSTANCE_ENABLED") + .hasStackTraceContaining("distributedLockProvider"); + // feature-migration-startup-contract D7: missing required bean exits 72. + StartupFailureException ex = + startupFailure(context, RequiredAdapterDisabledException.class); + assertThat(ex.getExitCode()).isEqualTo(72); + }); + } + + @Test + void multiInstanceEnabledWithAllCoordinationBeansStartsCleanly() { + runner + .withPropertyValues("ca-skeleton.runtime.multi-instance-enabled=true") + .withUserConfiguration(CoordinationBeans.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void multiInstanceDisabledIsAllowed() { + runner.run(context -> assertThat(context).hasNotFailed()); + } + + /** Walks the startup-failure chain to the expected typed exception regardless of wrapping. */ + private static StartupFailureException startupFailure( + AssertableApplicationContext context, Class<? extends StartupFailureException> type) { + Throwable t = context.getStartupFailure(); + while (t != null && !type.isInstance(t)) { + t = t.getCause(); + } + assertThat(t).isInstanceOf(type); + return (StartupFailureException) t; + } + + @Configuration + @EnableConfigurationProperties(RuntimeSafetySettings.class) + static class ValidatorConfig { + @Bean + StartupSafetyValidator startupSafetyValidator( + Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) { + return new StartupSafetyValidator(environment, settings, beanFactory); + } + } + + @Configuration + static class CoordinationBeans { + @Bean + Object distributedLockProvider() { + return new Object(); + } + + @Bean + Object cacheStampedeProtection() { + return new Object(); + } + + @Bean + Object outboxLeaderElection() { + return new Object(); + } + + @Bean + Object distributedRateLimiter() { + return new Object(); + } + + @Bean + Object migrationStartupRunner() { + return new Object(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/FlywayProdSafetyValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/FlywayProdSafetyValidatorTest.java new file mode 100644 index 00000000..d212d2f6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/FlywayProdSafetyValidatorTest.java @@ -0,0 +1,111 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * D2 / D4 enforcement (PROFILE_MISMATCH, exit 71): under the {@code prod} profile the Flyway + * audit-trail safety nets must stay on — {@code baseline-on-migrate} and {@code out-of-order} + * forbidden true, {@code clean-disabled} forbidden false. Enabling any of them under prod is a + * profile/setting conflict and must fail the boot fast (branch-note §2: runtime fail-fast, + * homologous to env-driven {@code validateProdSafety()}). Non-prod keeps the freedom. + */ +class FlywayProdSafetyValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @Test + void prodProfileWithPinnedSafeDefaultsStartsCleanly() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues( + "spring.flyway.baseline-on-migrate=false", + "spring.flyway.out-of-order=false", + "spring.flyway.clean-disabled=true") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void prodProfileWithOutOfOrderEnabledFailsWithExit71() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.flyway.out-of-order=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasStackTraceContaining("out-of-order"); + ProfileMismatchException ex = profileFailure(context); + assertThat(ex.getExitCode()).isEqualTo(71); + }); + } + + @Test + void prodProfileWithBaselineOnMigrateEnabledFailsWithExit71() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.flyway.baseline-on-migrate=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("baseline-on-migrate"); + assertThat(profileFailure(context).getExitCode()).isEqualTo(71); + }); + } + + @Test + void prodProfileWithCleanEnabledFailsWithExit71() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("spring.flyway.clean-disabled=false") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasStackTraceContaining("clean-disabled"); + assertThat(profileFailure(context).getExitCode()).isEqualTo(71); + }); + } + + @Test + void prodProfileWithCaseInsensitiveProfileNameStillGuards() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("PROD")) + .withPropertyValues("spring.flyway.out-of-order=true") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void nonProdProfileWithForbiddenOptionsEnabledIsAllowed() { + runner + .withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles("dev")) + .withPropertyValues( + "spring.flyway.baseline-on-migrate=true", + "spring.flyway.out-of-order=true", + "spring.flyway.clean-disabled=false") + .run(context -> assertThat(context).hasNotFailed()); + } + + private static ProfileMismatchException profileFailure(AssertableApplicationContext context) { + Throwable t = context.getStartupFailure(); + while (t != null && !(t instanceof ProfileMismatchException)) { + t = t.getCause(); + } + assertThat(t).isInstanceOf(ProfileMismatchException.class); + return (ProfileMismatchException) t; + } + + @Configuration + static class ValidatorConfig { + @Bean + FlywayProdSafetyValidator flywayProdSafetyValidator(Environment environment) { + return new FlywayProdSafetyValidator(environment); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunnerTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunnerTest.java new file mode 100644 index 00000000..f3b2e120 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunnerTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings; +import java.util.Arrays; +import java.util.stream.Collectors; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy; +import org.springframework.context.ApplicationListener; +import org.springframework.context.SmartLifecycle; + +/** + * D1/D5/D6: the migrationStartupRunner drives Flyway's forward-only migration during context + * refresh (so it is readiness-gated — refresh completes before the app reports ready, D5) and turns + * a Flyway failure into the exit-70 MIGRATION_FAILED contract with the D8 structured log (branch + * test "migration failure가 원인 없이 generic log로만 남으면 실패"). + */ +class MigrationStartupRunnerTest { + + private final RuntimeSafetySettings settings = + new RuntimeSafetySettings(false, false, false, true); + private final MigrationStartupRunner runner = new MigrationStartupRunner(settings); + + @Test + void migrationGatesReadinessByRunningAsARefreshTimeStrategyNotAPostReadyHook() { + // D5 (§테스트 계약 "migration 완료 전 readiness가 healthy이면 실패"): a FlywayMigrationStrategy is + // invoked by Spring Boot during context refresh (Flyway bean initialization), which + // completes BEFORE ApplicationReadyEvent / readiness=UP. The runner must therefore BE a + // FlywayMigrationStrategy and must NOT be any post-refresh hook that would run after the + // app already reports ready — otherwise a half-migrated schema could serve traffic. The + // readiness probe *endpoint shape* is delegated to feature-runtime-health-lifecycle-contract; + // this branch owns only the "migration before ready" ordering asserted here. + assertThat(runner).isInstanceOf(FlywayMigrationStrategy.class); + assertThat(runner) + .isNotInstanceOf(ApplicationRunner.class) + .isNotInstanceOf(CommandLineRunner.class) + .isNotInstanceOf(SmartLifecycle.class) + .isNotInstanceOf(ApplicationListener.class); + } + + @Test + void migrateDelegatesToFlywayOnSuccess() { + Flyway flyway = mock(Flyway.class); + when(flyway.migrate()).thenReturn(mock(MigrateResult.class)); + + runner.migrate(flyway); + + verify(flyway, times(1)).migrate(); + } + + @Test + void migrateOnFlywayExceptionThrowsMigrationFailedExit70PreservingCause() { + Flyway flyway = mock(Flyway.class); + FlywayException root = new FlywayException("V2__x.sql: relation already exists"); + when(flyway.migrate()).thenThrow(root); + + assertThatThrownBy(() -> runner.migrate(flyway)) + .isInstanceOf(MigrationFailedException.class) + .hasCause(root) + .satisfies(ex -> assertThat(((MigrationFailedException) ex).getExitCode()).isEqualTo(70)); + } + + @Test + void migrateOnFailureEmitsStructuredMigrationPhaseLog() { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) + LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup"); + ListAppender<ILoggingEvent> appender = new ListAppender<>(); + appender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + appender.start(); + logger.addAppender(appender); + try { + Flyway flyway = mock(Flyway.class); + when(flyway.migrate()).thenThrow(new FlywayException("boom")); + + assertThatThrownBy(() -> runner.migrate(flyway)).isInstanceOf(MigrationFailedException.class); + + String args = + appender.list.stream() + .flatMap(e -> Arrays.stream(e.getArgumentArray())) + .map(String::valueOf) + .collect(Collectors.joining(" | ")); + assertThat(args) + .contains("startup.phase=migration") + .contains("error.code=MIGRATION_FAILED") + .contains("error.category=INTERNAL"); + } finally { + logger.detachAppender(appender); + } + } + + @Test + void migrateWhenMigrationOnStartupIsFalseBypassesMigrationExecution() { + RuntimeSafetySettings bypassSettings = new RuntimeSafetySettings(false, false, false, false); + MigrationStartupRunner bypassRunner = new MigrationStartupRunner(bypassSettings); + Flyway flyway = mock(Flyway.class); + + bypassRunner.migrate(flyway); + + verifyNoInteractions(flyway); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidatorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidatorTest.java new file mode 100644 index 00000000..971d2554 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidatorTest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * D7 env-validation contract (exit 78): the datasource connection env vars are migration + * prerequisites — a Flyway migration cannot run without them — so their absence must fail the boot + * fast with STARTUP_VALIDATION_FAILED rather than letting the app crash later with an opaque + * connection error. Maps to the branch test contract "required env 누락 시 startup이 성공하면 실패". + */ +class RequiredEnvironmentValidatorTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class); + + @Test + void allDatasourceEnvPresentStartsCleanly() { + runner + .withPropertyValues( + "spring.datasource.url=jdbc:postgresql://localhost:5432/db", + "spring.datasource.username=app", + "spring.datasource.driver-class-name=org.postgresql.Driver") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void blankDatasourceUrlFailsStartupWithExit78() { + runner + .withPropertyValues( + "spring.datasource.url=", + "spring.datasource.username=app", + "spring.datasource.driver-class-name=org.postgresql.Driver") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasStackTraceContaining("APP_DATASOURCE_URL"); + StartupValidationException ex = startupValidationFailure(context); + assertThat(ex.getExitCode()).isEqualTo(78); + assertThat(ex.errorCode()).isEqualTo(StartupErrorCode.STARTUP_VALIDATION_FAILED); + }); + } + + @Test + void multipleMissingNamesEveryMissingEnvKey() { + runner + .withPropertyValues("spring.datasource.url=jdbc:postgresql://localhost:5432/db") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("APP_DATASOURCE_USERNAME") + .hasStackTraceContaining("APP_DATASOURCE_DRIVER"); + }); + } + + /** Walks the failure chain to the StartupValidationException regardless of any wrapping. */ + private static StartupValidationException startupValidationFailure( + AssertableApplicationContext context) { + Throwable t = context.getStartupFailure(); + while (t != null && !(t instanceof StartupValidationException)) { + t = t.getCause(); + } + assertThat(t).isInstanceOf(StartupValidationException.class); + return (StartupValidationException) t; + } + + @Configuration + static class ValidatorConfig { + @Bean + RequiredEnvironmentValidator requiredEnvironmentValidator(Environment environment) { + return new RequiredEnvironmentValidator(environment); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCodeTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCodeTest.java new file mode 100644 index 00000000..f1cdbf61 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCodeTest.java @@ -0,0 +1,57 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Contract test for the D7 startup exit-code standard (feature-migration-startup-contract: env=78, + * migration=70, profile=71, adapter=72). + * + * <p>78/70 are sysexits-aligned (SYSEXIT-C1 EX_CONFIG, SYSEXIT-C2 EX_SOFTWARE); 71/72 are a + * documented ca-tmpl internal convention (SYSEXIT-C3/C4 — sysexits meanings do not match + * profile-mismatch / adapter-disabled). This test pins the numbers so a silent drift fails the + * build, and pins the registry error code + INTERNAL category for the D8 log fields. + */ +class StartupErrorCodeTest { + + @Test + void envValidationIsExit78AndRegistryBacked() { + StartupErrorCode code = StartupErrorCode.STARTUP_VALIDATION_FAILED; + assertThat(code.exitCode()).isEqualTo(78); + assertThat(code.phase()).isEqualTo(StartupPhase.ENV_VALIDATION); + assertThat(code.category()).isEqualTo("INTERNAL"); + } + + @Test + void migrationIsExit70AndRegistryBacked() { + StartupErrorCode code = StartupErrorCode.MIGRATION_FAILED; + assertThat(code.exitCode()).isEqualTo(70); + assertThat(code.phase()).isEqualTo(StartupPhase.MIGRATION); + assertThat(code.category()).isEqualTo("INTERNAL"); + } + + @Test + void profileMismatchIsExit71InternalConvention() { + StartupErrorCode code = StartupErrorCode.PROFILE_MISMATCH; + assertThat(code.exitCode()).isEqualTo(71); + assertThat(code.phase()).isEqualTo(StartupPhase.PROFILE_CHECK); + assertThat(code.category()).isEqualTo("INTERNAL"); + } + + @Test + void requiredAdapterDisabledIsExit72InternalConvention() { + StartupErrorCode code = StartupErrorCode.REQUIRED_ADAPTER_DISABLED; + assertThat(code.exitCode()).isEqualTo(72); + assertThat(code.phase()).isEqualTo(StartupPhase.ADAPTER_ENABLEMENT); + assertThat(code.category()).isEqualTo("INTERNAL"); + } + + @Test + void everyPhaseHasAStableWireName() { + assertThat(StartupPhase.ENV_VALIDATION.wireName()).isEqualTo("env-validation"); + assertThat(StartupPhase.MIGRATION.wireName()).isEqualTo("migration"); + assertThat(StartupPhase.ADAPTER_ENABLEMENT.wireName()).isEqualTo("adapter-enablement"); + assertThat(StartupPhase.PROFILE_CHECK.wireName()).isEqualTo("profile-check"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporterTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporterTest.java new file mode 100644 index 00000000..774e0f47 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporterTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanCreationException; + +class StartupFailureExceptionReporterTest { + + private final StartupFailureExceptionReporter reporter = new StartupFailureExceptionReporter(); + + @Test + void reportsWrappedStartupFailureAsAlreadyHandled() { + MigrationFailedException startupFailure = + new MigrationFailedException("Flyway forward-only migration failed during startup", null); + BeanCreationException wrapped = + new BeanCreationException("flywayInitializer", "failed", startupFailure); + + assertThat(reporter.reportException(wrapped)).isTrue(); + } + + @Test + void ignoresNonStartupFailuresSoSpringBootCanReportThemNormally() { + RuntimeException ordinaryFailure = new RuntimeException("ordinary failure"); + + assertThat(reporter.reportException(ordinaryFailure)).isFalse(); + } + + @Test + void handlesSelfReferentialCauseChains() { + SelfReferentialException failure = new SelfReferentialException(); + + assertThat(reporter.reportException(failure)).isFalse(); + } + + private static final class SelfReferentialException extends RuntimeException { + + @Override + public synchronized Throwable getCause() { + return this; + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionTest.java new file mode 100644 index 00000000..70357703 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.ExitCodeGenerator; + +/** + * Each startup-failure exception must carry its D7 exit code through Spring Boot's {@link + * ExitCodeGenerator} contract. That is the ONLY wiring that makes the custom code the JVM exit + * status: when {@code SpringApplication.run(...)} fails, the registered {@code + * SpringBootExceptionHandler} reads {@code getExitCode()} off the failing exception and calls + * {@code System.exit(code)} — no {@code main()} change is required (and wrapping main with {@code + * System.exit(SpringApplication.exit(run(...)))} would instead close the context and exit 0 on a + * healthy boot, killing a long-running server — see branch-note F2 deviation). + */ +class StartupFailureExceptionTest { + + @Test + void everyStartupExceptionIsAnExitCodeGenerator() { + assertThat((ExitCodeGenerator) new StartupValidationException("env missing")).isNotNull(); + assertThat((ExitCodeGenerator) new ProfileMismatchException("profile")).isNotNull(); + assertThat((ExitCodeGenerator) new RequiredAdapterDisabledException("adapter")).isNotNull(); + assertThat((ExitCodeGenerator) new MigrationFailedException("migration", null)).isNotNull(); + } + + @Test + void exitCodesMatchTheD7Standard() { + assertThat(new StartupValidationException("x").getExitCode()).isEqualTo(78); + assertThat(new MigrationFailedException("x", null).getExitCode()).isEqualTo(70); + assertThat(new ProfileMismatchException("x").getExitCode()).isEqualTo(71); + assertThat(new RequiredAdapterDisabledException("x").getExitCode()).isEqualTo(72); + } + + @Test + void eachExceptionExposesItsRegistryErrorCode() { + assertThat(new StartupValidationException("x").errorCode()) + .isEqualTo(StartupErrorCode.STARTUP_VALIDATION_FAILED); + assertThat(new MigrationFailedException("x", null).errorCode()) + .isEqualTo(StartupErrorCode.MIGRATION_FAILED); + assertThat(new ProfileMismatchException("x").errorCode()) + .isEqualTo(StartupErrorCode.PROFILE_MISMATCH); + assertThat(new RequiredAdapterDisabledException("x").errorCode()) + .isEqualTo(StartupErrorCode.REQUIRED_ADAPTER_DISABLED); + } + + @Test + void remainsAnIllegalStateExceptionSoExistingStartupGuardsStayCompatible() { + // StartupSafetyValidator (feature-env-driven) historically threw IllegalStateException; + // keeping that supertype means existing callers / tests asserting on it stay green. + assertThat(new ProfileMismatchException("x")).isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailuresTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailuresTest.java new file mode 100644 index 00000000..d503e436 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailuresTest.java @@ -0,0 +1,102 @@ +package dev.caskeleton.bootstrap.runtime.startup; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.Arrays; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * D8 contract: a startup failure must be logged with the structured discriminator fields {@code + * startup.phase}, {@code error.code}, {@code error.category} — never a generic, cause-less + * stacktrace. These fields are emitted as Logstash {@code StructuredArguments} (the {@code + * LogstashEncoder} serialises them to top-level JSON fields), so the test asserts on the raw + * logging-event argument array. + */ +class StartupFailuresTest { + + private final ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) + LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup"); + private ListAppender<ILoggingEvent> appender; + + @BeforeEach + void attachAppender() { + appender = new ListAppender<>(); + appender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + @Test + void envValidationFailureLogsAllThreeStructuredFieldsAtErrorLevel() { + StartupValidationException ex = StartupFailures.envValidation("APP_DATASOURCE_URL is blank"); + + assertThat(ex.getExitCode()).isEqualTo(78); + ILoggingEvent event = singleEvent(); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(renderedArguments(event)) + .contains("startup.phase=env-validation") + .contains("error.code=STARTUP_VALIDATION_FAILED") + .contains("error.category=INTERNAL"); + assertThat(event.getFormattedMessage()).contains("APP_DATASOURCE_URL is blank"); + } + + @Test + void migrationFailurePreservesCauseAndLogsMigrationPhase() { + RuntimeException root = new RuntimeException("V2__x.sql failed"); + MigrationFailedException ex = StartupFailures.migrationFailed("Flyway migrate failed", root); + + assertThat(ex.getExitCode()).isEqualTo(70); + assertThat(ex).hasCause(root); + ILoggingEvent event = singleEvent(); + assertThat(renderedArguments(event)) + .contains("startup.phase=migration") + .contains("error.code=MIGRATION_FAILED") + .contains("error.category=INTERNAL") + .contains("error.root_cause.class=java.lang.RuntimeException") + .contains("error.root_cause.message=V2__x.sql failed"); + assertThat(event.getThrowableProxy()).isNull(); + } + + @Test + void profileMismatchFailureLogsProfileCheckPhase() { + StartupFailures.profileMismatch("prod forbids out-of-order"); + + assertThat(renderedArguments(singleEvent())) + .contains("startup.phase=profile-check") + .contains("error.code=PROFILE_MISMATCH"); + } + + @Test + void requiredAdapterDisabledFailureLogsAdapterEnablementPhase() { + StartupFailures.requiredAdapterDisabled("migrationStartupRunner missing"); + + assertThat(renderedArguments(singleEvent())) + .contains("startup.phase=adapter-enablement") + .contains("error.code=REQUIRED_ADAPTER_DISABLED"); + } + + private ILoggingEvent singleEvent() { + assertThat(appender.list).hasSize(1); + return appender.list.get(0); + } + + private static String renderedArguments(ILoggingEvent event) { + return Arrays.stream(event.getArgumentArray()) + .map(String::valueOf) + .collect(Collectors.joining(" | ")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/BootstrapSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/BootstrapSettingsTest.java new file mode 100644 index 00000000..3fe3fe56 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/BootstrapSettingsTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.bootstrap.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration; +import org.springframework.context.annotation.Configuration; + +class BootstrapSettingsTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration.class)) + .withUserConfiguration(EnableProperties.class); + + @Test + void appNameValidBinds() { + runner + .withPropertyValues("ca-skeleton.bootstrap.app-name=ca-skeleton") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(BootstrapSettings.class).appName()) + .isEqualTo("ca-skeleton"); + }); + } + + @Test + void appNameBlankFailsStartup() { + runner + .withPropertyValues("ca-skeleton.bootstrap.app-name=") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasStackTraceContaining("APP_NAME"); + }); + } + + @Test + void appNameMissingFailsStartup() { + runner.run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasStackTraceContaining("APP_NAME"); + }); + } + + @Configuration + @EnableConfigurationProperties(BootstrapSettings.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/JacksonDeserializationPolicyTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/JacksonDeserializationPolicyTest.java new file mode 100644 index 00000000..dc8d9197 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/JacksonDeserializationPolicyTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.bootstrap.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.jackson.autoconfigure.JacksonProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.cfg.EnumFeature; + +/** + * Pins the Jackson deserialization policy from {@code feature-boundary-validation-mapping-contract} + * B1. + * + * <p>The branch note records the four switches that protect the request boundary: + * + * <ul> + * <li>{@code FAIL_ON_UNKNOWN_PROPERTIES=true} + * <li>{@code FAIL_ON_NULL_FOR_PRIMITIVES=true} + * <li>{@code FAIL_ON_IGNORED_PROPERTIES=true} + * <li>{@code READ_UNKNOWN_ENUM_VALUES_AS_NULL=false} + * </ul> + * + * <p>If a future profile or upstream change reverses any of these, this test fails before runtime — + * without it the contract would live in documentation only. + */ +class JacksonDeserializationPolicyTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withUserConfiguration(EnableProperties.class); + + @Test + void boundarySwitchesMatchBranchNoteContract() { + runner + .withPropertyValues( + "spring.jackson.deserialization.fail-on-unknown-properties=true", + "spring.jackson.deserialization.fail-on-null-for-primitives=true", + "spring.jackson.deserialization.fail-on-ignored-properties=true", + "spring.jackson.datatype.enum.read-unknown-enum-values-as-null=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + JacksonProperties props = context.getBean(JacksonProperties.class); + assertThat(props.getDeserialization()) + .as("B1: request boundary must fail-fast on unknown / coerced / dropped fields") + .containsEntry(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) + .containsEntry(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) + .containsEntry(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, true); + assertThat(props.getDatatype().getEnum()) + .as("B1: unknown enum values must fail, not coerce to null") + .containsEntry(EnumFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, false); + }); + } + + @Configuration + @EnableConfigurationProperties(JacksonProperties.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/JacksonSerializationPolicyTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/JacksonSerializationPolicyTest.java new file mode 100644 index 00000000..c7b62846 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/JacksonSerializationPolicyTest.java @@ -0,0 +1,102 @@ +package dev.caskeleton.bootstrap.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.jackson.autoconfigure.JacksonProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.DateTimeFeature; + +/** + * Pins the Jackson <em>serialization output</em> policy from {@code + * feature-schema-serialization-contract} D2 (datetime) and D3 (BigDecimal). + * + * <p>The deserialization input side (B1) is pinned by {@link JacksonDeserializationPolicyTest}. + * This test pins the producer side: + * + * <ul> + * <li>{@code WRITE_DATES_AS_TIMESTAMPS=false} — {@code java.time} values serialize as ISO-8601 + * strings, never as numeric epoch / array (D2 / RFC3339-C4,C7); + * <li>{@code BigDecimal} serializes in plain notation, never scientific ({@code 1E+2}) (D3 / + * SBMS-C4). + * </ul> + * + * <p>The first block asserts the {@link JacksonProperties} binding (a typo in the {@code + * application.yml} key would surface here). The second block exercises the wired {@link + * ObjectMapper} so a dropped {@code jackson-datatype-jsr310} dependency / missing {@code + * JavaTimeModule} registration — which would silently turn {@code OffsetDateTime} into a {@code + * [2026,5,21,...]} array (branch note §엣지·실패·의존) — fails the build instead of leaking at runtime. + */ +class JacksonSerializationPolicyTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withUserConfiguration(EnableProperties.class); + + @Test + void serializationSwitchesMatchBranchNoteContract() { + runner + .withPropertyValues("spring.jackson.datatype.datetime.write-dates-as-timestamps=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + JacksonProperties props = context.getBean(JacksonProperties.class); + assertThat(props.getDatatype().getDatetime()) + .as("D2: java.time values must serialize as ISO-8601 strings, not timestamps") + .containsEntry(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS, false); + }); + } + + @Test + void wiredObjectMapperSerializesDatetimeAsIso8601AndBigdecimalAsPlain() { + runner + .withPropertyValues("spring.jackson.datatype.datetime.write-dates-as-timestamps=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + ObjectMapper mapper = context.getBean(ObjectMapper.class); + + // D2: OffsetDateTime in UTC → ISO-8601 string with "Z" offset, never an array. + OffsetDateTime utc = + OffsetDateTime.of(1985, 4, 12, 23, 20, 50, 520_000_000, ZoneOffset.UTC); + String dateTimeJson = mapper.writeValueAsString(utc); + assertThat(dateTimeJson) + .as("D2 / RFC3339-C7,C8: OffsetDateTime must be an ISO-8601 string ending in Z") + .isEqualTo("\"1985-04-12T23:20:50.52Z\""); + + // D2 allowed: date-only calendar field stays a plain ISO date. + String dateJson = mapper.writeValueAsString(LocalDate.of(2026, 6, 2)); + assertThat(dateJson) + .as("D2 allowed: date-only calendar field serializes as YYYY-MM-DD") + .isEqualTo("\"2026-06-02\""); + + // D3: BigDecimal preserves trailing-zero scale and uses plain (non-scientific) + // notation. + String scaledJson = mapper.writeValueAsString(new BigDecimal("1.10")); + assertThat(scaledJson) + .as("D3 / SBMS-C1: scale-2 BigDecimal keeps its trailing zero") + .isEqualTo("1.10"); + + String largeJson = + mapper.writeValueAsString(new BigDecimal("12300000000000000000.00")); + assertThat(largeJson) + .as("D3 / SBMS-C4: large BigDecimal must not serialize in scientific notation") + .doesNotContain("E") + .isEqualTo("12300000000000000000.00"); + }); + } + + @Configuration + @EnableConfigurationProperties(JacksonProperties.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/LoggingSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/LoggingSettingsTest.java new file mode 100644 index 00000000..32f26143 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/LoggingSettingsTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.bootstrap.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +/** + * Verifies the warn-and-default validation policy of {@link LoggingSettings}. + * + * <p>Warnings are asserted via a logback {@link ListAppender} attached directly to the settings + * package logger rather than Spring's {@code CapturedOutput}. Reason: {@code LoggingSettings} emits + * warnings through SLF4J, and when the full app-bootstrap suite has already initialised logback + * with the async console appender ({@code ASYNC_CONSOLE} in {@code logback-spring.xml}), the + * warning is written to stdout by the async worker thread. A synchronous {@code + * CapturedOutput.getOut()} read right after {@code .run(...)} then races that flush and fails + * intermittently (passes in isolation, fails inside the full suite). A {@code ListAppender} bound + * to the logger receives each event synchronously on the logging thread, independent of appender + * topology, so the assertion is deterministic. This unit boundary also matches the record's real + * responsibility — "did it warn?" — not "did logback route it to a console". + */ +class LoggingSettingsTest { + + private static final String SETTINGS_LOGGER = "dev.caskeleton.bootstrap.settings"; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + private Logger settingsLogger; + private ListAppender<ILoggingEvent> warnings; + + @BeforeEach + void attachWarningCapture() { + settingsLogger = (Logger) LoggerFactory.getLogger(SETTINGS_LOGGER); + warnings = new ListAppender<>(); + warnings.start(); + settingsLogger.addAppender(warnings); + // Ensure WARN passes regardless of the level logback was left at by prior tests. + settingsLogger.setLevel(Level.WARN); + } + + @AfterEach + void detachWarningCapture() { + settingsLogger.detachAppender(warnings); + warnings.stop(); + } + + @Test + void badTimezoneWarnsAndFallsBack() { + runner + .withPropertyValues( + "ca-skeleton.logging.json.timezone=Mars/Olympus_Mons", + "ca-skeleton.logging.json.timestamp-pattern=yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "ca-skeleton.logging.json.include-caller-data=false", + "ca-skeleton.logging.json.logger-name-length=0", + "ca-skeleton.logging.async.enabled=false", + "ca-skeleton.logging.file.enabled=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + LoggingSettings settings = context.getBean(LoggingSettings.class); + assertThat(settings.json().timezone()).isEqualTo("UTC"); + assertThat(warningMessages()) + .anyMatch(m -> m.contains("APP_LOG_JSON_TIMEZONE 'Mars/Olympus_Mons'")); + }); + } + + @Test + void badAsyncQueueSizeWarnsAndFallsBack() { + runner + .withPropertyValues( + "ca-skeleton.logging.async.enabled=true", + "ca-skeleton.logging.async.queue-size=-1", + "ca-skeleton.logging.async.discarding-threshold=20", + "ca-skeleton.logging.file.enabled=false", + "ca-skeleton.logging.json.timezone=UTC", + "ca-skeleton.logging.json.timestamp-pattern=yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "ca-skeleton.logging.json.include-caller-data=false", + "ca-skeleton.logging.json.logger-name-length=0") + .run( + context -> { + assertThat(context).hasNotFailed(); + LoggingSettings settings = context.getBean(LoggingSettings.class); + assertThat(settings.async().queueSize()).isEqualTo(512); + assertThat(warningMessages()).anyMatch(m -> m.contains("APP_LOG_ASYNC_QUEUE_SIZE")); + }); + } + + @Test + void validValuesNoWarning() { + runner + .withPropertyValues( + "ca-skeleton.logging.async.enabled=true", + "ca-skeleton.logging.async.queue-size=1024", + "ca-skeleton.logging.async.discarding-threshold=10", + "ca-skeleton.logging.file.enabled=false", + "ca-skeleton.logging.json.timezone=Asia/Seoul", + "ca-skeleton.logging.json.timestamp-pattern=yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "ca-skeleton.logging.json.include-caller-data=false", + "ca-skeleton.logging.json.logger-name-length=0") + .run( + context -> { + assertThat(context).hasNotFailed(); + LoggingSettings settings = context.getBean(LoggingSettings.class); + assertThat(settings.async().queueSize()).isEqualTo(1024); + assertThat(settings.json().timezone()).isEqualTo("Asia/Seoul"); + assertThat(warningMessages()) + .noneMatch(m -> m.contains("APP_LOG_JSON_TIMEZONE")) + .noneMatch(m -> m.contains("APP_LOG_ASYNC_QUEUE_SIZE")); + }); + } + + private List<String> warningMessages() { + return warnings.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + @Configuration + @EnableConfigurationProperties(LoggingSettings.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/PrivacySettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/PrivacySettingsTest.java new file mode 100644 index 00000000..c2fa02e0 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/PrivacySettingsTest.java @@ -0,0 +1,87 @@ +package dev.caskeleton.bootstrap.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +class PrivacySettingsTest { + + private static final String BLANK_SALT_WARNING = "APP_PRIVACY_PSEUDONYMIZATION_SALT is blank"; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + // A ListAppender attached directly to the PrivacySettings logger records events synchronously on + // the thread that calls log.warn(). This is why it replaced CapturedOutput, which had this test + // quarantined: CapturedOutput reads a shared stream that the JVM-global async appender (installed + // on root by a sibling @SpringBootTest) flushes late, racing output.getOut(). A per-logger + // ListAppender is immune — the event lands before runner.run() returns. + private Logger privacyLogger; + private ListAppender<ILoggingEvent> logAppender; + + @BeforeEach + void attachAppender() { + privacyLogger = (Logger) LoggerFactory.getLogger(PrivacySettings.class); + logAppender = new ListAppender<>(); + logAppender.start(); + privacyLogger.addAppender(logAppender); + } + + @AfterEach + void detachAppender() { + privacyLogger.detachAppender(logAppender); + logAppender.stop(); + } + + @Test + void blankSaltWarnsAndFallsBackToDevSentinel() { + runner + .withPropertyValues("ca-skeleton.privacy.pseudonymization-salt=") + .run( + context -> { + assertThat(context).hasNotFailed(); + PrivacySettings settings = context.getBean(PrivacySettings.class); + assertThat(settings.pseudonymizationSalt()) + .isEqualTo(PrivacySettings.DEV_SENTINEL_SALT); + assertThat(logAppender.list) + .anySatisfy( + event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains(BLANK_SALT_WARNING); + }); + }); + } + + @Test + void realSaltIsUsedAndExposedAsUtf8Bytes() { + runner + .withPropertyValues("ca-skeleton.privacy.pseudonymization-salt=prod-salt-9f") + .run( + context -> { + assertThat(context).hasNotFailed(); + PrivacySettings settings = context.getBean(PrivacySettings.class); + assertThat(settings.pseudonymizationSalt()).isEqualTo("prod-salt-9f"); + assertThat(settings.saltBytes()) + .isEqualTo("prod-salt-9f".getBytes(StandardCharsets.UTF_8)); + assertThat(logAppender.list) + .noneSatisfy( + event -> + assertThat(event.getFormattedMessage()).contains(BLANK_SALT_WARNING)); + }); + } + + @Configuration + @EnableConfigurationProperties(PrivacySettings.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/ProblemDetailDisabledConfigTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/ProblemDetailDisabledConfigTest.java new file mode 100644 index 00000000..cddbb47f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/settings/ProblemDetailDisabledConfigTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.bootstrap.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties; +import org.springframework.context.annotation.Configuration; +import org.yaml.snakeyaml.Yaml; + +/** + * feature-business-rule-validation-contract C6 (Claim 6, {@code needs-confirmation}): RFC 7807 + * {@code ProblemDetail} is explicitly rejected (D1/D5) in favour of the custom {@code + * {success,data,error,meta}} envelope, so Spring Boot's {@code spring.mvc.problemdetails.enabled} + * must stay {@code false}. Spring Boot's default is already {@code false}, but a future default + * flip would silently re-enable {@code application/problem+json} on every Spring MVC exception and + * shadow the envelope — this test fails before that can ship. + * + * <p>Two layers: (1) the shipped {@code application.yml} literally pins the flag off, and (2) the + * property binds to the {@link WebMvcProperties} target the autoconfigure reads, so a rename of the + * key would surface as a binding miss. + */ +class ProblemDetailDisabledConfigTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + @Test + void shippedApplicationYmlPinsProblemdetailsOff() throws Exception { + try (InputStream in = getClass().getClassLoader().getResourceAsStream("application.yml")) { + assertThat(in).as("application.yml must be on the classpath").isNotNull(); + Map<String, Object> root = new Yaml().load(in); + Object enabled = navigate(root, "spring", "mvc", "problemdetails", "enabled"); + assertThat(enabled) + .as( + "D1/D5/C6: spring.mvc.problemdetails.enabled must be the literal false " + + "in the shipped application.yml (not an env placeholder)") + .isEqualTo(Boolean.FALSE); + } + } + + @Test + void problemdetailsFlagBindsToWebmvcPropertiesTarget() { + runner + .withPropertyValues("spring.mvc.problemdetails.enabled=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + WebMvcProperties props = context.getBean(WebMvcProperties.class); + assertThat(props.getProblemdetails().isEnabled()) + .as( + "the key must bind to WebMvcProperties#problemdetails.enabled — the target " + + "WebMvcAutoConfiguration reads when deciding to register the " + + "ProblemDetailsExceptionHandler") + .isFalse(); + }); + } + + @SuppressWarnings("unchecked") + private static Object navigate(Map<String, Object> root, String... path) { + Object current = root; + for (String key : path) { + if (!(current instanceof Map<?, ?> map)) { + return null; + } + current = ((Map<String, Object>) map).get(key); + } + return current; + } + + @Configuration + @EnableConfigurationProperties(WebMvcProperties.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/smoke/.gitkeep b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/smoke/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/smoke/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/taxonomyfixtures/TestcontainersUsingFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/taxonomyfixtures/TestcontainersUsingFixture.java new file mode 100644 index 00000000..4e411ac6 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/taxonomyfixtures/TestcontainersUsingFixture.java @@ -0,0 +1,26 @@ +package dev.caskeleton.bootstrap.taxonomyfixtures; + +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Non-vacuity fixture for {@code TestTaxonomyArchitectureTest}'s Testcontainers-ban rule + * (feature-test-taxonomy-fixture-contract §테스트 계약 #4 / D3). + * + * <p>It declares a {@link PostgreSQLContainer} field so its bytecode carries a dependency on {@code + * org.testcontainers..}. The positive-control meta-test imports this class via {@code + * importClasses(TestcontainersUsingFixture.class)} — a deterministic import (the field type is + * never resolved or constructed; only the class file is read) — and asserts the rule fires. This + * proves the rule is not a vacuous no-match. + * + * <p>Deliberately placed OUTSIDE the {@code ..contract..} and {@code ..architecture..} packages so + * the clean-check corpora (which scan those packages) never include it, and is excluded from + * production analysis by {@code ImportOption.DoNotIncludeTests}. It has NO {@code @Test} methods + * and is never executed. + */ +public class TestcontainersUsingFixture { + + // Field type alone creates the org.testcontainers bytecode dependency the rule detects. + // Never initialized/constructed — importClasses reads bytecode without class init. + @SuppressWarnings("unused") + private PostgreSQLContainer container; +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/taxonomyfixtures/package-info.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/taxonomyfixtures/package-info.java new file mode 100644 index 00000000..3efbed64 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/taxonomyfixtures/package-info.java @@ -0,0 +1,10 @@ +/** + * Test-taxonomy non-vacuity fixtures. + * + * <p>Plain classes (no {@code @Test} methods) loaded only as bytecode by {@code + * TestTaxonomyArchitectureTest} via {@code importClasses(...)} to prove its ArchUnit rules actually + * fire (positive controls). Excluded from production analysis by {@code + * ImportOption.DoNotIncludeTests}, and placed outside {@code ..contract..} / {@code + * ..architecture..} so the clean-check corpora never include them. + */ +package dev.caskeleton.bootstrap.taxonomyfixtures; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/MicrometerSpanErrorRecorderTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/MicrometerSpanErrorRecorderTest.java new file mode 100644 index 00000000..6a5007c4 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/MicrometerSpanErrorRecorderTest.java @@ -0,0 +1,56 @@ +package dev.caskeleton.bootstrap.tracing; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Test; + +/** + * D12 계약 검증: {@link MicrometerSpanErrorRecorder}가 현재 span에 예외와 error.code 태그를 기록하는지, null/no-span + * 케이스를 no-op 처리하는지. + */ +class MicrometerSpanErrorRecorderTest { + + @Test + void recordsErrorAndErrorCodeOnCurrentSpan() { + Tracer tracer = mock(Tracer.class); + Span span = mock(Span.class); + when(tracer.currentSpan()).thenReturn(span); + when(span.error(any())).thenReturn(span); + when(span.tag(anyString(), anyString())).thenReturn(span); + + SpanErrorRecorder recorder = new MicrometerSpanErrorRecorder(tracer); + RuntimeException boom = new RuntimeException("boom"); + + recorder.recordException(boom, "INTERNAL_ERROR"); + + verify(span).error(boom); + verify(span).tag("error.code", "INTERNAL_ERROR"); + } + + @Test + void nullErrorIsNoOp() { + Tracer tracer = mock(Tracer.class); + SpanErrorRecorder recorder = new MicrometerSpanErrorRecorder(tracer); + + recorder.recordException(null, "X"); // must not throw + + verifyNoInteractions(tracer); + } + + @Test + void noCurrentSpanIsNoOp() { + Tracer tracer = mock(Tracer.class); + when(tracer.currentSpan()).thenReturn(null); + SpanErrorRecorder recorder = new MicrometerSpanErrorRecorder(tracer); + + recorder.recordException(new RuntimeException("boom"), "X"); // must not throw + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingActivationContextTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingActivationContextTest.java new file mode 100644 index 00000000..002ffa31 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingActivationContextTest.java @@ -0,0 +1,83 @@ +package dev.caskeleton.bootstrap.tracing; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; + +/** + * 통합 검증: tracer 런타임 의존성 추가 후 {@link SpanErrorRecorder} 실제 빈 등록 + {@link Tracer} 빈 존재. + * + * <p>D-1 + D12: blank exporter endpoint에서도 컨텍스트 정상 기동(export off, 무해). + * + * <p>Slice 범위: 데이터 계층(DB/Redis/Flyway/JPA) 제외. Tracing + Actuator 자동설정만 활성. Testcontainers 불필요. + */ +@SpringBootTest( + classes = TracingActivationContextTest.MinimalTracingApp.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + // Isolate from application.yml which carries unresolved ${APP_*}/${SPRING_PROFILES_ACTIVE} + // placeholders (resolved only when src/.env is on the working dir at bootRun time). + // Same pattern as ActuatorSecurityHttpTest. + "spring.config.location=optional:classpath:tracing-activation-test-no-config.yml", + // blank exporter endpoint = export off (무해) + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=", + "ca-skeleton.tracing.exporter.otlp-endpoint=", + // Spring Boot management.tracing: enable for this slice + "management.tracing.enabled=true", + "spring.application.name=tracing-test" + }) +class TracingActivationContextTest { + + @Autowired ApplicationContext context; + + @Test + void realSpanErrorRecorderBeanReplacesNoop() { + SpanErrorRecorder recorder = context.getBean(SpanErrorRecorder.class); + assertThat(recorder) + .as("TracingConfig must register MicrometerSpanErrorRecorder when Tracer is present") + .isInstanceOf(MicrometerSpanErrorRecorder.class); + } + + @Test + void tracerBeanIsPresent() { + // Tracer bean provided by micrometer-tracing-bridge-otel autoconfiguration + assertThat(context.getBean(Tracer.class)) + .as("Tracer bean must be present after adding micrometer-tracing-bridge-otel") + .isNotNull(); + } + + @Test + void contextLoadsWithBlankExporterEndpoint() { + // Validates that blank OTEL_EXPORTER_OTLP_ENDPOINT does not cause startup failure. + // The OTel exporter autoconfiguration silently disables the exporter when endpoint is blank. + assertThat(context).isNotNull(); + } + + /** + * Minimal Spring Boot application for tracing slice tests. Excludes all data-layer + * autoconfiguration; enables only tracing + TracingConfig. + */ + @EnableAutoConfiguration( + exclude = { + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + DataJpaRepositoriesAutoConfiguration.class, + FlywayAutoConfiguration.class + }) + @Import(TracingConfig.class) + static class MinimalTracingApp {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSampleRateResolverTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSampleRateResolverTest.java new file mode 100644 index 00000000..9de7ce05 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSampleRateResolverTest.java @@ -0,0 +1,90 @@ +package dev.caskeleton.bootstrap.tracing; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * required_test: {@code tracing-contract:sample-rate-per-profile} + * (feature-distributed-tracing-contract D6). + */ +class TracingSampleRateResolverTest { + + private final TracingSampleRateResolver resolver = new TracingSampleRateResolver(); + + // ---- defaultRateForProfile ---- + + @Test + void prodProfileDefaultIsOnePercent() { + assertThat(TracingSampleRateResolver.defaultRateForProfile("prod")).isEqualTo(0.01); + } + + @Test + void stagingProfileDefaultIsTenPercent() { + assertThat(TracingSampleRateResolver.defaultRateForProfile("staging")).isEqualTo(0.10); + } + + @Test + void devProfileDefaultIsOneHundredPercent() { + assertThat(TracingSampleRateResolver.defaultRateForProfile("dev")).isEqualTo(1.0); + } + + @Test + void localProfileDefaultIsOneHundredPercent() { + assertThat(TracingSampleRateResolver.defaultRateForProfile("local")).isEqualTo(1.0); + } + + @ParameterizedTest + @ValueSource(strings = {"qa", "test", "integration", ""}) + void unknownProfileDefaultIsOneHundredPercent(String profile) { + assertThat(TracingSampleRateResolver.defaultRateForProfile(profile)).isEqualTo(1.0); + } + + @Test + void nullProfileDefaultIsOneHundredPercent() { + assertThat(TracingSampleRateResolver.defaultRateForProfile(null)).isEqualTo(1.0); + } + + // ---- resolve: configured override wins over profile default ---- + + @Test + void configuredRateOverridesProfileDefaultWhenValid() { + // required_test: tracing-contract:sample-rate-per-profile + // 0.25 is a valid float in [0,1], overrides prod's default of 0.01 + assertThat(resolver.resolve("prod", "0.25")).isEqualTo(0.25); + } + + @Test + void configuredRateZeroIsValidOverride() { + assertThat(resolver.resolve("local", "0.0")).isEqualTo(0.0); + } + + @Test + void configuredRateOneIsValidOverride() { + assertThat(resolver.resolve("prod", "1.0")).isEqualTo(1.0); + } + + @Test + void blankConfiguredRateFallsThroughToProfileDefault() { + assertThat(resolver.resolve("prod", "")).isEqualTo(0.01); + assertThat(resolver.resolve("prod", " ")).isEqualTo(0.01); + } + + @Test + void nullConfiguredRateFallsThroughToProfileDefault() { + assertThat(resolver.resolve("staging", null)).isEqualTo(0.10); + } + + @Test + void outOfRangeConfiguredRateFallsThroughToProfileDefault() { + // 1.5 is out of [0,1] — falls through to profile default + assertThat(resolver.resolve("prod", "1.5")).isEqualTo(0.01); + } + + @Test + void nonNumericConfiguredRateFallsThroughToProfileDefault() { + assertThat(resolver.resolve("staging", "not-a-number")).isEqualTo(0.10); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessorTest.java new file mode 100644 index 00000000..584e5966 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessorTest.java @@ -0,0 +1,50 @@ +package dev.caskeleton.bootstrap.tracing; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.mock.env.MockEnvironment; + +/** + * D-1 (ISSUE-1 해소): {@link TracingSamplingEnvironmentPostProcessor}가 유효 샘플링 비율을 {@code + * management.tracing.sampling.probability}로 브리지하는지 검증. + */ +class TracingSamplingEnvironmentPostProcessorTest { + + private final TracingSamplingEnvironmentPostProcessor epp = + new TracingSamplingEnvironmentPostProcessor(); + + @Test + void prodProfileBlankRateResolvesToOnePercent() { + MockEnvironment env = new MockEnvironment(); + env.setActiveProfiles("prod"); + // APP_TRACING_SAMPLE_RATE 미설정(blank) — per-profile 기본값이 적용돼야 한다. + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(env.getProperty("management.tracing.sampling.probability")).isEqualTo("0.01"); + } + + @Test + void explicitRateWins() { + MockEnvironment env = new MockEnvironment(); + env.setActiveProfiles("prod"); + env.setProperty("ca-skeleton.tracing.sample-rate", "0.25"); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(env.getProperty("management.tracing.sampling.probability")).isEqualTo("0.25"); + } + + @Test + void doesNotOverrideExplicitProbability() { + MockEnvironment env = new MockEnvironment(); + env.setActiveProfiles("prod"); + env.setProperty("management.tracing.sampling.probability", "0.5"); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(env.getProperty("management.tracing.sampling.probability")).isEqualTo("0.5"); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSettingsTest.java new file mode 100644 index 00000000..2309e2af --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/tracing/TracingSettingsTest.java @@ -0,0 +1,211 @@ +package dev.caskeleton.bootstrap.tracing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.bootstrap.runtime.startup.StartupValidationException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +/** + * Settings binding + validation tests for {@link TracingSettings} + * (feature-distributed-tracing-contract — required_tests: {@code + * tracing-contract:exporter-endpoint-resolvable} / {@code + * tracing-contract:sample-rate-per-profile}). + */ +class TracingSettingsTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(EnableProperties.class); + + // ---- sampleRate binding and validation ---- + + @Test + void validSampleRateIsBoundAndParseable() { + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=0.5", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + TracingSettings props = ctx.getBean(TracingSettings.class); + assertThat(props.sampleRate()).isEqualTo("0.5"); + assertThat(props.sampleRateValue()).isEqualTo(0.5); + }); + } + + @Test + void sampleRateZeroIsValidBoundary() { + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=false", + "ca-skeleton.tracing.sample-rate=0.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(TracingSettings.class).sampleRateValue()).isEqualTo(0.0); + }); + } + + @Test + void sampleRateOneIsValidBoundary() { + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=1.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(TracingSettings.class).sampleRateValue()).isEqualTo(1.0); + }); + } + + @Test + void invalidSampleRateAboveOneFailsStartup() { + // required_test: tracing-contract:exporter-endpoint-resolvable (sampleRate sub-case) + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=1.5", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + @Test + void invalidSampleRateNonNumericFailsStartup() { + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=not-a-number", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + @Test + void negativeSampleRateFailsStartup() { + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=-0.1", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + // ---- otlpEndpoint validation (required_test: tracing-contract:exporter-endpoint-resolvable) + // ---- + + @Test + void emptyOtlpEndpointPassesValidation() { + // required_test: tracing-contract:exporter-endpoint-resolvable (empty = exporter off) + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=1.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(TracingSettings.class).exporter().otlpEndpoint()).isBlank(); + }); + } + + @Test + void validUrlEndpointPassesValidation() { + // required_test: tracing-contract:exporter-endpoint-resolvable (valid URL) + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=1.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=http://otel-collector:4317") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(TracingSettings.class).exporter().otlpEndpoint()) + .isEqualTo("http://otel-collector:4317"); + }); + } + + @Test + void malformedEndpointFailsStartup() { + // required_test: tracing-contract:exporter-endpoint-resolvable (malformed URL) + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=1.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=not a valid url$$") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + @Test + void endpointWithSpacesFailsStartup() { + // url_or_empty: URI.create throws on illegal characters (spaces) + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=true", + "ca-skeleton.tracing.sample-rate=1.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=not valid url") + .run(ctx -> assertThat(ctx).hasFailed()); + } + + @Test + void enabledFlagIsBoundCorrectly() { + runner + .withPropertyValues( + "ca-skeleton.tracing.enabled=false", + "ca-skeleton.tracing.sample-rate=1.0", + "ca-skeleton.tracing.exporter.otlp-endpoint=") + .run( + ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx.getBean(TracingSettings.class).enabled()).isFalse(); + }); + } + + // ---- D-1 (ISSUE-1): blank sampleRate pass-through ---- + + @Test + void blankSampleRateIsPreservedAsBlankNotCoercedToOne() { + // Before fix: "" -> "1.0" coercion. After fix: blank preserved → resolver applies per-profile + // default. + TracingSettings settings = new TracingSettings(true, "", null); + assertThat(settings.sampleRate()).isBlank(); + } + + @Test + void blankSampleRatePassesValidation() { + // blank = "per-profile default" signal → must not throw at construction + assertThatNoException().isThrownBy(() -> new TracingSettings(true, " ", null)); + } + + @Test + void nonBlankInvalidSampleRateStillThrows() { + assertThatThrownBy(() -> new TracingSettings(true, "1.5", null)) + .isInstanceOf(StartupValidationException.class); + } + + // ---- F3: sampleRateValue() precondition contract ---- + + /** + * Blank sampleRate means "defer to per-profile default"; calling sampleRateValue() when the rate + * is blank is a caller contract violation. Must throw {@link IllegalStateException} with a clear + * message — NOT a cryptic NFE. + */ + @Test + void sampleRateValueThrowsIllegalStateExceptionWhenBlank() { + TracingSettings settings = new TracingSettings(true, "", null); + assertThatThrownBy(settings::sampleRateValue) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("blank"); + } + + @Configuration + @EnableConfigurationProperties(TracingSettings.class) + static class EnableProperties {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/controller/FeatureAggregateController.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/controller/FeatureAggregateController.java new file mode 100644 index 00000000..3dcb918f --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/controller/FeatureAggregateController.java @@ -0,0 +1,40 @@ +package dev.caskeleton.onboarding.adapter.inbound.web.controller; + +import dev.caskeleton.onboarding.adapter.inbound.web.dto.CreateFeatureAggregateRequest; +import dev.caskeleton.onboarding.adapter.inbound.web.dto.FeatureAggregateResponse; +import dev.caskeleton.onboarding.adapter.inbound.web.dto.FeatureAggregateSummaryResponse; +import dev.caskeleton.onboarding.adapter.inbound.web.mapper.FeatureAggregateResponseMapper; +import dev.caskeleton.onboarding.application.query.ListFeatureAggregatesQuery; +import dev.caskeleton.onboarding.application.usecase.CreateFeatureAggregateUseCase; +import dev.caskeleton.onboarding.application.usecase.ListFeatureAggregatesUseCase; +import dev.caskeleton.shared.response.Envelope; +import java.util.List; + +public final class FeatureAggregateController { + + private final ListFeatureAggregatesUseCase listFeatureAggregates; + private final CreateFeatureAggregateUseCase createFeatureAggregate; + private final FeatureAggregateResponseMapper mapper; + + public FeatureAggregateController( + ListFeatureAggregatesUseCase listFeatureAggregates, + CreateFeatureAggregateUseCase createFeatureAggregate, + FeatureAggregateResponseMapper mapper) { + this.listFeatureAggregates = listFeatureAggregates; + this.createFeatureAggregate = createFeatureAggregate; + this.mapper = mapper; + } + + public Envelope<List<FeatureAggregateSummaryResponse>> list(int limit) { + List<FeatureAggregateSummaryResponse> data = + listFeatureAggregates.handle(new ListFeatureAggregatesQuery(limit)).stream() + .map(mapper::toSummaryResponse) + .toList(); + return Envelope.ok(data, null); + } + + public Envelope<FeatureAggregateResponse> create(CreateFeatureAggregateRequest request) { + return Envelope.ok( + mapper.toResponse(createFeatureAggregate.handle(mapper.toCommand(request))), null); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/CreateFeatureAggregateRequest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/CreateFeatureAggregateRequest.java new file mode 100644 index 00000000..43cccae7 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/CreateFeatureAggregateRequest.java @@ -0,0 +1,3 @@ +package dev.caskeleton.onboarding.adapter.inbound.web.dto; + +public record CreateFeatureAggregateRequest(String name) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/FeatureAggregateResponse.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/FeatureAggregateResponse.java new file mode 100644 index 00000000..3a4ffdfc --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/FeatureAggregateResponse.java @@ -0,0 +1,3 @@ +package dev.caskeleton.onboarding.adapter.inbound.web.dto; + +public record FeatureAggregateResponse(String id, String name) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/FeatureAggregateSummaryResponse.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/FeatureAggregateSummaryResponse.java new file mode 100644 index 00000000..47b368a1 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/dto/FeatureAggregateSummaryResponse.java @@ -0,0 +1,3 @@ +package dev.caskeleton.onboarding.adapter.inbound.web.dto; + +public record FeatureAggregateSummaryResponse(String id, String name) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/mapper/FeatureAggregateResponseMapper.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/mapper/FeatureAggregateResponseMapper.java new file mode 100644 index 00000000..d95d8245 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/inbound/web/mapper/FeatureAggregateResponseMapper.java @@ -0,0 +1,23 @@ +package dev.caskeleton.onboarding.adapter.inbound.web.mapper; + +import dev.caskeleton.onboarding.adapter.inbound.web.dto.CreateFeatureAggregateRequest; +import dev.caskeleton.onboarding.adapter.inbound.web.dto.FeatureAggregateResponse; +import dev.caskeleton.onboarding.adapter.inbound.web.dto.FeatureAggregateSummaryResponse; +import dev.caskeleton.onboarding.application.command.CreateFeatureAggregateCommand; +import dev.caskeleton.onboarding.application.query.FeatureAggregateSummary; +import dev.caskeleton.onboarding.domain.feature.FeatureAggregate; + +public final class FeatureAggregateResponseMapper { + + public CreateFeatureAggregateCommand toCommand(CreateFeatureAggregateRequest request) { + return new CreateFeatureAggregateCommand(request.name()); + } + + public FeatureAggregateResponse toResponse(FeatureAggregate featureAggregate) { + return new FeatureAggregateResponse(featureAggregate.id().value(), featureAggregate.name()); + } + + public FeatureAggregateSummaryResponse toSummaryResponse(FeatureAggregateSummary summary) { + return new FeatureAggregateSummaryResponse(summary.id(), summary.name()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/entity/FeatureAggregateEntity.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/entity/FeatureAggregateEntity.java new file mode 100644 index 00000000..5f8e608e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/entity/FeatureAggregateEntity.java @@ -0,0 +1,20 @@ +package dev.caskeleton.onboarding.adapter.outbound.persistence.entity; + +public final class FeatureAggregateEntity { + + private final String id; + private final String name; + + public FeatureAggregateEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String id() { + return id; + } + + public String name() { + return name; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/mapper/FeatureAggregateEntityMapper.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/mapper/FeatureAggregateEntityMapper.java new file mode 100644 index 00000000..5f0586ab --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/mapper/FeatureAggregateEntityMapper.java @@ -0,0 +1,16 @@ +package dev.caskeleton.onboarding.adapter.outbound.persistence.mapper; + +import dev.caskeleton.onboarding.adapter.outbound.persistence.entity.FeatureAggregateEntity; +import dev.caskeleton.onboarding.domain.feature.FeatureAggregate; +import dev.caskeleton.onboarding.domain.feature.FeatureAggregateId; + +public final class FeatureAggregateEntityMapper { + + public FeatureAggregateEntity toEntity(FeatureAggregate featureAggregate) { + return new FeatureAggregateEntity(featureAggregate.id().value(), featureAggregate.name()); + } + + public FeatureAggregate toDomain(FeatureAggregateEntity entity) { + return FeatureAggregate.create(new FeatureAggregateId(entity.id()), entity.name()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/repository/FeatureAggregateRepositoryAdapter.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/repository/FeatureAggregateRepositoryAdapter.java new file mode 100644 index 00000000..c73e9ea0 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/adapter/outbound/persistence/repository/FeatureAggregateRepositoryAdapter.java @@ -0,0 +1,21 @@ +package dev.caskeleton.onboarding.adapter.outbound.persistence.repository; + +import dev.caskeleton.onboarding.adapter.outbound.persistence.entity.FeatureAggregateEntity; +import dev.caskeleton.onboarding.adapter.outbound.persistence.mapper.FeatureAggregateEntityMapper; +import dev.caskeleton.onboarding.application.port.FeatureAggregateWritePort; +import dev.caskeleton.onboarding.domain.feature.FeatureAggregate; + +public final class FeatureAggregateRepositoryAdapter implements FeatureAggregateWritePort { + + private final FeatureAggregateEntityMapper mapper; + + public FeatureAggregateRepositoryAdapter(FeatureAggregateEntityMapper mapper) { + this.mapper = mapper; + } + + @Override + public FeatureAggregate save(FeatureAggregate featureAggregate) { + FeatureAggregateEntity entity = mapper.toEntity(featureAggregate); + return mapper.toDomain(entity); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/command/CreateFeatureAggregateCommand.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/command/CreateFeatureAggregateCommand.java new file mode 100644 index 00000000..753d5f98 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/command/CreateFeatureAggregateCommand.java @@ -0,0 +1,5 @@ +package dev.caskeleton.onboarding.application.command; + +import dev.caskeleton.application.command.Command; + +public record CreateFeatureAggregateCommand(String name) implements Command {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/port/FeatureAggregateSummaryQueryPort.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/port/FeatureAggregateSummaryQueryPort.java new file mode 100644 index 00000000..563faec0 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/port/FeatureAggregateSummaryQueryPort.java @@ -0,0 +1,9 @@ +package dev.caskeleton.onboarding.application.port; + +import dev.caskeleton.onboarding.application.query.FeatureAggregateSummary; +import java.util.List; + +public interface FeatureAggregateSummaryQueryPort { + + List<FeatureAggregateSummary> findRecent(int limit); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/port/FeatureAggregateWritePort.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/port/FeatureAggregateWritePort.java new file mode 100644 index 00000000..5c8c253b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/port/FeatureAggregateWritePort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.onboarding.application.port; + +import dev.caskeleton.onboarding.domain.feature.FeatureAggregate; + +public interface FeatureAggregateWritePort { + + FeatureAggregate save(FeatureAggregate featureAggregate); +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/query/FeatureAggregateSummary.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/query/FeatureAggregateSummary.java new file mode 100644 index 00000000..66c82770 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/query/FeatureAggregateSummary.java @@ -0,0 +1,3 @@ +package dev.caskeleton.onboarding.application.query; + +public record FeatureAggregateSummary(String id, String name) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/query/ListFeatureAggregatesQuery.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/query/ListFeatureAggregatesQuery.java new file mode 100644 index 00000000..b914735d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/query/ListFeatureAggregatesQuery.java @@ -0,0 +1,5 @@ +package dev.caskeleton.onboarding.application.query; + +import dev.caskeleton.application.query.Query; + +public record ListFeatureAggregatesQuery(int limit) implements Query {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/usecase/CreateFeatureAggregateUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/usecase/CreateFeatureAggregateUseCase.java new file mode 100644 index 00000000..781a6437 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/usecase/CreateFeatureAggregateUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.onboarding.application.usecase; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.onboarding.application.command.CreateFeatureAggregateCommand; +import dev.caskeleton.onboarding.application.port.FeatureAggregateWritePort; +import dev.caskeleton.onboarding.domain.feature.FeatureAggregate; +import dev.caskeleton.onboarding.domain.feature.FeatureAggregateIdFactory; + +@RequiresPermission("feature-aggregate:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public final class CreateFeatureAggregateUseCase + implements CommandUseCase<CreateFeatureAggregateCommand, FeatureAggregate> { + + private final FeatureAggregateWritePort writePort; + private final FeatureAggregateIdFactory idFactory; + private final TransactionPort tx; + + public CreateFeatureAggregateUseCase( + FeatureAggregateWritePort writePort, + FeatureAggregateIdFactory idFactory, + TransactionPort tx) { + this.writePort = writePort; + this.idFactory = idFactory; + this.tx = tx; + } + + @Override + public FeatureAggregate handle(CreateFeatureAggregateCommand command) { + return tx.inWrite( + () -> writePort.save(FeatureAggregate.create(idFactory.newId(), command.name()))); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/usecase/ListFeatureAggregatesUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/usecase/ListFeatureAggregatesUseCase.java new file mode 100644 index 00000000..a50b6f54 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/application/usecase/ListFeatureAggregatesUseCase.java @@ -0,0 +1,34 @@ +package dev.caskeleton.onboarding.application.usecase; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.onboarding.application.port.FeatureAggregateSummaryQueryPort; +import dev.caskeleton.onboarding.application.query.FeatureAggregateSummary; +import dev.caskeleton.onboarding.application.query.ListFeatureAggregatesQuery; +import java.util.List; + +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListFeatureAggregatesUseCase + implements QueryUseCase<ListFeatureAggregatesQuery, List<FeatureAggregateSummary>> { + + private final FeatureAggregateSummaryQueryPort queryPort; + private final TransactionPort tx; + + public ListFeatureAggregatesUseCase( + FeatureAggregateSummaryQueryPort queryPort, TransactionPort tx) { + this.queryPort = queryPort; + this.tx = tx; + } + + @Override + public List<FeatureAggregateSummary> handle(ListFeatureAggregatesQuery query) { + return tx.inRead(() -> queryPort.findRecent(query.limit())); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregate.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregate.java new file mode 100644 index 00000000..bca60a0a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregate.java @@ -0,0 +1,33 @@ +package dev.caskeleton.onboarding.domain.feature; + +import dev.caskeleton.domain.stereotype.AggregateRoot; + +@AggregateRoot +public final class FeatureAggregate { + + private final FeatureAggregateId id; + private final String name; + + private FeatureAggregate(FeatureAggregateId id, String name) { + if (id == null) { + throw new IllegalArgumentException("feature aggregate id must not be null"); + } + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("feature aggregate name must not be blank"); + } + this.id = id; + this.name = name; + } + + public static FeatureAggregate create(FeatureAggregateId id, String name) { + return new FeatureAggregate(id, name); + } + + public FeatureAggregateId id() { + return id; + } + + public String name() { + return name; + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateCreated.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateCreated.java new file mode 100644 index 00000000..5cc31099 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateCreated.java @@ -0,0 +1,6 @@ +package dev.caskeleton.onboarding.domain.feature; + +import dev.caskeleton.domain.stereotype.DomainEvent; + +@DomainEvent +public record FeatureAggregateCreated(FeatureAggregateId featureAggregateId) {} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateId.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateId.java new file mode 100644 index 00000000..6fe08a07 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateId.java @@ -0,0 +1,12 @@ +package dev.caskeleton.onboarding.domain.feature; + +import dev.caskeleton.domain.stereotype.ValueObject; + +@ValueObject +public record FeatureAggregateId(String value) { + public FeatureAggregateId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("feature aggregate id must not be blank"); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateIdFactory.java b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateIdFactory.java new file mode 100644 index 00000000..e78c8612 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/domain/feature/FeatureAggregateIdFactory.java @@ -0,0 +1,6 @@ +package dev.caskeleton.onboarding.domain.feature; + +public interface FeatureAggregateIdFactory { + + FeatureAggregateId newId(); +} diff --git a/src/app-bootstrap/src/test/resources/application-test.yml b/src/app-bootstrap/src/test/resources/application-test.yml new file mode 100644 index 00000000..03a064e8 --- /dev/null +++ b/src/app-bootstrap/src/test/resources/application-test.yml @@ -0,0 +1,164 @@ +# Test-only defaults for OperationalContractRuntimeTest and any future +# app-bootstrap slice test that boots a CaSkeletonApplication-anchored context. +# +# The production application.yml resolves values from ${ENV_VAR} placeholders +# fed by src/.env via spring-dotenv. Under `gradlew test` the working directory +# is not src/, so these profile-specific values override the eagerly-bound +# settings with safe, non-networked defaults. + +spring: + application: + name: ca-skeleton-test + mvc: + problemdetails: + # D1/D5: ProblemDetail rejected; pin OFF (mirrors production application.yml). + enabled: false + web: + error: + include-stacktrace: never + include-message: never + datasource: + url: jdbc:postgresql://localhost:5432/ca_skeleton_test + username: ca_skeleton_test + password: ca_skeleton_test + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + # feature-database-connection-pool-contract greenfield knobs (D4/D5/D6/D7). + # Literal values mirror application.yml policy defaults and form a valid set: + # validation-timeout 3000 < connection-timeout 30000 (D7/HIKARI-CFG-C6) ✓ + # keepalive-time 120000 < max-lifetime 1800000 (D4/HIKARI-CFG-C4) ✓ + # leak-detection-threshold 30000 >= 2000 (D5/HIKARI-CFG-C5) ✓ + # initialization-fail-timeout 1 >= 1 => fail-fast (D6/HIKARI-CFG-C7) ✓ + keepalive-time: 120000 + leak-detection-threshold: 30000 + validation-timeout: 3000 + initialization-fail-timeout: 1 + jpa: + hibernate: + ddl-auto: none + show-sql: false + open-in-view: false + properties: + hibernate: + format_sql: false + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8081/realms/ca-skeleton-test + audiences: ca-skeleton-test + main: + banner-mode: off + lazy-initialization: false + log-startup-info: false + threads: + virtual: + enabled: false + jackson: + deserialization: + fail-on-unknown-properties: true + fail-on-null-for-primitives: true + fail-on-ignored-properties: true + datatype: + enum: + read-unknown-enum-values-as-null: false + datetime: + write-dates-as-timestamps: false + lifecycle: + timeout-per-shutdown-phase: 30s + +management: + # feature-management-actuator-security-contract D1 (test override): bind management + # server on a random port so full-context tests do not conflict with the production + # default 9001. Do NOT set this in production config — port=0 means "random" and breaks + # network-ACL-based scrape-access contracts (D1). + server: + port: 0 + +server: + port: 0 + shutdown: immediate + forward-headers-strategy: none + tomcat: + threads: + max: 200 + min-spare: 10 + accept-count: 100 + max-connections: 8192 + connection-timeout: 20s + compression: + enabled: true + min-response-size: 1024 +logging: + level: + root: WARN + dev.caskeleton: WARN + org.springframework: WARN + org.springframework.web: WARN + org.hibernate.SQL: WARN + +ca-skeleton: + bootstrap: + app-name: ca-skeleton-test + runtime: + error-detail-exposure-enabled: false + log-body-capture-enabled: false + multi-instance-enabled: false + presentation: + # Empty prefix so the probe controller path resolves without an /api prefix. + api-base-path: "" + rate-limit: + enabled: false + limit: 100 + window: 1s + algorithm: fixed-window + idempotency: + ttl: 24h + reaper-interval: 10m + security: + issuer-uri: http://localhost:8081/realms/ca-skeleton-test + audience: ca-skeleton-test + public-paths: /api/healthcheck + cors: + enabled: true + allowed-origins: http://localhost:3000 + allowed-methods: "" + allowed-headers: "*" + allow-credentials: true + max-age-seconds: 3600 + logging: + file: + enabled: false + path: logs/ca-skeleton-test.json + max-size: 100MB + max-history: 14 + total-size-cap: 3GB + async: + enabled: false + queue-size: 512 + discarding-threshold: 20 + json: + timezone: UTC + timestamp-pattern: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" + include-caller-data: false + logger-name-length: 0 + sampling-rate: 1.0 + privacy: + pseudonymization-salt: __LOCAL_DEV_test_salt + +# feature-outbound-http-client-baseline: required timeout properties for any test +# context that scans dev.caskeleton (OutboundHttpSettings requires non-zero timeouts). +app: + outbound: + http: + connect-timeout: 2s + read-timeout: 5s + global-call-timeout: 10s + retry-enabled: false + circuit-breaker-enabled: false + response-size-limit: 10MB diff --git a/src/app-bootstrap/src/test/resources/db/onboarding-migration/V999__feature_aggregate.sql b/src/app-bootstrap/src/test/resources/db/onboarding-migration/V999__feature_aggregate.sql new file mode 100644 index 00000000..28a33e4e --- /dev/null +++ b/src/app-bootstrap/src/test/resources/db/onboarding-migration/V999__feature_aggregate.sql @@ -0,0 +1,4 @@ +CREATE TABLE feature_aggregate_onboarding_dry_run ( + id VARCHAR(26) PRIMARY KEY, + name VARCHAR(200) NOT NULL +); diff --git a/src/app-bootstrap/src/test/resources/developer-experience/broken-readme.md b/src/app-bootstrap/src/test/resources/developer-experience/broken-readme.md new file mode 100644 index 00000000..cd9b2779 --- /dev/null +++ b/src/app-bootstrap/src/test/resources/developer-experience/broken-readme.md @@ -0,0 +1,6 @@ +# Broken developer experience fixture + +```bash +cd src +./gradlew definitelyMissingDxTask +``` diff --git a/src/application-core/CLAUDE.md b/src/application-core/CLAUDE.md new file mode 100644 index 00000000..4c95dad0 --- /dev/null +++ b/src/application-core/CLAUDE.md @@ -0,0 +1,187 @@ +# application-core — application use cases + +## Registered identity + +- Module ID: `application-core` +- Gradle path: `:application-core` +- Focused test: `./gradlew :application-core:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.application`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT). + +## Responsibility + +- Use case inbound ports (`CommandUseCase`, `QueryUseCase`) and their command / query contracts. +- Outbound ports (`*Port` interfaces) the use cases depend on. +- Application exceptions and policy types. +- Coordinate domain models through ports. +- Own application transaction boundaries through the `TransactionPort` abstraction. + +## Allowed + +- `:domain-core` +- `:shared-contract` +- `org.springframework.boot:spring-boot-starter` — so use cases may opt into + `@Service` / `@Component` DI registration (D13). Spring core (`spring-context` / + `spring-beans`) is intentionally kept on the compile classpath because the + alternative — manual `@Configuration` per use case — explodes boilerplate. + +## Forbidden + +- `adapter-*` implementation classes. +- `app-bootstrap`. +- Controller request/response DTOs. +- JPA entities and Spring Data repositories. +- HTTP status, transport types (`org.springframework.web..`). +- `org.springframework.transaction.annotation.Transactional` (use `TransactionPort` instead — D3). +- `org.springframework.context.ApplicationContext` — direct dependency forbidden + (`getBean(Class)` reflection-style bypass blocked by ArchUnit D11). String-key + bean lookup / `Class.forName(String)` / `BeanFactory#getBeansOfType` remain + ArchUnit's static-analysis blind spot per D12 — guard via code review checklist. +- Lombok (`lombok..`) — also forbidden in `domain-core`. Within `application-core`, + Lombok is currently not in scope for the contract; if you intend to use it, + weigh the bytecode opacity cost first. +- Persistence-layer transaction annotations of any kind inside this module. + +## Contract types + +| Type | Purpose | +|---|---| +| `usecase.UseCase<I, O>` | Base type for inbound ports. Concrete inbound ports MUST extend `CommandUseCase` or `QueryUseCase`. | +| `usecase.CommandUseCase<C extends Command, R>` | Inbound port for write use cases. Implementations MUST be annotated `@UseCaseCapability`. | +| `usecase.QueryUseCase<Q extends Query, R>` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. | +| `command.Command` | Marker for write intents. Plain immutable types built from domain values. | +| `query.Query` | Marker for read intents. Plain immutable types built from domain values. | +| `transaction.TransactionPort` | Outbound port for transactional boundaries. Implemented by `adapter-persistence`. | +| `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. | +| `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. | +| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. | +| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. | +| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. | + +## Naming convention + +- Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit. +- Outbound port interfaces end with `Port` (e.g. `NotificationPort`). +- Command records end with `Command`; query records end with `Query`. + +## Canonical use case shape + +```java +@Service +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public final class RegisterUserUseCase implements CommandUseCase<RegisterUserCommand, User> { + + private final UserRepository users; + private final TransactionPort tx; + + public RegisterUserUseCase(UserRepository users, TransactionPort tx) { + this.users = users; + this.tx = tx; + } + + @Override + public User handle(RegisterUserCommand cmd) { + return tx.inWrite(() -> { + // ... domain coordination + }); + } +} +``` + +## Allowed transactional shapes + +| Use case shape | `transactionMode` | TransactionPort call | When | +|---|---|---|---| +| Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. | +| Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. | +| Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. | + +`NESTED` and `NEVER` propagation are forbidden. + +### Callback signature contract (D11) + +`TransactionPort` callbacks are `Supplier<T>` / `Runnable` and cannot throw checked +exceptions. This matches Spring's `TransactionCallback<T>` constraint. Wrap domain +checked exceptions into `RuntimeException` subclasses +(`DomainException extends RuntimeException`); `IOException` → `UncheckedIOException`; +`SQLException` is auto-translated by Spring's `DataAccessException` hierarchy. + +### `inNew` pool-sizing constraint (D12) + +`inNew` acquires a new physical JDBC connection. Pool size MUST satisfy: + +``` +hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1 +``` + +**Forbidden**: calling `tx.inNew(...)` inside a loop over many records — pool +exhaustion + deadlock risk. Batch records inside ONE `inNew` call, or move the +loop outside the transaction boundary. + +## Idempotency (KEYED) — feature-rate-limit-idempotency-contract + +`@UseCaseCapability(idempotency = Idempotency.KEYED)` is now **supported** (the D14 +freeze is lifted). A KEYED use case wraps its work with the `idempotency`-package +`IdempotencyExecutor`: + +- **Key source**: the `Idempotency-Key` HTTP header, assembled by `adapter:inbound:web`'s + `IdempotencyKeySupport` into an `IdempotencyScope` of + `(authenticatedPrincipal, idempotencyKey, useCaseName)` (tenant 4-tuple when active). +- **Storage**: a DB table (`IdempotencyStore` port → `adapter-persistence` + `IdempotencyStoreAdapter` over `idempotency_record`); in-memory prod storage is forbidden. +- **TTL**: `APP_IDEMPOTENCY_TTL` (default 24h, ≤72h override). +- Concurrency (200ms in-flight wait → 409) and fingerprint mismatch (SHA-256 → 422) + are enforced by the executor; the codes live in `OperationalError`. + +The former ArchUnit freeze rule `inbound_port_implementations_do_not_declare_keyed_idempotency` +and its fixture were removed when this branch merged. + +## Read / query path (feature-application-query-bypass-contract) + +The read side has two equally-valid shapes; pick per read, do not force one: + +| Shape | Returns | When | How | +|---|---|---|---| +| **Through-aggregate** (default for simple reads) | domain aggregate via a `*Repository` port | read shape == write aggregate **and** the aggregate is the minimal invariant boundary (no lazy collections needed) | `QueryUseCase` → repository port → `WorkLog` | +| **Projection (CQRS-lite)** | application-layer projection DTO via a `*QueryPort` | read shape ≠ write, or to skip aggregate hydration / lazy-collection joins | `QueryUseCase` → `*QueryPort` → `WorkLogSummary` (record); query via JPQL `SELECT new` / JdbcTemplate | + +- **D1 — purity guardrail (core, machine-enforced):** a read port whose simple name ends + with `QueryPort` MUST return application-layer projection DTOs only — never a domain + aggregate, JPA entity, or web type, **including through generic type arguments** + (`List<DomainType>`). Enforced by ArchUnit `query_ports_do_not_leak_domain_jpa_or_web_types`. + Projection usage itself is **opt-in**, not a forced default; the demo lives in + `sample-portfolio` (`WorkLogSummaryQueryPort` / `WorkLogSummary`). +- **D3 — Strict ceremony:** every read goes through a `QueryUseCase` bean. There is no thin + web→read-port path — that would bypass the mandatory `@UseCaseCapability` fitness function. +- **D4 — transaction:** reads default to `TransactionPort.inRead`. A no-tx (autocommit) read + is an opt-in only when `spring.jpa.open-in-view=false` is confirmed **and** the read is + projection-only (no lazy access) **and** a single statement; otherwise keep `inRead`. +- **D5 — capability:** a repository-backed projection read is still + `repositoryAccess = READ_REPOSITORY`. "Projection vs aggregate" is the return *shape* axis, + orthogonal to the repository-access *level* axis — no new enum. Outbound-HTTP reads (no + repository) stay `RepositoryAccess.NONE`. +- Full CQRS with a separate physical read store (**D2**) is out of scope — escalation only. + +## ArchUnit guardrails (enforced) + +- `application_does_not_depend_on_adapters_or_transport` +- `application_does_not_use_spring_transactional_annotation` +- `application_does_not_depend_on_application_context` (D11) +- `inbound_port_implementations_end_with_use_case` +- `inbound_port_implementations_declare_capability` +- `query_ports_do_not_leak_domain_jpa_or_web_types` (query-bypass D1 — `*QueryPort` return purity) + +## Test + +```bash +cd src +./gradlew :application-core:test +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' +``` diff --git a/src/application-core/README.md b/src/application-core/README.md new file mode 100644 index 00000000..21ae61f2 --- /dev/null +++ b/src/application-core/README.md @@ -0,0 +1,480 @@ +# application-core — 설계 결정 참조 + +애플리케이션 유스케이스 계층. 패키지 루트: `dev.caskeleton.application`. + +허용/금지 의존, 유스케이스 형태, 트랜잭션 모드, 명명 규칙 같은 **모듈 규칙**은 +[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 +모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 아래 설명은 별도 +추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다. + +이 계층을 관통하는 큰 원칙 하나: **application-core 는 프레임워크-free 다.** Spring/JPA/HTTP +타입을 직접 들이지 않고, 필요한 인프라 능력(트랜잭션·락·인가·알림 등)은 전부 `*Port` +인터페이스로 추상화한다. 구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. 아래 결정 +대부분이 이 원칙에서 파생된다. + +--- + +## 유스케이스 계약 (usecase / command / query / capability) + +### UseCase / CommandUseCase / QueryUseCase + +- inbound port(헥사고날의 primary port)다. 모든 구체 유스케이스는 쓰기면 `CommandUseCase`, + 읽기면 `QueryUseCase` 를 extends 해서 **연산의 종류를 타입에 박아 둔다.** 이렇게 해야 + capability·트랜잭션 모드 같은 계약을 컴파일/ArchUnit 단계에서 강제할 수 있다. 클래스 이름은 + 반드시 `UseCase` 로 끝난다(ArchUnit 강제). +- 제네릭 입출력 타입(`<I, O>`)에는 web 요청 DTO·JPA 엔티티·외부 클라이언트 응답 타입이 올 수 + 없다. 입력은 `Command`/`Query`, 출력은 도메인 객체·도메인 프로젝션·`Void` 만 허용. 이 자리에 + transport 타입이 들어오면 application 계층 경계가 깨진다. + +### Command / Query (마커) + +- 둘 다 마커 인터페이스. `Command` = 쓰기 의도, `Query` = 읽기 의도. +- 반드시 불변 타입(가능하면 `record`)이고 도메인 타입·원시 값 객체로만 구성한다. web DTO, + JPA 엔티티, 외부 응답 타입은 필드에 넣을 수 없다 — 이게 들어오면 transport 관심사가 + application 계층으로 새는 것. + +### UseCaseCapability (모든 유스케이스 필수 애너테이션) + +- 모든 구체 `*UseCase` 클래스에 필수. 유스케이스의 **트랜잭션 모양·멱등성·리포지토리 접근·외부 + 호출 여부를 본문을 읽지 않고도** 알 수 있게 만든다. ArchUnit 이 모든 구체 inbound port 에 이 + 애너테이션이 일관되게 붙어 있는지 검사한다. +- 일관성 규칙(ArchUnit 으로 강제): + - `QueryUseCase` 구현은 `transactionMode = READ_ONLY` + `repositoryAccess = READ_REPOSITORY` 여야 한다. + - `transactionMode = REQUIRES_NEW` 는 outbox / audit / compensation 흐름 전용이다. + - 외부 `*Port`(outbound adapter 에 바인딩된 포트)를 호출하려면 `externalOutboundAllowed = true` + 가 필요하다. 없으면 outbound 어댑터 호출 금지. + - `repositoryAccess` 가 `WRITE_REPOSITORY` 가 아닌 유스케이스는 리포지토리 포트의 쓰기 + 메서드(save/delete/update/insert)를 호출할 수 없다 — `read_only_use_cases_do_not_call_repository_write_methods` + 규칙이 막는다. 단 정적 분석은 **직접 호출만** 잡으므로, helper/mapper 를 거친 쓰기는 리뷰가 본다. + - `bulkWrite = true` 는 `WRITE_REPOSITORY` 를 함께 요구한다(대량 쓰기도 결국 쓰기). + `bulk_write_capability_requires_write_repository_access` 가 강제. +- 이 애너테이션은 `docs/registries/capabilities.yaml` 의 7개 capability 를 코드로 구현한 것이며, + **코드가 SSOT** 다. 추가 플래그의 의미: + - `sensitiveRead` — PII/자격증명/비밀을 읽는 유스케이스 선언. 필드 단위 마커(엔티티 FQN + + 필드명 테이블)와 자동 강제는 이 영역의 책임이고 아직 미구현이라, + 지금은 리뷰 기반의 선언적 계약이다. 이 마커를 **도메인/JPA 엔티티 애너테이션으로 표현하면 안 + 된다**(프레임워크 의존이 도메인에 새는 것을 막기 위함). + - `bulkWrite` — 단일 트랜잭션에서 100행/배치를 초과하는 쓰기(registry 임계치 = 100). 임계치 + 미만이면 평범한 `WRITE_REPOSITORY` 선언으로 충분하다. + - `crossTenantAdmin` — 테넌트 경계를 넘는 admin 연산. cross-tenant 접근 정책 자체는 + 이 영역의 책임(여기선 어휘만 제공). single-tenant 유스케이스에 붙이면 + 리뷰 reject. + +--- + +## 트랜잭션 경계 (transaction) + +### TransactionPort + +- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional` + 을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통 + `SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다. + application/domain 을 프레임워크-free 로 유지하는 핵심 장치. +- 세 가지 경계: + - `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본. + - `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본. + - `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한 + 유스케이스(outbox/audit/compensation)에서만 허용. +- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질 + 수 없다. Spring `TransactionCallback<T>` 제약과 동일하다. 그래서 호출자는 도메인 checked + exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`). + `IOException` → `UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이 + 자동 변환한다. 콜백 안에서 던진 `RuntimeException` 은 롤백 + 호출자 전파. +- **`inNew` 풀 사이징 비용(D12)**: REQUIRES_NEW 는 바깥 트랜잭션의 커넥션을 잡아둔 채 **새 물리 + JDBC 커넥션을 추가로** 잡는다. 즉 미완료 `inNew` 호출 하나당 풀에서 커넥션 하나를 더 쓴다. + + ``` + hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1 + ``` + + 여기서 `max_inNew_depth` 는 스레드당 미완료 `inNew` 중첩의 최대 깊이다. + **금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 + + 데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라. +- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서 + `@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출. + +### TransactionMode + +- `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. TransactionPort API 와 UseCaseCapability 양쪽에서 쓰여, + 유스케이스의 트랜잭션 계약을 본문 없이 드러낸다. +- `READ_ONLY` 일 때 persistence adapter 는 `readOnly = true` 를 켜서 드라이버 읽기 최적화 + (Hibernate flush-mode MANUAL 등)를 적용하는 것이 좋다. + +### Isolation + +- `READ_COMMITTED` 가 고정 기본값이고, **모든 트랜잭션 템플릿에 명시적으로** 설정한다. vendor + 기본값에 위임하지 않는 이유: 엔진마다 기본 isolation 이 다르다(PostgreSQL = READ COMMITTED, + MySQL InnoDB = REPEATABLE READ). 위임하면 같은 코드가 DB 에 따라 다른 isolation 으로 도는 + silent 위험이 생긴다. +- `REPEATABLE_READ` / `SERIALIZABLE` 은 쓰기 무거운/읽기 일관성 유스케이스를 위한 명시적 + opt-in. `READ_UNCOMMITTED` 는 금지라 enum 에 아예 선언하지 않았다. +- 단, 더 엄격한 레벨을 TransactionPort 호출 경로로 라우팅하는 per-use-case 선택 메커니즘은 + 관련 추상화 설계와의 공동 변경이라 아직 `planned` 다. + 그전까지 이 상수들은 **어휘만** 선언하고, 실제 호출 경로는 `READ_COMMITTED` 로 고정된다. + +--- + +## 인가 (security) + +인증("누구냐")은 web/security adapter 책임이고, 이 패키지는 인가("이걸 해도 되냐")만 소유한다. + +### AuthorizationPort + +- 제품 인가 enforcement point(PEP). 인증된 호출자가 권한이 필요한 연산을 수행해도 되는지 + 결정한다. 인증된 principal 의 raw role 을 입력으로 받는다(인증 자체는 베이스라인 소유). +- **왜 포트인가, `@PreAuthorize` 가 아니라**: Spring method-security 애너테이션은 빈을 + `org.springframework.security` 타입에 묶는다. application/domain 은 프레임워크-free 여야 하므로 + (TransactionPort 선례), enforcement *결정* 은 평범한 Java 포트로 표현하고 Spring 의존 + enforcement *메커니즘*(커스텀 `AuthorizationManager`)은 web adapter 에 둔다. +- **fail-closed**: 호출자가 필요한 권한을 가진 경우에만 정상 반환한다. 그 외(미지 role, role 없음, + 매핑 누락)는 전부 `AuthorizationDeniedException`. + +### RequiresPermission (애너테이션) + +- 인증된 호출자가 가드된 유스케이스를 호출하려면 가져야 하는 `Permission` 을 선언한다. 값은 + `resource:action` 토큰(예: `"worklog:close"`). `@UseCaseCapability` 패턴을 그대로 따라, 유스케이스가 + 요구하는 권한을 본문 없이 보이게 한다. +- **순수 선언 — Spring-free**: Spring Security 타입을 일절 담지 않는다. 실제 enforcement 는 web + adapter 의 `RequiresPermissionAuthorizationManager` 가 RUNTIME retention 으로 이 애너테이션을 + 읽어 `AuthorizationPort` 에 위임한다. enforcement 가 Spring AOP 프록시를 거치므로 + self-invocation 이나 비-Spring 빈 호출은 우회된다 — 모든 mutating 진입점을 가드하는 책임은 + 아키텍처 강제 규칙 몫이다. +- **적용 대상**: skeleton 기본은 mutating/민감 유스케이스(`repositoryAccess = WRITE_REPOSITORY`)에 + 필수, public read 는 항상 면제(D4). 타입/메서드 어디에도 붙일 수 있다. + +### AuthorizationPrincipal + +- `AuthorizationPort` 가 소비하는 프레임워크-free 호출자 뷰 — IdP subject + 호출자의 raw role + 이름. web adapter 는 Spring 인지 principal(`AuthenticatedPrincipal`, Keycloak 스타일 raw role)을 + 노출하지만 application 계층은 그 타입을 import 할 수 없으므로, enforcement point 가 이 추상으로 + 내려 매핑한다. +- **role 은 raw, Spring authority 가 아니다**: role set 은 IdP 가 발급한 그대로의 문자열 + (`"admin"`, `"user"`)이며 `ROLE_*` GrantedAuthority 형태가 아니다. role→permission 해석이 raw + 이름을 키로 쓰므로(D3), 둘을 섞으면 권한 0개로 해석돼 fail-closed denial 이 된다. + +### AuthorizationDeniedException + +- 인증된 호출자가 필요한 `Permission` 을 못 가졌을 때 `AuthorizationPort` 가 던진다. +- **프레임워크-free, application 소유**: application 계층은 Spring Security 에 의존할 수 없어 + Spring `AccessDeniedException` 을 던질 수 없다. web adapter 의 method-security enforcement + point 가 경계에서 이 예외를 잡아 Spring authorization 실패로 번역하고, error pipeline 이 + `AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 매핑한다(코드 SSOT 는 베이스라인 소유). +- **fail-closed**: denial 은 필요한 권한과 호출자 subject 만 담고, 호출자의 *유효 권한 집합* 은 + 절대 담지 않는다(인가 표면을 클라이언트에 노출하지 않기 위함). + +--- + +## 멱등성 (idempotency) + +application 계층이 in-flight 대기·replay **정책** 을 소유하고, 저장은 +`IdempotencyStorePort` 로 위임한다. + +### IdempotencyExecutor + +- application 유스케이스 경계에서 멱등 실행을 조율한다. 하나의 `IdempotencyScope` 에 대해 순서대로: + 1. **claim** — store 유니크 제약 위의 atomic insert-or-read(D7). 이긴 caller 가 action 을 + 정확히 한 번 실행한다. + 2. **fingerprint mismatch** — 같은 key, 다른 body(다른 fingerprint)인 live record 는 + `IdempotencyRequestMismatchException` → 422(D8). + 3. **replay** — `COMPLETED` record 는 저장된 응답을 codec 으로 재생(§B). + 4. **in-flight wait** — 동시 `IN_FLIGHT` record 는 최대 `IN_FLIGHT_WAIT`(200ms) 동안 폴링하고, + 여전히 미해결이면 `IdempotencyInFlightException` → 409(D7). +- claim 한 action 이 예외를 던지면 in-flight record 를 `discard` 한다 — 그래야 재시도가 TTL + 만료까지 409 에 갇히지 않는다. +- **200ms 대기 창의 출처(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: 이 200ms 대기 창은 + 인용 가능한 표준이 강제하는 값이 아니다(IETF/Toss 는 즉시 409 를 권장). 표준의 운영 친화적 + 변형이다 — "표준 기반 + 운영 변형" 으로 설명해야지 "표준을 따른다" 고 말하면 안 된다. 블로킹 + 폴링이 요청 스레드를 점유하므로, 중복 도착이 몰리면 스레드 점유 vs 클라이언트 재시도 친화성의 + 트레이드오프가 있다(임계치는 부하 테스트로 검증 필요). +- 상수: `IN_FLIGHT_WAIT = 200ms`(D7), `MAX_TTL = 72h`(D6 하드 캡), `POLL_INTERVAL = 20ms`. +- package-private 한 `Sleeper` 주입 생성자가 따로 있는 이유: 테스트 `Sleeper` 가 mutable clock 을 + 전진시켜 실제 시간 블로킹 없이 in-flight wait 를 결정적으로 검증하기 위함. + +### IdempotencyStorePort + +- 멱등 record 저장 outbound 포트(D3). `adapter-persistence` 가 유니크 제약 + `(tenant, principal, idempotency_key, use_case_name)` 위에 구현한다. 포트는 저장 primitive + (tryBegin/find/complete/discard)만 노출하고, 정책은 executor 가 가진다. +- **in-memory 프로덕션 구현 금지**: 계약상 내구성 있고 유일성을 강제하는 백킹 스토어가 필요하다. + Redis 는 내구 스토어 앞단의 선택적 캐시로만 허용된다. +- **만료 record 는 reclaimable**: `expiresAt <= now` 인 record 는 없는 것처럼 취급해야 한다. + `tryBegin` 은 만료된 죽은 행을 purge/replace 해서 새 claim 이 이기게 하고, `find` 는 만료된 + record 를 absent 로 보아 만료 replay 를 거부한다(§E / TTL 경계). + +### IdempotencyScope + +- 멱등 요청의 정체성이자 계약의 SSOT scope 모양(D2). 기본은 triple + `(authenticatedPrincipal, idempotencyKey, useCaseName)`. 테넌트 격리가 활성이면 `tenant` 차원을 + 앞에 붙인 4-tuple 이 되고, single-tenant 모드에선 `tenant = null`. +- **여기서 막는 실패 모드**: `principal`/`useCaseName` 이 빠진 key 는 전역적으로 충돌해 다른 + caller 의 응답을 재생할 수 있다. `of` 팩토리가 blank 필수 차원을 `IdempotencyScopeMissingException` + 으로 거부해, scope 없는 key 가 store 에 닿지 못하게 막는다. 막으려는 실패 모드는 "scope 누락으로 + 인한 silent 전역 충돌"이다. +- `storageKey()` 는 진단/단일 컬럼 조회용으로 사람이 읽는 join 문자열일 뿐이다. 유일성은 차원 + 컬럼에 강제되지 이 문자열에 강제되지 않는다. + +### RequestFingerprint + +- 요청 body 의 SHA-256 지문(D8). 같은 scope 인데 body 가 다르면(지문이 다르면) 클라이언트 + 버그로 보고 executor 가 422 로 거부한다. null/빈 body 는 zero-length payload 지문으로 취급해 + 빈 replay 의 일관성을 지킨다. +- **SHA-256 선택(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: MD5/SHA-1 대비 충돌 저항성 때문에 + SHA-256 을 골랐다 — 인용 가능한 표준이 강제한 것도, 측정된 성능 근거가 있는 것도 아니다. body + **정규화**(JSON 키 순서·공백·인코딩)는 의도적으로 적용하지 않는다 — 전송된 raw 바이트를 그대로 + 해시한다. 정규 동등성이 필요한 caller 는 `ofSha256` 호출 전에 직접 정규화해야 한다. 안 하면 키 + 순서만 바뀐 의미상 동일 replay 가 false mismatch 가 된다(부하와 실제 요청 형태로 검증 필요). +- `NoSuchAlgorithmException` catch 는 도달 불가다(SHA-256 은 모든 JDK 에 필수). 그래서 + `IllegalStateException` 으로 감싼다. + +### 멱등성 값/계약 타입 (Context · Record · StoredResponse · Codec · Status · Sleeper) + +- `IdempotencyContext` — executor 입력. scope + fingerprint + 선택적 per-use-case TTL + override(D6). 결제/송금 같은 장기 유스케이스가 72h 캡까지 override 가능하고, 캡 초과는 executor 가 + 거부한다. +- `IdempotencyRecord` — application 이 보는 영속 record. `COMPLETED` 면 반드시 response 를 + 동반해야 한다(생성자가 강제). adapter 가 테이블 행과 매핑한다. +- `StoredResponse` — 완료된 응답의 불투명 직렬화 표현으로, 중복 caller 에게 그대로 재생된다(§B). + **어디에** 물리 저장되는지(작으면 행 인라인 ≤8KB, 크면 object store + 행엔 ref 만 — §F/D9)는 + 전적으로 persistence adapter 관심사라 여기선 보이지 않는다 → application 계층을 transport·저장 + 중립으로 유지. +- `IdempotentResponseCodec` — 유스케이스 결과를 `StoredResponse` payload 로 직렬화/역직렬화. + application 은 wire-format 중립이다 — 구체 JSON 인코딩은 `R` 의 응답 모양을 아는 `adapter-web` + caller 가 소유하고, executor 는 그 문자열을 store 로 왕복시키기만 한다. +- `IdempotencyStatus` — `IN_FLIGHT` / `COMPLETED` 두 값. +- `Sleeper` — `Thread.sleep` 우회 인터페이스. executor 의 in-flight 폴링 대기를 결정적으로 + 테스트하기 위함(테스트 Sleeper 가 실제 시간 블로킹 대신 mutable clock 전진). + +### 멱등성 예외 3종 (공통 패턴) + +- `IdempotencyRequestMismatchException`(422), `IdempotencyInFlightException`(409), + `IdempotencyScopeMissingException`(400, `VALIDATION_FAILED`). +- 셋 다 **프레임워크-free, application 소유**(`AuthorizationDeniedException` 선례). web adapter 가 + 경계에서 각각의 HTTP 코드로 매핑한다. 진단 메시지는 scope storage key / 누락 차원을 **로그용으로만** + 담고, 클라이언트에는 핸들러가 고정 client-safe 메시지로 대체한다(fingerprint 자체는 노출 안 함). +- retryable: mismatch / in-flight 모두 `false`. 클라이언트는 body 를 고치거나 결과를 polling 해야지 + 단순 재시도를 하면 안 된다. + +--- + +## 트랜잭셔널 아웃박스 릴레이 (outbox) + +핵심 패턴: 비즈니스 쓰기와 같은 트랜잭션에 이벤트를 append → 별도 relay 가 짧은 트랜잭션으로 +claim → 트랜잭션 밖에서 발행 → at-least-once 보장. + +### OutboxAppendPort + +- 트랜잭셔널 아웃박스에 새 이벤트를 append 하는 outbound 포트. +- **dual-write 금지(D2)**: 반드시 이벤트를 만든 비즈니스 연산과 **같은 DB 트랜잭션 안에서** + 호출해야 한다(그 트랜잭션은 caller 가 `TransactionPort.inWrite` 로 연다). 트랜잭션 밖에서, 또는 + 비즈니스 연산과 다른 트랜잭션에서 호출하면 dual-write 금지 위반이다 — 비즈니스는 커밋됐는데 + outbox append 가 안 되거나(또는 그 반대) 이벤트가 silent 손실/허위 방출된다. 따라서 구현은 + 내부에서 새 트랜잭션을 열면 안 되고, 바깥 트랜잭션에 참여해야 한다. + +### OutboxStorePort + +- relay 측에서 outbox store 에 접근하는 포트. 모든 mutating 연산 + (`claimBatch`/`markPublished`/`markFailed`/`markDead`)은 relay 유스케이스가 소유한 + `TransactionPort.inWrite` 경계 안에서 호출하며, 구현은 자체 트랜잭션을 열지 않는다. +- **claim 의미(I4 / I6)** — `claimBatch` 가 적격 행을 atomic 하게 `IN_FLIGHT` 로 전이시킨다: + - 적격 행: `PENDING`, `next_attempt_at <= now` 인 `FAILED`, `next_attempt_at <= now` 인 + `IN_FLIGHT`(고아 in-flight — I6 timeout 재사용). + - **FIFO 게이트(I4)**: 같은 `aggregate_id` 의 더 이른 `occurred_at` 행이 아직 `PUBLISHED` 가 + 아니면 그 aggregate 의 뒤 행들은 skip 한다. `DEAD` 행도 그 aggregate 큐를 막는다(strict + FIFO — 해제하려면 runbook 개입 필요). + - claim 후: `status = IN_FLIGHT`, `attempt_count += 1`, + `next_attempt_at = now + inFlightTimeout`. + - 순서: adapter 는 claim 쿼리에 `ORDER BY occurred_at ASC` 를 쓰는 게 좋고, relay 도 받은 + 배치를 방어적으로 재정렬한다. +- **in-flight timeout 재사용(I6)**: `next_attempt_at` 컬럼을 in-flight 고아 timeout 으로 + 재사용한다. relay 인스턴스가 죽어 `IN_FLIGHT` 로 남은 행이 `next_attempt_at` 을 지나면 다음 + 폴링에서 다시 claim 가능해진다. 별도 `claimed_at` 컬럼 없이 at-least-once 전달을 보장하는 트릭. +- `countByStatus` / `oldestUnpublishedAgeSecondsByEventType` 는 메트릭 게이지 + (`outbox.pending.size`, `outbox.publisher.lag`)의 데이터 소스다. 트랜잭션 밖 read-only + (metric-scrape 경로)로 호출된다. + +### OutboxMessagePublishPort + +- claim 된 outbox 이벤트를 메시지 브로커에 발행하는 outbound 포트. +- **fail-closed 계약(I8)**: 발행 실패는 반드시 `RuntimeException` 으로 표면화해야 한다. 구현은 + 예외를 삼키거나 실패 시 log-and-return 하면 안 된다. 일반적인 fail-open 메시징 publisher(잡고 + 로그 후 정상 반환)와의 **의도적·문서화된 차이** 다 — relay 의 Failure condition 이 발행 실패를 + 예외로 관측해야 FAILED/DEAD 전이 + 에러 코드 로그(`OUTBOX_PUBLISH_FAILED` / + `OUTBOX_DEAD_LETTER`)를 구동할 수 있기 때문. 삼킨 실패(예외 없음·전이 없음·ERROR 로그 없음)가 + 금지 조건이다 — 행이 영원히 `IN_FLIGHT` 로 남고, aggregate FIFO 큐가 조용히 막히며, 메트릭엔 + 이상이 안 보인다. +- **호출 위치**: relay 유스케이스가 **트랜잭션 밖에서** 호출한다. 짧은 `inWrite` 로 배치 claim → + 트랜잭션 해제 → 발행 → 결과별로 다시 짧은 `inWrite` 로 상태 갱신. 브로커 호출이 진행되는 동안 + 트랜잭션 보유 시간을 최소화한다. + +### PublishPendingOutboxEventsUseCase (릴레이 본체) + +- pending outbox 이벤트를 claim 해 브로커에 발행하는 relay 유스케이스. +- **알고리즘(claim short, publish outside tx)**: + 1. 짧은 write 트랜잭션 안에서 배치를 claim 한다. + 2. `occurred_at` 오름차순으로 방어적 정렬(claim 쿼리도 정렬하지만, adapter 가 안 해도 relay 가 + FIFO 를 강제한다). + 3. 각 이벤트를 **트랜잭션 밖에서** 발행하고 결과로 상태 머신을 구동한다: + - 발행 성공 → `inWrite { markPublished }` → `PUBLISHED`. + - 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead` + + `OUTBOX_DEAD_LETTER` ERROR 로그; 아니면 `markFailed(nextAttemptAt)` + + `OUTBOX_PUBLISH_FAILED` ERROR 로그. +- **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을 + 구동하고 ERROR 로그를 낸 뒤 rethrow 하지 않는다(스케줄러 루프가 다음 이벤트로 계속 가야 + 하므로). 모든 발행 실패는 반드시 (a) 상태 전이와 (b) error code·correlationId·eventId·eventType· + attemptCount 를 담은 ERROR 로그를 **둘 다** 남긴다. 둘 중 하나라도 빠지면 금지된 silent-swallow. +- **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라 + 에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를 + dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아 + visibility-timeout 재claim 경로로 복구된다 → 재발행 → consumer dedupe 가 중복을 + 흡수(at-least-once). 배치 중간의 상태 갱신 실패가 그 tick 의 남은 배치를 중단시키는 것은 + 허용된다 — DB 가 실패 중이면 뒤따르는 갱신도 실패할 테고, 다음 tick 이 모든 `IN_FLIGHT` 고아를 + 재시도하기 때문. +- **수동 와이어링(@Service 아님, context 빈 아님)**: `app-bootstrap` 이 수동으로 생성한다 + (`batchSize`·`inFlightTimeout` 같은 설정값이 필요해서 자동 등록이 안 된다). `UseCaseCapability` + 는 빈 등록 방식과 무관하게 필수다(ArchUnit 강제). 그리고 **Spring 빈으로 등록하면 안 된다**: + 클래스 레벨 `@RequiresPermission` pointcut(web `MethodSecurityConfig`)이 이 타입 빈을 CGLIB + 프록시하려 드는데, `final` 클래스라 불가능하고, 스케줄러 스레드엔 `Authentication` 이 없어 매 + relay tick 이 fail-closed 거부될 것이다. +- **권한**: `"outbox:relay"` 는 시스템 내부 권한이다. relay 는 엔드유저가 아니라 스케줄러 빈이 + 호출한다. `@RequiresPermission` 선언은 ArchUnit `mutating_use_cases_declare_required_permission` + (D4) 을 충족시키기 위함이고, 스케줄러 컨텍스트에서의 실제 강제는 관례에 맡긴다(스케줄러는 + app-bootstrap 내부). + +### OutboxBackoffPolicy + +- relay 재시도 스케줄링용 exponential-backoff-with-full-jitter 정책(I10). +- 공식: `base = 30s`, `maxAttempts = 3`, `delay = base × 2^(attemptCount-1) + jitter(0..base)`, + `nextAttemptAt = now + delay`. 여기서 `attemptCount` 는 방금 실패한 시도의 1-based 번호다. +- full jitter 는 주입된 `RandomGenerator` 로 `[0, base]` 균등 분포에서 뽑는다. 고정 시드/0 을 + 반환하는 generator 를 주면 테스트가 결정적이 된다(I10). +- **상수는 고정이다(I10)**: `BASE_DELAY`·`MAX_ATTEMPTS`·jitter 범위는 설정 프로퍼티가 아니다. + 외부화하면 registry alert 임계치(`error-codes.yaml` 의 `retry_after_seconds=30`)와 + `outbox-publish-failed` runbook 이 실제 런타임 값과 어긋난다. 이 상수는 registry·runbook + 업데이트와 **함께만** 바꾼다. +- 지수 계산은 overflow 방지를 위해 `2^30` 에서 cap 한다(`2^30 × 30s > 30년` 이라 안전). + +### outbox 값/상태 타입 (Status · Event · NewOutboxEvent · RelayResult · Command) + +- `OutboxEventStatus` — 행 상태 머신(D5). `PENDING → IN_FLIGHT → {PUBLISHED | FAILED | DEAD}`, + `FAILED → IN_FLIGHT`(재claim), `IN_FLIGHT 고아 → IN_FLIGHT`(I6). `DEAD` 는 FIFO 형제를 막는다 — + 같은 aggregate 의 뒤 행 claim 을 차단하므로 runbook 개입이 필요하다. +- `OutboxEvent` — `claimBatch` 가 반환하는 claim-result 읽기 모델. `NewOutboxEvent` 필드 + 현재 + `status` + `attemptCount`. relay 가 `attemptCount` 로 다음 실패 시 dead-letter 여부를 결정한다. +- `NewOutboxEvent` — caller 의 write 트랜잭션 안에서 append 할 새 이벤트 값 객체. 모든 필드가 + 필수이고 null/blank 면 생성 시점에 거부한다(D12). caller 가 `eventId`·`idempotencyKey` 를 + 공급한다(I12 — outbox core 는 ID 생성에 비의존). 권장 기본값은 `idempotencyKey = eventId` + (per-event dedup, CloudEvents-C2). +- `OutboxRelayResult` — relay 한 사이클의 결과. `claimedCount` + per-event outcome 목록. 내부 + `Outcome` enum 이 `OutboxEventStatus` 의 5값을 재사용하지 않고 3값(PUBLISHED/FAILED/DEAD)을 + 따로 두는 이유: relay 관점은 "이번 실행에서 무슨 일이 있었나" 이지 "행의 현재 영속 상태" 가 + 아니다(후자는 store 가 5값으로 추적). 별도 enum 이 메트릭·스케줄러 로깅 consumer 에게 relay API + 를 깔끔하게 유지해 준다. +- `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가 + 없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를 + 만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다. + +--- + +## 분산 락 (lock) + +application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapter 가 소유한다. + +### DistributedLockPort + +- 분산 락 획득 outbound 포트. +- **왜 이 포트가 있나(D2)**: 유스케이스는 락 클라이언트·Spring `LockRegistry`·advisory-lock SQL + 을 직접 import 하면 안 된다. 이 포트가 application-core 안에서 협조적 상호배제를 위해 허용된 + 유일한 의존이다. adapter 구현(`JdbcLockRegistry` 멀티 인스턴스, `DefaultLockRegistry` 단일 + 인스턴스)은 `adapter-persistence` 에 있고 컴파일 타임엔 application 계층에 안 보인다. +- **try-lock + 유한 waitTime + 필수 leaseTtl(D5)**: `tryAcquire` 는 try-lock 이다 — 최대 + `waitTime` 만 블로킹하고 held 핸들을 반환하거나 `LockAcquisitionTimeoutException` 을 던진다. + 무한 블로킹은 금지(항상 유한 `waitTime` 공급). `leaseTtl` 은 JVM 이 `close()` 전에 죽어도 + adapter 가 락을 유지하는 최대 시간으로, 죽은 보유자가 시스템을 무한 데드락 시키는 것을 + 막는다. `JdbcLockRegistry` 는 registry 기본 TTL 과 호출별 lock TTL 을 강제하고, in-process + `DefaultLockRegistry` 는 advisory(강제 없음)다. +- **트랜잭션 커밋 순서 불변식(D4)**: 반환된 핸들은 보호 작업의 DB 트랜잭션이 **커밋된 후에만** + 해제해야 한다. 트랜잭션 안(커밋 전)에서 해제하면 lost-update 경합이 생긴다 — 두 번째 스레드가 + 락을 얻어, 첫 트랜잭션의 쓰기가 DB 에 보이기 전에 자신의 read-modify-write 를 시작한다. + - 올바른 패턴: `tryAcquire` → `try { txPort.inWrite(...) } finally { lock.close() }`(커밋 후 해제). + - 금지된 역순: `inWrite` 콜백 안에서 `lock.close()`(커밋 전 해제 → 다른 스레드가 stale 상태를 본다). +- **효율 락이지 정합성 락이 아니다(D6)**: 이 락은 경합·불필요한 재시도를 줄이는 *효율* 장치다. + 정합성(중복·충돌 쓰기 방지)은 여전히 DB 제약(유니크 인덱스·낙관적 동시성)이 강제한다. 이 락 + 하나만 정합성 가드로 의존하면 안 된다. +- **lease 만료 시 해제(SI-LOCK-C5)**: TTL 기반 provider(`JdbcLockRegistry`)에서 보유자가 + `close()` 하기 전에 lease 가 만료되면, 락 행은 이미 다른 인스턴스가 회수했을 수 있다. metered + `distributedLockProvider` 가 이를 로그 + 카운트 이벤트(`lock.lease.expired`)로 표면화하고 + `close()` 는 정상 반환한다 — 만료가 caller 의 `finally` 를 터뜨리거나 보호 작업 자신의 예외를 + 가리지 않게 하기 위함. +- `leaseTtl` 이 provider 설정 TTL 을 초과하면 `IllegalArgumentException`. shipped 와이어링은 provider + 설정과 같은 `LockSettings.leaseTtl()` 로 바인딩하므로 런타임엔 안 터지고, mis-wired caller/test 를 + 잡는 가드다. + +### DistributedLock (핸들) + +- 획득한 분산 락 핸들(`AutoCloseable`). `close()` 가 해제이고 `finally` 에서 한 번 호출하기에 + 안전하며, 구현은 idempotent 해야 한다(여러 번 close 해도 throw 금지). +- D4 커밋 순서 불변식은 `DistributedLockPort` 와 동일하다 — 커밋 후에만 `close`. +- `close()` 가 `AutoCloseable` 의 `throws Exception` 을 제거하도록 override 한 이유: 구현이 해제 + 시 checked exception 을 던지지 못하게 해서, caller 가 checked-exception 의식 없이 `finally` 에 + 둘 수 있게 하기 위함. + +### LockAcquisitionTimeoutException + +- `waitTime` 안에 락을 얻지 못하면 `tryAcquire` 가 던진다(D5). +- `OperationalError.LOCK_ACQUISITION_TIMEOUT`(CONFLICT, 409, retryable=true)을 운반한다. 락 + 경합은 일시적이다 — 현재 보유자가 임계 구역을 떠나거나 lease TTL 이 만료되면 재시도가 + 획득한다(D6 효율 락). +- web adapter 가 `errorCode()` + 고정 client-safe 메시지로 409 를 만든다. 진단용 `getMessage()` + (key·waitTime 포함)는 서버 로그 전용이고 API 클라이언트에 전달하면 안 된다. + +--- + +## 알림 포트 (notification) + +### NotificationPort + +- named channel + 논리 route 로 알림을 전달하는 outbound 포트. +- 2-인자 overload 는 관례적 `"default"` route 를 쓰는 편의 메서드다. adapter 가 + `app.notification.routes.<channel>.<route>` 에서 구체 provider 목록을 해석하므로, application + 계층은 provider 선택과 분리된다(호출부에 provider id 가 없다). +- fan-out(route 당 여러 provider)은 adapter 관심사다. 어떤 provider 가 실패하면 adapter 가 + fail-open 정책(관측만, 전파 안 함)을 적용하고, route 자체가 unbound 면 + `AdapterDisabledException("notification", ...)` 을 던진다. +- outbound 포트의 `*Port` 접미사 명명 규칙을 따른다(CLAUDE.md). + +### Notification (값 객체) + +- transport-중립 알림 값으로, routing target 과 내용만 운반한다. +- **절대 logger(또는 dependency logger)에 넘기면 안 된다** — recipient 주소·body 가 로그 라인에 + 닿을 수 없게 하기 위함(PII 계약). +- `adapter-outbound` 에서 `application-core` 로 옮긴 이유: 유스케이스가 adapter 타입을 import 하지 + 않고도 알림을 만들어 `NotificationPort` 로 전달할 수 있게 하기 위함(clean-architecture + HARD-STOP #3). + +### Channel + +- 알림 채널 판별자(`EMAIL`/`SLACK`). provider 와 독립적으로 전달 매체를 식별한다. +- caller 가 `Channel` 값(compile-safe) + 논리 route 이름을 `NotificationPort` 에 넘기면 adapter 가 + `app.notification.routes.<channel>.<route>` 로 provider 목록을 해석한다 — 도메인은 provider + 세부를 들여다보지 않는다(HARD-STOP #4). + +--- + +## 로그 가명화 포트 (observability) + +### UserPrincipalPseudonymizerPort + +- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound + 포트. +- **null/blank 의미**: `rawPrincipal` 이 null/blank 면 null 을 반환한다. 가명화할 게 없으니, 이 + 경우 caller 는 `user_principal` MDC 키에 아무 값도 넣으면 안 된다. +- **안정 토큰 계약**: non-blank 입력에 대해 (1) 같은 입력 + 같은 salt 는 같은 salt epoch 안에서 + 항상 같은 출력을 낸다(안정), (2) raw principal 의 trivial 복원이 불가능하다(일방향 derivation + 이지 인코딩·가역 변환이 아니다), (3) `user_principal` MDC 와 로그 라인에 안전하다. +- **알고리즘 SSOT**: 구체 알고리즘(90일 회전 salt 로 keyed 한 HMAC-SHA-256)은 + 이 영역의 책임다. 이 포트는 + "보안 principal 을 가명 형태로 기록한다" 는 계약만 소유한다. 구현은 `adapter-identifier` 에 + 있고 `app-bootstrap` 이 와이어링한다. +- **계층**: 순수 Java 인터페이스다. 구현은 application-core/domain-core 에서 참조하면 안 된다. + consumer(`adapter-web` 의 `RequestLoggingFilter`)가 이 포트를 주입받아 + `AuthenticatedPrincipal.idpUserId()` 를 가명화한 뒤 `MDC.put("user_principal", ...)` 한다. diff --git a/src/application-core/build.gradle b/src/application-core/build.gradle new file mode 100644 index 00000000..ca016cb7 --- /dev/null +++ b/src/application-core/build.gradle @@ -0,0 +1,15 @@ +// Application use case contract. +// +// Depends only on the domain and operational contracts. spring-boot-starter is kept on +// the compile classpath so application use cases can opt into @Service registration +// without depending on transport / persistence frameworks. +// +// spring-tx is intentionally NOT declared: application code MUST NOT import +// `org.springframework.transaction.annotation.Transactional`. Use the +// `TransactionPort` abstraction. The CleanArchitectureTest ArchUnit suite enforces +// this for any module that resides under `..application..`. +dependencies { + implementation project(':domain-core') + implementation project(':shared-contract') + implementation 'org.springframework.boot:spring-boot-starter' +} diff --git a/src/application-core/gradle.lockfile b/src/application-core/gradle.lockfile new file mode 100644 index 00000000..b131a71a --- /dev/null +++ b/src/application-core/gradle.lockfile @@ -0,0 +1,151 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty= diff --git a/src/application-core/src/main/java/dev/caskeleton/application/capability/Idempotency.java b/src/application-core/src/main/java/dev/caskeleton/application/capability/Idempotency.java new file mode 100644 index 00000000..ef1136ee --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/capability/Idempotency.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.capability; + +/** + * Idempotency contract a write use case declares — the basis downstream contracts (rate-limit, + * idempotency-key, retry) build on. See README. + */ +public enum Idempotency { + + /** Safe to retry with the same input — same effect, same result. */ + IDEMPOTENT, + + /** Safe to retry only after applying an idempotency key (e.g. de-dup table). */ + KEYED, + + /** Repeating the operation produces a new effect (create / append / charge). */ + NOT_IDEMPOTENT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/capability/RepositoryAccess.java b/src/application-core/src/main/java/dev/caskeleton/application/capability/RepositoryAccess.java new file mode 100644 index 00000000..9b7c8193 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/capability/RepositoryAccess.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.capability; + +/** + * Declares the repository access a use case is allowed to perform. Paired with {@link + * dev.caskeleton.application.transaction.TransactionMode} so an undeclared read→write upgrade is + * caught by review and fitness functions. + */ +public enum RepositoryAccess { + + /** Use case does not touch any repository port. */ + NONE, + + /** Use case may only call read methods of repository ports. */ + READ_REPOSITORY, + + /** Use case may call read and write methods of repository ports. */ + WRITE_REPOSITORY +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/capability/UseCaseCapability.java b/src/application-core/src/main/java/dev/caskeleton/application/capability/UseCaseCapability.java new file mode 100644 index 00000000..2a522f7c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/capability/UseCaseCapability.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.capability; + +import dev.caskeleton.application.transaction.TransactionMode; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Mandatory capability contract attached to every concrete {@code *UseCase} class: it makes the + * transactional shape, idempotency, repository access, and outbound-side-effect surface readable + * without inspecting the body. ArchUnit enforces its presence and the coherence rules. See README + * for the coherence rules and the {@code capabilities.yaml} registry mapping. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface UseCaseCapability { + + TransactionMode transactionMode(); + + Idempotency idempotency(); + + RepositoryAccess repositoryAccess(); + + boolean externalOutboundAllowed() default false; + + /** Declares the use case reads sensitive fields (PII / credentials / secrets). See README. */ + boolean sensitiveRead() default false; + + /** Declares a bulk write (>100 rows/batch); requires {@code WRITE_REPOSITORY}. See README. */ + boolean bulkWrite() default false; + + /** Declares the use case crosses tenant boundaries (an admin operation). See README. */ + boolean crossTenantAdmin() default false; +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/command/Command.java b/src/application-core/src/main/java/dev/caskeleton/application/command/Command.java new file mode 100644 index 00000000..3669a125 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/command/Command.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.command; + +/** + * Marker for application command contracts (write intents). A {@code Command} is a plain immutable + * type (preferably a {@code record}) of domain/primitive values. See README for the forbidden field + * types. + */ +public interface Command {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java b/src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java new file mode 100644 index 00000000..d4a4fc5f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/fileexport/ExportedFile.java @@ -0,0 +1,30 @@ +package dev.caskeleton.application.fileexport; + +/** + * Immutable receipt for a file written through {@link FileExportPort}. Framework-neutral value + * object (no Spring / IO types) so the application layer stays decoupled from the export backend. + * + * @param fileName the name the file was written under (never null/blank) + * @param path the absolute path the file was written to (never null/blank) + * @param byteSize the written file size in bytes (never negative) + * @param rowCount the number of data rows written, excluding the header (never negative) + */ +public record ExportedFile(String fileName, String path, long byteSize, long rowCount) { + + public ExportedFile { + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("ExportedFile.fileName must be non-null and non-blank"); + } + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("ExportedFile.path must be non-null and non-blank"); + } + if (byteSize < 0) { + throw new IllegalArgumentException( + "ExportedFile.byteSize must be non-negative, was " + byteSize); + } + if (rowCount < 0) { + throw new IllegalArgumentException( + "ExportedFile.rowCount must be non-negative, was " + rowCount); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java b/src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java new file mode 100644 index 00000000..f0cd5aef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/fileexport/FileExportPort.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.fileexport; + +import java.util.List; + +/** + * Outbound port for exporting tabular data as a delimited file — the "file server" boundary (a + * stand-in for an NFS mount, shared file server, or SFTP drop). The application layer hands over + * plain strings, so use cases stay decoupled from the export format and the destination filesystem. + * The adapter is selected by configuration ({@code ca-skeleton.fileserver}); see the {@code + * adapter:outbound:fileserver} README for the on-disk layout and the CSV-escaping contract. + * + * <p>The contract is deliberately domain-neutral: no framework or domain type crosses it. A caller + * supplies a bare {@code fileName}, an optional {@code header} row, and the data {@code rows} as + * lists of already-stringified field values; the adapter owns file placement, RFC-4180 escaping, + * and byte encoding, and returns an {@link ExportedFile} receipt. + */ +public interface FileExportPort { + + /** + * Writes {@code header} + {@code rows} as a CSV file named {@code fileName} under the adapter's + * configured base directory, overwriting any existing file at that name. + * + * <p>Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage + * return, or line feed is wrapped in double-quotes with embedded quotes doubled. A {@code null} + * field is written as an empty field. The file is UTF-8 encoded. + * + * @param fileName the target file name (not a path); must be non-null and non-blank and must not + * escape the base directory (no path separators that resolve outside it) + * @param header the column names written as the first line; must be non-null (may be empty, in + * which case no header line is written) + * @param rows the data rows, each a list of field values in column order; must be non-null (may + * be empty); individual field values may be {@code null} + * @return an {@link ExportedFile} receipt (file name, absolute path, byte size, row count) + * @throws IllegalArgumentException if {@code fileName} is blank or escapes the base directory + */ + ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyContext.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyContext.java new file mode 100644 index 00000000..6b10f556 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyContext.java @@ -0,0 +1,33 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; +import java.util.Objects; + +/** + * The per-request inputs to {@link IdempotencyExecutor#execute}: the request {@link + * IdempotencyScope identity}, its body {@link RequestFingerprint}, and an optional per-use-case TTL + * override (≤ 72h, enforced by the executor — see {@link #withTtl}). + * + * @param scope request identity (triple or tenant 4-tuple) + * @param fingerprint SHA-256 of the request body + * @param ttlOverride per-use-case TTL, or {@code null} to use the configured default + */ +public record IdempotencyContext( + IdempotencyScope scope, RequestFingerprint fingerprint, Duration ttlOverride) { + + public IdempotencyContext { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(fingerprint, "fingerprint"); + } + + /** Context using the executor's configured default TTL. */ + public static IdempotencyContext of(IdempotencyScope scope, RequestFingerprint fingerprint) { + return new IdempotencyContext(scope, fingerprint, null); + } + + /** Context with a per-use-case TTL override (must be ≤ 72h, enforced by the executor). */ + public static IdempotencyContext withTtl( + IdempotencyScope scope, RequestFingerprint fingerprint, Duration ttl) { + return new IdempotencyContext(scope, fingerprint, Objects.requireNonNull(ttl, "ttl")); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java new file mode 100644 index 00000000..2e33b64e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java @@ -0,0 +1,123 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Orchestrates idempotent execution at the application use case boundary. For a single {@link + * IdempotencyScope} it runs, in order: claim (atomic {@link IdempotencyStorePort#tryBegin + * insert-or-read}, winner runs the action once) → fingerprint mismatch ({@link + * IdempotencyRequestMismatchException}, 422) → replay of a {@link IdempotencyStatus#COMPLETED} + * record → in-flight wait up to {@link #IN_FLIGHT_WAIT} then {@link IdempotencyInFlightException} + * (409). A throwing action is {@link IdempotencyStorePort#discard discarded} so the request can be + * retried. See README for the policy details and the rationale behind the 200ms wait window. + */ +public final class IdempotencyExecutor { + + /** In-flight wait window before a concurrent duplicate gets 409. */ + public static final Duration IN_FLIGHT_WAIT = Duration.ofMillis(200); + + /** Hard cap: a per-use-case TTL override may not exceed 72h. */ + public static final Duration MAX_TTL = Duration.ofHours(72); + + private static final Duration POLL_INTERVAL = Duration.ofMillis(20); + + private final IdempotencyStorePort store; + private final Clock clock; + private final Duration defaultTtl; + private final Sleeper sleeper; + + public IdempotencyExecutor(IdempotencyStorePort store, Clock clock, Duration defaultTtl) { + this(store, clock, defaultTtl, Sleeper.realtime()); + } + + // Visible for testing: a test Sleeper advances a mutable clock so the in-flight + // wait is deterministic without blocking real time. + IdempotencyExecutor( + IdempotencyStorePort store, Clock clock, Duration defaultTtl, Sleeper sleeper) { + this.store = Objects.requireNonNull(store, "store"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.defaultTtl = requireSaneTtl(defaultTtl); + this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); + } + + /** + * Execute {@code action} idempotently under {@code context}, replaying a prior response when the + * scope has already been seen. + * + * @throws IdempotencyRequestMismatchException same key, different body (422) + * @throws IdempotencyInFlightException a duplicate is still in flight (409) + */ + public <R> R execute( + IdempotencyContext context, Supplier<R> action, IdempotentResponseCodec<R> codec) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(codec, "codec"); + + Duration ttl = + requireSaneTtl(context.ttlOverride() != null ? context.ttlOverride() : defaultTtl); + IdempotencyScope scope = context.scope(); + RequestFingerprint fingerprint = context.fingerprint(); + Instant deadline = clock.instant().plus(IN_FLIGHT_WAIT); + + while (true) { + Instant now = clock.instant(); + Optional<IdempotencyRecord> existing = store.find(scope, now); + + if (existing.isEmpty()) { + // No live record — try to claim the scope and own the execution. + if (store.tryBegin(scope, fingerprint, now.plus(ttl))) { + return runAndComplete(scope, action, codec); + } + // Lost the claim race (another caller inserted concurrently); loop to + // read their record. Bounded by the in-flight deadline below. + if (!now.isBefore(deadline)) { + throw new IdempotencyInFlightException(scope); + } + sleeper.sleep(POLL_INTERVAL); + continue; + } + + IdempotencyRecord record = existing.get(); + // Mismatch is terminal regardless of status: same key, different body. + if (!record.fingerprint().equals(fingerprint)) { + throw new IdempotencyRequestMismatchException(scope); + } + if (record.status() == IdempotencyStatus.COMPLETED) { + return codec.deserialize(record.response().payload()); + } + // IN_FLIGHT by another caller — wait out the window then surface 409. + if (!now.isBefore(deadline)) { + throw new IdempotencyInFlightException(scope); + } + sleeper.sleep(POLL_INTERVAL); + } + } + + private <R> R runAndComplete( + IdempotencyScope scope, Supplier<R> action, IdempotentResponseCodec<R> codec) { + try { + R result = action.get(); + store.complete(scope, new StoredResponse(codec.serialize(result))); + return result; + } catch (RuntimeException e) { + store.discard(scope); + throw e; + } + } + + private static Duration requireSaneTtl(Duration ttl) { + Objects.requireNonNull(ttl, "ttl"); + if (ttl.isZero() || ttl.isNegative()) { + throw new IllegalArgumentException("idempotency TTL must be positive, was " + ttl); + } + if (ttl.compareTo(MAX_TTL) > 0) { + throw new IllegalArgumentException("idempotency TTL exceeds the 72h cap (D6), was " + ttl); + } + return ttl; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java new file mode 100644 index 00000000..643fd4f7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyInFlightException.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.idempotency; + +/** + * Raised when a concurrent request for the same {@link IdempotencyScope} is still processing after + * the in-flight wait window elapses. Framework-free, application-owned; the web adapter maps it to + * a {@code IDEMPOTENT_IN_FLIGHT} 409 (retryable=false — the client polls). The diagnostic carries + * the scope storage key for logs only. See README. + */ +public class IdempotencyInFlightException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient IdempotencyScope scope; + + public IdempotencyInFlightException(IdempotencyScope scope) { + super("idempotent request still in flight: " + (scope == null ? "<null>" : scope.storageKey())); + this.scope = scope; + } + + public IdempotencyScope scope() { + return scope; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecord.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecord.java new file mode 100644 index 00000000..58748a93 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRecord.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Instant; +import java.util.Objects; + +/** + * A persisted idempotency record as seen by the application layer; the persistence adapter maps it + * to and from table rows. + * + * @param scope the request identity (triple or tenant 4-tuple) + * @param fingerprint SHA-256 of the original request body + * @param status lifecycle state + * @param response the stored response — present only when {@code status == COMPLETED} + * @param createdAt when the record was first claimed + * @param expiresAt TTL boundary; a record at/after this instant is treated as absent + */ +public record IdempotencyRecord( + IdempotencyScope scope, + RequestFingerprint fingerprint, + IdempotencyStatus status, + StoredResponse response, + Instant createdAt, + Instant expiresAt) { + + public IdempotencyRecord { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(fingerprint, "fingerprint"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + if (status == IdempotencyStatus.COMPLETED && response == null) { + throw new IllegalArgumentException("a COMPLETED record must carry a stored response"); + } + } + + public boolean isExpiredAt(Instant now) { + return !now.isBefore(expiresAt); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java new file mode 100644 index 00000000..c9c0ab00 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyRequestMismatchException.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.idempotency; + +/** + * Raised when an {@code Idempotency-Key} is reused with a different request body (same {@link + * IdempotencyScope}, different {@link RequestFingerprint}). Framework-free, application-owned; the + * web adapter maps it to a {@code IDEMPOTENT_REQUEST_MISMATCH} 422 (retryable=false). The + * diagnostic carries the scope storage key for logs only. See README. + */ +public class IdempotencyRequestMismatchException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient IdempotencyScope scope; + + public IdempotencyRequestMismatchException(IdempotencyScope scope) { + super( + "idempotency key reused with a different request body: " + + (scope == null ? "<null>" : scope.storageKey())); + this.scope = scope; + } + + public IdempotencyScope scope() { + return scope; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyScope.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyScope.java new file mode 100644 index 00000000..89f32dd0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyScope.java @@ -0,0 +1,54 @@ +package dev.caskeleton.application.idempotency; + +/** + * Identity of an idempotent request: the triple {@code (principal, idempotencyKey, useCaseName)}, + * or a 4-tuple with a leading {@code tenant} when tenant isolation is active ({@code null} + * otherwise). The {@link #of} factory rejects blank required dimensions with {@link + * IdempotencyScopeMissingException}. See README for the global-collision failure mode. + * + * @param tenant tenant id, or {@code null} in single-tenant mode + * @param principal authenticated principal (pseudonymized) + * @param idempotencyKey the client-supplied {@code Idempotency-Key} value + * @param useCaseName the {@code application-core} use case identifier + */ +public record IdempotencyScope( + String tenant, String principal, String idempotencyKey, String useCaseName) { + + /** Single-tenant triple scope. */ + public static IdempotencyScope of(String principal, String idempotencyKey, String useCaseName) { + return of(null, principal, idempotencyKey, useCaseName); + } + + /** + * Tenant-aware scope. {@code tenant} may be {@code null} (single-tenant); the other three + * dimensions are mandatory and rejected when blank. + */ + public static IdempotencyScope of( + String tenant, String principal, String idempotencyKey, String useCaseName) { + requirePresent("principal", principal); + requirePresent("idempotencyKey", idempotencyKey); + requirePresent("useCaseName", useCaseName); + String normalizedTenant = (tenant == null || tenant.isBlank()) ? null : tenant; + return new IdempotencyScope(normalizedTenant, principal, idempotencyKey, useCaseName); + } + + private static void requirePresent(String dimension, String value) { + if (value == null || value.isBlank()) { + throw new IdempotencyScopeMissingException(dimension); + } + } + + /** True when this scope carries a tenant dimension (4-tuple form). */ + public boolean isTenantScoped() { + return tenant != null; + } + + /** + * Stable storage key for diagnostics / single-column lookups. The persistence adapter enforces + * uniqueness on the dimension columns, not on this string; this is a human-readable join only. + */ + public String storageKey() { + String prefix = tenant == null ? "" : tenant + "::"; + return prefix + principal + "::" + idempotencyKey + "::" + useCaseName; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyScopeMissingException.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyScopeMissingException.java new file mode 100644 index 00000000..72dbbbbd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyScopeMissingException.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.idempotency; + +/** + * Raised when an idempotency request omits a required scope dimension. Framework-free, + * application-owned; the web adapter maps it to a {@code VALIDATION_FAILED} 400. The diagnostic + * names the missing dimension for logs only. See README. + */ +public class IdempotencyScopeMissingException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String missingDimension; + + public IdempotencyScopeMissingException(String missingDimension) { + super("idempotency scope is incomplete: '" + missingDimension + "' is blank"); + this.missingDimension = missingDimension; + } + + public String missingDimension() { + return missingDimension; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStatus.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStatus.java new file mode 100644 index 00000000..d9249b8f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStatus.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.idempotency; + +/** + * Lifecycle of an idempotency record. + * + * <ul> + * <li>{@link #IN_FLIGHT} — a caller has claimed the scope and is executing the action; concurrent + * arrivals wait then receive 409. + * <li>{@link #COMPLETED} — the action finished and the response is stored for replay. + * </ul> + */ +public enum IdempotencyStatus { + IN_FLIGHT, + COMPLETED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePort.java new file mode 100644 index 00000000..a9292a6e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyStorePort.java @@ -0,0 +1,43 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Instant; +import java.util.Optional; + +/** + * Outbound port for idempotency record storage, implemented by {@code adapter-persistence} over a + * DB table unique on {@code (tenant, principal, idempotency_key, use_case_name)}. The {@link + * IdempotencyExecutor} owns the wait/replay policy; this port exposes only storage primitives. See + * README (in-memory production impls forbidden; Redis cache-only). + */ +public interface IdempotencyStorePort { + + /** + * Atomically claim the scope by inserting an {@link IdempotencyStatus#IN_FLIGHT} record. Returns + * {@code true} when this caller won the claim, {@code false} when a live record already exists + * (the unique constraint is the arbiter — race-safe). An expired record must be treated as + * reclaimable. See README. + * + * @param scope the request identity + * @param fingerprint SHA-256 of the request body + * @param expiresAt TTL boundary for the new record + */ + boolean tryBegin(IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt); + + /** + * Read the current live record for a scope. A record at/after its {@code expiresAt} must be + * treated as absent (expired replay is refused). + */ + Optional<IdempotencyRecord> find(IdempotencyScope scope, Instant now); + + /** + * Transition the in-flight record for {@code scope} to {@link IdempotencyStatus#COMPLETED}, + * persisting the response for replay. + */ + void complete(IdempotencyScope scope, StoredResponse response); + + /** + * Remove the in-flight record for {@code scope} so the original request can be retried. Called + * when the claimed action throws (a stuck IN_FLIGHT row would 409 every retry). + */ + void discard(IdempotencyScope scope); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentResponseCodec.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentResponseCodec.java new file mode 100644 index 00000000..e0544a21 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotentResponseCodec.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.idempotency; + +/** + * Serializes a use case result to/from the opaque {@link StoredResponse} payload so the {@link + * IdempotencyExecutor} can replay a completed response. The application layer is + * wire-format-neutral — the concrete encoding is owned by the {@code adapter-web} caller (see + * README). + * + * @param <R> the use case result type being made idempotent + */ +public interface IdempotentResponseCodec<R> { + + /** Serialize a freshly-produced result for storage. */ + String serialize(R result); + + /** Reconstruct a result from a stored payload during replay. */ + R deserialize(String payload); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java new file mode 100644 index 00000000..0171ec5a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/RequestFingerprint.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.idempotency; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * SHA-256 fingerprint of a request body. Same {@link IdempotencyScope} but a different body + * (different fingerprint) is a client bug, rejected by the executor with {@link + * IdempotencyRequestMismatchException} (422); a null/empty body hashes as a zero-length payload. + * The raw transmitted bytes are hashed — no canonicalization. See README for the SHA-256 choice and + * the canonicalization caveat. + * + * @param hex lowercase hex SHA-256 digest + */ +public record RequestFingerprint(String hex) { + + public RequestFingerprint { + Objects.requireNonNull(hex, "hex"); + if (hex.length() != 64) { + throw new IllegalArgumentException( + "SHA-256 fingerprint must be 64 hex chars, was " + hex.length()); + } + } + + /** Compute the SHA-256 fingerprint of the raw request body bytes. */ + public static RequestFingerprint ofSha256(byte[] body) { + byte[] payload = body == null ? new byte[0] : body; + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(payload); + return new RequestFingerprint(HexFormat.of().formatHex(digest)); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by every JDK — unreachable. + throw new IllegalStateException("SHA-256 algorithm unavailable", e); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/Sleeper.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/Sleeper.java new file mode 100644 index 00000000..7566a4db --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/Sleeper.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.idempotency; + +import java.time.Duration; + +/** + * Indirection over {@code Thread.sleep} so the {@link IdempotencyExecutor}'s in-flight poll wait is + * deterministically testable (a test {@code Sleeper} can advance a mutable clock instead of + * blocking real time). + */ +@FunctionalInterface +public interface Sleeper { + + void sleep(Duration duration); + + /** Real-time sleeper that restores the interrupt flag and stops waiting on interruption. */ + static Sleeper realtime() { + return duration -> { + try { + Thread.sleep(Math.max(0L, duration.toMillis())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/StoredResponse.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/StoredResponse.java new file mode 100644 index 00000000..72521656 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/StoredResponse.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.idempotency; + +import java.util.Objects; + +/** + * Opaque serialized representation of a completed idempotent response, replayed verbatim to + * duplicate callers. Holds only the {@code payload} string from an {@link IdempotentResponseCodec}; + * where it is physically stored is a persistence-adapter concern (see README). + * + * @param payload codec-serialized response body + */ +public record StoredResponse(String payload) { + + public StoredResponse { + Objects.requireNonNull(payload, "payload"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lock/DistributedLock.java b/src/application-core/src/main/java/dev/caskeleton/application/lock/DistributedLock.java new file mode 100644 index 00000000..5d5b9f99 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lock/DistributedLock.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.lock; + +/** + * Handle to an acquired distributed lock, returned by {@link DistributedLockPort#tryAcquire(String, + * java.time.Duration, java.time.Duration)}. {@code close()} releases the lock, is safe in a {@code + * finally} block, and should be idempotent. It must be called only after the protected transaction + * commits. See README. + */ +public interface DistributedLock extends AutoCloseable { + + /** + * Releases this distributed lock. Overrides {@link AutoCloseable#close()} to drop the {@code + * throws Exception} so callers need no checked-exception ceremony in {@code finally}. Call only + * after the protected transaction has committed. + */ + @Override + void close(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java b/src/application-core/src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java new file mode 100644 index 00000000..61b666d3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java @@ -0,0 +1,30 @@ +package dev.caskeleton.application.lock; + +import java.time.Duration; + +/** + * Outbound port for acquiring a distributed lock — the only dependency the application layer may + * use for coordinated mutual exclusion (no lock client / SQL imports in use cases). {@code + * tryAcquire} is a try-lock with a finite {@code waitTime} and a crash-safety {@code leaseTtl}; the + * handle must be released only after the protected transaction commits. This is an efficiency lock, + * not a correctness lock (DB constraints still guard correctness). See README for the full + * rationale. + */ +public interface DistributedLockPort { + + /** + * Attempts to acquire the distributed lock identified by {@code key}, blocking for at most {@code + * waitTime}. Returns a {@link DistributedLock} whose {@code close()} releases the lock — call it + * only after the protected transaction commits. See README for lease-expiry-on-release behaviour. + * + * @param key the lock identifier; must be non-null and non-blank + * @param waitTime the maximum time to wait for the lock; must be finite and positive + * @param leaseTtl the maximum duration the lock may be held before the adapter auto-expires it + * (crash-safety; must be positive and not exceed the provider's configured TTL) + * @return the acquired lock handle — caller is responsible for releasing it + * @throws LockAcquisitionTimeoutException if the lock could not be acquired within {@code + * waitTime} + * @throws IllegalArgumentException if {@code leaseTtl} exceeds the provider's configured TTL + */ + DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/lock/LockAcquisitionTimeoutException.java b/src/application-core/src/main/java/dev/caskeleton/application/lock/LockAcquisitionTimeoutException.java new file mode 100644 index 00000000..25724ba9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/lock/LockAcquisitionTimeoutException.java @@ -0,0 +1,47 @@ +package dev.caskeleton.application.lock; + +import dev.caskeleton.shared.error.OperationalError; +import java.time.Duration; + +/** + * Thrown by {@link DistributedLockPort#tryAcquire(String, Duration, Duration)} when the lock could + * not be acquired within the bounded {@code waitTime}. Carries {@link + * OperationalError#LOCK_ACQUISITION_TIMEOUT} (CONFLICT, 409, retryable — contention is transient). + * The web adapter maps it to 409 with a client-safe message; {@link #getMessage()} is + * server-log-only. See README. + */ +public final class LockAcquisitionTimeoutException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String key; + private final transient Duration waitTime; + + /** + * @param key the lock key that could not be acquired + * @param waitTime the time that elapsed before the attempt was abandoned + */ + public LockAcquisitionTimeoutException(String key, Duration waitTime) { + super("failed to acquire distributed lock '" + key + "' within " + waitTime); + this.key = key; + this.waitTime = waitTime; + } + + /** The lock key that could not be acquired within the allotted wait time. */ + public String key() { + return key; + } + + /** The wait duration that elapsed before the acquisition attempt was abandoned. */ + public Duration waitTime() { + return waitTime; + } + + /** + * Returns {@link OperationalError#LOCK_ACQUISITION_TIMEOUT}, the stable client-facing error code + * for a distributed-lock timeout. + */ + public OperationalError errorCode() { + return OperationalError.LOCK_ACQUISITION_TIMEOUT; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java new file mode 100644 index 00000000..7d56a0fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +/** + * Notification channel discriminator — identifies the delivery medium (e.g. email, Slack) + * independently of the provider. The adapter resolves the provider list via {@code + * app.notification.routes.<channel>.<route>}. See README. + */ +public enum Channel { + /** Electronic mail channel. */ + EMAIL, + /** Slack messaging channel. */ + SLACK +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java new file mode 100644 index 00000000..afa2d525 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** + * Transport-neutral notification value delivered via {@link NotificationPort}, carrying routing + * target and content only. It MUST NEVER be passed to any logger — recipient and body are PII. + * Lives in {@code application-core} (not the adapter) so use cases need no adapter imports. See + * README. + * + * @param recipient channel / address (e.g. Slack channel id or email address — PII) + * @param subject short subject / title + * @param body message body (may contain PII) + */ +public record Notification(String recipient, String subject, String body) { + + public Notification { + Objects.requireNonNull(recipient, "recipient"); + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(body, "body"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java new file mode 100644 index 00000000..105c3f75 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.notification; + +/** + * Outbound port for delivering notifications via a named channel and logical route. The adapter + * resolves the provider list from {@code app.notification.routes.<channel>.<route>}, so the + * application layer is decoupled from provider selection. See README for fan-out / fail-open + * behaviour. + */ +public interface NotificationPort { + + /** + * Delivers a notification on {@code channel} via the {@code "default"} route. + * + * @param channel delivery channel (compile-safe) + * @param notification content to deliver (contains PII — never log this value) + */ + default void notify(Channel channel, Notification notification) { + notify(channel, "default", notification); + } + + /** + * Delivers a notification on {@code channel} via a named logical {@code route}. + * + * @param channel delivery channel (compile-safe) + * @param route logical route name bound in {@code app.notification.routes} + * @param notification content to deliver (contains PII — never log this value) + */ + void notify(Channel channel, String route, Notification notification); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/observability/UserPrincipalPseudonymizerPort.java b/src/application-core/src/main/java/dev/caskeleton/application/observability/UserPrincipalPseudonymizerPort.java new file mode 100644 index 00000000..1c9e26c9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/observability/UserPrincipalPseudonymizerPort.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.observability; + +/** + * Outbound port that converts a raw security principal id into a stable pseudonymous token before + * it is written to logs or MDC. Returns {@code null} for a null/blank input. The token is stable + * and one-way (no trivial recovery). The concrete algorithm lives in {@code + * adapter:outbound:identifier} ({@code dev.caskeleton.adapter.outbound.identifier}); + * implementations must not be referenced from application-core/domain-core. See README. + */ +public interface UserPrincipalPseudonymizerPort { + + /** + * Returns a stable pseudonymous token derived from {@code rawPrincipal}, or {@code null} when it + * is {@code null} or blank. + * + * @param rawPrincipal the raw security principal id (may be {@code null}/blank for + * unauthenticated requests) + * @return a stable pseudonymous token for logging/MDC, or {@code null} for null/blank input + */ + String pseudonymize(String rawPrincipal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java new file mode 100644 index 00000000..5ff8b0f1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java @@ -0,0 +1,45 @@ +package dev.caskeleton.application.outbox; + +import java.time.Instant; +import java.util.Objects; + +/** + * Value object for a new outbox event, appended within the caller's write transaction. All fields + * are required (null/blank is rejected at construction). Callers supply {@code eventId} and {@code + * idempotencyKey} (the outbox core is ID-generation-agnostic); the recommended default is {@code + * idempotencyKey = eventId}. See README. + * + * @param eventId unique identifier for this event (e.g. a UUIDv7 string) + * @param eventType logical event type name (e.g. {@code "UserCreated"}) + * @param aggregateId the aggregate that emitted this event; used as the message routing key and + * FIFO-gate anchor + * @param payload serialised event payload (pre-serialised JSON string) + * @param occurredAt wall-clock time at which the domain event occurred + * @param correlationId trace / correlation identifier for log correlation + * @param idempotencyKey consumer-side deduplication key (D12 / I12) + */ +public record NewOutboxEvent( + String eventId, + String eventType, + String aggregateId, + String payload, + Instant occurredAt, + String correlationId, + String idempotencyKey) { + + public NewOutboxEvent { + requireNonBlank(eventId, "eventId"); + requireNonBlank(eventType, "eventType"); + requireNonBlank(aggregateId, "aggregateId"); + requireNonBlank(payload, "payload"); + Objects.requireNonNull(occurredAt, "occurredAt must not be null"); + requireNonBlank(correlationId, "correlationId"); + requireNonBlank(idempotencyKey, "idempotencyKey"); + } + + private static void requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be null or blank"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java new file mode 100644 index 00000000..734fbb36 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.outbox; + +/** + * Outbound port for appending a new event to the transactional outbox. <strong>Dual-write + * prohibition</strong>: must be called inside the same DB transaction as the business operation + * that generates the event ({@code TransactionPort.inWrite(...)}, opened by the caller); + * implementations must not open their own transaction. See README. + */ +public interface OutboxAppendPort { + + /** + * Appends {@code event} to the outbox table, participating in the caller's existing write + * transaction. Calling outside {@code TransactionPort.inWrite(...)} is a contract violation + * (silent event loss). + * + * @param event the new event to persist; must not be {@code null} + */ + void append(NewOutboxEvent event); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxBackoffPolicy.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxBackoffPolicy.java new file mode 100644 index 00000000..34058fe8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxBackoffPolicy.java @@ -0,0 +1,71 @@ +package dev.caskeleton.application.outbox; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.random.RandomGenerator; + +/** + * Exponential-backoff-with-full-jitter policy for outbox relay retry scheduling: {@code delay = + * base × 2^(attemptCount-1) + jitter(0..base)} with {@code base = 30s}, {@code maxAttempts = 3}; + * {@code attemptCount} is the 1-based just-failed attempt. Full jitter uses the injected {@link + * RandomGenerator} (deterministic in tests with a fixed-seed generator). The constants are fixed, + * not configurable — see README for why. + */ +public final class OutboxBackoffPolicy { + + /** Base retry delay (registry SSOT: retry_after_seconds=30). */ + public static final Duration BASE_DELAY = Duration.ofSeconds(30); + + /** Maximum delivery attempts before a row is DEAD-lettered. */ + public static final int MAX_ATTEMPTS = 3; + + private final RandomGenerator random; + + /** + * Creates a policy using the supplied {@link RandomGenerator} for jitter (pass a fixed-value + * generator for deterministic tests). + * + * @param random source of randomness for full-jitter computation; must not be null + */ + public OutboxBackoffPolicy(RandomGenerator random) { + this.random = Objects.requireNonNull(random, "random must not be null"); + } + + /** + * Returns the maximum number of delivery attempts before an event is dead-lettered. + * + * @return {@value #MAX_ATTEMPTS} + */ + public int maxAttempts() { + return MAX_ATTEMPTS; + } + + /** + * Computes the next attempt instant for a failed event. + * + * @param attemptCount the 1-based attempt number that just failed (e.g. {@code 1} for the first + * failure) + * @param now the current wall-clock instant + * @return the earliest instant at which the event may be re-claimed + * @throws IllegalArgumentException if {@code attemptCount < 1} + */ + public Instant nextAttemptAt(int attemptCount, Instant now) { + if (attemptCount < 1) { + throw new IllegalArgumentException( + "attemptCount must be >= 1 (1-based), was " + attemptCount); + } + Objects.requireNonNull(now, "now must not be null"); + + // Exponential base: base * 2^(attemptCount-1) + // Capped to avoid overflow for very large attemptCount values. + long exponent = Math.min(attemptCount - 1, 30); // 2^30 * 30s > 30 years — safe cap + long baseSeconds = BASE_DELAY.toSeconds() * (1L << exponent); + + // Full jitter: uniform in [0, base] + double jitterSeconds = random.nextDouble() * BASE_DELAY.toSeconds(); + + long totalSeconds = baseSeconds + (long) jitterSeconds; + return now.plusSeconds(totalSeconds); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java new file mode 100644 index 00000000..e3a47a78 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java @@ -0,0 +1,49 @@ +package dev.caskeleton.application.outbox; + +import java.time.Instant; +import java.util.Objects; + +/** + * Immutable view of an outbox event row returned by {@link OutboxStorePort#claimBatch}. + * + * <p>This is a claim-result read model: it carries the full {@link NewOutboxEvent} fields plus the + * current {@link OutboxEventStatus} and the number of delivery attempts already made. The relay + * uses {@code attemptCount} to decide whether to DEAD-letter the event on the next failure ({@code + * attemptCount >= maxAttempts}). + * + * @param eventId unique identifier for this event + * @param eventType logical event type name + * @param aggregateId the aggregate that emitted this event + * @param payload serialised event payload (pre-serialised JSON string) + * @param occurredAt wall-clock time at which the domain event occurred + * @param correlationId trace / correlation identifier + * @param idempotencyKey consumer-side deduplication key + * @param status current lifecycle status (will be {@link OutboxEventStatus#IN_FLIGHT} immediately + * after a successful claim) + * @param attemptCount number of delivery attempts already made (1-based after the first claim) + */ +public record OutboxEvent( + String eventId, + String eventType, + String aggregateId, + String payload, + Instant occurredAt, + String correlationId, + String idempotencyKey, + OutboxEventStatus status, + int attemptCount) { + + public OutboxEvent { + Objects.requireNonNull(eventId, "eventId"); + Objects.requireNonNull(eventType, "eventType"); + Objects.requireNonNull(aggregateId, "aggregateId"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(occurredAt, "occurredAt"); + Objects.requireNonNull(correlationId, "correlationId"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey"); + Objects.requireNonNull(status, "status"); + if (attemptCount < 0) { + throw new IllegalArgumentException("attemptCount must be >= 0, was " + attemptCount); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEventStatus.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEventStatus.java new file mode 100644 index 00000000..355633b6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEventStatus.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.outbox; + +/** + * Status state machine for a transactional outbox event row. {@code DEAD} blocks its aggregate's + * FIFO siblings until manually resolved. See README for the full transition diagram. + */ +public enum OutboxEventStatus { + + /** Row inserted by the domain operation; not yet picked up by the relay. */ + PENDING, + + /** + * Relay has claimed this row and is attempting to publish. {@code next_attempt_at} is set to + * {@code claim_time + in_flight_timeout} so an orphaned IN_FLIGHT row is re-claimable after that + * deadline. + */ + IN_FLIGHT, + + /** + * Broker acknowledged the publish. Terminal success state. The reaper will purge rows in this + * state after the configured retention period. + */ + PUBLISHED, + + /** + * Publish failed transiently; {@code next_attempt_at} carries the backoff deadline. The row will + * be re-claimed once {@code now >= next_attempt_at}. + */ + FAILED, + + /** + * All retry attempts exhausted. Terminal failure state. Manual operator intervention is required; + * see {@code OperationalError.OUTBOX_DEAD_LETTER}. While a row is DEAD its aggregate's FIFO queue + * is blocked. + */ + DEAD +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java new file mode 100644 index 00000000..e0c1747d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.outbox; + +/** + * Outbound port for publishing a claimed outbox event to the message broker. + * <strong>Fail-closed</strong>: any publish failure MUST be surfaced by throwing a {@link + * RuntimeException} — implementations must not swallow or log-and-return — so the relay can drive + * the FAILED/DEAD transition. Called by the relay <em>outside</em> any write transaction. See + * README for the fail-closed rationale and the call-site sequencing. + */ +public interface OutboxMessagePublishPort { + + /** + * Publishes {@code event} to the configured message broker. Must throw on any failure + * (fail-closed); must not catch-and-swallow the broker client's exceptions. + * + * @param event the claimed outbox event to publish; must not be {@code null} + * @throws RuntimeException if the publish fails for any reason + */ + void publish(OutboxEvent event); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java new file mode 100644 index 00000000..615c2a5c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.outbox; + +import java.util.List; +import java.util.Objects; + +/** + * Result returned by {@link PublishPendingOutboxEventsUseCase} after one relay cycle: the number of + * events claimed and a per-event {@link Outcome} list. See README for why {@code Outcome} is a + * separate 3-value enum rather than reusing {@link OutboxEventStatus}. + * + * @param claimedCount total number of events claimed from the store in this cycle + * @param outcomes per-event outcomes in the order they were processed + */ +public record OutboxRelayResult(int claimedCount, List<EventOutcome> outcomes) { + + public OutboxRelayResult { + if (claimedCount < 0) { + throw new IllegalArgumentException("claimedCount must be >= 0, was " + claimedCount); + } + outcomes = List.copyOf(Objects.requireNonNull(outcomes, "outcomes must not be null")); + } + + /** + * Outcome of a single event during one relay cycle. + * + * @param eventId the event identifier + * @param eventType the logical event type name + * @param outcome the result of the publish attempt + */ + public record EventOutcome(String eventId, String eventType, Outcome outcome) { + + public EventOutcome { + Objects.requireNonNull(eventId, "eventId must not be null"); + Objects.requireNonNull(eventType, "eventType must not be null"); + Objects.requireNonNull(outcome, "outcome must not be null"); + } + } + + /** + * Per-event relay cycle outcome. + * + * <p>Three values mirror the three terminal states reachable in a single relay cycle: broker ack + * ({@link #PUBLISHED}), transient failure below the retry cap ({@link #FAILED}), or retry cap + * exhausted ({@link #DEAD}). + */ + public enum Outcome { + + /** Broker acknowledged the publish; row transitions to PUBLISHED. */ + PUBLISHED, + + /** Publish failed transiently; row transitions to FAILED with a backoff window. */ + FAILED, + + /** All retry attempts exhausted; row transitions to DEAD. */ + DEAD + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java new file mode 100644 index 00000000..ee123a66 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java @@ -0,0 +1,72 @@ +package dev.caskeleton.application.outbox; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Outbound port for relay-side access to the outbox store. All mutating operations ({@link + * #claimBatch}, {@link #markPublished}, {@link #markFailed}, {@link #markDead}) must be called + * inside a {@code TransactionPort.inWrite(...)} boundary owned by the relay; implementations must + * not open their own transaction. See README for the claim/FIFO/in-flight-timeout semantics. + */ +public interface OutboxStorePort { + + /** + * Atomically claims up to {@code batchSize} eligible rows and transitions them to {@link + * OutboxEventStatus#IN_FLIGHT}. Must be called inside {@code TransactionPort.inWrite(...)}. See + * README for eligibility and the FIFO gate. + * + * @param batchSize maximum number of events to claim + * @param now current wall-clock time used for eligibility checks + * @param inFlightTimeout duration after which an IN_FLIGHT orphan becomes re-claimable + * @return claimed events ({@code status = IN_FLIGHT}, incremented {@code attemptCount}); empty if + * none + */ + List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout); + + /** + * Marks the event as successfully published. Must be called inside {@code + * TransactionPort.inWrite(...)} after a successful publish. + * + * @param eventId the identifier of the event to mark published + */ + void markPublished(String eventId); + + /** + * Marks the event as failed and schedules the next retry attempt. Must be called inside {@code + * TransactionPort.inWrite(...)} when a publish fails transiently and {@code attemptCount < + * maxAttempts}. + * + * @param eventId the identifier of the event to mark failed + * @param nextAttemptAt the earliest instant at which the event may be re-claimed + */ + void markFailed(String eventId, Instant nextAttemptAt); + + /** + * Marks the event as dead-lettered after all retry attempts are exhausted. Must be called inside + * {@code TransactionPort.inWrite(...)}. A DEAD row blocks its aggregate's FIFO queue until + * manually resolved (see README). + * + * @param eventId the identifier of the event to dead-letter + */ + void markDead(String eventId); + + /** + * Returns a count of rows grouped by {@link OutboxEventStatus} — gauge source for the {@code + * outbox.pending.size} metric. Called outside a transaction (read-only). + * + * @return map from status to row count; statuses with zero rows may be absent + */ + Map<OutboxEventStatus, Long> countByStatus(); + + /** + * Returns the age in seconds of the oldest unpublished row, grouped by event type — gauge source + * for the {@code outbox.publisher.lag} metric. Called outside a transaction (read-only). + * + * @param now current wall-clock time used to compute age + * @return map from event type to oldest-unpublished-row age in seconds; absent when none + */ + Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsCommand.java new file mode 100644 index 00000000..a6b46393 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsCommand.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.outbox; + +import dev.caskeleton.application.command.Command; + +/** + * Command marker for the outbox relay use case. The relay is scheduler-driven and carries no caller + * parameters (operational params are injected at construction); the {@link #INSTANCE} singleton + * conveys the intent. See README. + */ +public record PublishPendingOutboxEventsCommand() implements Command { + + /** Canonical no-parameter instance — the relay command carries no parameters. */ + public static final PublishPendingOutboxEventsCommand INSTANCE = + new PublishPendingOutboxEventsCommand(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java new file mode 100644 index 00000000..45b4efe9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java @@ -0,0 +1,169 @@ +package dev.caskeleton.application.outbox; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Relay use case that claims pending outbox events and publishes them to the broker: claim a batch + * in a short write transaction, sort by {@code occurredAt}, then publish each event + * <em>outside</em> any transaction and drive the PUBLISHED / FAILED / DEAD state machine per + * result. Publish failures are logged and never rethrown; a status-update failure after a + * successful publish propagates and the row is recovered via the in-flight timeout. Wired manually + * by {@code app-bootstrap} (not a Spring bean). See README for the full algorithm, failure + * semantics, manual-wiring rationale, and the {@code "outbox:relay"} permission. + */ +@RequiresPermission("outbox:relay") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +public final class PublishPendingOutboxEventsUseCase + implements CommandUseCase<PublishPendingOutboxEventsCommand, OutboxRelayResult> { + + private static final Logger log = + LoggerFactory.getLogger(PublishPendingOutboxEventsUseCase.class); + + private final OutboxStorePort store; + private final OutboxMessagePublishPort publishPort; + private final TransactionPort tx; + private final OutboxBackoffPolicy backoffPolicy; + private final Clock clock; + private final int batchSize; + private final Duration inFlightTimeout; + + /** + * Constructs the relay use case with all required collaborators (wired manually by {@code + * app-bootstrap}; see README). + * + * @param store outbox store port (claim + status update) + * @param publishPort fail-closed broker publish port + * @param tx transaction port for short write boundaries + * @param backoffPolicy retry backoff policy + * @param clock wall-clock source (injected for testability) + * @param batchSize maximum events to claim per relay cycle + * @param inFlightTimeout how long a claimed row stays IN_FLIGHT before re-claim + */ + public PublishPendingOutboxEventsUseCase( + OutboxStorePort store, + OutboxMessagePublishPort publishPort, + TransactionPort tx, + OutboxBackoffPolicy backoffPolicy, + Clock clock, + int batchSize, + Duration inFlightTimeout) { + this.store = Objects.requireNonNull(store, "store must not be null"); + this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null"); + this.tx = Objects.requireNonNull(tx, "tx must not be null"); + this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + if (batchSize <= 0) { + throw new IllegalArgumentException("batchSize must be > 0, was " + batchSize); + } + this.batchSize = batchSize; + this.inFlightTimeout = + Objects.requireNonNull(inFlightTimeout, "inFlightTimeout must not be null"); + } + + @Override + public OutboxRelayResult handle(PublishPendingOutboxEventsCommand command) { + Objects.requireNonNull(command, "command must not be null"); + Instant now = clock.instant(); + + // Step 1: Claim a batch inside a short write transaction. + List<OutboxEvent> claimed = tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout)); + + if (claimed.isEmpty()) { + return new OutboxRelayResult(0, List.of()); + } + + // Step 2: Defensive sort by occurredAt ascending (relay enforces FIFO even if the adapter does + // not). + List<OutboxEvent> sorted = new ArrayList<>(claimed); + sorted.sort(Comparator.comparing(OutboxEvent::occurredAt)); + + // Step 3: Publish each event outside any transaction; drive status machine per result. + List<OutboxRelayResult.EventOutcome> outcomes = new ArrayList<>(sorted.size()); + for (OutboxEvent event : sorted) { + OutboxRelayResult.Outcome outcome = publishOne(event, now); + outcomes.add(new OutboxRelayResult.EventOutcome(event.eventId(), event.eventType(), outcome)); + } + + return new OutboxRelayResult(claimed.size(), outcomes); + } + + /** + * Attempts to publish one event and drives the FAILED/DEAD state machine on publish failure. A + * publish failure is caught here and never rethrown; a {@code markPublished} failure after a + * successful publish is NOT caught — it propagates so the row is recovered via the in-flight + * timeout. See README for both failure modes. + */ + private OutboxRelayResult.Outcome publishOne(OutboxEvent event, Instant now) { + try { + publishPort.publish(event); + } catch (RuntimeException publishEx) { + // Publish failure: drive FAILED/DEAD state machine + ERROR log; do NOT rethrow. + return handlePublishFailure(event, now, publishEx); + } + // markPublished failure (if any) propagates: the row stays IN_FLIGHT and is + // recovered via the orphan visibility-timeout reclaim path. + tx.inWrite(() -> store.markPublished(event.eventId())); + return OutboxRelayResult.Outcome.PUBLISHED; + } + + /** + * Drives the FAILED/DEAD state transition and produces a mandatory ERROR log — always both a + * status transition and an ERROR log (omitting either is the forbidden silent-swallow). See + * README. + */ + private OutboxRelayResult.Outcome handlePublishFailure( + OutboxEvent event, Instant now, RuntimeException cause) { + + if (event.attemptCount() >= backoffPolicy.maxAttempts()) { + // All attempts exhausted — DEAD-letter the event. + tx.inWrite(() -> store.markDead(event.eventId())); + log.error( + "error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} " + + "— outbox event dead-lettered after {} attempts; manual intervention required", + "OUTBOX_DEAD_LETTER", + event.eventId(), + event.eventType(), + event.aggregateId(), + event.correlationId(), + event.attemptCount(), + backoffPolicy.maxAttempts(), + cause); + return OutboxRelayResult.Outcome.DEAD; + } else { + // Transient failure — schedule retry with exponential backoff. + Instant nextAttemptAt = backoffPolicy.nextAttemptAt(event.attemptCount(), now); + tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt)); + log.error( + "error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} " + + "nextAttemptAt={} — outbox publish failed transiently; will retry", + "OUTBOX_PUBLISH_FAILED", + event.eventId(), + event.eventType(), + event.aggregateId(), + event.correlationId(), + event.attemptCount(), + nextAttemptAt, + cause); + return OutboxRelayResult.Outcome.FAILED; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/query/Query.java b/src/application-core/src/main/java/dev/caskeleton/application/query/Query.java new file mode 100644 index 00000000..e2cbe36e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/query/Query.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.query; + +/** + * Marker for application query contracts (read intents). A {@code Query} is a plain immutable type + * (preferably a {@code record}) of domain/primitive values. See README for the forbidden field + * types. + */ +public interface Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java b/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java new file mode 100644 index 00000000..2652683c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.security; + +import dev.caskeleton.shared.security.Permission; + +/** + * Raised by {@link AuthorizationPort} when an authenticated caller lacks a required {@link + * Permission}. Framework-free and application-owned (the layer cannot throw Spring's {@code + * AccessDeniedException}); the web adapter translates it to a {@code AUTHZ_INSUFFICIENT_PERMISSION} + * 403. Fail-closed: it carries the required permission and subject for diagnostics, never the + * caller's effective permission set. See README. + */ +public class AuthorizationDeniedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient Permission requiredPermission; + private final String subject; + + public AuthorizationDeniedException(String subject, Permission requiredPermission) { + super( + "authorization denied: subject '" + + subject + + "' lacks permission '" + + (requiredPermission == null ? "<null>" : requiredPermission.value()) + + "'"); + this.subject = subject; + this.requiredPermission = requiredPermission; + } + + public Permission requiredPermission() { + return requiredPermission; + } + + public String subject() { + return subject; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationPort.java new file mode 100644 index 00000000..a0ba58f6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationPort.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.security; + +import dev.caskeleton.shared.security.Permission; + +/** + * Product-authorization enforcement point (PEP): decides whether an authenticated caller may + * perform a permission-guarded operation. Authentication itself is owned by + * feature-security-operational-baseline; this port consumes the principal's raw roles. Fail-closed + * — it returns only when the caller holds the required permission, else throws {@link + * AuthorizationDeniedException}. See README for why this is a plain port (not + * {@code @PreAuthorize}) and how it maps raw roles to permissions. + */ +public interface AuthorizationPort { + + /** + * Requires that {@code principal} holds {@code required}; otherwise denies access. + * + * @param principal the framework-free view of the authenticated caller + * @param required the permission the guarded operation demands + * @throws AuthorizationDeniedException if the caller's effective permissions do not include + * {@code required} (fail-closed) + */ + void requirePermission(AuthorizationPrincipal principal, Permission required); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java b/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java new file mode 100644 index 00000000..8a63c2ed --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.security; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Framework-free view of the authenticated caller consumed by {@link AuthorizationPort}: the IdP + * subject plus the caller's <em>raw</em> role names (e.g. {@code "admin"}, not the {@code ROLE_*} + * authority form). See README for why the application layer maps down to this abstraction and why + * raw role names matter for role→permission resolution. + */ +public record AuthorizationPrincipal(String subject, Set<String> roles) { + + public AuthorizationPrincipal { + roles = roles == null ? Set.of() : Collections.unmodifiableSet(new HashSet<>(roles)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/security/RequiresPermission.java b/src/application-core/src/main/java/dev/caskeleton/application/security/RequiresPermission.java new file mode 100644 index 00000000..a85b5f60 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/security/RequiresPermission.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the {@link dev.caskeleton.shared.security.Permission} an authenticated caller must hold + * to invoke a guarded use case. The value is a {@code resource:action} token (e.g. {@code + * "worklog:close"}), making the requirement visible without reading the body — mirrors the + * {@code @UseCaseCapability} pattern. Pure declaration, Spring-free: the web adapter's {@code + * RequiresPermissionAuthorizationManager} reads it (retention {@code RUNTIME}) and delegates to + * {@link AuthorizationPort}. See README for the AOP-proxy bypass caveat and where it is required. + */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequiresPermission { + + /** The required permission as a {@code resource:action} token. */ + String value(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java new file mode 100644 index 00000000..c89f6e1b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.storage; + +import java.util.Optional; + +/** + * Outbound port for blob (object) storage — the only dependency the application layer may use to + * persist and retrieve binary content, so use cases stay decoupled from the storage backend (local + * filesystem, S3, or MinIO). The adapter is selected by configuration ({@code + * ca-skeleton.objectstorage.backend}); see the {@code adapter:outbound:objectstorage} README for + * the backend matrix and the key-mapping contract. + * + * <p>Keys are backend-relative, caller-supplied, opaque strings (e.g. {@code + * "posters/2026/cover.png"}). Implementations MUST reject a key that escapes the backend's + * namespace (path traversal) with {@link IllegalArgumentException}. Content is passed and returned + * as raw bytes; this port intentionally exposes no streaming/presigned-URL surface — a fork adds + * those when a concrete feature needs them. + */ +public interface ObjectStoragePort { + + /** + * Stores {@code content} under {@code key}, overwriting any existing object at that key. + * + * @param key the backend-relative object key; must be non-null and non-blank + * @param content the raw bytes to store; must be non-null (may be empty) + * @param contentType the MIME type to record for the object; must be non-null and non-blank + * @return a {@link StoredObject} receipt (key, size, content type, backend locator) + * @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace + */ + StoredObject put(String key, byte[] content, String contentType); + + /** + * Reads the object stored under {@code key}. + * + * @param key the backend-relative object key; must be non-null and non-blank + * @return the object bytes, or {@link Optional#empty()} if no object exists at {@code key} + * @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace + */ + Optional<byte[]> get(String key); + + /** + * Deletes the object stored under {@code key}. A no-op when no object exists at {@code key} + * (idempotent). + * + * @param key the backend-relative object key; must be non-null and non-blank + * @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace + */ + void delete(String key); + + /** + * Reports whether an object exists at {@code key}. + * + * @param key the backend-relative object key; must be non-null and non-blank + * @return {@code true} if an object exists at {@code key}, {@code false} otherwise + * @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace + */ + boolean exists(String key); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java new file mode 100644 index 00000000..c75955f5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/StoredObject.java @@ -0,0 +1,30 @@ +package dev.caskeleton.application.storage; + +import java.net.URI; +import java.util.Objects; + +/** + * Immutable receipt for a blob stored through {@link ObjectStoragePort}. Framework-neutral value + * object (no Spring / AWS types) so the application layer stays decoupled from the storage backend. + * + * @param key the object key the blob was stored under (backend-relative, never null/blank) + * @param size the stored content length in bytes (never negative) + * @param contentType the MIME type the blob was stored with (never null/blank) + * @param location a backend-specific locator — a {@code file://} URI for the filesystem backend, an + * {@code s3://bucket/key} URI for the S3/MinIO backend (never null) + */ +public record StoredObject(String key, long size, String contentType, URI location) { + + public StoredObject { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("StoredObject.key must be non-null and non-blank"); + } + if (size < 0) { + throw new IllegalArgumentException("StoredObject.size must be non-negative, was " + size); + } + if (contentType == null || contentType.isBlank()) { + throw new IllegalArgumentException("StoredObject.contentType must be non-null and non-blank"); + } + Objects.requireNonNull(location, "StoredObject.location must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/Isolation.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/Isolation.java new file mode 100644 index 00000000..ca1b8449 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/Isolation.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.transaction; + +/** + * Transaction isolation values exposed by {@link TransactionPort}. {@link #READ_COMMITTED} is the + * pinned default, set explicitly on every transaction template. See README for why the vendor + * default is never used, why {@code READ_UNCOMMITTED} is not declared, and the still-planned + * per-use-case routing of the stricter levels. + */ +public enum Isolation { + + /** Pinned default for every use case (statement-level snapshot semantics). */ + READ_COMMITTED, + + /** Explicit opt-in for write-heavy / read-consistency use cases (transaction-level snapshot). */ + REPEATABLE_READ, + + /** Explicit opt-in for the strongest guarantee. Serialization anomalies fail and are retried. */ + SERIALIZABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionMode.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionMode.java new file mode 100644 index 00000000..cab81740 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionMode.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.transaction; + +/** + * Declares the transactional intent of a use case. + * + * <p>Used both at the API surface of {@link TransactionPort} and in the {@link + * dev.caskeleton.application.capability.UseCaseCapability} annotation so a use case's transactional + * contract is visible without reading the body. + */ +public enum TransactionMode { + + /** REQUIRED read-write at {@link Isolation#READ_COMMITTED}; default for command use cases. */ + WRITE, + + /** REQUIRED read-only at {@link Isolation#READ_COMMITTED}; default for query use cases. */ + READ_ONLY, + + /** + * REQUIRES_NEW read-write; outbox / audit / compensation only. Must be declared on the {@link + * dev.caskeleton.application.capability.UseCaseCapability} annotation. + */ + REQUIRES_NEW +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java new file mode 100644 index 00000000..35c79eb2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.transaction; + +import java.util.function.Supplier; + +/** + * Outbound port for application-managed transaction boundaries: use cases declare transactional + * intent without importing Spring's {@code @Transactional}. The persistence adapter (typically + * {@code SpringTransactionPort}) implements it over Spring's {@code PlatformTransactionManager}. + * + * <ul> + * <li>{@link #inWrite(Supplier)} — REQUIRED + read-write, {@code READ_COMMITTED}. Command + * default. + * <li>{@link #inRead(Supplier)} — REQUIRED + read-only, {@code READ_COMMITTED}. Query default. + * <li>{@link #inNew(Supplier)} — REQUIRES_NEW; outbox / audit / compensation only. + * </ul> + * + * <p>Callbacks are {@link Supplier} / {@link Runnable} (no checked exceptions); a thrown {@link + * RuntimeException} rolls back and propagates. See README for the checked-exception wrapping rules, + * the {@code inNew} pool-sizing formula, and the forbidden propagation/isolation list. + */ +public interface TransactionPort { + + <T> T inWrite(Supplier<T> action); + + <T> T inRead(Supplier<T> action); + + /** + * Run {@code action} in a NEW physical transaction (PROPAGATION_REQUIRES_NEW), reserved for + * outbox / audit / compensation flows that must commit independently of the caller. See README + * for the pool-sizing cost and the per-record loop anti-pattern. + */ + <T> T inNew(Supplier<T> action); + + default void inWrite(Runnable action) { + inWrite( + () -> { + action.run(); + return null; + }); + } + + default void inRead(Runnable action) { + inRead( + () -> { + action.run(); + return null; + }); + } + + default void inNew(Runnable action) { + inNew( + () -> { + action.run(); + return null; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/usecase/CommandUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/usecase/CommandUseCase.java new file mode 100644 index 00000000..8d9461a5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/usecase/CommandUseCase.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.usecase; + +import dev.caskeleton.application.command.Command; + +/** + * Inbound port for write use cases. Implementations must carry {@link + * dev.caskeleton.application.capability.UseCaseCapability}. + * + * @param <C> the command contract describing the write intent + * @param <R> the result returned to the caller + */ +public interface CommandUseCase<C extends Command, R> extends UseCase<C, R> {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/usecase/QueryUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/usecase/QueryUseCase.java new file mode 100644 index 00000000..39070319 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/usecase/QueryUseCase.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.usecase; + +import dev.caskeleton.application.query.Query; + +/** + * Inbound port for read-only use cases. Implementations declare {@code READ_ONLY} + {@code + * READ_REPOSITORY} on {@link dev.caskeleton.application.capability.UseCaseCapability} (see README). + * + * @param <Q> the query contract describing the read intent + * @param <R> the projection or domain object returned to the caller + */ +public interface QueryUseCase<Q extends Query, R> extends UseCase<Q, R> {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/usecase/UseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/usecase/UseCase.java new file mode 100644 index 00000000..d72c4a32 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/usecase/UseCase.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.usecase; + +/** + * Marker interface for inbound application use cases (primary ports in hexagonal terms). Concrete + * ports extend {@link CommandUseCase} or {@link QueryUseCase}. See README for the input/output type + * rules and the {@code UseCase} naming contract. + * + * @param <I> the input contract (a {@code Command} or {@code Query}) + * @param <O> the output contract (a domain object, domain projection, or {@code Void}) + */ +public interface UseCase<I, O> { + + O handle(I input); +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/capability/UseCaseCapabilityTest.java b/src/application-core/src/test/java/dev/caskeleton/application/capability/UseCaseCapabilityTest.java new file mode 100644 index 00000000..14a4b684 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/capability/UseCaseCapabilityTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.application.capability; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.CommandUseCase; +import org.junit.jupiter.api.Test; + +class UseCaseCapabilityTest { + + @Test + void exposesDeclaredTransactionModeIdempotencyAndRepositoryAccess() { + UseCaseCapability capability = ExampleWriteUseCase.class.getAnnotation(UseCaseCapability.class); + + assertThat(capability).isNotNull(); + assertThat(capability.transactionMode()).isEqualTo(TransactionMode.WRITE); + assertThat(capability.idempotency()).isEqualTo(Idempotency.KEYED); + assertThat(capability.repositoryAccess()).isEqualTo(RepositoryAccess.WRITE_REPOSITORY); + } + + @Test + void externalOutboundDefaultsToFalseWhenUnspecified() { + UseCaseCapability capability = ExampleWriteUseCase.class.getAnnotation(UseCaseCapability.class); + + assertThat(capability.externalOutboundAllowed()).isFalse(); + } + + @Test + void externalOutboundIsReadableWhenExplicitlyEnabled() { + UseCaseCapability capability = + ExampleOutboundUseCase.class.getAnnotation(UseCaseCapability.class); + + assertThat(capability.externalOutboundAllowed()).isTrue(); + } + + @Test + void sensitiveBulkAndCrossTenantFlagsDefaultToFalseWhenUnspecified() { + UseCaseCapability capability = ExampleWriteUseCase.class.getAnnotation(UseCaseCapability.class); + + assertThat(capability.sensitiveRead()).isFalse(); + assertThat(capability.bulkWrite()).isFalse(); + assertThat(capability.crossTenantAdmin()).isFalse(); + } + + @Test + void sensitiveBulkAndCrossTenantFlagsAreReadableWhenExplicitlyEnabled() { + UseCaseCapability capability = + ExampleAdminBulkUseCase.class.getAnnotation(UseCaseCapability.class); + + assertThat(capability.sensitiveRead()).isTrue(); + assertThat(capability.bulkWrite()).isTrue(); + assertThat(capability.crossTenantAdmin()).isTrue(); + } + + record ExampleCommand() implements Command {} + + @UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) + static final class ExampleWriteUseCase implements CommandUseCase<ExampleCommand, Void> { + @Override + public Void handle(ExampleCommand input) { + return null; + } + } + + @UseCaseCapability( + transactionMode = TransactionMode.REQUIRES_NEW, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) + static final class ExampleOutboundUseCase implements CommandUseCase<ExampleCommand, Void> { + @Override + public Void handle(ExampleCommand input) { + return null; + } + } + + @UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + sensitiveRead = true, + bulkWrite = true, + crossTenantAdmin = true) + static final class ExampleAdminBulkUseCase implements CommandUseCase<ExampleCommand, Void> { + @Override + public Void handle(ExampleCommand input) { + return null; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorTest.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorTest.java new file mode 100644 index 00000000..84bb8942 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyExecutorTest.java @@ -0,0 +1,291 @@ +package dev.caskeleton.application.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class IdempotencyExecutorTest { + + private static final Instant T0 = Instant.parse("2026-06-09T00:00:00Z"); + private static final Duration DEFAULT_TTL = Duration.ofHours(24); + + private static final IdempotencyScope SCOPE = + IdempotencyScope.of("user-42", "key-abc", "CreateWorkLogUseCase"); + private static final RequestFingerprint FP_A = RequestFingerprint.ofSha256("body-A".getBytes()); + private static final RequestFingerprint FP_B = RequestFingerprint.ofSha256("body-B".getBytes()); + + private static final IdempotentResponseCodec<String> STRING_CODEC = + new IdempotentResponseCodec<>() { + @Override + public String serialize(String result) { + return result; + } + + @Override + public String deserialize(String payload) { + return payload; + } + }; + + @Test + void firstCallerRunsActionOnceAndStoresResponse() { + FakeStore store = new FakeStore(); + MutableClock clock = new MutableClock(T0); + AtomicInteger runs = new AtomicInteger(); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + String result = + executor.execute( + IdempotencyContext.of(SCOPE, FP_A), + () -> { + runs.incrementAndGet(); + return "created"; + }, + STRING_CODEC); + + assertThat(result).isEqualTo("created"); + assertThat(runs.get()).isEqualTo(1); + assertThat(store.find(SCOPE, T0)) + .hasValueSatisfying(r -> assertThat(r.status()).isEqualTo(IdempotencyStatus.COMPLETED)); + } + + @Test + void duplicateWithSameBodyReplaysWithoutRerunningAction() { + FakeStore store = new FakeStore(); + MutableClock clock = new MutableClock(T0); + AtomicInteger runs = new AtomicInteger(); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + executor.execute( + IdempotencyContext.of(SCOPE, FP_A), + () -> { + runs.incrementAndGet(); + return "created"; + }, + STRING_CODEC); + String replay = + executor.execute( + IdempotencyContext.of(SCOPE, FP_A), + () -> { + runs.incrementAndGet(); + return "SHOULD-NOT-RUN"; + }, + STRING_CODEC); + + assertThat(replay).isEqualTo("created"); + assertThat(runs.get()).as("action runs exactly once across the duplicate").isEqualTo(1); + } + + @Test + void sameKeyDifferentBodyIsRejectedAsMismatch() { + FakeStore store = new FakeStore(); + MutableClock clock = new MutableClock(T0); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "created", STRING_CODEC); + + assertThatThrownBy( + () -> executor.execute(IdempotencyContext.of(SCOPE, FP_B), () -> "other", STRING_CODEC)) + .isInstanceOf(IdempotencyRequestMismatchException.class); + } + + @Test + void concurrentInFlightArrivalWaitsThenSurfaces409() { + FakeStore store = new FakeStore(); + MutableClock clock = new MutableClock(T0); + // Another caller has already claimed the scope and is still running. + store.tryBegin(SCOPE, FP_A, T0.plus(Duration.ofHours(1))); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + assertThatThrownBy( + () -> executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "x", STRING_CODEC)) + .isInstanceOf(IdempotencyInFlightException.class); + // The poll loop advanced the clock by at least the 200ms in-flight window. + assertThat(Duration.between(T0, clock.instant())) + .isGreaterThanOrEqualTo(IdempotencyExecutor.IN_FLIGHT_WAIT); + } + + @Test + void inFlightThatCompletesDuringWaitReplaysTheResult() { + // The in-flight record flips to COMPLETED after the first poll sleep. + MutableClock clock = new MutableClock(T0); + FakeStore store = new FakeStore(); + store.tryBegin(SCOPE, FP_A, T0.plus(Duration.ofHours(1))); + store.completeAfterFirstFind = "winner-result"; + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + String result = executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "x", STRING_CODEC); + + assertThat(result).isEqualTo("winner-result"); + } + + @Test + void actionFailureDiscardsTheRecordSoARetryCanWin() { + FakeStore store = new FakeStore(); + MutableClock clock = new MutableClock(T0); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + assertThatThrownBy( + () -> + executor.execute( + IdempotencyContext.of(SCOPE, FP_A), + () -> { + throw new IllegalStateException("boom"); + }, + STRING_CODEC)) + .isInstanceOf(IllegalStateException.class); + + assertThat(store.find(SCOPE, T0)).as("failed claim is discarded").isEmpty(); + String retry = + executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "recovered", STRING_CODEC); + assertThat(retry).isEqualTo("recovered"); + } + + @Test + void ttlOverrideAbove72hCapIsRejected() { + FakeStore store = new FakeStore(); + MutableClock clock = new MutableClock(T0); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + assertThatThrownBy( + () -> + executor.execute( + IdempotencyContext.withTtl(SCOPE, FP_A, Duration.ofHours(73)), + () -> "x", + STRING_CODEC)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("72h"); + } + + @Test + void defaultTtlAbove72hCapIsRejectedAtConstruction() { + assertThatThrownBy( + () -> + new IdempotencyExecutor( + new FakeStore(), new MutableClock(T0), Duration.ofHours(73))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void expiredRecordIsTreatedAsAbsentAndReclaimed() { + FakeStore store = new FakeStore(); + store.tryBegin(SCOPE, FP_A, T0.plus(Duration.ofHours(1))); + store.complete(SCOPE, new StoredResponse("stale")); + // Clock is now past the record's expiry → replay must be refused, new run wins. + MutableClock clock = new MutableClock(T0.plus(Duration.ofHours(2))); + IdempotencyExecutor executor = + new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper()); + + String result = + executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "fresh", STRING_CODEC); + assertThat(result).isEqualTo("fresh"); + } + + // ---- test doubles ---- + + /** In-memory store keyed by storage key; honours TTL expiry in {@link #find}. */ + static final class FakeStore implements IdempotencyStorePort { + final Map<String, IdempotencyRecord> rows = new ConcurrentHashMap<>(); + String completeAfterFirstFind; + private boolean firstFindSeen; + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + IdempotencyRecord claim = + new IdempotencyRecord( + scope, + fingerprint, + IdempotencyStatus.IN_FLIGHT, + null, + expiresAt.minusSeconds(1), + expiresAt); + return rows.putIfAbsent(scope.storageKey(), claim) == null; + } + + @Override + public Optional<IdempotencyRecord> find(IdempotencyScope scope, Instant now) { + IdempotencyRecord record = rows.get(scope.storageKey()); + if (record == null) { + return Optional.empty(); + } + if (record.isExpiredAt(now)) { + // Lazy expiry: an expired row is reclaimable, so purge it on read. + rows.remove(scope.storageKey(), record); + return Optional.empty(); + } + // Simulate the concurrent winner completing mid-wait. + if (completeAfterFirstFind != null + && firstFindSeen + && record.status() == IdempotencyStatus.IN_FLIGHT) { + complete(scope, new StoredResponse(completeAfterFirstFind)); + record = rows.get(scope.storageKey()); + } + firstFindSeen = true; + return Optional.of(record); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + rows.compute( + scope.storageKey(), + (k, cur) -> + new IdempotencyRecord( + cur.scope(), + cur.fingerprint(), + IdempotencyStatus.COMPLETED, + response, + cur.createdAt(), + cur.expiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + rows.remove(scope.storageKey()); + } + } + + /** A clock whose {@code instant()} is advanced explicitly by the test sleeper. */ + static final class MutableClock extends Clock { + private Instant instant; + + MutableClock(Instant start) { + this.instant = start; + } + + Sleeper sleeper() { + return duration -> instant = instant.plus(duration); + } + + @Override + public Instant instant() { + return instant; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyScopeTest.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyScopeTest.java new file mode 100644 index 00000000..08cdcd43 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/IdempotencyScopeTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.application.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class IdempotencyScopeTest { + + @Test + void tripleScopeHasNoTenantDimension() { + IdempotencyScope scope = IdempotencyScope.of("user-1", "key-1", "RegisterUserUseCase"); + assertThat(scope.isTenantScoped()).isFalse(); + assertThat(scope.tenant()).isNull(); + assertThat(scope.storageKey()).isEqualTo("user-1::key-1::RegisterUserUseCase"); + } + + @Test + void tenantScopePrependsTheTenantDimension() { + IdempotencyScope scope = IdempotencyScope.of("acme", "user-1", "key-1", "RegisterUserUseCase"); + assertThat(scope.isTenantScoped()).isTrue(); + assertThat(scope.storageKey()).isEqualTo("acme::user-1::key-1::RegisterUserUseCase"); + } + + @Test + void blankTenantCollapsesToSingleTenantTriple() { + IdempotencyScope scope = IdempotencyScope.of(" ", "user-1", "key-1", "RegisterUserUseCase"); + assertThat(scope.isTenantScoped()).isFalse(); + } + + @Test + void missingPrincipalIsRejectedToPreventGlobalCollision() { + // branch-note §실패 모드: a scope-less key would replay another caller's response. + assertThatThrownBy(() -> IdempotencyScope.of(" ", "key-1", "UseCase")) + .isInstanceOf(IdempotencyScopeMissingException.class) + .satisfies( + e -> + assertThat(((IdempotencyScopeMissingException) e).missingDimension()) + .isEqualTo("principal")); + } + + @Test + void missingIdempotencyKeyIsRejected() { + assertThatThrownBy(() -> IdempotencyScope.of("user-1", null, "UseCase")) + .isInstanceOf(IdempotencyScopeMissingException.class); + } + + @Test + void missingUseCaseNameIsRejected() { + assertThatThrownBy(() -> IdempotencyScope.of("user-1", "key-1", "")) + .isInstanceOf(IdempotencyScopeMissingException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/idempotency/RequestFingerprintTest.java b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/RequestFingerprintTest.java new file mode 100644 index 00000000..26921620 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/idempotency/RequestFingerprintTest.java @@ -0,0 +1,47 @@ +package dev.caskeleton.application.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class RequestFingerprintTest { + + @Test + void sameBytesProduceTheSameFingerprint() { + RequestFingerprint a = + RequestFingerprint.ofSha256("{\"x\":1}".getBytes(StandardCharsets.UTF_8)); + RequestFingerprint b = + RequestFingerprint.ofSha256("{\"x\":1}".getBytes(StandardCharsets.UTF_8)); + assertThat(a).isEqualTo(b); + } + + @Test + void differentBytesProduceDifferentFingerprints() { + RequestFingerprint a = + RequestFingerprint.ofSha256("{\"x\":1}".getBytes(StandardCharsets.UTF_8)); + RequestFingerprint b = + RequestFingerprint.ofSha256("{\"x\":2}".getBytes(StandardCharsets.UTF_8)); + assertThat(a).isNotEqualTo(b); + } + + @Test + void digestIsAKnownSha256Vector() { + // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + assertThat(RequestFingerprint.ofSha256(new byte[0]).hex()) + .isEqualTo("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + } + + @Test + void nullBodyIsTreatedAsEmptyBody() { + assertThat(RequestFingerprint.ofSha256(null)) + .isEqualTo(RequestFingerprint.ofSha256(new byte[0])); + } + + @Test + void hexMustBe64Chars() { + assertThatThrownBy(() -> new RequestFingerprint("deadbeef")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/lock/DistributedLockPortContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/lock/DistributedLockPortContractTest.java new file mode 100644 index 00000000..9e029c7f --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/lock/DistributedLockPortContractTest.java @@ -0,0 +1,196 @@ +package dev.caskeleton.application.lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import org.junit.jupiter.api.Test; + +/** + * Contract-shape test for {@link DistributedLockPort} and {@link DistributedLock}. + * + * <p>Uses an in-test fake backed by {@link ReentrantLock} to prove: + * + * <ol> + * <li>{@code tryAcquire} returns a non-null handle. + * <li>{@code close()} releases the lock so a subsequent acquire on the same key succeeds. + * <li>A try-finally release pattern demonstrates the D4 ordering (work inside try, release in + * finally after work completes). + * <li>A second attempt while the key is held times out and throws {@link + * LockAcquisitionTimeoutException} carrying the right key. + * </ol> + */ +class DistributedLockPortContractTest { + + private static final Duration SHORT_WAIT = Duration.ofMillis(50); + private static final Duration LEASE_TTL = Duration.ofSeconds(30); + + // ----------------------------------------------------------------------- + // Fake implementation — kept in-test to avoid polluting production sources + // ----------------------------------------------------------------------- + + /** + * In-test fake backed by {@link ReentrantLock} (non-reentrant mode enforced via {@link + * ReentrantLock#tryLock(long, TimeUnit)} with the supplied waitTime). + * + * <p>TTL is not enforced by the in-process fake (TTL enforcement is the JDBC registry adapter's + * responsibility). The leaseTtl parameter is accepted but ignored here, mirroring the fact that + * the port surface carries it as a contract hint to the adapter. + */ + private static final class FakeDistributedLockPort implements DistributedLockPort { + + private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>(); + + @Override + public DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl) { + ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock()); + boolean acquired; + try { + acquired = lock.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LockAcquisitionTimeoutException(key, waitTime); + } + if (!acquired) { + throw new LockAcquisitionTimeoutException(key, waitTime); + } + return () -> lock.unlock(); + } + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + @Test + void acquireReturnsNonNullHandle() { + DistributedLockPort port = new FakeDistributedLockPort(); + DistributedLock lock = port.tryAcquire("test-key", SHORT_WAIT, LEASE_TTL); + + assertThat(lock).isNotNull(); + lock.close(); // release to avoid resource leak + } + + @Test + void closeReleasesLockSoSubsequentAcquireSucceeds() { + DistributedLockPort port = new FakeDistributedLockPort(); + + DistributedLock first = port.tryAcquire("orders/1", SHORT_WAIT, LEASE_TTL); + first.close(); // release + + // second acquire on the same key must succeed after release + DistributedLock second = port.tryAcquire("orders/1", SHORT_WAIT, LEASE_TTL); + assertThat(second).isNotNull(); + second.close(); + } + + /** + * Demonstrates the D4 ordering invariant: the lock handle is released AFTER the protected work + * completes (represented here by a side-effect list), not before. + * + * <p>Canonical pattern (from port Javadoc): + * + * <pre> + * DistributedLock lock = port.tryAcquire(key, waitTime, leaseTtl); + * try { + * txPort.inWrite(() -> { ... protected work ... }); // commit returns here + * } finally { + * lock.close(); // release AFTER commit + * } + * </pre> + */ + @Test + void tryFinallyReleaseAfterWorkDemonstratesD4Ordering() { + DistributedLockPort port = new FakeDistributedLockPort(); + List<String> events = new ArrayList<>(); + + DistributedLock lock = port.tryAcquire("payment/99", SHORT_WAIT, LEASE_TTL); + try { + events.add("work-done"); // represents: tx.inWrite(() -> { ... }) + } finally { + lock.close(); // release AFTER work (D4 ordering) + events.add("lock-released"); + } + + // work must complete before release + assertThat(events).containsExactly("work-done", "lock-released"); + } + + /** + * While the main thread holds the lock, a second attempt on the same key (same thread — + * ReentrantLock is non-reentrant when used via tryLock with a zero-ish wait) must time out and + * throw {@link LockAcquisitionTimeoutException} carrying the correct key. + * + * <p>We hold the lock on the main thread and call tryAcquire again immediately with a very short + * waitTime. Because the fake uses a {@link ReentrantLock} and the second attempt uses a + * <em>different</em> {@link ReentrantLock#tryLock(long, TimeUnit)} call (not the re-entrant + * path), this is deterministic without needing a second thread. + * + * <p>Note: {@link ReentrantLock} IS re-entrant by design, so for true non-reentrancy the fake + * uses a second thread here to keep the test deterministic. + */ + @Test + void timeoutWhileKeyIsHeldThrowsLockAcquisitionTimeoutExceptionWithCorrectKey() + throws InterruptedException { + + FakeDistributedLockPort port = new FakeDistributedLockPort(); + String key = "inventory/sku-99"; + + // Hold the lock in a background thread until the main thread's test completes. + CountDownLatch held = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + Thread holder = + new Thread( + () -> { + DistributedLock lock = port.tryAcquire(key, SHORT_WAIT, LEASE_TTL); + held.countDown(); // signal: lock is held + try { + release.await(); // wait until main thread is done asserting + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + lock.close(); + } + }, + "lock-holder"); + holder.start(); + + held.await(); // wait for the holder thread to acquire the lock + + // Now try to acquire from the main thread — must time out + assertThatThrownBy(() -> port.tryAcquire(key, SHORT_WAIT, LEASE_TTL)) + .isInstanceOf(LockAcquisitionTimeoutException.class) + .satisfies( + ex -> { + LockAcquisitionTimeoutException timeout = (LockAcquisitionTimeoutException) ex; + assertThat(timeout.key()).isEqualTo(key); + assertThat(timeout.waitTime()).isEqualTo(SHORT_WAIT); + }); + + release.countDown(); // unblock the holder thread + holder.join(1_000); // wait for holder to finish cleanly + } + + @Test + void tryWithResourcesCompilesAndReleases() { + DistributedLockPort port = new FakeDistributedLockPort(); + + // DistributedLock extends AutoCloseable — must compile with try-with-resources + try (DistributedLock ignored = port.tryAcquire("key", SHORT_WAIT, LEASE_TTL)) { + // work + } + + // after the try-with-resources block, the lock must be released + // so a new acquire succeeds + DistributedLock second = port.tryAcquire("key", SHORT_WAIT, LEASE_TTL); + assertThat(second).isNotNull(); + second.close(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/lock/LockAcquisitionTimeoutExceptionTest.java b/src/application-core/src/test/java/dev/caskeleton/application/lock/LockAcquisitionTimeoutExceptionTest.java new file mode 100644 index 00000000..811d5a32 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/lock/LockAcquisitionTimeoutExceptionTest.java @@ -0,0 +1,52 @@ +package dev.caskeleton.application.lock; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.OperationalError; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class LockAcquisitionTimeoutExceptionTest { + + @Test + void carriesKeyAndWaitTime() { + Duration waitTime = Duration.ofMillis(50); + LockAcquisitionTimeoutException ex = new LockAcquisitionTimeoutException("orders/42", waitTime); + + assertThat(ex.key()).isEqualTo("orders/42"); + assertThat(ex.waitTime()).isEqualTo(waitTime); + } + + @Test + void errorCodeIsLOCKACQUISITIONTIMEOUT() { + LockAcquisitionTimeoutException ex = + new LockAcquisitionTimeoutException("orders/42", Duration.ofMillis(50)); + + assertThat(ex.errorCode()).isEqualTo(OperationalError.LOCK_ACQUISITION_TIMEOUT); + } + + @Test + void messageContainsKey() { + LockAcquisitionTimeoutException ex = + new LockAcquisitionTimeoutException("orders/42", Duration.ofSeconds(3)); + + assertThat(ex.getMessage()).contains("orders/42"); + } + + @Test + void messageContainsWaitTime() { + Duration waitTime = Duration.ofMillis(500); + LockAcquisitionTimeoutException ex = new LockAcquisitionTimeoutException("some-key", waitTime); + + // Duration.toString() produces ISO-8601 form e.g. "PT0.5S" + assertThat(ex.getMessage()).contains(waitTime.toString()); + } + + @Test + void isARuntimeException() { + LockAcquisitionTimeoutException ex = + new LockAcquisitionTimeoutException("k", Duration.ofSeconds(1)); + + assertThat(ex).isInstanceOf(RuntimeException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortContractTest.java new file mode 100644 index 00000000..29330f3a --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortContractTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Contract test for the application-core notification types: {@link Channel}, {@link Notification}, + * and {@link NotificationPort}. + * + * <p>Verifies: + * + * <ul> + * <li>{@link Notification} compact constructor rejects nulls (invariant). + * <li>{@link NotificationPort#notify(Channel, Notification)} delegates to the two-arg overload + * with route="default". + * <li>{@link Channel} enum values are stable (EMAIL, SLACK). + * <li>No Spring/JPA/HTTP import is needed — pure unit test. + * </ul> + */ +class NotificationPortContractTest { + + /** Fake port that records calls for assertion. */ + private static final class RecordingPort implements NotificationPort { + + record Call(Channel channel, String route, Notification notification) {} + + final List<Call> calls = new ArrayList<>(); + + @Override + public void notify(Channel channel, String route, Notification notification) { + calls.add(new Call(channel, route, notification)); + } + } + + @Test + void notificationRejectsNullRecipient() { + assertThatNullPointerException() + .isThrownBy(() -> new Notification(null, "subject", "body")) + .withMessageContaining("recipient"); + } + + @Test + void notificationRejectsNullSubject() { + assertThatNullPointerException() + .isThrownBy(() -> new Notification("me@example.com", null, "body")) + .withMessageContaining("subject"); + } + + @Test + void notificationRejectsNullBody() { + assertThatNullPointerException() + .isThrownBy(() -> new Notification("me@example.com", "subject", null)) + .withMessageContaining("body"); + } + + @Test + void notificationConstructsCorrectlyWithValidFields() { + Notification n = new Notification("me@example.com", "Hello", "World"); + assertThat(n.recipient()).isEqualTo("me@example.com"); + assertThat(n.subject()).isEqualTo("Hello"); + assertThat(n.body()).isEqualTo("World"); + } + + @Test + void channelEnumHasEmailAndSlack() { + assertThat(Channel.values()).containsExactlyInAnyOrder(Channel.EMAIL, Channel.SLACK); + } + + @Test + void defaultNotifyDelegatesToRouteDefault() { + RecordingPort port = new RecordingPort(); + Notification n = new Notification("me@example.com", "s", "b"); + + port.notify(Channel.EMAIL, n); + + assertThat(port.calls).hasSize(1); + assertThat(port.calls.get(0).channel()).isEqualTo(Channel.EMAIL); + assertThat(port.calls.get(0).route()).isEqualTo("default"); + assertThat(port.calls.get(0).notification()).isSameAs(n); + } + + @Test + void explicitRouteOverloadPassesThroughTheRouteName() { + RecordingPort port = new RecordingPort(); + Notification n = new Notification("me@example.com", "s", "b"); + + port.notify(Channel.SLACK, "alerts", n); + + assertThat(port.calls).hasSize(1); + assertThat(port.calls.get(0).route()).isEqualTo("alerts"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/NewOutboxEventTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/NewOutboxEventTest.java new file mode 100644 index 00000000..57d41f05 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/NewOutboxEventTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.application.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Validates {@link NewOutboxEvent} required-field invariants (D12). */ +class NewOutboxEventTest { + + private static final Instant OCCURRED_AT = Instant.parse("2026-06-11T10:00:00Z"); + + @Test + void validEventConstructsSuccessfully() { + NewOutboxEvent event = + new NewOutboxEvent("evt-1", "UserCreated", "agg-1", "{}", OCCURRED_AT, "corr-1", "ikey-1"); + assertThat(event.eventId()).isEqualTo("evt-1"); + assertThat(event.eventType()).isEqualTo("UserCreated"); + assertThat(event.aggregateId()).isEqualTo("agg-1"); + assertThat(event.payload()).isEqualTo("{}"); + assertThat(event.occurredAt()).isEqualTo(OCCURRED_AT); + assertThat(event.correlationId()).isEqualTo("corr-1"); + assertThat(event.idempotencyKey()).isEqualTo("ikey-1"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void nullOrBlankEventIdIsRejected(String bad) { + assertThatThrownBy( + () -> + new NewOutboxEvent( + bad, "UserCreated", "agg-1", "{}", OCCURRED_AT, "corr-1", "ikey-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("eventId"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void nullOrBlankEventTypeIsRejected(String bad) { + assertThatThrownBy( + () -> new NewOutboxEvent("evt-1", bad, "agg-1", "{}", OCCURRED_AT, "corr-1", "ikey-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("eventType"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void nullOrBlankAggregateIdIsRejected(String bad) { + assertThatThrownBy( + () -> + new NewOutboxEvent( + "evt-1", "UserCreated", bad, "{}", OCCURRED_AT, "corr-1", "ikey-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aggregateId"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void nullOrBlankPayloadIsRejected(String bad) { + assertThatThrownBy( + () -> + new NewOutboxEvent( + "evt-1", "UserCreated", "agg-1", bad, OCCURRED_AT, "corr-1", "ikey-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payload"); + } + + @Test + void nullOccurredAtIsRejected() { + assertThatThrownBy( + () -> + new NewOutboxEvent("evt-1", "UserCreated", "agg-1", "{}", null, "corr-1", "ikey-1")) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("occurredAt"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void nullOrBlankCorrelationIdIsRejected(String bad) { + assertThatThrownBy( + () -> + new NewOutboxEvent( + "evt-1", "UserCreated", "agg-1", "{}", OCCURRED_AT, bad, "ikey-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("correlationId"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void nullOrBlankIdempotencyKeyIsRejected(String bad) { + assertThatThrownBy( + () -> + new NewOutboxEvent( + "evt-1", "UserCreated", "agg-1", "{}", OCCURRED_AT, "corr-1", bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("idempotencyKey"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxBackoffPolicyTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxBackoffPolicyTest.java new file mode 100644 index 00000000..eb5d074c --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxBackoffPolicyTest.java @@ -0,0 +1,126 @@ +package dev.caskeleton.application.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link OutboxBackoffPolicy}: exponential base, jitter boundaries, and the maxAttempts + * accessor. + */ +class OutboxBackoffPolicyTest { + + private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z"); + + /** Deterministic random that always returns 0.0 — produces zero jitter. */ + private static final RandomGenerator ZERO_RANDOM = + new RandomGenerator() { + @Override + public long nextLong() { + return 0L; + } + + @Override + public double nextDouble() { + return 0.0; + } + }; + + /** + * Deterministic random that returns the largest double strictly less than 1.0 — produces + * near-maximum jitter without touching the boundary. + * + * <p>{@code 1.0 - Double.MIN_VALUE} underflows to {@code 1.0} in double arithmetic (the ULP at + * 1.0 is {@code ~2.2e-16}, far larger than {@code Double.MIN_VALUE ~4.9e-324}). {@link + * Math#nextDown(double)} returns the correct predecessor representable double. + */ + private static final RandomGenerator MAX_RANDOM = + new RandomGenerator() { + @Override + public long nextLong() { + return Long.MAX_VALUE; + } + + @Override + public double nextDouble() { + return Math.nextDown(1.0); + } + }; + + @Test + void maxAttemptsIsThree() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + assertThat(policy.maxAttempts()).isEqualTo(3); + } + + @Test + void firstAttemptBaseDelayIs30sWithZeroJitter() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + Instant next = policy.nextAttemptAt(1, NOW); + // base * 2^(1-1) = 30s * 1 = 30s; jitter = 0 + assertThat(next).isEqualTo(NOW.plusSeconds(30)); + } + + @Test + void secondAttemptBaseDelayIs60sWithZeroJitter() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + Instant next = policy.nextAttemptAt(2, NOW); + // base * 2^(2-1) = 30s * 2 = 60s; jitter = 0 + assertThat(next).isEqualTo(NOW.plusSeconds(60)); + } + + @Test + void thirdAttemptBaseDelayIs120sWithZeroJitter() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + Instant next = policy.nextAttemptAt(3, NOW); + // base * 2^(3-1) = 30s * 4 = 120s; jitter = 0 + assertThat(next).isEqualTo(NOW.plusSeconds(120)); + } + + @Test + void jitterAddsUpToBaseSeconds() { + OutboxBackoffPolicy zeroPolicy = new OutboxBackoffPolicy(ZERO_RANDOM); + OutboxBackoffPolicy maxPolicy = new OutboxBackoffPolicy(MAX_RANDOM); + + Instant zeroNext = zeroPolicy.nextAttemptAt(1, NOW); + Instant maxNext = maxPolicy.nextAttemptAt(1, NOW); + + Duration delta = Duration.between(zeroNext, maxNext); + // Full jitter range is [0, 30s), so delta must be in [0, 30s) + assertThat(delta.toSeconds()).isGreaterThanOrEqualTo(0); + assertThat(delta.toSeconds()).isLessThan(OutboxBackoffPolicy.BASE_DELAY.toSeconds()); + } + + @Test + void nextAttemptAtIsAlwaysAfterNow() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + for (int attempt = 1; attempt <= 5; attempt++) { + assertThat(policy.nextAttemptAt(attempt, NOW)).isAfter(NOW); + } + } + + @Test + void attemptCountZeroIsRejected() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + assertThatThrownBy(() -> policy.nextAttemptAt(0, NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1-based"); + } + + @Test + void nullNowIsRejected() { + OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM); + assertThatThrownBy(() -> policy.nextAttemptAt(1, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void nullRandomIsRejectedAtConstruction() { + assertThatThrownBy(() -> new OutboxBackoffPolicy(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java new file mode 100644 index 00000000..8a2ae949 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java @@ -0,0 +1,395 @@ +package dev.caskeleton.application.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PublishPendingOutboxEventsUseCase} using fake ports. + * + * <p>Covers: + * + * <ul> + * <li>Successful transition: IN_FLIGHT → PUBLISHED + * <li>Transient failure → FAILED + backoff window + * <li>3 attempt exhaustion → DEAD + * <li>Failure is NOT swallowed (status transition + ERROR log required) + * <li>Events processed in {@code occurredAt} ascending order + * </ul> + */ +class PublishPendingOutboxEventsUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z"); + private static final Duration IN_FLIGHT_TIMEOUT = Duration.ofMinutes(5); + private static final int BATCH_SIZE = 10; + + private FakeOutboxStorePort store; + private FakeOutboxMessagePublishPort publishPort; + private FakeTransactionPort tx; + private OutboxBackoffPolicy backoffPolicy; + private Clock clock; + private PublishPendingOutboxEventsUseCase useCase; + + @BeforeEach + void setUp() { + store = new FakeOutboxStorePort(); + publishPort = new FakeOutboxMessagePublishPort(); + tx = new FakeTransactionPort(); + clock = Clock.fixed(NOW, ZoneOffset.UTC); + // Use fixed random for determinism: always returns 0.0 jitter (nextDouble() = 0.0) + backoffPolicy = new OutboxBackoffPolicy(new ZeroRandom()); + useCase = + new PublishPendingOutboxEventsUseCase( + store, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); + } + + // ---- success path ---- + + @Test + void successfulPublishTransitionsEventToPublished() { + OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); + store.addClaimable(event); + + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.claimedCount()).isEqualTo(1); + assertThat(result.outcomes()).hasSize(1); + assertThat(result.outcomes().getFirst().outcome()) + .isEqualTo(OutboxRelayResult.Outcome.PUBLISHED); + assertThat(store.publishedEvents).containsExactly("evt-1"); + assertThat(publishPort.publishedEvents).containsExactly("evt-1"); + } + + @Test + void multipleEventsPublishedInOccurredAtAscendingOrder() { + // Add events out of order to ensure relay sorts defensively + OutboxEvent late = makeEvent("evt-late", "UserUpdated", "agg-1", NOW.minusSeconds(10), 1); + OutboxEvent early = makeEvent("evt-early", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); + store.addClaimable(late); + store.addClaimable(early); + + useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + // The publish port must receive events in occurredAt order (early before late) + assertThat(publishPort.publishedEvents).containsExactly("evt-early", "evt-late"); + } + + // ---- transient failure path ---- + + @Test + void transientFailureTransitionsEventToFailedWithBackoff() { + OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); + store.addClaimable(event); + publishPort.failOn("evt-fail", new RuntimeException("broker down")); + + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.outcomes()).hasSize(1); + assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.FAILED); + // The store must record the FAILED transition with a future nextAttemptAt + assertThat(store.failedEvents).containsKey("evt-fail"); + Instant nextAttemptAt = store.failedEvents.get("evt-fail"); + // backoff = now + 30s * 2^(1-1) = now + 30s (jitter=0 with fixed rng) + assertThat(nextAttemptAt).isAfter(NOW); + // Not yet DEAD (attempt 1 < maxAttempts 3) + assertThat(store.deadEvents).doesNotContain("evt-fail"); + } + + @Test + void failureOnThirdAttemptTransitionsEventToDead() { + // attemptCount=3 means this is the 3rd attempt — next failure should DEAD + OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3); + store.addClaimable(event); + publishPort.failOn("evt-dead", new RuntimeException("persistent broker down")); + + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.DEAD); + assertThat(store.deadEvents).contains("evt-dead"); + assertThat(store.failedEvents).doesNotContainKey("evt-dead"); + } + + // ---- failure NOT swallowed ---- + + @Test + void failureIsNotSwallowedRelayContinuesWithNextEvent() { + // Two events: first fails, second succeeds — both must produce status transitions + OutboxEvent fail = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(120), 1); + OutboxEvent ok = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1); + store.addClaimable(fail); + store.addClaimable(ok); + publishPort.failOn("evt-fail", new RuntimeException("transient")); + + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + // Both events must have outcomes — failure did not cause the relay loop to abort + assertThat(result.claimedCount()).isEqualTo(2); + assertThat(result.outcomes()).hasSize(2); + // First event failed (earlier occurredAt) + OutboxRelayResult.EventOutcome failOutcome = + result.outcomes().stream() + .filter(o -> o.eventId().equals("evt-fail")) + .findFirst() + .orElseThrow(); + assertThat(failOutcome.outcome()).isEqualTo(OutboxRelayResult.Outcome.FAILED); + // Second event succeeded + OutboxRelayResult.EventOutcome okOutcome = + result.outcomes().stream() + .filter(o -> o.eventId().equals("evt-ok")) + .findFirst() + .orElseThrow(); + assertThat(okOutcome.outcome()).isEqualTo(OutboxRelayResult.Outcome.PUBLISHED); + // FAILED event must have a status transition (not silently swallowed) + assertThat(store.failedEvents).containsKey("evt-fail"); + } + + @Test + void failureOnSecondAttemptTransitionsToFailedNotDead() { + OutboxEvent event = makeEvent("evt-2nd", "UserCreated", "agg-1", NOW.minusSeconds(60), 2); + store.addClaimable(event); + publishPort.failOn("evt-2nd", new RuntimeException("2nd attempt failure")); + + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.FAILED); + assertThat(store.failedEvents).containsKey("evt-2nd"); + assertThat(store.deadEvents).doesNotContain("evt-2nd"); + } + + @Test + void emptyBatchReturnsZeroClaimed() { + OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE); + + assertThat(result.claimedCount()).isEqualTo(0); + assertThat(result.outcomes()).isEmpty(); + } + + // ---- markPublished failure — spec §엣지·실패·의존 semantics ---- + + /** + * When {@code store.markPublished} throws after a SUCCESSFUL publish, the exception must + * propagate out of {@code handle()} rather than being caught and misclassified as a publish + * failure (which would trigger FAILED/DEAD state machine and potentially dead-letter a + * successfully-delivered event). + * + * <p>Contract (spec §엣지·실패·의존): + * + * <ul> + * <li>The exception propagates — it is NOT swallowed inside {@code publishOne}. + * <li>{@code markFailed} is NOT called for the event (no misclassification). + * <li>{@code markDead} is NOT called for the event (no misclassification). + * <li>{@code publishPort.publish} was called exactly once. + * <li>The row remains IN_FLIGHT and is recovered via the orphan visibility-timeout reclaim path + * on the next tick — re-published → duplicate absorbed by consumer dedupe (at-least-once). + * </ul> + */ + @Test + void markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure() { + OutboxEvent event = + makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); + + ThrowingOnMarkPublishedStorePort throwingStore = + new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished")); + throwingStore.addClaimable(event); + + PublishPendingOutboxEventsUseCase useCaseWithThrowingStore = + new PublishPendingOutboxEventsUseCase( + throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); + + // The exception must propagate — handle() must throw. + assertThatThrownBy( + () -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("DB down on markPublished"); + + // publish was called exactly once — the broker call succeeded. + assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail"); + + // No misclassification: the event must NOT be marked FAILED or DEAD. + assertThat(throwingStore.failedEvents) + .as("markFailed must NOT be called when only markPublished fails") + .doesNotContainKey("evt-store-fail"); + assertThat(throwingStore.deadEvents) + .as("markDead must NOT be called when only markPublished fails") + .doesNotContain("evt-store-fail"); + } + + /** + * In a multi-event batch where the first event's markPublished fails, the exception propagates + * out of handle(), aborting the remaining batch for that tick. The second event must NOT have + * been published (no partial-batch silent swallow). + * + * <p>Trade-off (spec §엣지·실패·의존 note): if the DB is failing during status updates, subsequent + * markPublished calls would fail too — aborting the batch is acceptable. The next tick retries + * all IN_FLIGHT orphans after the visibility timeout. + */ + @Test + void markPublishedFailureAbortsRemainingBatchForCurrentTick() { + OutboxEvent first = makeEvent("evt-first", "UserCreated", "agg-1", NOW.minusSeconds(120), 1); + OutboxEvent second = makeEvent("evt-second", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1); + + ThrowingOnMarkPublishedStorePort throwingStore = + new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished")); + throwingStore.addClaimable(first); + throwingStore.addClaimable(second); + + PublishPendingOutboxEventsUseCase useCaseWithThrowingStore = + new PublishPendingOutboxEventsUseCase( + throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); + + assertThatThrownBy( + () -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("DB down on markPublished"); + + // First event (earlier occurredAt) must have been published to the broker, + // and the second event must NOT have been published (no partial-batch silent swallow). + assertThat(publishPort.publishedEvents).containsExactly("evt-first"); + + // No FAILED or DEAD misclassification for the first event. + assertThat(throwingStore.failedEvents).doesNotContainKey("evt-first"); + assertThat(throwingStore.deadEvents).doesNotContain("evt-first"); + + // No FAILED or DEAD misclassification for the second event either. + assertThat(throwingStore.failedEvents).doesNotContainKey("evt-second"); + assertThat(throwingStore.deadEvents).doesNotContain("evt-second"); + } + + // ---- helper ---- + + private static OutboxEvent makeEvent( + String eventId, String eventType, String aggregateId, Instant occurredAt, int attemptCount) { + return new OutboxEvent( + eventId, + eventType, + aggregateId, + "{\"data\": \"test\"}", + occurredAt, + "corr-" + eventId, + "ikey-" + eventId, + OutboxEventStatus.IN_FLIGHT, + attemptCount); + } + + // ---- test doubles ---- + + static class FakeOutboxStorePort implements OutboxStorePort { + final List<OutboxEvent> claimable = new ArrayList<>(); + final List<String> publishedEvents = new ArrayList<>(); + final Map<String, Instant> failedEvents = new LinkedHashMap<>(); + final List<String> deadEvents = new ArrayList<>(); + + void addClaimable(OutboxEvent event) { + claimable.add(event); + } + + @Override + public List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout) { + return List.copyOf(claimable); + } + + @Override + public void markPublished(String eventId) { + publishedEvents.add(eventId); + } + + @Override + public void markFailed(String eventId, Instant nextAttemptAt) { + failedEvents.put(eventId, nextAttemptAt); + } + + @Override + public void markDead(String eventId) { + deadEvents.add(eventId); + } + + @Override + public Map<OutboxEventStatus, Long> countByStatus() { + return Map.of(); + } + + @Override + public Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now) { + return Map.of(); + } + } + + /** + * Extends {@link FakeOutboxStorePort}, overriding only {@link #markPublished} to throw, + * simulating a DB-down scenario after a successful broker publish. All other operations — + * claimBatch, markFailed, markDead, countByStatus, oldestUnpublishedAgeSecondsByEventType — are + * inherited unchanged. + */ + static final class ThrowingOnMarkPublishedStorePort extends FakeOutboxStorePort { + private final RuntimeException markPublishedEx; + + ThrowingOnMarkPublishedStorePort(RuntimeException markPublishedEx) { + this.markPublishedEx = markPublishedEx; + } + + @Override + public void markPublished(String eventId) { + throw markPublishedEx; + } + } + + static final class FakeOutboxMessagePublishPort implements OutboxMessagePublishPort { + final List<String> publishedEvents = new ArrayList<>(); + private final Map<String, RuntimeException> failureMap = new LinkedHashMap<>(); + + void failOn(String eventId, RuntimeException ex) { + failureMap.put(eventId, ex); + } + + @Override + public void publish(OutboxEvent event) { + if (failureMap.containsKey(event.eventId())) { + throw failureMap.get(event.eventId()); + } + publishedEvents.add(event.eventId()); + } + } + + static final class FakeTransactionPort implements TransactionPort { + @Override + public <T> T inWrite(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inRead(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inNew(Supplier<T> action) { + return action.get(); + } + } + + /** Deterministic RandomGenerator that always returns 0 — produces zero jitter. */ + static final class ZeroRandom implements RandomGenerator { + @Override + public long nextLong() { + return 0L; + } + + @Override + public double nextDouble() { + return 0.0; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/security/AuthorizationContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/security/AuthorizationContractTest.java new file mode 100644 index 00000000..063f1198 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/security/AuthorizationContractTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.application.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.security.Permission; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AuthorizationContractTest { + + @Test + void principalDefendsAgainstNullRoles() { + AuthorizationPrincipal p = new AuthorizationPrincipal("sub-1", null); + + assertThat(p.subject()).isEqualTo("sub-1"); + assertThat(p.roles()).isEmpty(); + } + + @Test + void principalRolesAreAnUnmodifiableCopy() { + Set<String> mutable = new HashSet<>(Set.of("admin")); + AuthorizationPrincipal p = new AuthorizationPrincipal("sub-1", mutable); + + mutable.add("user"); // must not bleed into the principal + assertThat(p.roles()).containsExactly("admin"); + assertThatThrownBy(() -> p.roles().add("hacker")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void deniedExceptionCarriesRequiredPermissionAndSubject() { + Permission required = Permission.parse("worklog:close"); + AuthorizationDeniedException ex = new AuthorizationDeniedException("sub-1", required); + + assertThat(ex.requiredPermission()).isEqualTo(required); + assertThat(ex.subject()).isEqualTo("sub-1"); + assertThat(ex.getMessage()).contains("worklog:close").contains("sub-1"); + } + + @Test + void requiresPermissionAnnotationIsRuntimeAndTargetsTypeOrMethod() { + Retention retention = RequiresPermission.class.getAnnotation(Retention.class); + Target target = RequiresPermission.class.getAnnotation(Target.class); + + assertThat(retention.value()).isEqualTo(RetentionPolicy.RUNTIME); + assertThat(target.value()).containsExactlyInAnyOrder(ElementType.TYPE, ElementType.METHOD); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/IsolationTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/IsolationTest.java new file mode 100644 index 00000000..2696cf6c --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/IsolationTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.application.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * feature-transaction-concurrency-contract D3 / §구현 가이드 1 — isolation vocabulary policy. + * + * <p>This branch owns the {@link Isolation} vocabulary. {@code READ_COMMITTED} is the pinned + * default (every {@code SpringTransactionPort} template pins it explicitly — see {@code + * SpringTransactionPortTest}); {@code REPEATABLE_READ}/{@code SERIALIZABLE} are the explicit opt-in + * levels for write-heavy / read-consistency use cases; {@code READ_UNCOMMITTED} is forbidden and + * therefore MUST NOT be a DECLARED constant. + * + * <p>Wiring these stricter levels through the {@code TransactionPort} call path is a joint change + * with {@code feature-application-port-usecase-contract} (the abstraction owner) and stays {@code + * planned}; this test pins only the vocabulary surface this branch ships. + */ +class IsolationTest { + + private static final List<String> DECLARED = + Arrays.stream(Isolation.values()).map(Enum::name).toList(); + + @Test + void readCommittedIsThePinnedDefaultLevel() { + assertThat(DECLARED).contains("READ_COMMITTED"); + } + + @Test + void stricterLevelsAreAvailableForExplicitOptIn() { + assertThat(DECLARED) + .as("write-heavy / read-consistency use cases pin REPEATABLE_READ or SERIALIZABLE") + .contains("REPEATABLE_READ", "SERIALIZABLE"); + } + + @Test + void readUncommittedIsForbiddenAndNeverDeclared() { + assertThat(DECLARED) + .as("READ_UNCOMMITTED is forbidden — it must not be a DECLARED constant") + .doesNotContain("READ_UNCOMMITTED"); + } + + @Test + void onlyTheThreeContractedLevelsExist() { + assertThat(Stream.of(Isolation.values())) + .as("the exposed isolation surface is exactly the contracted three levels") + .hasSize(3); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java new file mode 100644 index 00000000..0281bc95 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.application.transaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class TransactionPortTest { + + @Test + void runnableInWriteDelegatesToSupplierInWrite() { + RecordingTransactionPort port = new RecordingTransactionPort(); + List<String> sideEffect = new ArrayList<>(); + + port.inWrite(() -> sideEffect.add("ran")); + + assertThat(port.invocations).containsExactly(TransactionMode.WRITE); + assertThat(sideEffect).containsExactly("ran"); + } + + @Test + void runnableInReadDelegatesToSupplierInRead() { + RecordingTransactionPort port = new RecordingTransactionPort(); + List<String> sideEffect = new ArrayList<>(); + + port.inRead(() -> sideEffect.add("ran")); + + assertThat(port.invocations).containsExactly(TransactionMode.READ_ONLY); + assertThat(sideEffect).containsExactly("ran"); + } + + @Test + void runnableInNewDelegatesToSupplierInNew() { + RecordingTransactionPort port = new RecordingTransactionPort(); + List<String> sideEffect = new ArrayList<>(); + + port.inNew(() -> sideEffect.add("ran")); + + assertThat(port.invocations).containsExactly(TransactionMode.REQUIRES_NEW); + assertThat(sideEffect).containsExactly("ran"); + } + + @Test + void supplierInWriteReturnsActionValue() { + RecordingTransactionPort port = new RecordingTransactionPort(); + + String result = port.inWrite(() -> "v"); + + assertThat(result).isEqualTo("v"); + assertThat(port.invocations).containsExactly(TransactionMode.WRITE); + } + + private static final class RecordingTransactionPort implements TransactionPort { + + private final List<TransactionMode> invocations = new ArrayList<>(); + + @Override + public <T> T inWrite(Supplier<T> action) { + invocations.add(TransactionMode.WRITE); + return action.get(); + } + + @Override + public <T> T inRead(Supplier<T> action) { + invocations.add(TransactionMode.READ_ONLY); + return action.get(); + } + + @Override + public <T> T inNew(Supplier<T> action) { + invocations.add(TransactionMode.REQUIRES_NEW); + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/usecase/UseCaseContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/usecase/UseCaseContractTest.java new file mode 100644 index 00000000..3ea6a0cb --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/usecase/UseCaseContractTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.query.Query; +import dev.caskeleton.application.transaction.TransactionMode; +import org.junit.jupiter.api.Test; + +class UseCaseContractTest { + + @Test + void commandUseCaseImplementsUseCaseWithCommandInput() { + assertThat(UseCase.class.isAssignableFrom(CommandUseCase.class)).isTrue(); + assertThat(new RegisterUseCase().handle(new RegisterCommand("alice"))) + .isEqualTo("registered:alice"); + } + + @Test + void queryUseCaseImplementsUseCaseWithQueryInput() { + assertThat(UseCase.class.isAssignableFrom(QueryUseCase.class)).isTrue(); + assertThat(new FindUseCase().handle(new FindQuery("alice"))).isEqualTo("found:alice"); + } + + record RegisterCommand(String name) implements Command {} + + record FindQuery(String name) implements Query {} + + @UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) + static final class RegisterUseCase implements CommandUseCase<RegisterCommand, String> { + @Override + public String handle(RegisterCommand input) { + return "registered:" + input.name(); + } + } + + @UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) + static final class FindUseCase implements QueryUseCase<FindQuery, String> { + @Override + public String handle(FindQuery input) { + return "found:" + input.name(); + } + } +} diff --git a/src/build.gradle b/src/build.gradle new file mode 100644 index 00000000..9fbd646b --- /dev/null +++ b/src/build.gradle @@ -0,0 +1,1133 @@ +import groovy.json.JsonSlurper +import org.gradle.api.artifacts.dsl.LockMode +import org.gradle.api.tasks.bundling.AbstractArchiveTask +import org.gradle.api.tasks.bundling.Jar + +plugins { + id 'org.springframework.boot' version '4.0.0' apply false + id 'io.spring.dependency-management' version '1.1.6' apply false + // feature-static-analysis-quality-contract — static analysis / code quality baseline. + id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format) + id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs) + id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker +} + +// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer +// release coordinate plus the source revision that produced it. The MAJOR.MINOR.PATCH base can be +// supplied with -PreleaseVersion or RELEASE_VERSION. The revision can be supplied with +// -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git commit. +String releaseVersion = providers.gradleProperty('releaseVersion') + .orElse(providers.environmentVariable('RELEASE_VERSION')) + .getOrElse('0.0.1') +if (!(releaseVersion ==~ /\d+\.\d+\.\d+/)) { + throw new GradleException( + "releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '${releaseVersion}'.") +} + +def localGitRevision = providers.exec { + commandLine 'git', 'rev-parse', '--short=12', 'HEAD' + ignoreExitValue = true +}.standardOutput.asText.map { it.trim() } +String sourceRevision = providers.gradleProperty('gitRevision') + .orElse(providers.environmentVariable('GIT_SHA')) + .orElse(providers.environmentVariable('GITHUB_SHA')) + .orElse(localGitRevision) + .getOrElse('') +if (!(sourceRevision ==~ /(?i)[0-9a-f]{7,40}/)) { + throw new GradleException( + "A 7-40 character hexadecimal source revision is required; use -PgitRevision=<sha> when Git metadata is unavailable.") +} +sourceRevision = sourceRevision.toLowerCase(Locale.ROOT).take(12) +String traceableVersion = "${releaseVersion}+${sourceRevision}" + +ext.releaseVersion = releaseVersion +ext.sourceRevision = sourceRevision +ext.traceableVersion = traceableVersion + +// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or +// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the +// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as +// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the +// strict-locking blast radius to the grpc module alone. +ext.grpcVersion = '1.68.1' +ext.protobufVersion = '3.25.5' + +// Outbound objectstorage adapter (adapter:outbound:objectstorage) — the Spring Boot BOM does NOT +// manage software.amazon.awssdk:* versions, and this repo has no version catalog. Pin the AWS SDK +// v2 BOM here as the single SSOT so the objectstorage module imports software.amazon.awssdk:bom as +// a platform at MODULE scope (not the shared dependencyManagement block below) — mirroring the grpc +// approach above and keeping the strict-locking blast radius to the objectstorage module alone. +ext.awsSdkVersion = '2.30.0' + +Closure<Boolean> isTraceableArchiveFor = { Jar archiveTask, String fileName -> + String baseName = java.util.regex.Pattern.quote(archiveTask.archiveBaseName.get()) + String classifier = archiveTask.archiveClassifier.orNull + String classifierPart = classifier == null || classifier.isBlank() + ? '' + : "-${java.util.regex.Pattern.quote(classifier)}" + fileName ==~ /^${baseName}-\d+\.\d+\.\d+\+[0-9a-f]{7,40}${classifierPart}\.jar$/ +} + +Closure<List<File>> staleTraceableArchivesFor = { Jar archiveTask -> + File outputDir = archiveTask.destinationDirectory.get().asFile + if (!outputDir.isDirectory()) { + return [] + } + + String currentName = archiveTask.archiveFileName.get() + List<File> stale = outputDir.listFiles({ File ignored, String fileName -> + isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName + } as FilenameFilter)?.toList() ?: [] + stale.sort { it.name } +} + +allprojects { + group = 'dev.caskeleton' + version = rootProject.ext.traceableVersion + + repositories { + mavenCentral() + } +} + +configure(subprojects.findAll { it.childProjects.isEmpty() }) { + apply plugin: 'java' + apply plugin: 'io.spring.dependency-management' + + // feature-static-analysis-quality-contract — apply the static analysis baseline to every + // module (D8: extend the existing subprojects {} block rather than a convention plugin). + apply plugin: 'com.diffplug.spotless' // D1 formatter + apply plugin: 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id) + apply plugin: 'com.github.spotbugs' // D3 bytecode bug finder + apply plugin: 'net.ltgt.errorprone' // D5 compile-time checker + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + // D8 — Gradle-default <project>/gradle.lockfile files are Renovate-compatible. STRICT means a + // missing or stale lock state fails resolution instead of silently selecting a new version. + dependencyLocking { + lockAllConfigurations() + lockMode = LockMode.STRICT + } + + // D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and + // permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes. + tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true + dirPermissions { unix('755') } + filePermissions { unix('644') } + } + + // D1/D9 — a JAR is independently traceable even when copied out of its container/release. + tasks.withType(Jar).configureEach { + manifest { + attributes( + 'Implementation-Version': project.version.toString(), + 'Build-Revision': rootProject.ext.sourceRevision + ) + } + doFirst { + staleTraceableArchivesFor(it).each { File stale -> + logger.lifecycle("${path}: deleting stale traceable archive ${stale.name}") + if (!stale.delete()) { + throw new GradleException("${path}: failed to delete stale traceable archive ${stale}") + } + } + } + } + + // Official Gradle pattern: resolve every resolvable configuration while --write-locks is set. + // This captures transitive compile/test/analysis dependencies, not only direct declarations. + tasks.register('resolveAndLockAll') { + group = 'build setup' + description = 'Resolves every configuration and writes this project\'s dependency lock state.' + notCompatibleWithConfigurationCache('Filters configurations at execution time') + doFirst { + if (!gradle.startParameter.writeDependencyLocks) { + throw new GradleException("${path} requires the --write-locks command-line flag.") + } + } + doLast { + configurations.findAll { it.canBeResolved }.each { it.resolve() } + } + } + + // Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and + // propagates a missing/stale lock entry as a non-zero build failure. + tasks.register('verifyDependencyLocks') { + group = 'verification' + description = 'Resolves every configuration and fails when strict dependency locks drift.' + notCompatibleWithConfigurationCache('Filters configurations at execution time') + doLast { + configurations.findAll { it.canBeResolved }.each { it.resolve() } + } + } + + // Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam + // binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it + // auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here. + tasks.withType(JavaCompile).configureEach { + options.compilerArgs << '-parameters' + options.errorprone { + disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5) + } + } + + // D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, + // spotlessCheck (wired into check) verifies. CI must NEVER run spotlessApply. + spotless { + java { + googleJavaFormat('1.35.0') + importOrder() + removeUnusedImports() + } + } + + // D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML. + checkstyle { + toolVersion = '13.5.0' + configFile = rootProject.file('config/checkstyle/checkstyle.xml') + configDirectory = rootProject.file('config/checkstyle') + ignoreFailures = false + // No warning-tier checks in the default build. Javadoc coverage is a documentation backlog, + // not a signal to print on every migration/build run. + maxWarnings = Integer.MAX_VALUE + } + // §4 routing — checkstyleMain blocking; checkstyleTest warning-only (test-helper exception). + tasks.named('checkstyleTest') { + ignoreFailures = true + } + + // D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below. + // reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings + // block, which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP + // defensive-copy noise on DI'd collaborators). effort left at default (UNSUPPORTED_IMPL_DECISION + // — strictness is a user trade-off; default is functionally valid). Confirmed false positives + // go in config/spotbugs/exclude.xml. + spotbugs { + toolVersion = '4.10.2' + reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') + excludeFilter = rootProject.file('config/spotbugs/exclude.xml') + } + // SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the + // Spring Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management + // overrides resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError. + // Override the BOM-managed version property (the documented Spring mechanism). No production + // module imports commons.lang3, so this only affects the SpotBugs tool classpath in practice. + ext['commons-lang3.version'] = '3.20.0' + // §4 routing — spotbugsMain blocking; spotbugsTest warning-only (test-source trade-off). + tasks.named('spotbugsTest') { + ignoreFailures = true + } + + dependencyManagement { + imports { + mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES + } + } + + dependencies { + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0' // D4 code-level security + errorprone 'com.google.errorprone:error_prone_core:2.49.0' // D5 compile-time checker + } + + // feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky + // quarantine bucket so a quarantined test can never block merge. Quarantined tests carry + // JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking) + // and their 14-day sunset is enforced by verifyQuarantineSunset. With zero quarantined tests + // (the skeleton default) excludeTags is a no-op. + tasks.named('test') { + useJUnitPlatform { + excludeTags 'quarantine' + } + } + + // feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY + // @Tag("quarantine") tests, isolated from `check`, never blocking the build (ignoreFailures). + // failOnNoDiscoveredTests=false so the empty bucket (skeleton ships zero flaky tests) passes. + tasks.register('quarantineTest', Test) { + group = 'verification' + description = 'Flaky-test quarantine bucket (feature-ci-quality-gates-contract §4): runs only ' + + '@Tag("quarantine") tests, non-blocking, isolated from the release gate.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'quarantine' + } + ignoreFailures = true + failOnNoDiscoveredTests = false + // Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result. + outputs.upToDateWhen { false } + // Pin UTC like the main test task for host-locale independence. + jvmArgs '-Duser.timezone=UTC' + } + + tasks.named('check') { + dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies') + dependsOn rootProject.tasks.named('verifyEnvKeys') + dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars') + dependsOn rootProject.tasks.named('verifyOneTypePerFile') + dependsOn rootProject.tasks.named('verifyTrivyignore') + dependsOn rootProject.tasks.named('verifyQuarantineSunset') + } +} + +// One explicit command regenerates every module's Gradle-default lockfile. +tasks.register('resolveAndLockAll') { + group = 'build setup' + description = 'Regenerates dependency locks for all subprojects (requires --write-locks).' + dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('resolveAndLockAll') } +} + +tasks.register('verifyDependencyLocks') { + group = 'verification' + description = 'Verifies strict dependency lock state for all subprojects.' + dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('verifyDependencyLocks') } +} + +tasks.register('cleanStaleTraceableJars') { + group = 'build' + description = 'Deletes older git-revision JARs from build/libs so IDE runtime classpaths cannot load stale module artifacts.' + + doLast { + int deleted = 0 + subprojects.each { sub -> + sub.tasks.withType(Jar).each { Jar archiveTask -> + staleTraceableArchivesFor(archiveTask).each { File stale -> + if (!stale.delete()) { + throw new GradleException("cleanStaleTraceableJars: failed to delete ${stale}") + } + deleted++ + logger.lifecycle("cleanStaleTraceableJars: deleted ${stale}") + } + } + } + logger.lifecycle("cleanStaleTraceableJars: deleted ${deleted} stale archive(s).") + } +} + +tasks.register('verifyNoStaleTraceableJars') { + group = 'verification' + description = 'Verifies build/libs does not retain older git-revision JARs that can poison IDE runtime classpaths.' + dependsOn tasks.named('cleanStaleTraceableJars') + + doLast { + List<String> violations = [] + subprojects.each { sub -> + sub.tasks.withType(Jar).each { Jar archiveTask -> + List<String> staleJars = staleTraceableArchivesFor(archiveTask).collect { it.name } + + if (!staleJars.isEmpty()) { + violations << ":${sub.name}:${archiveTask.name}: stale JAR(s) ${staleJars}; current archive is ${archiveTask.archiveFileName.get()}" + } + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyNoStaleTraceableJars: ${violations.size()} module(s) retain old traceable JARs.\n " + + violations.join('\n ')) + } + logger.lifecycle('verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.') + } +} + +// feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a +// separate task so the task name and exit code identify the failed phase without log archaeology. +def repositoryDir = rootProject.projectDir.parentFile +def baseComposeFile = new File(repositoryDir, 'docker-compose.yml') +def localComposeFile = new File(repositoryDir, 'docker-compose.local.yml') +def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath, + '-f', localComposeFile.absolutePath] + +def bootstrapCompile = tasks.register('bootstrapCompile') { + group = 'developer experience' + description = 'Stage 1/5: compiles every main and test source set as a local sanity check.' + dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') } +} + +def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) { + group = 'developer experience' + description = 'Checks that the Docker CLI can reach a running Docker daemon.' + commandLine 'docker', 'info' + ignoreExitValue = true + standardOutput = new ByteArrayOutputStream() + errorOutput = new ByteArrayOutputStream() + doLast { + if (executionResult.get().exitValue != 0) { + throw new GradleException( + 'bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 ' + + '`docker info`가 성공하는지 확인하세요.\n' + errorOutput.toString()) + } + } +} +bootstrapDockerPreflight.configure { dependsOn bootstrapCompile } + +def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) { + group = 'developer experience' + description = 'Stage 2/5: starts the local PostgreSQL dependency and waits for readiness.' + commandLine composeCommand + ['up', '-d', '--wait', 'db'] +} +bootstrapDependencies.configure { dependsOn bootstrapCompile } +bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight } + +def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) { + group = 'developer experience' + description = 'Stage 3/5: builds/starts the app; startup Flyway must finish before health is ready.' + commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app'] +} +bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies } + +def bootstrapSampleContract = tasks.register('bootstrapSampleContract') { + group = 'developer experience' + description = 'Stage 4/5: verifies the delegated sample production-isolation/build contract.' + dependsOn project(':app-bootstrap').tasks.named('bootstrapSampleContract') +} +bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart } + +def bootstrapSmoke = tasks.register('bootstrapSmoke') { + group = 'developer experience' + description = 'Stage 5/5: requires HTTP 200 and status=UP from GET /api/healthcheck.' + doLast { + URI endpoint = URI.create('http://localhost:8080/api/healthcheck') + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) + String lastFailure = 'no response' + while (System.nanoTime() < deadline) { + HttpURLConnection connection = null + try { + connection = (HttpURLConnection) endpoint.toURL().openConnection() + connection.connectTimeout = 2_000 + connection.readTimeout = 2_000 + connection.requestMethod = 'GET' + int status = connection.responseCode + String body = status >= 200 && status < 400 ? connection.inputStream.text : + connection.errorStream?.text + if (status == 200 && body != null && body.contains('"status":"UP"')) { + logger.lifecycle('bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP.') + return + } + lastFailure = "HTTP ${status}: ${body}" + } catch (IOException ex) { + lastFailure = ex.message + } finally { + connection?.disconnect() + } + sleep(1_000) + } + throw new GradleException( + "bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}") + } +} +bootstrapSmoke.configure { dependsOn bootstrapSampleContract } + +tasks.register('bootstrap') { + group = 'developer experience' + description = 'Runs the complete five-stage local bootstrap contract.' + dependsOn bootstrapSmoke +} + +// feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second +// build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose. +tasks.register('verifyReadmeCommands') { + group = 'verification' + description = 'Verifies README Gradle/Compose/Make commands refer to real tasks, files, and targets.' + + File readmeFile = project.hasProperty('readmeFile') + ? file(project.property('readmeFile')) + : new File(repositoryDir, 'README.md') + inputs.file(readmeFile) + + doLast { + if (!readmeFile.isFile()) { + throw new GradleException("verifyReadmeCommands: missing ${readmeFile}") + } + + List<String> violations = [] + boolean inShellBlock = false + readmeFile.eachLine { String rawLine, int lineNumber -> + String line = rawLine.trim() + if (line == '```bash' || line == '```sh') { + inShellBlock = true + return + } + if (line == '```' && inShellBlock) { + inShellBlock = false + return + } + if (!inShellBlock || line.isEmpty() || line.startsWith('#')) { + return + } + + int gradleIndex = line.indexOf('./gradlew ') + if (gradleIndex >= 0) { + List<String> tokens = line.substring(gradleIndex + './gradlew '.length()) + .split(/\s+/).toList() + List<String> requestedTasks = [] + for (String token : tokens) { + if (token.startsWith('-') || token in ['&&', '||', '|']) { + break + } + requestedTasks << token + } + if (requestedTasks.isEmpty()) { + violations << "${readmeFile}:${lineNumber}: ./gradlew command has no task" + } + requestedTasks.each { String taskPath -> + boolean exists + if (taskPath.startsWith(':') && taskPath.count(':') >= 2) { + int lastSeparator = taskPath.lastIndexOf(':') + String projectPath = taskPath.substring(0, lastSeparator) + String taskName = taskPath.substring(lastSeparator + 1) + Project targetProject = rootProject.findProject(projectPath) + exists = targetProject != null && targetProject.tasks.findByName(taskName) != null + } else { + exists = !rootProject.getTasksByName(taskPath, true).isEmpty() + } + if (!exists) { + violations << "${readmeFile}:${lineNumber}: unknown Gradle task '${taskPath}' in `${line}`" + } + } + } + + if (line.startsWith('docker compose ')) { + List<String> tokens = line.split(/\s+/).toList() + int index = 2 + while (index < tokens.size() && tokens[index].startsWith('-')) { + String option = tokens[index++] + if (option in ['-f', '--file', '--env-file', '-p', '--project-name']) { + if (index >= tokens.size()) { + violations << "${readmeFile}:${lineNumber}: '${option}' has no value in `${line}`" + break + } + String value = tokens[index++] + if (option in ['-f', '--file']) { + File composeFile = new File(repositoryDir, value) + if (!composeFile.isFile()) { + violations << "${readmeFile}:${lineNumber}: missing Compose file '${value}'" + } + } + } + } + Set<String> supported = ['build', 'config', 'down', 'logs', 'ps', 'pull', 'restart', + 'run', 'start', 'stop', 'up'] as Set + if (index >= tokens.size() || !supported.contains(tokens[index])) { + String actual = index < tokens.size() ? tokens[index] : '<missing>' + violations << "${readmeFile}:${lineNumber}: unsupported Compose subcommand '${actual}'" + } + } + + if (line.startsWith('make ')) { + File makefile = new File(repositoryDir, 'Makefile') + String target = line.substring('make '.length()).split(/\s+/)[0] + if (!makefile.isFile()) { + violations << "${readmeFile}:${lineNumber}: make command documented but Makefile is absent" + } else if (!(makefile.text =~ /(?m)^${java.util.regex.Pattern.quote(target)}\s*:/).find()) { + violations << "${readmeFile}:${lineNumber}: unknown Make target '${target}'" + } + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyReadmeCommands: ${violations.size()} command drift violation(s):\n " + + violations.join('\n ')) + } + logger.lifecycle("verifyReadmeCommands: OK — executable commands in ${readmeFile} resolve.") + } +} + +configure(subprojects.findAll { it.childProjects.isEmpty() }) { + tasks.named('check') { + dependsOn rootProject.tasks.named('verifyReadmeCommands') + } +} + +tasks.register('verifyCleanArchitectureDependencies') { + group = 'verification' + description = 'Verifies Clean Architecture project dependency direction.' + + File moduleRegistryFile = new File(rootProject.projectDir.parentFile, '.harness/project/modules.yaml') + inputs.file(moduleRegistryFile) + + doLast { + if (!moduleRegistryFile.isFile()) { + throw new GradleException("Missing module registry: ${moduleRegistryFile}") + } + def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile) + if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) { + throw new GradleException("Module registry has no modules: ${moduleRegistryFile}") + } + + Map<String, Object> modulesById = moduleRegistry.modules.collectEntries { module -> + [(module.id as String): module] + } + if (modulesById.size() != moduleRegistry.modules.size()) { + throw new GradleException('Module registry contains duplicate module ids.') + } + + Map<String, Set<String>> allowedProjectDependencies = moduleRegistry.modules.collectEntries { module -> + String moduleName = (module.gradle_path as String).replaceFirst('^:', '') + Set<String> allowed = (module.allowed_dependencies as List).collect { dependencyId -> + def dependency = modulesById[dependencyId as String] + if (dependency == null) { + throw new GradleException( + "Module registry '${module.id}' references unknown allowed dependency id '${dependencyId}'.") + } + (dependency.gradle_path as String).replaceFirst('^:', '') + }.toSet() + [(moduleName): allowed] + } + + Set<String> declaredModules = subprojects.findAll { it.childProjects.isEmpty() } + .collect { it.path.replaceFirst('^:', '') }.toSet() + Set<String> governedModules = allowedProjectDependencies.keySet() + Set<String> missingFromBuild = governedModules - declaredModules + Set<String> missingFromPolicy = declaredModules - governedModules + + if (!missingFromBuild.isEmpty()) { + throw new GradleException( + "Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " + + "Declared modules are ${declaredModules}." + ) + } + + if (!missingFromPolicy.isEmpty()) { + throw new GradleException( + "Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " + + "Add an explicit dependency policy before using them." + ) + } + + allowedProjectDependencies.each { moduleName, allowed -> + Project module = rootProject.project(":${moduleName}") + Set<String> actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly'] + .collect { configurationName -> module.configurations.findByName(configurationName) } + .findAll { it != null } + .collectMany { configuration -> + configuration.dependencies.withType(ProjectDependency).collect { dependency -> + dependency.path.replaceFirst('^:', '') + } + } + .toSet() + + Set<String> forbidden = actual - allowed + if (!forbidden.isEmpty()) { + throw new GradleException( + "Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " + + "Allowed dependencies are ${allowed}. " + + "Production modules must not depend on ':sample-ticket', and adapter modules must not depend on each other." + ) + } + } + } +} + +// verifyOneTypePerFile — one public top-level type per file, file name == type name +// (code-conventions I6). Rationale in README.md. +tasks.register('verifyOneTypePerFile') { + group = 'verification' + description = 'code-conventions I6: one public top-level type per file; file name == type name.' + + doLast { + def typeDecl = ~/^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(?:class|interface|record|enum|@interface)\s+([A-Za-z0-9_]+)/ + List<String> violations = [] + rootProject.subprojects.each { sub -> + File mainJava = sub.file('src/main/java') + if (!mainJava.exists()) { + return + } + mainJava.eachFileRecurse { File f -> + if (!f.name.endsWith('.java') || f.name == 'package-info.java' || f.name == 'module-info.java') { + return + } + List<String> names = [] + f.eachLine { String line -> + def m = (line =~ typeDecl) + if (m.find()) { + names << m.group(1) + } + } + if (names.size() > 1) { + violations << "${f.path}: ${names.size()} public top-level types ${names}".toString() + } else if (names.size() == 1) { + String expected = f.name.replaceFirst(/\.java$/, '') + if (names[0] != expected) { + violations << "${f.path}: file name != public type name (type is '${names[0]}')".toString() + } + } + } + } + if (!violations.isEmpty()) { + throw new GradleException( + "verifyOneTypePerFile: ${violations.size()} violation(s) of code-conventions I6:\n " + + violations.join("\n ")) + } + logger.lifecycle("verifyOneTypePerFile: OK — one public top-level type per file, names match.") + } +} + +// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env in lock-step. +// Rationale in README.md. +tasks.register('verifyEnvKeys') { + group = 'verification' + description = 'Verifies src/.env covers application.yml placeholders and every APP_ key is registered.' + + File envFile = file("${rootProject.projectDir}/.env") + File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml") + File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml") + + inputs.files(envFile, appYml, registryFile) + + doLast { + if (!envFile.exists()) { + throw new GradleException("verifyEnvKeys: missing ${envFile}") + } + if (!appYml.exists()) { + throw new GradleException("verifyEnvKeys: missing ${appYml}") + } + if (!registryFile.exists()) { + throw new GradleException("verifyEnvKeys: missing ${registryFile}") + } + + def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/ + Set<String> envKeys = envFile.readLines().findResults { String line -> + def m = keyPattern.matcher(line) + m.matches() ? m.group(1) : null + }.toSet() + + // Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional. + Set<String> requiredPlaceholders = new TreeSet<>() + Set<String> allPlaceholders = new TreeSet<>() + def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/) + while (pm.find()) { + allPlaceholders << pm.group(1) + if (pm.group(2) == null) { + requiredPlaceholders << pm.group(1) + } + } + + // A. Every required (no inline default) placeholder must exist in .env. + Set<String> missingKeys = new TreeSet<>(requiredPlaceholders - envKeys) + if (!missingKeys.isEmpty()) { + throw new GradleException( + "verifyEnvKeys: application.yml references required env absent from src/.env: ${missingKeys}") + } + + // B. Every .env key must be referenced by some application.yml placeholder. + Set<String> orphanedKeys = new TreeSet<>(envKeys - allPlaceholders) + if (!orphanedKeys.isEmpty()) { + throw new GradleException( + "verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses: ${orphanedKeys}") + } + + // C. Every APP_ key in src/.env must be registered in env-keys.yaml (APP_-scoped; + // SPRING_* native keys are intentionally not tracked — see README.md). + def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/ + Set<String> registryAppKeys = registryFile.readLines().findResults { String line -> + def m = registryNamePattern.matcher(line) + m.find() ? m.group(1) : null + }.toSet() + + Set<String> envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet() + Set<String> unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys) + if (!unregisteredAppKeys.isEmpty()) { + throw new GradleException( + "verifyEnvKeys: src/.env declares APP_ keys absent from docs/registries/env-keys.yaml " + + "(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}") + } + + logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " + + "${requiredPlaceholders.size()} required placeholders covered, " + + "${envAppKeys.size()} APP_ keys registered.") + } +} + +// verifyPublicPathSnapshot — fail the build on an unapproved change to the deny-by-default +// public path surface (SECURITY_PUBLIC_PATHS). Approve with -PapprovePublicPathChange. +// Rationale and the snapshot-vs-reflection decision are in README.md. +tasks.register('verifyPublicPathSnapshot') { + group = 'verification' + description = 'Fails on an unapproved change to the deny-by-default public path surface.' + + File envFile = file("${rootProject.projectDir}/.env") + File snapshotFile = file("${rootProject.projectDir}/../docs/security/public-paths-snapshot.txt") + boolean approved = project.hasProperty('approvePublicPathChange') + + inputs.file(envFile) + inputs.property('approved', approved) + + doLast { + if (!envFile.exists()) { + throw new GradleException("verifyPublicPathSnapshot: missing ${envFile}") + } + + def valuePattern = ~/^SECURITY_PUBLIC_PATHS=(.*)$/ + String raw = envFile.readLines().findResult { String line -> + def m = valuePattern.matcher(line) + m.matches() ? m.group(1) : null + } ?: '' + + List<String> publicPaths = raw.split(',') + .collect { it.trim() } + .findAll { !it.isEmpty() } + .toSorted() + + String header = + "# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" + + "# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.\n" + + "# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange\n" + String canonical = header + (publicPaths.isEmpty() ? "" : publicPaths.join('\n') + '\n') + + if (!snapshotFile.exists()) { + snapshotFile.parentFile.mkdirs() + snapshotFile.text = canonical + logger.lifecycle("verifyPublicPathSnapshot: snapshot created at ${snapshotFile} " + + "(${publicPaths.size()} public path(s)). Review and commit it.") + return + } + + String existing = snapshotFile.text + if (existing == canonical) { + logger.lifecycle("verifyPublicPathSnapshot: OK — ${publicPaths.size()} public path(s) unchanged.") + return + } + + if (approved) { + snapshotFile.text = canonical + logger.lifecycle("verifyPublicPathSnapshot: snapshot updated (approved). " + + "Now ${publicPaths.size()} public path(s).") + return + } + + throw new GradleException( + "verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" + + " expected (snapshot):\n${existing}\n" + + " actual (SECURITY_PUBLIC_PATHS):\n${canonical}\n" + + "A protected endpoint may now be public. If this change is intended, get it reviewed " + + "(security:public-path-change) and regenerate with:\n" + + " ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange") + } +} + +// verifyTrivyignore — feature-dependency-vulnerability-management-contract D5 (Suppression +// governance). Fails the build when a Trivy suppression entry in the repo-root .trivyignore.yaml +// lacks an expiry or a reason, is already expired, or exceeds the 90-day max window. Closes the +// 2026-05-25 ca-tmpl audit finding: a suppression could be added with no expiry/reason (a silent +// permanent bypass). This is the CI-side field check; CODEOWNERS adds the merge-time approval — +// the two controls are complementary (see .trivyignore.yaml header and src/README.md). +tasks.register('verifyTrivyignore') { + group = 'verification' + description = 'feature-dependency-vulnerability-management-contract D5: every Trivy suppression has a reason and a non-expired, bounded expiry.' + + // UNSUPPORTED_IMPL_DECISION (team-policy): 90-day max suppression window. The Trivy docs only + // guarantee the `expired_at` field exists (raw/official-docs/trivy-filtering-suppression-policy + // C4); they recommend no specific bound. Trade-off: shorter forces frequent re-review; longer + // approaches a de-facto permanent ignore. + int maxWindowDays = 90 + Set<String> governedSections = ['vulnerabilities', 'licenses', 'misconfigurations', 'secrets'] as Set + + File suppressionFile = file("${rootProject.projectDir}/../.trivyignore.yaml") + inputs.file(suppressionFile) + + doLast { + if (!suppressionFile.exists()) { + throw new GradleException("verifyTrivyignore: missing ${suppressionFile} (D5 requires the structured suppression file to exist, even if empty).") + } + + java.time.LocalDate today = java.time.LocalDate.now() + java.time.LocalDate maxDate = today.plusDays(maxWindowDays) + List<String> violations = [] + int entryCount = 0 + + String section = null + int entryIndent = -1 + Map<String, String> entry = null + int entryLine = -1 + + Closure<Void> validate = { Map<String, String> e, String sec, int lineNo -> + entryCount++ + String id = e['id'] + String where = "${sec} entry at .trivyignore.yaml:${lineNo}" + (id ? " (id: ${id})" : "") + if (!id?.trim()) { + violations << "${where}: missing 'id'." + } + if (!e['statement']?.trim()) { + violations << "${where}: missing non-empty 'statement' (every suppression must record a reason)." + } + String exp = e['expired_at']?.trim() + if (!exp) { + violations << "${where}: missing 'expired_at' (a missing expiry never expires in Trivy — permanent suppression is forbidden)." + } else { + try { + java.time.LocalDate expDate = java.time.LocalDate.parse(exp) + if (!expDate.isAfter(today)) { + violations << "${where}: 'expired_at' ${exp} is not in the future (already expired — remove or renew the suppression)." + } else if (expDate.isAfter(maxDate)) { + violations << "${where}: 'expired_at' ${exp} exceeds the ${maxWindowDays}-day max window (must be on or before ${maxDate})." + } + } catch (java.time.format.DateTimeParseException ignored) { + violations << "${where}: 'expired_at' '${exp}' is not a valid ISO date (expected YYYY-MM-DD)." + } + } + return null + } + + suppressionFile.eachLine { String rawLine, int number -> + String noTab = rawLine.replace('\t', ' ') + String trimmed = noTab.trim() + if (trimmed.isEmpty() || trimmed.startsWith('#')) { + return + } + int indent = noTab.length() - noTab.replaceAll('^ +', '').length() + + // Top-level section header (column 0), e.g. `vulnerabilities:` or `vulnerabilities: []`. + def sectionMatch = (trimmed =~ /^([a-z_]+):(\s*\[\s*\])?\s*$/) + if (indent == 0 && sectionMatch.find()) { + if (entry != null) { validate(entry, section, entryLine); entry = null } + section = sectionMatch.group(1) + entryIndent = -1 + return + } + if (section == null || !governedSections.contains(section)) { + return + } + + if (trimmed.startsWith('- ') || trimmed == '-') { + if (entryIndent == -1) { + entryIndent = indent + } + if (indent == entryIndent) { + if (entry != null) { validate(entry, section, entryLine) } + entry = [:] + entryLine = number + // A field may sit inline on the dash line, e.g. `- id: CVE-2024-0001`. + String inline = trimmed.replaceFirst(/^-\s*/, '') + int colon = inline.indexOf(':') + if (colon > 0) { + String k = inline.substring(0, colon).trim() + String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') + if (!k.isEmpty()) { entry[k] = v } + } + } + // indent > entryIndent → nested list item (e.g. under `paths:`) → ignore. + return + } + + // A `key: value` field of the current entry (one indent level deeper than the dash). + if (entry != null && indent == entryIndent + 2) { + int colon = trimmed.indexOf(':') + if (colon > 0) { + String k = trimmed.substring(0, colon).trim() + String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') + entry[k] = v + } + } + } + if (entry != null) { validate(entry, section, entryLine) } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyTrivyignore: ${violations.size()} Trivy suppression governance violation(s) (D5):\n " + + violations.join("\n ") + + "\nFix the .trivyignore.yaml entries, or remove them. Every suppression needs an 'id', a " + + "'statement' reason, and a future 'expired_at' within ${maxWindowDays} days.") + } + logger.lifecycle("verifyTrivyignore: OK — ${entryCount} suppression(s) validated (reason + bounded, non-expired expiry).") + } +} + +// verifyQuarantineSunset — feature-ci-quality-gates-contract §4 (D7/D9, this branch is the flaky +// quarantine SSOT). The flaky-test quarantine bucket (@Tag("quarantine"), excluded from the release +// gate) is a TEMPORARY escape, not a parking lot: every quarantined test must be registered in the +// repo-root flaky-quarantine.yaml with a reason, a tracking issue, and a quarantined_since date, and +// it must leave quarantine within 14 days (the ca-tmpl compromise on the Spotify/Google/MS-vs-Fowler +// debate — company-case-study strength only, NOT an official best practice). This is the CI-side +// field + sunset check; like verifyTrivyignore it is the field-validation half and CODEOWNERS is the +// merge-approval half. Two enforcement directions: +// (a) sunset — fail when any registered quarantined_since is older than 14 days; +// (b) drift — fail when a test is @Tag("quarantine")-tagged in source but NOT registered +// (a flaky test must not escape sunset tracking by skipping the registry). +// Ships passing on the empty skeleton (zero tagged tests, `quarantined: []`). +tasks.register('verifyQuarantineSunset') { + group = 'verification' + description = 'feature-ci-quality-gates-contract §4 (D7): every @Tag("quarantine") test is registered and within its 14-day sunset.' + + // UNSUPPORTED_IMPL_DECISION (company-case-study + team-policy): 14-day sunset. The Spotify/Google/MS + // case studies establish that a quarantine bucket is legitimate; the 14-day quantum and its + // automatic enforcement are ca-tmpl's own compromise (no external standard). A fork tunes + // sunsetDays here. + int sunsetDays = 14 + + File registryFile = file("${rootProject.projectDir}/../flaky-quarantine.yaml") + inputs.file(registryFile) + rootProject.subprojects.each { sub -> + File testJava = sub.file('src/test/java') + if (testJava.exists()) { + inputs.dir(testJava) + } + } + + doLast { + if (!registryFile.exists()) { + throw new GradleException("verifyQuarantineSunset: missing ${registryFile} " + + "(D7 requires the quarantine registry to exist, even if empty: `quarantined: []`).") + } + + // --- Parse the registry (line-based, same shape family as .trivyignore.yaml). ----------- + // quarantined: + // - test: "fully.qualified.TestClass" | "...TestClass#method" + // quarantined_since: "YYYY-MM-DD" + // reason: "..." + // tracking_issue: "..." + List<Map<String, String>> entries = [] + List<Integer> entryLines = [] + boolean inSection = false + int entryIndent = -1 + Map<String, String> entry = null + int entryLine = -1 + + Closure<Void> closeEntry = { + if (entry != null) { entries << entry; entryLines << entryLine; entry = null } + return null + } + + registryFile.eachLine { String rawLine, int number -> + String noTab = rawLine.replace('\t', ' ') + String trimmed = noTab.trim() + if (trimmed.isEmpty() || trimmed.startsWith('#')) { + return + } + int indent = noTab.length() - noTab.replaceAll('^ +', '').length() + + // Top-level `quarantined:` or `quarantined: []`. + def sectionMatch = (trimmed =~ /^quarantined:(\s*\[\s*\])?\s*$/) + if (indent == 0 && sectionMatch.find()) { + closeEntry() + inSection = true + entryIndent = -1 + return + } + if (indent == 0) { // some other top-level key → leave the section + closeEntry() + inSection = false + return + } + if (!inSection) { + return + } + + if (trimmed.startsWith('- ') || trimmed == '-') { + if (entryIndent == -1) { entryIndent = indent } + if (indent == entryIndent) { + closeEntry() + entry = [:] + entryLine = number + String inline = trimmed.replaceFirst(/^-\s*/, '') + int colon = inline.indexOf(':') + if (colon > 0) { + String k = inline.substring(0, colon).trim() + String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') + if (!k.isEmpty()) { entry[k] = v } + } + } + return + } + + // A `key: value` field of the current entry (deeper than the dash). + if (entry != null && indent == entryIndent + 2) { + int colon = trimmed.indexOf(':') + if (colon > 0) { + String k = trimmed.substring(0, colon).trim() + String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') + entry[k] = v + } + } + } + closeEntry() + + // --- (a) Schema + sunset validation of every registered entry. ------------------------- + java.time.LocalDate today = java.time.LocalDate.now() + List<String> violations = [] + Set<String> registeredClasses = new HashSet<>() + + entries.eachWithIndex { Map<String, String> e, int i -> + String test = e['test']?.trim() + String where = "flaky-quarantine.yaml:${entryLines[i]}" + (test ? " (test: ${test})" : "") + if (!test) { + violations << "${where}: missing 'test' (fully-qualified test class, optionally '#method')." + } else { + registeredClasses << test.replaceAll(/#.*$/, '') + } + if (!e['reason']?.trim()) { + violations << "${where}: missing non-empty 'reason' (why is it flaky / what is the suspected cause)." + } + if (!e['tracking_issue']?.trim()) { + violations << "${where}: missing 'tracking_issue' (the issue tracking the fix — quarantine is not a parking lot)." + } + String since = e['quarantined_since']?.trim() + if (!since) { + violations << "${where}: missing 'quarantined_since' (a missing date cannot be sunset — forbidden)." + } else { + try { + java.time.LocalDate sinceDate = java.time.LocalDate.parse(since) + if (sinceDate.isAfter(today)) { + violations << "${where}: 'quarantined_since' ${since} is in the future." + } else if (sinceDate.plusDays(sunsetDays).isBefore(today)) { + long age = java.time.temporal.ChronoUnit.DAYS.between(sinceDate, today) + violations << "${where}: quarantined ${age} days ago — past the ${sunsetDays}-day sunset. " + + "Fix and un-quarantine the test, or escalate; do not extend silently." + } + } catch (java.time.format.DateTimeParseException ignored) { + violations << "${where}: 'quarantined_since' '${since}' is not a valid ISO date (expected YYYY-MM-DD)." + } + } + } + + // --- (b) Drift: every @Tag("quarantine") test in source must be registered. ------------- + def tagPattern = ~/@Tag\(\s*["']quarantine["']\s*\)/ + def classPattern = ~/(?:class|interface|enum|record)\s+([A-Za-z0-9_]+)/ + int taggedCount = 0 + rootProject.subprojects.each { sub -> + File testJava = sub.file('src/test/java') + if (!testJava.exists()) { + return + } + testJava.eachFileRecurse { File f -> + if (!f.name.endsWith('.java')) { + return + } + String text = f.text + if (!(text =~ tagPattern)) { + return + } + taggedCount++ + def cm = (text =~ classPattern) + String simpleName = cm ? cm[0][1] : f.name.replaceFirst(/\.java$/, '') + boolean registered = registeredClasses.any { it.endsWith('.' + simpleName) || it == simpleName } + if (!registered) { + violations << "${f.path}: test '${simpleName}' is @Tag(\"quarantine\") but is not registered " + + "in flaky-quarantine.yaml — a quarantined test must be tracked with a sunset date." + } + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "verifyQuarantineSunset: ${violations.size()} quarantine governance violation(s) (§4 / D7):\n " + + violations.join("\n ") + + "\nEach @Tag(\"quarantine\") test needs a flaky-quarantine.yaml entry with a 'reason', a " + + "'tracking_issue', and a 'quarantined_since' within ${sunsetDays} days.") + } + logger.lifecycle("verifyQuarantineSunset: OK — ${entries.size()} registered, ${taggedCount} tagged " + + "(${sunsetDays}-day sunset enforced).") + } +} diff --git a/src/config/checkstyle/checkstyle-suppressions.xml b/src/config/checkstyle/checkstyle-suppressions.xml new file mode 100644 index 00000000..1239a7cc --- /dev/null +++ b/src/config/checkstyle/checkstyle-suppressions.xml @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<!DOCTYPE suppressions PUBLIC + "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN" + "https://checkstyle.org/dtds/suppressions_1_2.dtd"> + +<!-- + feature-static-analysis-quality-contract D2 / §3 — Checkstyle suppression filter. + + Narrowly-scoped relaxations only. The contract keeps naming + logical rules + blocking on main source; this file carves out documented tool-contract exceptions. +--> +<suppressions> + <!-- ApprovalTests discovers this package setting by exact public static field name. --> + <suppress checks="StaticVariableName" + files="[\\/]src[\\/]test[\\/]java[\\/]dev[\\/]caskeleton[\\/]bootstrap[\\/]contract[\\/]PackageSettings\.java"/> +</suppressions> diff --git a/src/config/checkstyle/checkstyle.xml b/src/config/checkstyle/checkstyle.xml new file mode 100644 index 00000000..9a05087f --- /dev/null +++ b/src/config/checkstyle/checkstyle.xml @@ -0,0 +1,74 @@ +<?xml version="1.0"?> +<!DOCTYPE module PUBLIC + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> + +<!-- + feature-static-analysis-quality-contract D2 — custom minimal Checkstyle ruleset. + + Scope is deliberately limited to what google-java-format (Spotless, D1) CANNOT enforce: + naming and logical/design rules. Formatting and import-order modules are + intentionally ABSENT — the formatter owns them. Enabling LineLength/Indentation/Whitespace/ + CustomImportOrder here would fight the formatter (checkstyle #6527) and trap CI in an + infinite reformat loop. + + SSOT: LLM Wiki raw/branch-notes/feature-static-analysis-quality-contract.md §3 catalog. + Test-source relaxations live in checkstyle-suppressions.xml (resolved via ${config_loc}). +--> +<module name="Checker"> + <property name="charset" value="UTF-8"/> + <property name="severity" value="error"/> + <property name="fileExtensions" value="java"/> + + <!-- Test-source + generated-source relaxations (checkstyleTest is warning-only anyway). --> + <module name="SuppressionFilter"> + <property name="file" value="${config_loc}/checkstyle-suppressions.xml"/> + <property name="optional" value="false"/> + </module> + + <module name="TreeWalker"> + + <!-- ================= naming (KEEP, blocking) — CS-C2 / CS-C5 ================= --> + <module name="TypeName"/> + <module name="MethodName"/> + <!-- ConstantName: real constants are UPPER_SNAKE_CASE (code-conventions N9), but a + Logger has mutable observable state so it is NOT a constant — Google §5.2.4 names + it lowerCamelCase (log/logger). Permit those two idiomatic logger names. --> + <module name="ConstantName"> + <property name="format" value="^(log|logger|[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)$"/> + </module> + <module name="MemberName"/> + <module name="StaticVariableName"/> + <module name="ParameterName"/> + <module name="LambdaParameterName"/> + <module name="CatchParameterName"/> + <module name="LocalVariableName"/> + <module name="LocalFinalVariableName"/> + <module name="PackageName"/> + <!-- Type parameters are UPPER alphanumeric. Allows the conventional T/E/K/V/R and the + F-bounded self-type idiom SELF (see ResourceId<SELF extends ResourceId<SELF>>). --> + <module name="ClassTypeParameterName"> + <property name="format" value="^[A-Z][A-Z0-9]*$"/> + </module> + <module name="InterfaceTypeParameterName"> + <property name="format" value="^[A-Z][A-Z0-9]*$"/> + </module> + <module name="MethodTypeParameterName"> + <property name="format" value="^[A-Z][A-Z0-9]*$"/> + </module> + <module name="RecordComponentName"/> + <module name="RecordTypeParameterName"> + <property name="format" value="^[A-Z][A-Z0-9]*$"/> + </module> + <module name="PatternVariableName"/> + + <!-- ================= logical / design (KEEP) — google_checks CS-C1 ================= --> + <module name="NeedBraces"/> + <module name="FallThrough"/> + <module name="EmptyCatchBlock"> + <property name="exceptionVariableName" value="expected|ignored"/> + </module> + <module name="OneStatementPerLine"/> + <module name="MissingSwitchDefault"/> + </module> +</module> diff --git a/src/config/spotbugs/exclude.xml b/src/config/spotbugs/exclude.xml new file mode 100644 index 00000000..aff07952 --- /dev/null +++ b/src/config/spotbugs/exclude.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + feature-static-analysis-quality-contract D3/D4 — SpotBugs + FindSecBugs exclude filter. + + SpotBugs (D3) and FindSecBugs (D4, via spotbugsPlugins) analyse bytecode. Inter-procedural + taint analysis has known limits (FindSecBugs false positives) and generated code carries + patterns the source author never wrote. Add ONLY confirmed false positives here, each as a + narrowly-scoped <Match> — never a blanket bug-pattern exclusion across all sources. + + Reference: https://github.com/spotbugs/spotbugs / https://find-sec-bugs.github.io/ +--> +<FindBugsFilter> + + <!-- Generated sources (MapStruct / annotation processors): exclude from analysis. --> + <Match> + <Source name="~.*[\\/]generated[\\/].*"/> + </Match> + + <!-- SPRING_CSRF_PROTECTION_DISABLED on the SecurityConfig classes is intentional: this is a + stateless JWT bearer-token API (no session cookies / no ambient cookie auth), so CSRF + protection is deliberately disabled per the security baseline. FindSecBugs flags it for + classic cookie-session web apps; it is a false positive here. Scoped to *SecurityConfig + so a CSRF-disable anywhere else would still be reported. --> + <Match> + <Bug pattern="SPRING_CSRF_PROTECTION_DISABLED"/> + <Class name="~.*SecurityConfig"/> + </Match> + + <!-- Test-only violation fixture intentionally calls unsafe Jackson default typing so + architecture tests can prove the boundary rule catches it. Production and non-fixture + test classes remain covered by SECUJDES. --> + <Match> + <Bug code="SECUJDES"/> + <Source name="DefaultTypingFixture.java"/> + </Match> + +</FindBugsFilter> diff --git a/src/domain-core/CLAUDE.md b/src/domain-core/CLAUDE.md new file mode 100644 index 00000000..8cc58810 --- /dev/null +++ b/src/domain-core/CLAUDE.md @@ -0,0 +1,38 @@ +# domain-core — pure domain layer + +## Registered identity + +- Module ID: `domain-core` +- Gradle path: `:domain-core` +- Focused test: `./gradlew :domain-core:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.domain`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT). + +## Responsibility + +- Business concepts, entities, value objects, enums, domain events, domain services. +- Repository ports where this reference implementation still keeps domain-owned persistence contracts. +- Domain invariants and core rules. + +## Allowed + +- Java standard library. +- Value-only shared operational types only if truly needed. + +## Forbidden + +- Spring annotations. +- JPA annotations. +- Servlet/HTTP types. +- Adapter, application, bootstrap, or presentation DTO dependencies. + +## Test + +```bash +cd src +./gradlew :domain-core:test +``` diff --git a/src/domain-core/README.md b/src/domain-core/README.md new file mode 100644 index 00000000..497eb418 --- /dev/null +++ b/src/domain-core/README.md @@ -0,0 +1,96 @@ +# domain-core — 설계 결정 참조 + +순수 도메인 계층 모듈. 패키지 루트: `dev.caskeleton.domain`. + +허용/금지 의존, 책임 범위, 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. +이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 +"왜 이렇게 했나"가 궁금할 때 본다. 플랜/스펙 추적용 ID는 +더 이상 코드 주석에 두지 않고, 그 근거를 아래에 쉬운 말로 풀어 둔다. + +여기서 자주 나오는 "ArchUnit 규칙"이란, 빌드할 때 코드가 정해진 구조를 어겼는지 자동으로 +검사하는 아키텍처 테스트다. 즉 아래의 "이런 건 금지"라는 결정들은 말로만 정한 약속이 아니라 +빌드가 실제로 강제한다. + +--- + +## identifier — 리소스 식별자 + +### `ResourceId` — 모든 식별자가 구현하는 마커 인터페이스 + +시스템의 모든 리소스 식별자 값 객체(예: `WorkLogId`)가 구현하는 공통 인터페이스다. +식별자의 실제 문자열 값은 `value()` 하나로 노출한다. + +- **왜 `sealed` 로 닫지 않았나.** 원래 레퍼런스 설계는 `permits WorkLogId` 처럼 허용 구현을 + 못 박는 닫힌 집합(`sealed`)으로 만들려 했다. 그런데 `WorkLogId` 같은 실제 식별자는 + `sample-portfolio`(샘플 코드)에 있고, 이 마커는 재사용 가능한 `domain-core`에 있다. + `permits` 절을 쓰는 순간 `domain-core`가 `sample-portfolio`를 의존하게 되는데, 이건 + 모듈 의존성 규칙과 `production_code_does_not_depend_on_sample_portfolio` ArchUnit 규칙이 + 둘 다 금지하는 방향이다. 그래서 `sealed`을 포기했다. +- **그럼 "닫힌 집합" 보장은 어디서 하나.** `sealed`이 줬을 보장(아무 타입이나 식별자로 + 쓰지 못하게 하는 것)은 대신 빌드 타임의 `no_long_id_pk` ArchUnit 규칙(D17)이 강제한다 — + 도메인의 모든 `id` 필드는 반드시 `ResourceId` 구현이어야 하고, 날것의 `Long` PK 는 금지된다. +- **`<SELF>` 타입 파라미터(F-bounded generic).** 제네릭이 자기 자신을 다시 가리키는 + 재귀 구조다. 구현 타입이 "나는 나 자신 타입의 `ResourceId`다"라고 선언하게 해서, 자기 + 타입을 그대로 돌려주는 self-typed API 를 타입 안전하게 만들 수 있다. +- **`value()` 계약.** 36자 canonical UUID(RFC 9562 UUIDv7) 문자열을 반환한다(D2/D3). + +### `IdFactory` — 식별자 생성 포트 + +새 식별자를 발급하는 도메인 포트(인터페이스)다. + +- **"발급할 책임"과 "실제 생성 행위"를 분리한다.** 식별자를 발급할 *책임* 은 도메인(이 포트)이 + 소유한다. 하지만 실제로 식별자를 *만드는 행위* 는 인프라 어댑터(예: UUIDv7 생성기)가 + 수행하고, 애플리케이션 유스케이스가 그 둘을 조율한다. 이렇게 나누면 도메인은 구체적인 + 난수·시계 같은 소스를 전혀 알지 못한 채, 식별자에 대한 계약만 소유한 순수한 상태로 남는다. + +--- + +## stereotype — 도메인 모델링 스테레오타입 마커 + +`@ValueObject`, `@AggregateRoot`, `@DomainEvent` 세 개의 마커 애너테이션 묶음이다. + +- **왜 이름 규칙이 아니라 마커 애너테이션인가.** 이 애너테이션들은 동작도, 프레임워크 의존도 + 없는 순수 POJO 마커다. 아키텍처 테스트가 "클래스 이름이 `~VO`로 끝나는가" 같은 깨지기 쉬운 + 네이밍 규칙 대신, `@ValueObject` 처럼 **명시적이고 의도가 드러나는 선언**을 기준으로 모델링 + 가드레일을 걸 수 있게 한다. +- **왜 프레임워크 중립으로 두나.** 일부러 어떤 프레임워크에도 의존하지 않게 만들어서 + `domain-core`가 순수 라이브러리로 남게 한다(`domain_is_pure` 가드레일). + +### `@ValueObject` — 값 객체 + +불변이고, 개념적 식별자가 없으며, 자기 불변식을 스스로 검증하는 값 타입을 표시한다(D5/D6). + +- **불변식 검증은 유일한 생성 경로에서만.** 값 객체는 record canonical 생성자 또는 팩토리라는 + 단 하나의 생성 경로에서 자기 불변식을 검증한다. +- **강제 규칙 — public 무인자 생성자 금지.** `@ValueObject` 타입과 `..domain.vo..` 아래 모든 + 타입은 public 무인자 생성자를 노출하면 안 된다. 빈 생성자는 검증을 건너뛰고 객체를 만들 수 + 있는 "불변식 우회 뒷문"이기 때문이다(`value_objects_have_no_public_no_arg_constructor`). +- **왜 `RUNTIME` 리텐션인가.** ArchUnit(바이트코드 검사)과 리플렉션 기반 테스트가 둘 다 + 이 애너테이션을 읽을 수 있어야 해서 런타임까지 유지한다. + +### `@AggregateRoot` — 애그리거트 루트 + +애그리거트의 일관성 경계이자, 그 상태를 바꿀 수 있는 유일한 진입점인 타입을 표시한다 +(D7, Vernon "Effective Aggregate Design"). + +- **상태 변경은 의도가 드러나는 메서드로만.** 상태 변경은 불변식을 강제하는, 의도가 분명한 + 애그리거트 메서드를 통해서만 일어나야 한다. 날것의 `setXxx` 세터를 public 으로 열어두면 안 된다. +- **강제 규칙 — public 세터 금지.** `@AggregateRoot` 타입의 모든 `set*` 메서드는 + package-private 또는 protected 여야 한다(`aggregate_root_setters_are_not_public`). 이 + 가시성은 Vernon Option A(ORM 외부에서 매핑하는 방식)로 객체를 재구성할 때 의존하는 지점이다. +- **이 규칙의 한계.** ArchUnit 은 정적으로 `set*` 라는 이름 패턴까지만 잡을 수 있다. + `applyXxx` 처럼 이름이 다른 상태 변경 메서드는 자동 검사가 닿지 않으므로 코드 리뷰가 잡아야 + 할 몫으로 남는다(spec §4 PRE-DECISION). + +### `@DomainEvent` — 도메인 이벤트 + +도메인에서 일어난 사실을 담은, 불변이고 전송수단에 독립적인(transport-free) 타입을 표시한다 +(D4/D8). + +- **"transport-free"가 무슨 뜻인가.** 이벤트는 도메인 데이터만 담고, 메시지 브로커·와이어 + 포맷·HTTP 같은 전송 계층 타입을 절대 참조하지 않는다는 뜻이다. 도메인 이벤트를 통합 이벤트나 + 와이어 이벤트로 번역하는 일은 애플리케이션/인프라 경계의 책임이지 도메인의 책임이 아니다. +- **강제 규칙 두 가지.** 모든 `@DomainEvent` 타입은 (1) `record` 여야 하고 + (`domain_events_are_records`, 불변성 보장), (2) 어떤 전송 패키지(`org.apache.kafka..`, + `org.springframework.http..`, `jakarta.ws.rs..`)도 의존하면 안 된다 + (`domain_events_are_transport_free`). diff --git a/src/domain-core/build.gradle b/src/domain-core/build.gradle new file mode 100644 index 00000000..ab93bf08 --- /dev/null +++ b/src/domain-core/build.gradle @@ -0,0 +1,3 @@ +// Pure domain layer. No Spring, no infra dependencies. +dependencies { +} diff --git a/src/domain-core/gradle.lockfile b/src/domain-core/gradle.lockfile new file mode 100644 index 00000000..d74791d4 --- /dev/null +++ b/src/domain-core/gradle.lockfile @@ -0,0 +1,151 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty=compileClasspath,runtimeClasspath diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/identifier/IdFactory.java b/src/domain-core/src/main/java/dev/caskeleton/domain/identifier/IdFactory.java new file mode 100644 index 00000000..6b32c870 --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/identifier/IdFactory.java @@ -0,0 +1,15 @@ +package dev.caskeleton.domain.identifier; + +/** + * Domain port for server-assigned resource identifier generation. + * + * <p>The domain owns the identity contract; the act of generation is performed by an infrastructure + * adapter and orchestrated by an application use case. Design rationale in the module README. + * + * @param <T> the resource identifier type produced by this factory + */ +public interface IdFactory<T extends ResourceId<?>> { + + /** Mints a fresh, never-before-used identifier. */ + T newId(); +} diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/identifier/ResourceId.java b/src/domain-core/src/main/java/dev/caskeleton/domain/identifier/ResourceId.java new file mode 100644 index 00000000..12eab331 --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/identifier/ResourceId.java @@ -0,0 +1,15 @@ +package dev.caskeleton.domain.identifier; + +/** + * Marker for every resource identifier value object in the system. + * + * <p>Deliberately <strong>not</strong> {@code sealed} (the closed set is enforced by the {@code + * no_long_id_pk} ArchUnit rule instead). Design rationale in the module README. + * + * @param <SELF> the implementing identifier type (F-bounded for self-typed APIs) + */ +public interface ResourceId<SELF extends ResourceId<SELF>> { + + /** The canonical 36-character UUID string (RFC 9562 UUIDv7). */ + String value(); +} diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/package-info.java b/src/domain-core/src/main/java/dev/caskeleton/domain/package-info.java new file mode 100644 index 00000000..d82af2aa --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/package-info.java @@ -0,0 +1,2 @@ +/** Domain layer anchor for project-owned business concepts. */ +package dev.caskeleton.domain; diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java new file mode 100644 index 00000000..42396054 --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java @@ -0,0 +1,19 @@ +package dev.caskeleton.domain.stereotype; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a type as an <strong>aggregate root</strong> — the single consistency boundary and the only + * entry point through which the aggregate's state may change. + * + * <p>Pure marker, {@code RUNTIME} retention. Design rationale (Vernon "Effective Aggregate Design", + * enforced guardrails, the {@code set*} static-analysis limit) in the module README. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface AggregateRoot {} diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java new file mode 100644 index 00000000..e1de75d1 --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java @@ -0,0 +1,19 @@ +package dev.caskeleton.domain.stereotype; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a type as a <strong>domain event</strong> — an immutable, transport-free fact about + * something that happened in the domain. + * + * <p>Pure marker, {@code RUNTIME} retention. Design rationale (transport-free meaning, enforced + * guardrails) in the module README. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface DomainEvent {} diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java new file mode 100644 index 00000000..e43cb209 --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java @@ -0,0 +1,19 @@ +package dev.caskeleton.domain.stereotype; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a type as a domain <strong>value object</strong> — immutable, no conceptual identity, + * self-validating on its sole construction path (canonical record constructor or factory). + * + * <p>Pure marker, {@code RUNTIME} retention. Design rationale (enforced guardrails) in the module + * README. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValueObject {} diff --git a/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/package-info.java b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/package-info.java new file mode 100644 index 00000000..0e332cb3 --- /dev/null +++ b/src/domain-core/src/main/java/dev/caskeleton/domain/stereotype/package-info.java @@ -0,0 +1,15 @@ +/** + * Domain modeling stereotype markers — pure, framework-neutral POJO markers that let the + * architecture suite key modeling guardrails off an explicit declaration rather than a brittle + * naming convention. Design rationale in the module README. + * + * <ul> + * <li>{@link dev.caskeleton.domain.stereotype.ValueObject} — immutable, self-validating value + * type. + * <li>{@link dev.caskeleton.domain.stereotype.AggregateRoot} — consistency boundary whose state + * mutates only through its own methods. + * <li>{@link dev.caskeleton.domain.stereotype.DomainEvent} — transport-free fact emitted by the + * domain. + * </ul> + */ +package dev.caskeleton.domain.stereotype; diff --git a/src/domain-core/src/test/java/dev/caskeleton/domain/.gitkeep b/src/domain-core/src/test/java/dev/caskeleton/domain/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/domain-core/src/test/java/dev/caskeleton/domain/unit/.gitkeep b/src/domain-core/src/test/java/dev/caskeleton/domain/unit/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/domain-core/src/test/java/dev/caskeleton/domain/unit/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/gradle/wrapper/gradle-wrapper.jar b/src/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d997cfc6 Binary files /dev/null and b/src/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src/gradle/wrapper/gradle-wrapper.properties b/src/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2a84e188 --- /dev/null +++ b/src/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/gradlew b/src/gradlew new file mode 100755 index 00000000..0262dcbd --- /dev/null +++ b/src/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/src/gradlew.bat b/src/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/src/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/sample-portfolio/.jqwik-database b/src/sample-portfolio/.jqwik-database new file mode 100644 index 00000000..711006c3 Binary files /dev/null and b/src/sample-portfolio/.jqwik-database differ diff --git a/src/sample-portfolio/CLAUDE.md b/src/sample-portfolio/CLAUDE.md new file mode 100644 index 00000000..045a4909 --- /dev/null +++ b/src/sample-portfolio/CLAUDE.md @@ -0,0 +1,46 @@ +# sample-portfolio — fixture / reference consumer + +## Registered identity + +- Module ID: `sample-portfolio` +- Gradle path: `:sample-portfolio` +- Focused test: `./gradlew :sample-portfolio:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.sample.portfolio`. + +코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 의 "설계 결정 참조" 절이 모아둔다 (이 문서는 모듈 규칙 SSOT). + +## Responsibility + +- Reference implementation for template adopters: the WorkLog domain slice, layered + exactly like a consuming project — `domain/worklog` (`WorkLog`, `WorkLogId`, + `Period`, `WorkLogRepository`), `application/` (commands, queries, use cases, + ports such as `RepoStatsPort`), and reference adapters under `adapter/web`, + `adapter/persistence`, `adapter/outbound/repostats` (`RepoStatsPortClient` + + `RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`). +- Contract-test fixtures the app-bootstrap verification suite analyses + (`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests). + +## Allowed + +- Runtime leaves explicitly allowed for this fixture consumer by + `.harness/project/modules.yaml`; do not duplicate the 19-leaf list here. + +## Forbidden + +- Production code depending on this module — ArchUnit + `production_code_does_not_depend_on_sample_portfolio`. `app-bootstrap` references + it as `testImplementation` ONLY so the ArchUnit suite can analyse the reference + implementation ([app-bootstrap/build.gradle](../app-bootstrap/build.gradle) L21–24). +- Production-required behaviour living here: deleting this module must not break the + production build or runtime (the base `GlobalExceptionHandler`, envelope, and + error-code contract live in `adapter:inbound:web` / `shared-contract`). + +## Test + +```bash +cd src +./gradlew :sample-portfolio:test +``` diff --git a/src/sample-portfolio/README.md b/src/sample-portfolio/README.md new file mode 100644 index 00000000..1c74c13c --- /dev/null +++ b/src/sample-portfolio/README.md @@ -0,0 +1,459 @@ +# sample-portfolio — WorkLog 포트폴리오 게시판 (예시 도메인) + +이 모듈은 ca-skeleton 템플릿이 유지하는 **fixture/reference 예시 도메인**입니다. production 모듈 +(`domain-core`/`application-core`/`adapter-*`/`shared-contract`/`app-bootstrap`)의 +경계와 운영 계약을 "어떻게 쓰는지" 보여줍니다. 새 프로젝트는 이 모듈을 import하지 않고 자신의 +도메인을 production 모듈에 추가합니다. 템플릿에서는 이 모듈을 유지하며, 다운스트림 fork는 +`./gradlew :app-bootstrap:sampleOffTest` 통과 후에만 선택적으로 정리할 수 있습니다. + +## 도메인: WorkLog (엔지니어링 작업 기록 게시판) + +메인 화면 = `GET /work-logs` 목록. 각 항목은 직접 수행한 작업입니다. + +| 필드 | 설명 | +| --- | --- | +| title | 작업 제목 | +| category | INFRASTRUCTURE / DATABASE / BACKEND / PLATFORM | +| summary / content | 요약 / 본문 | +| techStack[] | 사용 기술 | +| links[] | 관련 링크 (repo/blog) | +| period | 기간 (end=null → 진행 중) | + +## 엔드포인트 + +| Method | Path | 설명 | 시연 계약 | +| --- | --- | --- | --- | +| GET | /work-logs | 목록 (메인) | Envelope wrap | +| GET | /work-logs/{id} | 상세 | 도메인 예외→404 envelope | +| POST | /work-logs | 생성 | Bean Validation @GroupSequence (B4), unknown-field 거부 (B1), mapper 실패→MAPPING_FAILED (B3) | +| PATCH | /work-logs/{id} | 부분수정 | JsonNullable 3-state (B2) | +| DELETE | /work-logs/{id} | 삭제 | 204 | +| POST | /work-logs/import | 일괄 등록 | BulkEnvelope 부분실패 (B8) | + +## 샘플 데이터 (curl 예시) + +```bash +# 1) 인프라 인증·인가 위임 (Keycloak + k3s + Vault) +curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{ + "title": "Keycloak + k3s + Vault 기반 인증·인가 위임", + "category": "INFRASTRUCTURE", + "summary": "인증/인가를 Keycloak으로 위임, k3s + Vault로 시크릿·구성 관리", + "content": "...", + "techStack": ["keycloak", "k3s", "vault"], + "links": ["https://example.com/infra"], + "periodStart": "2025-01-01" +}' + +# 2) DB 쿼리 튜닝 +curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{ + "title": "DB 쿼리 튜닝", + "category": "DATABASE", + "summary": "slow query 분석 및 인덱스/실행계획 개선", + "content": "...", + "techStack": ["postgresql"], + "links": [], + "periodStart": "2025-02-01" +}' +``` + +## 계층 (adapter-mirrored) +- `domain/worklog` — WorkLog/WorkCategory/Period/RepoStats/WorkLogRepository (POJO) +- `application/worklog` — *UseCase (CommandUseCase/QueryUseCase + @UseCaseCapability) +- `adapter/web` — controller/dto/mapper/error +- `adapter/persistence` — entity/repository/mapper +- `adapter/outbound/repostats` — B7 ACL 예시 + +--- + +# 설계 결정 참조 (코드 주석에서 이전) + +여기 아래는 예전에 각 클래스의 주석/JavaDoc 에 흩어져 있던 **"왜 이렇게 짰는가"** 설명을 +한곳에 모은 것입니다. 코드에는 "무엇을 하는지"만 짧게 남기고, 그 배경·결정·트레이드오프는 +이 문서로 옮겼습니다. 코드를 읽다 "왜 이렇게 했지?"가 궁금할 때 보세요. + +읽는 법: + +- 모듈 규칙(허용/금지 의존, 테스트 명령)의 기준은 [CLAUDE.md](CLAUDE.md) 입니다. 이 문서는 + 규칙이 아니라 **결정의 근거**를 모은 참조 기록입니다. +- 본문은 코드가 어떤 약속을 지키려고 존재하는지 설명합니다. 추적용 꼬리표는 코드 주석에 + 남기지 않고, 필요한 배경은 여기서 문장으로 풀어 설명합니다. + +## domain — 도메인 (순수 POJO, 프레임워크 의존 없음) + +### WorkLog (애그리거트 루트) +- WorkLog 애그리거트의 루트입니다. **상태를 바꾸는 길은 의도가 드러나는 메서드뿐**입니다 + (`rename`, `recategorize`, `startProgress`, `close` …). 공개 `setXxx` 세터를 두지 않은 이유는, + 세터를 열어두면 불변식(invariant)을 건너뛴 채로 객체를 망가뜨릴 수 있기 때문입니다. 모든 + 변경 경로가 불변식 검사를 거치도록 강제합니다. +- 불변식을 어기면 `WorkLogInvariantException` 을 던집니다. 이 예외는 안전한 사유(reason)만 + 담고, 로그 문자열이나 운영 에러 코드/HTTP 상태는 전혀 모릅니다 — 그 변환은 상위 + (application/web)의 몫입니다. +- **id 는 도메인이 직접 만들지 않습니다.** `WorkLog.create(...)` 는 이미 만들어진 + `WorkLogId` 를 받기만 합니다. UUIDv7 생성은 유스케이스가 `WorkLogIdFactory` 포트를 통해 + 하고, 도메인 안에는 `UUID.randomUUID()` 나 id 생성 라이브러리가 들어오지 않습니다(서버가 id + 를 정해주는 계약). +- `version` 필드(낙관적 락 버전)의 역할: 새로 만든(아직 저장 안 된) 객체는 `null` 이고, + 저장소가 값을 채우고 증가시킵니다. 영속 계층은 이 `null` 여부로 "INSERT 인지 UPDATE 인지"를 + 구분하고, 웹 경계에서는 이 값이 HTTP `ETag` / `If-Match` 의 출처가 됩니다. 즉 DB 가 + 낙관적 락 충돌로 잡아내는 그 충돌을, 웹에서는 HTTP 412 로 표현합니다. +- `title` 규칙: null 이면 안 되고(`NullPointerException`), 공백만 있어도 안 됩니다 + (`TITLE_BLANK`). 공백 금지는 **도메인 불변식**이라, 웹 경계의 `@NotBlank`(형식 검사)와는 + 별개로 한 번 더 지킵니다 — 웹을 거치지 않는 호출자가 있어도 규칙이 새지 않도록. + +### 값 객체 — WorkLogId / Period / WorkLogOwner +- **WorkLogId**: 36자 canonical UUID(RFC 9562 UUIDv7). 순수 값 객체라 형식(정규형)만 검증하고, + id 생성 라이브러리에는 의존하지 않습니다. 대소문자 정규화는 이 타입을 만들기 전에 웹 경계에서 + 끝냅니다(canonical 형태는 소문자 hex). 형식은 `8-4-4-4-12` hex 그룹입니다. +- **Period**: 작업 기간. `end == null` 이면 "진행 중"이라는 뜻입니다. 생성자에서 `end < start` + 를 막습니다(기간 역전 불가). +- **WorkLogOwner**: 소유자 식별자. 공백/빈 값을 막고 trim 합니다. + +### 열거형 — WorkLogStatus / WorkCategory / WorkLogSortField +- **WorkLogStatus**: OPEN → IN_PROGRESS → CLOSED. 전이 규칙은 WorkLog 가 강제합니다. +- **WorkCategory**: 포트폴리오 보드에 노출하는 작업 분류(INFRASTRUCTURE/DATABASE/BACKEND/PLATFORM). +- **WorkLogSortField**: 목록 정렬에 **허용된 필드만** 담은 닫힌 열거형입니다. 정렬 키를 ORM 에 + 넘기는 열린 문자열이 아니라 열거형으로 막아둔 이유는, 알 수 없는 필드를 쿼리까지 보내지 않고 + 웹 경계에서 400 으로 거절하기 위해서입니다(`?sort=field,direction` 계약을 안전하게 만드는 핵심). + +### WorkLogInvariantException +- WorkLog 불변식 위반을 알리는 예외입니다. `Reason`(명사형 안전 enum)만 담고, **운영 에러 + 코드·HTTP 상태·로깅은 모릅니다.** 도메인은 로거를 갖지 않고 운영 코드로 매핑하지도 않는다는 + 규칙 때문입니다. `reason()` 을 로그 라인과 `error.category` 로 번역하는 일은 application/web 의 + 책임입니다. 사유가 거친 명사라서 민감 정보가 클라이언트로 새지 않습니다. + +### WorkLogReserved (도메인 이벤트) +- "WorkLog 기간이 예약됨"이라는 도메인 사실을 표현하는 이벤트입니다. **전송(transport)과 + 무관**합니다 — 도메인 데이터(`WorkLogId`/`WorkCategory`/`LocalDate`)만 담고 broker/wire/HTTP + 타입을 일절 참조하지 않습니다. 이것을 발행 가능한 integration 이벤트로 바꾸는 일은 application + 경계의 몫입니다(`application.event.WorkLogReservedIntegrationEvent` 와 그 매퍼 참고). + +### 포트 — WorkLogRepository / WorkLogIdFactory / OutboxEventIdFactory +- **WorkLogRepository**: WorkLog 영속 아웃바운드 포트(구현은 adapter-persistence). `findPage` 는 + 한 페이지 + 전체 개수를 함께 돌려주며, `sortField`/`category` 가 `null` 이면 정렬/필터 없음을 + 뜻합니다. 전체 개수는 같은 필터 기준으로 세어 `meta.page.total` 이 필터된 결과를 반영하게 합니다. +- **WorkLogIdFactory / OutboxEventIdFactory**: id 를 만들어 주는 도메인 포트. 둘을 **굳이 따로 둔 + 이유**는, 애그리거트 식별자 타입(`WorkLogId`)이 이벤트 id 모양을 묶어버리지 않게 하기 위해서입니다. + 실제로는 둘 다 UUIDv7 기반이지만(identifier 어댑터가 구현), 도메인은 생성 방식을 모릅니다. + outbox 이벤트 id 는 outbox 봉투의 `eventId` 이자 `idempotencyKey` 로 쓰입니다(같은 값 → 이벤트 + 단위 중복 제거). + +### RepoStats +- 외부 저장소 통계를 정규화한 결과 값입니다. 아웃바운드 ACL(B7)을 통과한 뒤에만 만들어집니다. + +## application — 응용 (유스케이스) + +### Command / Query — 스스로 검증하는 입력 모델 +- `CreateWorkLogCommand`, `UpdateWorkLogCommand`, `DeleteWorkLogCommand`, `GetWorkLogQuery`, + `GetRepoStatsQuery`, `ListWorkLogsQuery`, `BatchCreateWorkLogsCommand` 는 **생성자에서 스스로 + 필수값을 검증**합니다("self-validating input model"). 이렇게 하면 어떤 인바운드 어댑터가 + 부르든(오늘은 웹, 내일은 메시징 컨슈머) 웹 경계의 jakarta validation 에 기대지 않고도 같은 + 계약을 보장받습니다. id 나 patch 가 null 인 것은 프로그래밍 오류이므로 생성 시점에 즉시 + 실패시킵니다("없음"은 `Patch.absent()` 로 표현하고 null 을 쓰지 않습니다). +- `UpdateWorkLogCommand` 의 필드는 `Patch<T>` 입니다. PATCH 의 3가지 상태 — 값 없음(변경 안 함) / + 명시적 null(필드 비움) / 값(교체) — 을 구분하기 위해서입니다. + +### CreateWorkLogUseCase — 생성 + outbox 동시 기록 (dual-write 금지) +- WorkLog 를 저장하면서 `WorkLogReserved` outbox 이벤트를 **같은 쓰기 트랜잭션 안에서** 함께 + 적재하는 예시입니다. 핵심은 `repository.save(...)` 와 `outboxAppendPort.append(...)` 가 같은 + `tx.inWrite { ... }` 블록 안에 있다는 점입니다. 저장이나 적재 중 하나라도 실패하면 트랜잭션 + 전체가 롤백됩니다 — "DB 한 번, 브로커 한 번" 식의 분리된 이중 쓰기(dual-write)를 막는 보장입니다. +- 순서: ① 저장된 애그리거트로 도메인 이벤트 생성 → ② 매퍼로 integration 이벤트로 변환(경계는 + application) → ③ 직접 만든 JSON 페이로드로 직렬화 → ④ 같은 트랜잭션에서 outbox 에 append. +- `eventId` 는 `OutboxEventIdFactory`(UUIDv7) 로 만들고, `idempotencyKey = eventId` 로 둡니다 + (이벤트 단위 중복 제거). +- `correlationId` 는 MDC 의 `correlation_id` 슬롯에서 읽습니다(인바운드 HTTP 필터가 채워줌). + 스케줄러/배치/테스트처럼 그 필터를 거치지 않는 경로에서는 값이 없으므로 `eventId` 로 자기 + 자신을 가리키게(self-correlation) 폴백합니다. +- 쓰기 작업이라 `@RequiresPermission("worklog:write")` 로 권한을 요구합니다(이 권한은 `user`/`admin` + 역할 묶음에 부여). + +### BatchCreateWorkLogsUseCase — 원자적 일괄 생성 +- 동기 일괄 생성은 **전부 성공 아니면 전부 롤백**입니다. 모든 항목을 하나의 `tx.inWrite` 단위로 + 처리하므로 한 항목이라도(매핑/검증/영속) 실패하면 배치 전체가 되돌려지고, 엔드포인트는 부분 + 성공 대신 단일 4xx 를 냅니다. (`BATCH_PARTIAL_FAILURE` 같은 부분 실패 형태는 비동기 폴링 + 응답용으로 예약돼 있고, 동기 배치에는 쓰지 않습니다.) + +### UpdateWorkLogUseCase — Patch 3-state 적용 +- 각 필드의 `Patch` 상태에 따라 다르게 동작합니다: `hasValue()` 면 교체, `isExplicitNull()` 이면 + 비움, 둘 다 아니면(없음) 변경하지 않습니다. 상태 전이는 도메인 메서드(`startProgress`/`close`)에 + 위임하고, OPEN 으로 되돌리는 시도는 잘못된 전이로 막습니다. + +### DeleteWorkLogUseCase — 권한 3-tier 가시화 +- 삭제는 파괴적인 admin 등급 작업이라 `@RequiresPermission("worklog:close")` 로 막습니다. + `worklog:close` 는 **`admin` 역할 묶음에만** 부여되고 `user` 에는 주지 않습니다(샘플 모델에는 별도 + "close" 유스케이스가 없어 delete 가 그 민감 작업 역할을 합니다). 이 한 군데가 3-tier 권한 모델을 + 눈에 보이게 만듭니다 — 인증된 `user` 는 여기서 거절되고 `admin` 만 통과합니다. (참고로 생성·수정· + 일괄 생성은 `worklog:write` 로 막고, 이 권한은 `user`/`admin` 양쪽에 부여됩니다.) + +### 읽기 모델(projection) — ListRecentWorkLogSummariesUseCase / WorkLogSummaryQueryPort / WorkLogSummary +- 이 세 타입은 **CQRS-lite "쿼리 우회(query bypass)"** 예시입니다. 보드/목록 화면처럼 가벼운 읽기는 + WorkLog 애그리거트를 통째로 복원하지 않고, 필요한 컬럼만 뽑은 projection 으로 읽습니다 + (`content`/`summary`/`techStack`/`links` 같은 무거운 상태를 빼서, lazy 컬렉션 조인 없는 컬럼-부분 + SELECT 가 됩니다). +- **이것은 강제 기본값이 아니라 선택적 최적화**입니다. 애그리거트를 통해 읽는 길 + (`WorkLogRepository`)도 단순한 읽기에서는 똑같이 유효한 기본 경로입니다. +- projection DTO 의 순수성: projection 은 **application 타입**이어야 합니다 — 도메인 애그리거트도, + JPA 엔티티도, 웹 DTO 도 아니어야 합니다. 도메인 *값* 타입(예: `WorkCategory` enum)을 참조하는 것은 + 허용됩니다(application 은 domain 에 의존 가능). 다만 쿼리 포트의 반환 시그니처에 도메인 애그리거트/ + JPA 엔티티/웹 타입이 새면 안 됩니다(`List<WorkLogSummary>` 같은 제네릭 인자까지 포함, ArchUnit 규칙 + `query_ports_do_not_leak_domain_jpa_or_web_types` 가 강제). +- **명명 규칙**: projection 을 반환하는 읽기 포트 이름은 `…QueryPort` 로 끝냅니다. +- **id 표현**: projection 의 `id` 는 애플리케이션의 표준 id 어휘인 36자 UUID 문자열입니다. 저장소 + 고유의 `UUID` 는 어댑터 안에서 변환되어, 읽기 모델은 저장 형식이 아니라 애플리케이션과 같은 + 식별자 형식을 말합니다. +- 격식(ceremony)은 그대로 지킵니다: projection 읽기여도 `QueryUseCase` 빈을 거치고 + (`@UseCaseCapability` 계약을 모든 읽기에 기계적으로 적용), 기본은 `tx.inRead` 안에서 읽습니다. + projection 도 저장소 기반이면 여전히 `READ_REPOSITORY` 입니다 — "projection 이냐 애그리거트냐"는 + 반환 *모양*의 축이고, 저장소 접근 *수준*과는 별개라서 새 capability enum 이 필요 없습니다. + +### WorkLogReservedIntegrationEvent / …Mapper — integration(wire) 이벤트 +- **IntegrationEvent**: 도메인 이벤트와 달리 아웃바운드 어댑터가 실제로 발행할(예: Kafka) + 직렬화 가능한 경계 소유 형태입니다. 도메인 사실을 wire 계약으로 옮기는 일 — 값 객체를 원시 + 타입으로 평탄화, wire 표현 선택 — 은 application 의 관심사라서 application 계층에 둡니다. 도메인은 + wire 를 모릅니다. +- **Mapper**: 도메인 이벤트 → integration 이벤트 변환을 **application 경계에서** 수행하고, 트랜잭션 + outbox 용 JSON 페이로드 문자열로 직렬화합니다. + - JSON 직렬화(`toJson`)는 의존성을 늘리지 않으려고 RFC 8259 §7 이스케이프까지 직접 구현했습니다. + 실제 프로젝트라면 스키마 직렬화 계약(Avro/Protobuf, 스키마 레지스트리 붙은 Jackson)을 써야 + 합니다 — 샘플은 새 의존성 0을 유지합니다. 세 필드는 도메인 식별자와 enum 이름뿐이라 PII/토큰/ + 원문 본문이 없습니다. + - 이벤트 타입 이름 `"worklog.reserved"`: `도메인.동사` 패턴의 안정적인 점-구분 논리명입니다. + outbox relay 규약(`topic = eventType`)에 따라 그대로 브로커 토픽 이름이 됩니다. integration + 이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가 + 깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것). + +### RepoStatsPort +- 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound). + +## adapter/web — 인바운드 HTTP / 보안 / DTO + +### WorkLogController +- 포트폴리오 보드 엔드포인트. 리소스 이름은 AIP-122 를 따릅니다: `/worklogs`(복수·소문자), + `/worklogs/repoStats`(lowerCamelCase 하위 세그먼트), 콜론-동사 커스텀 메서드 + `/worklogs:batchCreate`. `/v1` 버전 접두사는 `PresentationWebConfig` 가 중앙에서 붙입니다. +- **GET /worklogs**: 페이지네이션 + (선택) 정렬 + (선택) 평면 동등 필터. 정렬 문자열은 + `SortParam.parse` 가 파싱해 native 가 아닌 구문은 400 으로 거절하고, 허용 목록에 없는 필드도 + 거절합니다. offset 이 너무 깊으면 커서 사용을 권하는 `Deprecation` 헤더를 답니다. +- **GET /worklogs/{id}**: `ETag` 를 내보내고, `If-None-Match` 가 맞으면 본문 없이 304 를 답니다. +- **PATCH /worklogs/{id}**: `If-Match` 가 오면 현재 ETag 와 같아야 하고, 다르면 412(낙관적 동시성). + `If-Match` 가 없으면 검사를 건너뜁니다 — 스켈레톤은 권장 패턴을 보여주되 강제하지는 않습니다. + `ETag`/`If-Match` 는 HTTP 전송 관심사(RFC 7232)라, 읽고-비교하는 로직을 일부러 웹 어댑터에만 + 두고 application/domain 은 ETag 를 전혀 보지 않게 합니다. +- **POST /worklogs**: `Idempotency-Key` 헤더는 POST 요청 표면의 일부로 받아만 둡니다. 키의 + 모양·범위·재생(replay) 의미는 별도 계약(rate-limit-idempotency)의 몫이라, 그 브랜치가 replay 를 + 배선하기 전까지는 서버가 헤더를 그냥 허용(무시)합니다. +- **POST /worklogs:batchCreate**: AIP-136 콜론-동사 일괄 생성. **동기 = 원자적**이라 한 항목이라도 + 실패하면 배치 전체가 롤백되고 단일 4xx 가 납니다. 한 번에 보낼 수 있는 항목 수는 + `MAX_BATCH_SIZE`(1000)로 제한하고, 넘으면 400 입니다. 요청 봉투는 `{ "requests": [ ... ] }` 형태입니다. +- **소유자(owner) 스탬프**: 생성되는 WorkLog 에는 인증된 호출자의 IdP subject 를 소유자로 찍습니다. + 보안 컨텍스트에서 null 안전하게 읽으며, 이 값은 **가공 전 원시 principal id** 입니다 — + 가명화(pseudonymization)는 별도 프라이버시 계약의 몫이라 여기서 적용하지 않고, 소유자 범위 + 인가(ABAC)는 의도적으로 뒤로 미룬 확장입니다. 유스케이스에 건 `worklog:write` 권한이 생성이 실제 + 실행될 시점엔 인증된 principal 이 있음을 보장합니다. +- **id 정규화(`toId`)**: 대소문자 무관 canonical UUID 경로 입력을 받아, 도메인 id 를 만들기 전에 + 표준 36자 소문자 UUID 로 정규화합니다. 여기서 쓰는 `UUID.fromString(...)` 은 (생성기가 아니라) 파서라, + 잘못된 id 면 `IllegalArgumentException` 을 던지고 전역 핸들러가 이를 HTTP 400 으로 매핑합니다. + +### OperationsController / SampleOperationStore / WorkLogExportResult — 장기 실행 작업(LRO) 예시 +- `POST /worklogs:export` 는 `202 Accepted` + 폴링 경로를 가리키는 `Location` 헤더 + `operationId`/ + `statusUrl` 을 담은 봉투를 돌려주고, `GET /operations/{id}` 로 상태를 폴링합니다. 상태는 5값 + `OperationStatus` enum 에서 옵니다. +- **operation id 를 컨트롤러가 아니라 `SampleOperationStore` 에서 만드는 이유**: 컨트롤러/유스케이스는 + id 를 직접 생성하면 안 된다는 규칙(`no_uuid_random_in_controller`) 때문입니다. `SampleOperationStore` + 는 컨트롤러도 application 서비스도 아닌 웹 인프라 컴포넌트라 여기서 id 를 발급해도 됩니다. +- **단순화**: 이 "export" 는 동기로 끝나므로, 클라이언트가 폴링할 때 저장된 operation 은 이미 + `SUCCEEDED` 입니다. 진짜 비동기 잡이라면 PENDING → RUNNING → 종료 로 전이하겠지만, wire 모양 + (202 + Location + 폴링 enum)은 동일합니다. + +### SamplePolymorphicRequest — 안전한 다형 역직렬화(B5) +- 다형 JSON 을 안전하게 받는 표준 예시입니다. `@JsonTypeInfo(use = NAME, property = "kind")` + + 명시적 `@JsonSubTypes` 허용 목록은, RCE 취약점(CVE-2019-14379)의 입구인 + `ObjectMapper.enableDefaultTyping()` 대신 Jackson 이 권장하는 방식입니다. 목록에 없는 하위 타입은 + `InvalidTypeIdException` 으로 거절되고, 계약 핸들러가 이를 `MAPPING_FAILED` 로 보냅니다. +- `sealed` 키워드를 쓴 이유: 허용 목록과 타입 계층을 기계적으로 일치시키기 위해서입니다. `permits` + 만 추가하고 `@JsonSubTypes.Type` 을 안 넣거나(혹은 반대로) 하면 조용히 통과되는 게 아니라 + 컴파일/테스트에서 잡힙니다. + +### WorkLogWebMapper +- 요청 DTO ↔ application command 변환을 **웹 어댑터가 전부 책임**지게 모은 매퍼입니다. PATCH 도 + 컨트롤러에서 인라인으로 만들지 않고 매퍼를 거치게 해서 일관성을 지킵니다. `WorkLogId` 는 웹 + edge 에서 파싱(UUID 경로 정규화)해 넘겨받아, id 구문 관심사를 컨트롤러에 둡니다. +- 응답에는 **소유자(owner)를 일부러 넣지 않습니다** — 원시 principal id 노출은 프라이버시 문제라, + 가명화는 별도 프라이버시 계약의 몫입니다. +- 링크 URI 가 잘못되면 `MappingException` 을 던져 전역 핸들러가 `MAPPING_FAILED`(400)로 보냅니다. + +### WorkLogIdSerializer +- `WorkLogId` 를 record 기본 모양(`{"value":"..."}`)이 아니라 **맨 36자 소문자 UUID 문자열**로 + 직렬화합니다. `@JsonComponent` 로 Spring Boot 가 자동 등록합니다. + +### DomainExceptionHandler / PortfolioErrorCode +- **DomainExceptionHandler**: 이 샘플의 도메인 예외를 `Envelope` 로 매핑합니다. 운영/전송/보안 + 예외는 스켈레톤의 기본 `GlobalExceptionHandler` 가 처리하고, Spring 이 두 advice 를 함께 적용합니다. + 이 핸들러를 `@Order(HIGHEST_PRECEDENCE)` 로 **기본 핸들러보다 앞**에 두는 이유는, 그렇지 않으면 + 기본 advice 의 포괄 핸들러(`@ExceptionHandler(Exception.class)`)가 도메인 예외를 먼저 잡아 + INTERNAL_ERROR 로 만들어 버리기 때문입니다. +- **PortfolioErrorCode**: 이 샘플 전용 도메인 에러 코드. 운영/전송/보안 코드는 공용 + `OperationalError` 에 있습니다. + +### WorkLogValidationGroups +- `@GroupSequence` 용 검증 그룹 순서(형식 → 불변식)를 정의합니다 — 형식 검사가 실패하면 불변식 + 평가를 건너뛰게 합니다. + +## adapter/persistence — RDBMS / JPA 영속 + +### WorkLogEntity +- WorkLog 애그리거트의 JPA 엔티티. `AuditableEntity` 를 상속해 `created_at/updated_at/created_by/ + updated_by` 감사 컬럼을 물려받습니다. **감사 필드는 엔티티(영속 계층)에만 있고 도메인 WorkLog 에는 + 없습니다** — 값은 `WorkLogRepositoryAdapter` 가 Clock + 감사 컨텍스트로 찍어줍니다. +- **id 저장 형식**: UUIDv7 을 varchar(36)가 아니라 PostgreSQL 16 의 native `uuid` 타입(16바이트 + 바이너리)으로 저장합니다. UUID 문자열↔native uuid 변환은 `WorkLogPersistenceMapper` 가 합니다. + (tenant 범위 컬럼/복합 인덱스는 tenant 정책 계약으로 미뤄둠.) +- `@Version version`: 낙관적 락 버전이자 웹 경계 ETag/If-Match 의 출처. Hibernate 가 증가를 + 관리하고, `null` 이면 새 행이라 `save()` 가 INSERT 합니다. + +### WorkLogPersistenceMapper — UUID 문자열 ↔ native uuid 변환 +- 36자 canonical UUID 와 PostgreSQL native `uuid`(128비트)를 서로 변환합니다. `UuidCodec` 같은 공용 + 코덱이 아니라 JDK `java.util.UUID` 를 **직접** 쓰는 이유: 영속 어댑터는 경계 규칙상 + `adapter-outbound`(코덱이 있는 곳)에 의존하면 안 되기 때문입니다(stdlib 이라 의존 문제 자체가 없음). +- 엔티티로 변환할 때 도메인 `version` 을 그대로 실어, JPA 가 새 행(`null` → INSERT)과 추적 중인 행 + (non-null → 낙관적 merge)을 구분할 수 있게 합니다. + +### WorkLogRepositoryAdapter — 저장 + 감사 스탬프 +- `WorkLogRepository` 포트 구현체. 감사 메타데이터(생성/수정 시각·주체)를 **여기 영속 어댑터에서** + 찍습니다(생성자 주입 Clock + 감사 컨텍스트). 도메인 WorkLog 는 감사 필드를 들고 있지 않습니다. +- INSERT(버전 null): 새 애그리거트라 created/updated 둘 다 지금 시각·주체로 초기화합니다. +- UPDATE(버전 non-null): 기존 행의 created 값을 읽어 그대로 이어가고(같은 트랜잭션에서 유스케이스가 + 이미 로드해둔 JPA 1차 캐시에서 served), updated 만 갱신합니다. + +### WorkLogJpaRepository +- `findByCategory`: 계약이 허용하는 유일한 필터 구문인 **평면 동등 필터**를 데이터 계층 안에 둔 + 파생 쿼리입니다. +- `findRecentSummaryRows`: CQRS-lite projection 읽기. JPQL `SELECT new` 생성자 표현식으로 요약 + 컬럼만 골라(`content/summary/techStack/links` 제외) `WorkLogSummaryRow` 로 담는 컬럼-부분 읽기라, + 애그리거트 복원과 lazy `@ElementCollection` 조인을 건너뜁니다. 닫힌 인터페이스 projection 대신 + `SELECT new`/`JdbcTemplate` 을 택한 이유: 닫힌-projection 의 컬럼 가지치기는 구현체 의존적이고 + Hibernate 6 에서 검증되지 않았기 때문입니다. + +### WorkLogSummaryQueryAdapter / WorkLogSummaryRow +- **WorkLogSummaryQueryAdapter**: application `WorkLogSummaryQueryPort` 구현체. JPQL projection 쿼리에 + 위임하고, 저장소 native `WorkLogSummaryRow`(UUID id)를 application `WorkLogSummary`(UUID 문자열 id)로 + 매핑합니다. **저장소 native `UUID` 는 이 어댑터를 벗어나지 않습니다** — application 에 노출되는 읽기 + 모델은 canonical UUID 문자열 형식으로 말합니다. +- **WorkLogSummaryRow**: projection 의 영속-쪽 읽기 행. JPQL `SELECT new` 의 대상이며 저장 모양 + (id 가 native `UUID`)을 그대로 반영합니다. 이 행을 persistence 패키지 안에 두는 것 자체가 native + `UUID` 가 어댑터를 벗어나지 못하게 막는 장치입니다. + +### JpaConfig +- Spring Boot 메인 클래스가 `dev.caskeleton.bootstrap` 에 있어, 패키지 기준 기본 스캔이 + `…sample.portfolio.adapter.persistence.*` 를 놓칩니다. 그래서 엔티티/리포지토리 스캔 위치를 여기서 + 명시해, 그 배선을 실제로 소유하는 모듈 안에 둡니다. + +## adapter/outbound — 아웃바운드 통합 (저장소 통계 ACL) + +### RepoStatsPortClient / RepoStatsAclMapper / RawRepoStatsResponse +- **ACL(Anti-Corruption Layer) 패턴(B7) 예시**입니다. 외부 응답이 도메인으로 들어오기 전에 반드시 + ACL 매퍼를 거치게 해서, 원시 외부 타입이 도메인을 오염시키지 못하게 합니다. +- **RepoStatsPortClient**: 실제 HTTP 호출(`fetchRaw`)을 추상화해 템플릿을 가볍게 유지합니다 — 실제 + 프로젝트라면 여기에 WebClient/RestClient 를 끼웁니다. 원시 응답은 ACL 을 통과한 뒤에야 도메인 타입이 + 됩니다. +- **RepoStatsAclMapper**: ACL 의 세 가지 일 — 정규화(full name 소문자화), 마스킹(echoedToken 은 버려서 + 도메인에 닿지 않음), 공개 필드 선택(`fullName/stargazers/pushedAt` 만 `RepoStats` 로). 필수 필드가 + 없으면 `MappingException`. +- **RawRepoStatsResponse**: 외부 제공자 원시 모양. 도메인으로 넘어가지 않으며 package-private 입니다. + +## adapter/identifier — ID 생성 (UUIDv7) + +### UuidWorkLogIdFactory / UuidOutboxEventIdFactory +- 도메인 id 포트(`WorkLogIdFactory`, `OutboxEventIdFactory`)의 인프라 구현체입니다. +- 둘 다 `UuidCreator.getTimeOrderedEpochPlus1()` 을 씁니다 — RFC 9562 UUIDv7(time-ordered)이며 같은 + 밀리초 안에서도 단조 증가(monotonic)하고 secure random 으로 뒷받침됩니다. +- **여기가 샘플에서 `UuidCreator` 호출이 허용되는 유일한 곳**입니다. `no_uuid_random_in_controller` + ArchUnit 규칙이 웹/application 계층의 직접 id 생성을 금지하기 때문에, id 생성은 이 인프라 어댑터에만 + 둡니다. + +## bootstrap — 샘플 전용 구성 루트 + +> **이 패키지가 왜 존재하는가 (공통 배경):** 프로덕션 조립은 `app-bootstrap` 모듈이 합니다. 그런데 +> `sample-portfolio` 는 일부러 `app-bootstrap` 에 의존하지 않습니다(샘플이 부트스트랩을 끌어오면 +> 안 됨). 그래서 스캔으로 자동 발견되지 않고 오직 `app-bootstrap` 에만 있는 구성 루트 빈들 +> (tracing/metrics/idempotency/privacy/management/domain-context 등)을, 여기 `bootstrap.*` 패키지가 +> **샘플 전용으로 동등하게 복제**해 제공합니다. 모두 일회용(disposable)입니다 — `sample-portfolio` +> 모듈을 지우면 함께 사라지고 프로덕션에는 영향이 없습니다. 프로덕션(`CaSkeletonApplication`)은 원래 +> `app-bootstrap` 빈들을 그대로 씁니다. + +### SamplePortfolioApplication — 독립 실행 구성 루트 +- `dev.caskeleton` 하위 전부를 스캔해 프로덕션 모듈의 Spring 컴포넌트(adapter-web `SecurityConfig`, + adapter-persistence `PersistenceJpaConfig` 등)를 자동으로 발견·배선합니다. `app-bootstrap` 에만 있어 + 자동 발견되지 않는 구성 루트만 위 `bootstrap.*` 가 채웁니다. +- **`@SpringBootApplication` 대신 `@Configuration` + `@EnableAutoConfiguration` + `@ComponentScan` 을 + 쓴 이유**: `@SpringBootApplication` 은 `@SpringBootConfiguration` 을 포함하는데, 그러면 이 클래스가 + Spring Boot 의 `AnnotatedClassFinder` 에 보입니다. 그 finder 가 이 클래스와 (같은 패키지·테스트 + classpath 의) `SamplePortfolioTestApplication` **둘 다** 찾으면 "multiple @SpringBootConfiguration" + 오류를 냅니다. `@Configuration`(= `@SpringBootConfiguration` 아님)을 쓰면 finder 에 숨으면서도, + `@SpringBootTest(classes = SamplePortfolioApplication.class)` 로 부팅하는 데는 문제가 없습니다. +- **`@ComponentScan` 에 exclude 두 개를 둔 이유**: + - `PostgreSqlPersistenceConfig` 제외 — 이 클래스는 `@PersistenceContext EntityManager` 필드와 + `FlywayConfigurationCustomizer @Bean` 을 함께 가져, 깨지지 않는 초기화 순환을 만듭니다 + (flyway → customizer 수집 → PostgreSqlPersistenceConfig 인스턴스화 → `@PersistenceContext` 가 + entityManagerFactory 를 당김 → 그건 flywayInitializer 에 의존 → 순환). 대신 + `SamplePostgreSqlPersistenceConfig` 가 static `@Bean` 으로 동일 빈을 제공해 순환을 끊습니다(아래 참고). + - `TestEnclosedConfigurationFilter` 제외 — 테스트 클래스 안에 중첩된 `@Configuration` 들이 넓은 + 컴포넌트 스캔에 잡혀, 두 테스트 픽스처가 같은 이름의 빈(예: `clock`)을 정의하면 + `BeanDefinitionOverrideException` 이 나기 때문입니다. + +### SamplePostgreSqlPersistenceConfig — Flyway ↔ EntityManager 초기화 순환 끊기 +- `adapter-persistence-postgresql` 의 `PostgreSqlPersistenceConfig` 를 샘플 전용으로 대체한 것입니다. +- **왜 필요한가**: 원본은 `@PersistenceContext EntityManager` 필드와 `FlywayConfigurationCustomizer` + 빈을 함께 등록합니다. Spring Boot 의 Flyway 자동 구성은 `entityManagerFactory` 가 준비되기 전에 모든 + customizer 빈을 모읍니다(Flyway 가 먼저 마이그레이션을 돌려야 JPA 가 스키마를 검증할 수 있으니까). + 그 customizer 를 모으려고 `PostgreSqlPersistenceConfig` 를 인스턴스화하면 `@PersistenceContext` 가 + `entityManagerFactory` 를 당기는데, 그 빈은 `flywayInitializer` 완료에 의존합니다 → + `flyway → PostgreSqlPersistenceConfig → entityManagerFactory → flywayInitializer → flyway` 의 끊을 수 + 없는 순환이 생깁니다. +- **어떻게 끊는가**: customizer 를 **static `@Bean`** 으로 등록합니다. Spring 은 static `@Bean` 을 + 소유 클래스 인스턴스화 없이 호출하므로, Flyway 초기화 동안 `@PersistenceContext` 가 처리되지 + 않습니다. `EntityManager` 는 (런타임에 실제로 필요해질 때까지 미루도록) 빈 경로에서 늦게 해소됩니다. +- **마이그레이션 위치 주의**: 이 customizer 가 두 위치(프로덕션 `db/migration/postgresql` + 샘플 전용 + `db/sample-migration`)를 모두 설정합니다. `configuration.locations(...)` 는 기존 설정을 **덮어쓰므로** + (append 아님), `application.yml` 의 `spring.flyway.locations` 는 런타임 효과 없는 문서용일 뿐이고 + 실제 기준은 이 customizer 입니다. + +### TestEnclosedConfigurationFilter — 테스트 중첩 @Configuration 제외 +- 테스트 클래스 안에 중첩된 `@Configuration` 을 컴포넌트 스캔에서 빼는 필터입니다. +- **문제**: `:sample-portfolio:test` 를 돌리면 테스트 클래스가 classpath 에 올라옵니다. 테스트 안 + static 중첩 `@Configuration`(예: `WorkLogAuthorizationContractTest$AuthzTestConfig`)이 평범한 + `@Configuration` 이라, 전체 컨텍스트로 부팅할 때 스캔에 발견·등록됩니다. 두 테스트 설정이 같은 이름의 + 빈(예: `clock`)을 정의하면 `BeanDefinitionOverrideException` 이 납니다(override 기본 false). +- **휴리스틱**: 바이너리 이름에 `$` 가 있고 그 바깥(enclosing) 클래스 이름이 `Test` 로 끝나면(자바 + 테스트 클래스 관례) 제외합니다. 테스트 프레임워크 의존성을 프로덕션 소스에 들이지 않고도 중첩 + 테스트 픽스처를 전부 거릅니다. + +### 분산 추적 복제본 — SampleTracingConfig / …Settings / …SampleRateResolver / …EnvironmentPostProcessor / SampleMicrometerSpanErrorRecorder +- `app-bootstrap` 의 tracing 구성 루트를 샘플 전용으로 옮긴 것들입니다(위 "공통 배경" 참고). tracing + 런타임(OTel/Micrometer 브리지)이 샘플 build.gradle 에 켜져 있지만, 그 빈들을 등록하는 조립 코드는 + `app-bootstrap` 에만 있어서 복제했습니다. +- **per-profile 기본 샘플 레이트(SampleRateResolver)**: `prod=0.01`, `staging=0.10`, `dev/local=1.0` + (그 외 프로파일도 1.0). 설정값(`APP_TRACING_SAMPLE_RATE`)이 `[0.0, 1.0]` 범위로 들어오면 그 값이 + per-profile 기본을 이깁니다. +- **EnvironmentPostProcessor**: 해소된 effective 레이트를 native 키 + `management.tracing.sampling.probability` 로 이어줘, 실제 OTel sampler 가 gauge 값과 일치하게 합니다. + 사용자가 native 키를 직접 지정했으면 그 값을 우선합니다. (이 PostProcessor 는 샘플 자신의 + `META-INF/spring.factories` 로 등록됩니다.) +- **SampleMicrometerSpanErrorRecorder**: 현재 span 에 예외를 `error.code` 태그와 함께 기록(D12). + Tracer 가 있으면 실제 recorder, 없으면 NOOP 로 동작합니다(adapter-web `GlobalExceptionHandler` 가 + `ObjectProvider` 로 집어 씀). + +### 그 밖의 부트스트랩 복제본 +- **SampleMetricsContractConfig**: Micrometer `MeterFilter` 두 개를 meter 등록 전에 설치합니다 — + 고-cardinality 태그 키를 막는 필터(D8)와 SLO 기반 히스토그램 분포 필터(D9, 소유한 timer 이름에만 적용). +- **SampleIdempotencyConfig / …Settings**: 스캔된 `IdempotencyStoreAdapter`/`IdempotencyReaper` 와 + `IdempotencyExecutor` 가 모두 `Clock` 을 필요로 하고, `@EnableScheduling` 이 reaper 의 `@Scheduled` + 정리를 켭니다. TTL 은 양수이며 ≤ 72h 여야 합니다(아니면 시작 실패), reaper 주기 기본 10분. +- **SamplePseudonymizationConfig / SamplePrivacySettings**: adapter-web `RequestLoggingFilter` 가 + `UserPrincipalPseudonymizerPort` 를 주입받으므로 그 포트를 채웁니다. salt 는 + `APP_PRIVACY_PSEUDONYMIZATION_SALT` 에서 오며, 비어 있으면 로컬/테스트가 시작은 되도록 dev 센티넬 + salt 로 폴백합니다(프로덕션 전엔 실제 시크릿 값을 넣을 것). +- **SampleDomainContextConfig**: `adapter-persistence-rdbms` 의 `DomainContextAuditContextPort` 가 + `DomainContextPropagator` 빈을 무조건 주입받으므로, 기본 `THREAD_LOCAL` 전략 구현 + (`ThreadLocalDomainContextPropagator`)을 제공합니다 — async 데코레이터를 안 켜는 WorkLog 데모에 + 적합합니다. diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle new file mode 100644 index 00000000..ad4fccb4 --- /dev/null +++ b/src/sample-portfolio/build.gradle @@ -0,0 +1,93 @@ +// Fixture/sample module. Production modules must not depend on this module. +// Lean standalone boot: apply the Spring Boot plugin so bootJar / bootRun are available. +apply plugin: 'org.springframework.boot' + +// The objectstorage adapter (runtimeOnly) pulls in software.amazon.awssdk:s3, whose version is +// managed by the AWS SDK v2 BOM (NOT the Spring Boot BOM). Import that BOM at this consumer's scope +// — mirroring the objectstorage module — so the transitive s3 dependency resolves on this module's +// runtime/test classpath. The AWS-SDK version SSOT stays ext.awsSdkVersion. +dependencyManagement { + imports { + mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}" + } +} + +dependencies { + implementation project(':domain-core') + implementation project(':application-core') + implementation project(':adapter:outbound:persistence-jpa') + implementation project(':adapter:outbound:identifier') + // Object storage adapter (filesystem default / S3-MinIO opt-in) provides the ObjectStoragePort + // bean at runtime for the Poster image-upload use case; the port itself lives in application-core. + runtimeOnly project(':adapter:outbound:objectstorage') + implementation project(':adapter:inbound:web') + implementation project(':shared-contract') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // UUIDv7 generation (id factory) + UUID/String conversion (persistence mapper, web path). + implementation 'com.github.f4b6a3:uuid-creator:6.1.1' + // PATCH 3-state (absent / explicit-null / value) via JsonNullable. See README. + implementation 'org.openapitools:jackson-databind-nullable:0.2.6' + // Lean standalone boot: actuator + prometheus + tracing runtime + dotenv (mirrors app-bootstrap). + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + // Spring dotenv: picks up src/.env when bootRun workingDir = src/ (same as app-bootstrap). + implementation 'me.paulschwarz:spring-dotenv:4.0.0' + // Boot 4 Flyway API/autoconfiguration needed by sample-local persistence configuration. + implementation 'org.springframework.boot:spring-boot-flyway' + // Flyway core API needed by MigrationStartupConfig (scanned from adapter-persistence). + implementation 'org.flywaydb:flyway-core' + // Micrometer core for OutboxMetrics / BackgroundJobMetrics. + implementation 'io.micrometer:micrometer-core' + // (spring-boot-starter-security is already declared above; it also backs ManagementSecurityConfig.) + // Property-based testing for @ValueObject invariants (registers its own JUnit Platform engine). + testImplementation 'net.jqwik:jqwik:1.9.1' + // Real-DB integration test for the persistence adapter (disposable Postgres, real Flyway + JPA). + testImplementation 'org.testcontainers:testcontainers-postgresql' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + // @ServiceConnection supplies datasource connection details to Spring context/slice tests. + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + // Boot 4 split JPA/JDBC slice annotations and TestEntityManager into dedicated test modules. + testImplementation 'org.springframework.boot:spring-boot-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-jdbc-test' + testRuntimeOnly 'org.postgresql:postgresql' + // Spring Security test support (MockMvc + @WithMockUser) for any slice tests. + testImplementation 'org.springframework.security:spring-security-test' +} + +// feature-contract-verification-test-suite §5 — OpenAPI drift gate. +// OpenApiDriftContractTest runs as a normal test (so `check` is release-blocking on drift) AND +// can be invoked through this task to (re)generate the committed snapshot after an intentional +// API change: ./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange +tasks.register('openapiCheckSnapshot', Test) { + group = 'verification' + description = 'OpenAPI drift gate: runtime springdoc /v3/api-docs vs the committed snapshot. ' + + '-PapproveOpenApiChange regenerates the committed baseline.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching 'dev.caskeleton.sample.portfolio.adapter.inbound.web.contract.OpenApiDriftContractTest' + } + systemProperty 'openapi.snapshot.write', project.hasProperty('approveOpenApiChange') ? 'true' : 'false' + // Always re-check drift; never serve a stale UP-TO-DATE result. + outputs.upToDateWhen { false } + // Pin UTC like the main test task for host-locale independence. + jvmArgs '-Duser.timezone=UTC' +} + +bootJar { + mainClass = 'dev.caskeleton.sample.portfolio.SamplePortfolioApplication' +} + +// Run from the repo's src/ root so src/.env is picked up (same as app-bootstrap). +bootRun { + workingDir = rootProject.projectDir +} diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile new file mode 100644 index 00000000..42414441 --- /dev/null +++ b/src/sample-portfolio/gradle.lockfile @@ -0,0 +1,334 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-api:1.9.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-engine:1.9.1=testRuntimeClasspath +net.jqwik:jqwik-time:1.9.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik-web:1.9.1=testCompileClasspath,testRuntimeClasspath +net.jqwik:jqwik:1.9.1=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpcore:4.4.16=checkstyle,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath +org.aspectj:aspectjweaver:1.9.25=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath +org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.reflections:reflections:0.10.2=checkstyle +org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springdoc:springdoc-openapi-starter-common:2.8.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-testcontainers:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +software.amazon.awssdk:annotations:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:apache-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:arns:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-query-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:aws-xml-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:checksums-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:checksums:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:crt-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:endpoints-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-aws:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:http-client-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:identity-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:json-utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:metrics-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:netty-nio-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:profiles:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:protocol-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:regions:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:retries-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:retries:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:s3:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:sdk-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:third-party-jackson-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.awssdk:utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +software.amazon.eventstream:eventstream:1.0.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java new file mode 100644 index 00000000..c0430211 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java @@ -0,0 +1,52 @@ +package dev.caskeleton.sample.portfolio; + +import dev.caskeleton.adapter.inbound.web.auth.SecurityConfig; +import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig; +import dev.caskeleton.sample.portfolio.bootstrap.scan.TestEnclosedConfigurationFilter; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; + +/** + * Standalone composition root for the WorkLog sample/reference application. Scans all {@code + * dev.caskeleton} sub-packages so production-module components are auto-discovered; + * composition-root beans that live only in {@code app-bootstrap} are provided by the sample-local + * {@code bootstrap.*} package. Disposable: deleting the {@code sample-portfolio} module removes it + * without affecting production. + * + * <p>See README for the wiring rationale: why {@code @Configuration} is used instead of + * {@code @SpringBootApplication}, why the two component-scan exclusions exist, and the + * lean-bootstrap omissions a full clone would add. + */ +@Configuration +@EnableAutoConfiguration +@ComponentScan( + basePackages = "dev.caskeleton", + excludeFilters = { + // Replaced by SamplePostgreSqlPersistenceConfig to break a Flyway/EntityManager + // init cycle (see README). + @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + PostgreSqlPersistenceConfig.class, + // The standalone sample is a public demo app: URL access should not require a + // configured IdP or method-security principal. Production modules keep these guards. + SecurityConfig.class, + MethodSecurityConfig.class + }), + // Excludes @Configuration nested in test classes (see README). + @ComponentScan.Filter( + type = FilterType.CUSTOM, + classes = TestEnclosedConfigurationFilter.class) + }) +@ConfigurationPropertiesScan(basePackages = "dev.caskeleton") +public class SamplePortfolioApplication { + + public static void main(String[] args) { + SpringApplication.run(SamplePortfolioApplication.class, args); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java new file mode 100644 index 00000000..b52dceb4 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java @@ -0,0 +1,45 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.operation.SampleOperationStore; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.operation.WorkLogExportResult; +import dev.caskeleton.shared.operation.Operation; +import java.net.URI; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Long-running-operation fixture. {@code POST /worklogs:export} returns {@code 202 Accepted} + a + * {@code Location} header + a polling envelope; {@code GET /operations/{id}} returns the {@link + * Operation}. The id is minted by {@link SampleOperationStore}, never by this controller. See + * README. + */ +@RestController +public class OperationsController { + + private final SampleOperationStore operations; + private final PresentationSettings presentationSettings; + + public OperationsController( + SampleOperationStore operations, PresentationSettings presentationSettings) { + this.operations = operations; + this.presentationSettings = presentationSettings; + } + + @PostMapping("/worklogs:export") + public ResponseEntity<Operation<WorkLogExportResult>> export() { + Operation<WorkLogExportResult> accepted = + operations.startExport(presentationSettings.apiBasePath(), 0L); + return ResponseEntity.accepted().location(URI.create(accepted.statusUrl())).body(accepted); + } + + @GetMapping("/operations/{id}") + public Operation<WorkLogExportResult> poll(@PathVariable String id) { + return operations + .find(id) + .orElseThrow(() -> new IllegalArgumentException("unknown operation id: " + id)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java new file mode 100644 index 00000000..2574c611 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java @@ -0,0 +1,195 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import dev.caskeleton.adapter.inbound.web.pagination.PageParams; +import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; +import dev.caskeleton.adapter.inbound.web.pagination.SortParam; +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreatePosterRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdatePosterRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterWebMapper; +import dev.caskeleton.sample.portfolio.application.command.ArchivePosterCommand; +import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; +import dev.caskeleton.sample.portfolio.application.command.PublishPosterCommand; +import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.poster.ArchivePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.CreatePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.DeletePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.GetPosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.ListPostersUseCase; +import dev.caskeleton.sample.portfolio.application.poster.PublishPosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.UpdatePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; +import dev.caskeleton.sample.portfolio.application.query.GetPosterQuery; +import dev.caskeleton.sample.portfolio.application.query.ListPostersQuery; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; +import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import dev.caskeleton.shared.error.MappingException; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.response.PageMeta; +import dev.caskeleton.shared.response.ResponseMeta; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * Promotional-poster endpoints. Resource naming follows AIP-122 ({@code /posters}); lifecycle + * actions are modelled as sub-resource paths ({@code /posters/{id}/image}, {@code /publish}, {@code + * /archive}) — a resource-level colon-verb ({@code {id}:publish}) is rejected by the AIP-122 + * guardrail, which only permits collection-level colon verbs. The {@code /v1} prefix is applied + * centrally. The upload → publish flow is what ties object storage to a domain invariant (a poster + * cannot be published without an image). Mirrors {@code WorkLogController} (conditional-request / + * owner / batch machinery deliberately omitted — WorkLog demonstrates those). + */ +@RestController +public class PosterController { + + private final CreatePosterUseCase create; + private final UpdatePosterUseCase update; + private final UploadPosterImageUseCase uploadImage; + private final PublishPosterUseCase publish; + private final ArchivePosterUseCase archive; + private final DeletePosterUseCase delete; + private final GetPosterUseCase get; + private final ListPostersUseCase list; + + public PosterController( + CreatePosterUseCase create, + UpdatePosterUseCase update, + UploadPosterImageUseCase uploadImage, + PublishPosterUseCase publish, + ArchivePosterUseCase archive, + DeletePosterUseCase delete, + GetPosterUseCase get, + ListPostersUseCase list) { + this.create = create; + this.update = update; + this.uploadImage = uploadImage; + this.publish = publish; + this.archive = archive; + this.delete = delete; + this.get = get; + this.list = list; + } + + /** Paginated, optionally sorted + status-filtered listing with {@code meta.page}. */ + @GetMapping("/posters") + public Envelope<List<PosterResponse>> list( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) String sort, + @RequestParam(required = false) PosterStatus status) { + + PageParams pageParams = PageParams.of(page, size); + PosterSortField sortField = null; + boolean ascending = true; + String appliedSort = null; + if (sort != null && !sort.isBlank()) { + SortParam parsed = SortParam.parse(sort); // rejects non-native syntax → 400 + sortField = PosterSortField.fromRequest(parsed.field()); + if (sortField == null) { + throw new PageValidationException( + "sort", + "SORT_FIELD_NOT_ALLOWED", + "sort field '" + parsed.field() + "' is not sortable"); + } + ascending = parsed.ascending(); + appliedSort = parsed.canonical(); + } + + PosterPage result = + list.handle( + new ListPostersQuery( + pageParams.page(), pageParams.size(), sortField, ascending, status)); + List<PosterResponse> body = result.items().stream().map(PosterWebMapper::toResponse).toList(); + + PageMeta pageMeta = pageParams.toPageMeta(result.total(), appliedSort); + ResponseMeta meta = ResponseMetaFactory.fromMdc().withPage(pageMeta); + return Envelope.ok(body, meta); + } + + @GetMapping("/posters/{id}") + public PosterResponse getOne(@PathVariable String id) { + return PosterWebMapper.toResponse(get.handle(new GetPosterQuery(toId(id)))); + } + + @PostMapping("/posters") + public ResponseEntity<PosterResponse> create(@Valid @RequestBody CreatePosterRequest body) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(PosterWebMapper.toResponse(create.handle(PosterWebMapper.toCommand(body)))); + } + + @PatchMapping("/posters/{id}") + public PosterResponse patch( + @PathVariable String id, @Valid @RequestBody UpdatePosterRequest body) { + return PosterWebMapper.toResponse( + update.handle(PosterWebMapper.toUpdateCommand(toId(id), body))); + } + + /** + * Uploads the poster image (multipart) to object storage and records its key on the aggregate. An + * uploaded image is the precondition for {@link #publish}. + */ + @PostMapping(value = "/posters/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public StoredObjectResponse uploadImage( + @PathVariable String id, @RequestParam("file") MultipartFile file) { + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException e) { + throw new MappingException("failed to read uploaded file", e); + } + String contentType = + file.getContentType() == null ? "application/octet-stream" : file.getContentType(); + StoredObject stored = + uploadImage.handle(new UploadPosterImageCommand(toId(id), bytes, contentType)); + return PosterWebMapper.toStoredObjectResponse(stored); + } + + /** + * {@code DRAFT → PUBLISHED}. Fails with 400 {@code POSTER_IMAGE_REQUIRED} if no image is + * attached. + */ + @PostMapping("/posters/{id}/publish") + public PosterResponse publish(@PathVariable String id) { + return PosterWebMapper.toResponse(publish.handle(new PublishPosterCommand(toId(id)))); + } + + @PostMapping("/posters/{id}/archive") + public PosterResponse archive(@PathVariable String id) { + return PosterWebMapper.toResponse(archive.handle(new ArchivePosterCommand(toId(id)))); + } + + @DeleteMapping("/posters/{id}") + public ResponseEntity<Void> delete(@PathVariable String id) { + delete.handle(new DeletePosterCommand(toId(id))); + return ResponseEntity.noContent().build(); + } + + /** + * Accepts case-insensitive canonical UUID path input and normalises it to the canonical 36-char + * lowercase UUID. {@code UUID.fromString(...)} is a parser (throws {@link + * IllegalArgumentException} on a malformed id → HTTP 400). + */ + private static PosterId toId(String raw) { + return PosterId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java new file mode 100644 index 00000000..7bad8fee --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java @@ -0,0 +1,254 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.conditional.ETags; +import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; +import dev.caskeleton.adapter.inbound.web.pagination.PageParams; +import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; +import dev.caskeleton.adapter.inbound.web.pagination.SortParam; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreateWorkLogRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdateWorkLogRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.RepoStatsResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogSummaryResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.WorkLogWebMapper; +import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; +import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; +import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; +import dev.caskeleton.sample.portfolio.application.query.ListWorkLogsQuery; +import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; +import dev.caskeleton.shared.response.Envelope; +import dev.caskeleton.shared.response.PageMeta; +import dev.caskeleton.shared.response.ResponseMeta; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Portfolio board endpoints. Resource naming follows AIP-122 ({@code /worklogs}, {@code + * /worklogs/repoStats}, colon-verb {@code /worklogs:batchCreate}); the {@code /v1} prefix is + * applied centrally. Pagination/sort/filter drive the list, and conditional requests ({@code + * ETag}/{@code If-Match}/{@code If-None-Match}) drive GET/PATCH. See README. + */ +@RestController +public class WorkLogController { + + private final CreateWorkLogUseCase create; + private final BatchCreateWorkLogsUseCase batchCreate; + private final UpdateWorkLogUseCase update; + private final DeleteWorkLogUseCase delete; + private final GetWorkLogUseCase get; + private final ListWorkLogsUseCase list; + private final GetRepoStatsUseCase repoStats; + + public WorkLogController( + CreateWorkLogUseCase create, + BatchCreateWorkLogsUseCase batchCreate, + UpdateWorkLogUseCase update, + DeleteWorkLogUseCase delete, + GetWorkLogUseCase get, + ListWorkLogsUseCase list, + GetRepoStatsUseCase repoStats) { + this.create = create; + this.batchCreate = batchCreate; + this.update = update; + this.delete = delete; + this.get = get; + this.list = list; + this.repoStats = repoStats; + } + + /** Paginated, optionally sorted + flat-filtered listing with {@code meta.page}. */ + @GetMapping("/worklogs") + public ResponseEntity<Envelope<List<WorkLogSummaryResponse>>> list( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) String sort, + @RequestParam(required = false) WorkCategory category) { + + PageParams pageParams = PageParams.of(page, size); + WorkLogSortField sortField = null; + boolean ascending = true; + String appliedSort = null; + if (sort != null && !sort.isBlank()) { + SortParam parsed = SortParam.parse(sort); // rejects non-native syntax → 400 + sortField = WorkLogSortField.fromRequest(parsed.field()); + if (sortField == null) { + throw new PageValidationException( + "sort", + "SORT_FIELD_NOT_ALLOWED", + "sort field '" + parsed.field() + "' is not sortable"); + } + ascending = parsed.ascending(); + appliedSort = parsed.canonical(); + } + + WorkLogPage result = + list.handle( + new ListWorkLogsQuery( + pageParams.page(), pageParams.size(), sortField, ascending, category)); + List<WorkLogSummaryResponse> body = + result.items().stream().map(WorkLogWebMapper::toSummary).toList(); + + PageMeta pageMeta = pageParams.toPageMeta(result.total(), appliedSort); + ResponseMeta meta = ResponseMetaFactory.fromMdc().withPage(pageMeta); + + ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); + if (pageParams.isDeepOffset()) { // deep offset → recommend cursor + builder.header(ApiHeaders.DEPRECATION, "deep-offset-pagination"); + } + return builder.body(Envelope.ok(body, meta)); + } + + @GetMapping("/worklogs/repoStats") + public RepoStatsResponse repoStats(@RequestParam String repoUrl) { + return WorkLogWebMapper.toRepoStatsResponse(repoStats.handle(new GetRepoStatsQuery(repoUrl))); + } + + /** Emits an {@code ETag}; honours {@code If-None-Match} with a 304 (no body). */ + @GetMapping("/worklogs/{id}") + public ResponseEntity<WorkLogResponse> getOne( + @PathVariable String id, + @RequestHeader(value = ApiHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { + WorkLog workLog = get.handle(new GetWorkLogQuery(toId(id))); + String etag = etagOf(workLog); + if (ETags.matches(ifNoneMatch, etag)) { + return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build(); + } + return ResponseEntity.ok().eTag(etag).body(WorkLogWebMapper.toResponse(workLog)); + } + + /** + * {@code Idempotency-Key} is accepted on the POST surface but only tolerated (ignored) until the + * rate-limit/idempotency contract wires replay. See README. + */ + @PostMapping("/worklogs") + public ResponseEntity<WorkLogResponse> create( + @Valid @RequestBody CreateWorkLogRequest body, + @RequestHeader(value = ApiHeaders.IDEMPOTENCY_KEY, required = false) String idempotencyKey) { + WorkLog created = create.handle(WorkLogWebMapper.toCommand(body, currentOwnerSubject())); + return ResponseEntity.ok().eTag(etagOf(created)).body(WorkLogWebMapper.toResponse(created)); + } + + /** + * When {@code If-Match} is supplied it must match the current ETag, else 412 (optimistic + * concurrency); an absent {@code If-Match} is allowed. See README. + */ + @PatchMapping("/worklogs/{id}") + public ResponseEntity<WorkLogResponse> patch( + @PathVariable String id, + @RequestHeader(value = ApiHeaders.IF_MATCH, required = false) String ifMatch, + @Valid @RequestBody UpdateWorkLogRequest body) { + WorkLogId workLogId = toId(id); + requireIfMatch(ifMatch, workLogId); + WorkLog updated = update.handle(WorkLogWebMapper.toUpdateCommand(workLogId, body)); + return ResponseEntity.ok().eTag(etagOf(updated)).body(WorkLogWebMapper.toResponse(updated)); + } + + @DeleteMapping("/worklogs/{id}") + public ResponseEntity<Void> delete(@PathVariable String id) { + delete.handle(new DeleteWorkLogCommand(toId(id))); + return ResponseEntity.noContent().build(); + } + + /** + * AIP-136 colon-verb batch create. Sync = atomic: {@link BatchCreateWorkLogsUseCase} runs every + * item in one transaction, so any failure rolls back the whole batch (single 4xx, never partial + * success). See README. + */ + @PostMapping("/worklogs:batchCreate") + public List<WorkLogResponse> batchCreate( + @Valid @RequestBody BatchCreateRequest request, + @RequestHeader(value = ApiHeaders.IDEMPOTENCY_KEY, required = false) String idempotencyKey) { + String owner = currentOwnerSubject(); + List<WorkLog> created = + batchCreate.handle( + new BatchCreateWorkLogsCommand( + request.requests().stream() + .map(r -> WorkLogWebMapper.toCommand(r, owner)) + .toList())); + return created.stream().map(WorkLogWebMapper::toResponse).toList(); + } + + /** Batch size cap: a single sync batch may not exceed this many items (else 400). */ + public static final int MAX_BATCH_SIZE = 1000; + + /** AIP-233 batch request envelope: {@code { "requests": [ ... ] }}. */ + public record BatchCreateRequest( + @Valid + @Size(max = MAX_BATCH_SIZE, message = "batch may not exceed " + MAX_BATCH_SIZE + " items") + List<CreateWorkLogRequest> requests) {} + + private static String etagOf(WorkLog workLog) { + long version = workLog.version() == null ? 0L : workLog.version(); + return ETags.weakFromVersion(version); + } + + /** + * Optimistic-concurrency precondition. {@code ETag}/{@code If-Match} is an HTTP transport + * concern, so the read-then-compare stays in the web adapter; an absent/blank {@code If-Match} + * skips the check. See README. + */ + private void requireIfMatch(String ifMatch, WorkLogId id) { + if (ifMatch == null || ifMatch.isBlank()) { + return; + } + WorkLog current = get.handle(new GetWorkLogQuery(id)); + if (!ETags.matches(ifMatch, etagOf(current))) { + throw new PreconditionFailedException("If-Match does not match the current resource version"); + } + } + + /** + * The owner stamped on a created work log is the authenticated caller's IdP subject, read + * null-safely from the security context. This is the raw principal id (no pseudonymization here). + * See README. + */ + private static String currentOwnerSubject() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null + && auth.isAuthenticated() + && auth.getPrincipal() instanceof AuthenticatedPrincipal user) { + return user.idpUserId(); + } + return null; + } + + /** + * Accepts case-insensitive canonical UUID path input and normalises it to the canonical 36-char + * lowercase UUID. {@code UUID.fromString(...)} is a parser (not a generator) and throws {@link + * IllegalArgumentException} on a malformed id → HTTP 400. See README. + */ + private static WorkLogId toId(String raw) { + return WorkLogId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java new file mode 100644 index 00000000..e28a03f0 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java @@ -0,0 +1,11 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** Create a new poster (starts in DRAFT with no image). Bean Validation at the edge. */ +public record CreatePosterRequest( + @NotBlank(message = "title is required") + @Size(max = 200, message = "title must be at most 200 characters") + String title, + @Size(max = 2000, message = "caption must be at most 2000 characters") String caption) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java new file mode 100644 index 00000000..d2c5f1cf --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.WorkLogValidationGroups.Invariant; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.WorkLogValidationGroups.Syntax; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import jakarta.validation.GroupSequence; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; + +@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class}) +public record CreateWorkLogRequest( + @NotBlank(groups = Syntax.class) @Size(max = 200, groups = Syntax.class) String title, + @NotNull(groups = Syntax.class) WorkCategory category, + String summary, + String content, + List<String> techStack, + List<String> links, + @NotNull(groups = Syntax.class) LocalDate periodStart, + LocalDate periodEnd) { + + @AssertTrue(message = "periodEnd must not be before periodStart", groups = Invariant.class) + public boolean isPeriodOrdered() { + if (periodStart == null || periodEnd == null) { + return true; + } + return !periodEnd.isBefore(periodStart); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java new file mode 100644 index 00000000..4a369f9a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Reference for safe polymorphic deserialization. {@code @JsonTypeInfo(use = NAME)} plus an + * explicit {@code @JsonSubTypes} allowlist replaces Jackson's unsafe default-typing entry point + * (CVE-2019-14379); the {@code sealed} hierarchy keeps the allowlist mechanically aligned. See + * README. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind") +@JsonSubTypes({ + @JsonSubTypes.Type(value = SamplePolymorphicRequest.Text.class, name = "text"), + @JsonSubTypes.Type(value = SamplePolymorphicRequest.Image.class, name = "image") +}) +public sealed interface SamplePolymorphicRequest + permits SamplePolymorphicRequest.Text, SamplePolymorphicRequest.Image { + + record Text(String title, String body) implements SamplePolymorphicRequest {} + + record Image(String title, String imageUrl) implements SamplePolymorphicRequest {} +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java new file mode 100644 index 00000000..770d01b3 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import dev.caskeleton.shared.request.Patch; +import org.openapitools.jackson.nullable.JsonNullable; + +/** + * Partial update of a poster. {@code JsonNullable} captures the JSON merge-patch 3-state (field + * absent / explicit null / value) at the web edge, converted to {@link Patch} for the command. + * Mirrors {@code UpdateWorkLogRequest}. + */ +public record UpdatePosterRequest(JsonNullable<String> title, JsonNullable<String> caption) { + + public Patch<String> titlePatch() { + return toPatch(title); + } + + public Patch<String> captionPatch() { + return toPatch(caption); + } + + private static <T> Patch<T> toPatch(JsonNullable<T> field) { + if (field == null || !field.isPresent()) { + return Patch.absent(); + } + return field.get() == null ? Patch.ofNull() : Patch.of(field.get()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java new file mode 100644 index 00000000..eed5904f --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java @@ -0,0 +1,40 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import dev.caskeleton.shared.request.Patch; +import org.openapitools.jackson.nullable.JsonNullable; + +public record UpdateWorkLogRequest( + JsonNullable<String> title, + JsonNullable<String> summary, + JsonNullable<String> content, + JsonNullable<WorkCategory> category, + JsonNullable<WorkLogStatus> status) { + public Patch<String> titlePatch() { + return toPatch(title); + } + + public Patch<String> summaryPatch() { + return toPatch(summary); + } + + public Patch<String> contentPatch() { + return toPatch(content); + } + + public Patch<WorkCategory> categoryPatch() { + return toPatch(category); + } + + public Patch<WorkLogStatus> statusPatch() { + return toPatch(status); + } + + private static <T> Patch<T> toPatch(JsonNullable<T> field) { + if (field == null || !field.isPresent()) { + return Patch.absent(); + } + return field.get() == null ? Patch.ofNull() : Patch.of(field.get()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java new file mode 100644 index 00000000..0a380ed6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java @@ -0,0 +1,10 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +/** Validation group ordering for @GroupSequence (syntax → invariant). */ +public final class WorkLogValidationGroups { + private WorkLogValidationGroups() {} + + public interface Syntax {} + + public interface Invariant {} +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java new file mode 100644 index 00000000..89d980bd --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java @@ -0,0 +1,8 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; + +/** A poster with its current state. {@code imageKey} is null until an image is uploaded. */ +public record PosterResponse( + PosterId id, String title, String caption, String imageKey, PosterStatus status) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java new file mode 100644 index 00000000..40b795f5 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java @@ -0,0 +1,3 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +public record RepoStatsResponse(String fullName, int stars, String lastPushedIso) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java new file mode 100644 index 00000000..97868cd4 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java @@ -0,0 +1,4 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +/** Receipt for an uploaded poster image stored in object storage. */ +public record StoredObjectResponse(String key, long size, String contentType, String location) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java new file mode 100644 index 00000000..d3ad24bc --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java @@ -0,0 +1,19 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import java.time.LocalDate; +import java.util.List; + +public record WorkLogResponse( + WorkLogId id, + String title, + WorkCategory category, + WorkLogStatus status, + String summary, + String content, + List<String> techStack, + List<String> links, + LocalDate periodStart, + LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java new file mode 100644 index 00000000..7f9104a7 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import java.time.LocalDate; + +public record WorkLogSummaryResponse( + WorkLogId id, + String title, + WorkCategory category, + WorkLogStatus status, + LocalDate periodStart, + LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java new file mode 100644 index 00000000..abf94364 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java @@ -0,0 +1,61 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.application.exception.PosterTitleAlreadyExistsException; +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.PosterInvariantException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; +import dev.caskeleton.shared.response.Envelope; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Domain-specific exception → {@link Envelope} mapping for the portfolio sample. Operational/ + * transport/security exceptions are handled by the base {@code GlobalExceptionHandler}. This advice + * is ordered ahead of the base so domain exceptions resolve to their specific code instead of the + * base catch-all's INTERNAL_ERROR. See README. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice +public class DomainExceptionHandler { + + @ExceptionHandler(WorkLogNotFoundException.class) + public ResponseEntity<Envelope<Void>> handleWorkLogNotFound(WorkLogNotFoundException ex) { + return ErrorResponseFactory.envelope( + PortfolioErrorCode.WORKLOG_NOT_FOUND, ex.getMessage(), null); + } + + @ExceptionHandler(WorkLogInvariantException.class) + public ResponseEntity<Envelope<Void>> handleWorkLogInvariant(WorkLogInvariantException ex) { + return ErrorResponseFactory.envelope( + PortfolioErrorCode.WORKLOG_CONFLICT, ex.getMessage(), null); + } + + @ExceptionHandler(PosterNotFoundException.class) + public ResponseEntity<Envelope<Void>> handlePosterNotFound(PosterNotFoundException ex) { + return ErrorResponseFactory.envelope( + PortfolioErrorCode.POSTER_NOT_FOUND, ex.getMessage(), null); + } + + @ExceptionHandler(PosterTitleAlreadyExistsException.class) + public ResponseEntity<Envelope<Void>> handlePosterTitleConflict( + PosterTitleAlreadyExistsException ex) { + return ErrorResponseFactory.envelope( + PortfolioErrorCode.POSTER_ALREADY_EXISTS, ex.getMessage(), null); + } + + /** Maps the invariant's {@code reason()} to a specific code (finer-grained than WorkLog's). */ + @ExceptionHandler(PosterInvariantException.class) + public ResponseEntity<Envelope<Void>> handlePosterInvariant(PosterInvariantException ex) { + PortfolioErrorCode code = + switch (ex.reason()) { + case IMAGE_REQUIRED -> PortfolioErrorCode.POSTER_IMAGE_REQUIRED; + case INVALID_STATUS_TRANSITION, TITLE_BLANK -> PortfolioErrorCode.POSTER_INVALID_STATE; + }; + return ErrorResponseFactory.envelope(code, ex.getMessage(), null); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java new file mode 100644 index 00000000..eb0feb61 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java @@ -0,0 +1,47 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +/** + * Domain-specific error codes for the portfolio sample. Operational/transport/ security codes live + * in {@code dev.caskeleton.shared.error.OperationalError}. + */ +public enum PortfolioErrorCode implements ApiErrorCode { + WORKLOG_NOT_FOUND(Category.NOT_FOUND, 404, false), + WORKLOG_CONFLICT(Category.CONFLICT, 409, false), + POSTER_NOT_FOUND(Category.NOT_FOUND, 404, false), + POSTER_ALREADY_EXISTS(Category.CONFLICT, 409, false), + POSTER_INVALID_STATE(Category.VALIDATION, 400, false), + POSTER_IMAGE_REQUIRED(Category.VALIDATION, 400, false); + + private final Category category; + private final int httpStatus; + private final boolean retryable; + + PortfolioErrorCode(Category category, int httpStatus, boolean retryable) { + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return httpStatus; + } + + @Override + public boolean retryable() { + return retryable; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java new file mode 100644 index 00000000..a955f172 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java @@ -0,0 +1,23 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.json; + +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import org.springframework.boot.jackson.JacksonComponent; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; + +/** + * Serialises a {@link PosterId} as its bare 36-character lowercase UUID string instead of the + * record's default {@code {"value":"..."}} shape. Registered via {@link JacksonComponent}. Mirrors + * {@code WorkLogIdSerializer}. + */ +@JacksonComponent +public class PosterIdSerializer extends ValueSerializer<PosterId> { + + @Override + public void serialize(PosterId id, JsonGenerator gen, SerializationContext serializers) + throws JacksonException { + gen.writeString(id.value()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java new file mode 100644 index 00000000..8ac4c4dc --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java @@ -0,0 +1,23 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.json; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import org.springframework.boot.jackson.JacksonComponent; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; + +/** + * Serialises a {@link WorkLogId} as its bare 36-character lowercase UUID string instead of the + * record's default {@code {"value":"..."}} shape. Registered via {@link JacksonComponent}. See + * README. + */ +@JacksonComponent +public class WorkLogIdSerializer extends ValueSerializer<WorkLogId> { + + @Override + public void serialize(WorkLogId id, JsonGenerator gen, SerializationContext serializers) + throws JacksonException { + gen.writeString(id.value()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java new file mode 100644 index 00000000..83c9f1fd --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java @@ -0,0 +1,35 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper; + +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreatePosterRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdatePosterRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; +import dev.caskeleton.sample.portfolio.application.command.CreatePosterCommand; +import dev.caskeleton.sample.portfolio.application.command.UpdatePosterCommand; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +/** Translates between poster REST DTOs and application/domain types. */ +public final class PosterWebMapper { + + private PosterWebMapper() {} + + public static CreatePosterCommand toCommand(CreatePosterRequest r) { + return new CreatePosterCommand(r.title(), r.caption()); + } + + // PATCH goes through the mapper too (not built inline in the controller). The PosterId is parsed + // at the web edge and passed in. Mirrors WorkLogWebMapper. + public static UpdatePosterCommand toUpdateCommand(PosterId id, UpdatePosterRequest body) { + return new UpdatePosterCommand(id, body.titlePatch(), body.captionPatch()); + } + + public static PosterResponse toResponse(Poster p) { + return new PosterResponse(p.id(), p.title(), p.caption(), p.imageKey(), p.status()); + } + + public static StoredObjectResponse toStoredObjectResponse(StoredObject s) { + return new StoredObjectResponse(s.key(), s.size(), s.contentType(), s.location().toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java new file mode 100644 index 00000000..f9083db9 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java @@ -0,0 +1,79 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper; + +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreateWorkLogRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdateWorkLogRequest; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.RepoStatsResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogResponse; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogSummaryResponse; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.command.UpdateWorkLogCommand; +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.shared.error.MappingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +public final class WorkLogWebMapper { + + private WorkLogWebMapper() {} + + public static CreateWorkLogCommand toCommand(CreateWorkLogRequest r, String owner) { + List<String> links = r.links() == null ? List.of() : r.links(); + for (String link : links) { + try { + new URI(link); + } catch (URISyntaxException e) { + throw new MappingException("invalid link URI: " + link, e); + } + } + return new CreateWorkLogCommand( + owner, + r.title(), + r.category(), + r.summary(), + r.content(), + r.techStack() == null ? List.of() : r.techStack(), + links, + r.periodStart(), + r.periodEnd()); + } + + // PATCH goes through the mapper too (not built inline in the controller) so the web + // adapter owns all request DTO -> command translation. The WorkLogId is parsed at the + // web edge and passed in. See README. + public static UpdateWorkLogCommand toUpdateCommand(WorkLogId id, UpdateWorkLogRequest body) { + return new UpdateWorkLogCommand( + id, + body.titlePatch(), + body.summaryPatch(), + body.contentPatch(), + body.categoryPatch(), + body.statusPatch()); + } + + public static WorkLogResponse toResponse(WorkLog w) { + // owner (raw principal id) is intentionally NOT exposed in the response (privacy). See README. + return new WorkLogResponse( + w.id(), + w.title(), + w.category(), + w.status(), + w.summary(), + w.content(), + w.techStack(), + w.links(), + w.period().start(), + w.period().end()); + } + + public static WorkLogSummaryResponse toSummary(WorkLog w) { + return new WorkLogSummaryResponse( + w.id(), w.title(), w.category(), w.status(), w.period().start(), w.period().end()); + } + + public static RepoStatsResponse toRepoStatsResponse(RepoStats s) { + return new RepoStatsResponse(s.fullName(), s.stars(), s.lastPushedIso()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java new file mode 100644 index 00000000..eb7af812 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java @@ -0,0 +1,36 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.operation; + +import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.shared.operation.Operation; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +/** + * In-memory long-running-operation store backing the sample {@code :export} fixture. It mints the + * operation id here (web infrastructure), not in the controller, to respect the {@code + * no_uuid_random_in_controller} rule. The export completes synchronously, so the stored operation + * is already {@code SUCCEEDED} when polled. See README. + */ +@Component +public class SampleOperationStore { + + private final Map<String, Operation<WorkLogExportResult>> store = new ConcurrentHashMap<>(); + + /** + * Accepts an export, persisting a terminal operation, and returns the accept-time (PENDING) view + * used for the 202 body. {@code apiBasePath} is the configured version prefix so {@code + * statusUrl} matches the live {@code /operations/{id}} route. + */ + public Operation<WorkLogExportResult> startExport(String apiBasePath, long exportedCount) { + String id = UuidCreator.getTimeOrderedEpochPlus1().toString(); + String statusUrl = apiBasePath + "/operations/" + id; + store.put(id, Operation.succeeded(id, statusUrl, new WorkLogExportResult(exportedCount))); + return Operation.pending(id, statusUrl); + } + + public Optional<Operation<WorkLogExportResult>> find(String operationId) { + return Optional.ofNullable(store.get(operationId)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java new file mode 100644 index 00000000..be28282d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java @@ -0,0 +1,7 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.operation; + +/** + * Result payload of the sample long-running {@code :export} operation, surfaced as {@code + * data.result} on the polling response once the operation reaches {@code SUCCEEDED}. + */ +public record WorkLogExportResult(long exportedCount) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java new file mode 100644 index 00000000..00728e79 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java @@ -0,0 +1,19 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import org.springframework.stereotype.Component; + +/** + * Infrastructure adapter for the {@link OutboxEventIdFactory} domain port. Uses the same {@code + * UuidCreator.getTimeOrderedEpochPlus1()} mechanism as {@link UuidWorkLogIdFactory}; id generation + * lives here, never in the application layer. See README. + */ +@Component +public class UuidOutboxEventIdFactory implements OutboxEventIdFactory { + + @Override + public String newEventId() { + return UuidCreator.getTimeOrderedEpochPlus1().toString(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java new file mode 100644 index 00000000..7d554df2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterIdFactory; +import org.springframework.stereotype.Component; + +/** + * Infrastructure adapter for the {@link PosterIdFactory} domain port. {@code + * UuidCreator.getTimeOrderedEpochPlus1()} mints a monotonic UUIDv7 (RFC 9562, time-ordered): it + * increments strictly within a millisecond and is backed by a secure random source. Id generation + * is only allowed in this identifier adapter. Mirrors {@code UuidWorkLogIdFactory}. + */ +@Component +public class UuidPosterIdFactory implements PosterIdFactory { + + @Override + public PosterId newId() { + return PosterId.of(UuidCreator.getTimeOrderedEpochPlus1().toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java new file mode 100644 index 00000000..8403f8da --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; +import org.springframework.stereotype.Component; + +/** + * Infrastructure adapter for the {@link WorkLogIdFactory} domain port. {@code + * UuidCreator.getTimeOrderedEpochPlus1()} mints a monotonic UUIDv7 (RFC 9562, time-ordered): it + * increments strictly within a millisecond and is backed by a secure random source. This is the + * only place in the sample allowed to generate ids. See README. + */ +@Component +public class UuidWorkLogIdFactory implements WorkLogIdFactory { + + @Override + public WorkLogId newId() { + return WorkLogId.of(UuidCreator.getTimeOrderedEpochPlus1().toString()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java new file mode 100644 index 00000000..6eb855db --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java @@ -0,0 +1,17 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config; + +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * The Spring Boot main class lives in {@code dev.caskeleton.bootstrap}, so the default + * package-relative scan misses {@code + * dev.caskeleton.sample.portfolio.adapter.outbound.persistence.*}. Pointing entity and repository + * scans here keeps that wiring in the module that actually owns them. + */ +@Configuration +@EntityScan(basePackages = "dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity") +@EnableJpaRepositories( + basePackages = "dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository") +public class JpaConfig {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java new file mode 100644 index 00000000..0ecf1f9b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java @@ -0,0 +1,80 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +// Extends AuditableEntity to inherit the created/updated audit columns; the domain Poster carries +// none of them — they are stamped by PosterRepositoryAdapter. Mirrors WorkLogEntity. +@Entity +@Table(name = "poster") +public class PosterEntity extends AuditableEntity { + + // UUIDv7 stored as the PostgreSQL native `uuid` type (16-byte binary), not varchar. + // UUID string<->uuid conversion happens in PosterPersistenceMapper. + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + @JdbcTypeCode(SqlTypes.UUID) + private UUID id; + + private String title; + + @Column(length = 2000) + private String caption; + + // Object-storage key of the uploaded image; null until an image is attached. + @Column(name = "image_key", length = 512) + private String imageKey; + + @Enumerated(EnumType.STRING) + private PosterStatus status; + + // Optimistic-lock version (ETag source). Hibernate manages increments; `null` marks a new row so + // save() inserts. + @Version private Long version; + + protected PosterEntity() {} + + public PosterEntity( + UUID id, String title, String caption, String imageKey, PosterStatus status, Long version) { + this.id = id; + this.title = title; + this.caption = caption; + this.imageKey = imageKey; + this.status = status; + this.version = version; + } + + public UUID getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getCaption() { + return caption; + } + + public String getImageKey() { + return imageKey; + } + + public PosterStatus getStatus() { + return status; + } + + public Long getVersion() { + return version; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java new file mode 100644 index 00000000..68ff6813 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java @@ -0,0 +1,170 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +// Extends AuditableEntity to inherit the created/updated audit columns; the domain WorkLog +// carries none of them — they are stamped by WorkLogRepositoryAdapter. See README. +@Entity +@Table(name = "work_log") +public class WorkLogEntity extends AuditableEntity { + + // UUIDv7 stored as the PostgreSQL native `uuid` type (16-byte binary), not varchar. + // UUID string<->uuid conversion happens in WorkLogPersistenceMapper. See README. + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + @JdbcTypeCode(SqlTypes.UUID) + private UUID id; + + private String owner; + private String title; + + @Enumerated(EnumType.STRING) + private WorkCategory category; + + @Enumerated(EnumType.STRING) + private WorkLogStatus status; + + @Column(length = 500) + private String summary; + + @Column(length = 10000) + private String content; + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "work_log_tech_stack", joinColumns = @JoinColumn(name = "work_log_id")) + @Column(name = "tech") + private List<String> techStack = new ArrayList<>(); + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "work_log_link", joinColumns = @JoinColumn(name = "work_log_id")) + @Column(name = "url") + private List<String> links = new ArrayList<>(); + + private LocalDate periodStart; + private LocalDate periodEnd; + // Optimistic-lock version (ETag/If-Match source). Hibernate manages increments; `null` + // marks a new row so save() inserts. See README. + @Version private Long version; + + protected WorkLogEntity() {} + + public WorkLogEntity( + UUID id, + String title, + WorkCategory category, + String summary, + String content, + List<String> techStack, + List<String> links, + LocalDate periodStart, + LocalDate periodEnd, + Long version) { + this( + id, + "sample-owner", + title, + category, + WorkLogStatus.OPEN, + summary, + content, + techStack, + links, + periodStart, + periodEnd, + version); + } + + public WorkLogEntity( + UUID id, + String owner, + String title, + WorkCategory category, + WorkLogStatus status, + String summary, + String content, + List<String> techStack, + List<String> links, + LocalDate periodStart, + LocalDate periodEnd, + Long version) { + this.id = id; + this.owner = owner; + this.title = title; + this.category = category; + this.status = status; + this.summary = summary; + this.content = content; + this.techStack = new ArrayList<>(techStack); + this.links = new ArrayList<>(links); + this.periodStart = periodStart; + this.periodEnd = periodEnd; + this.version = version; + } + + public UUID getId() { + return id; + } + + public String getOwner() { + return owner; + } + + public String getTitle() { + return title; + } + + public WorkCategory getCategory() { + return category; + } + + public WorkLogStatus getStatus() { + return status; + } + + public String getSummary() { + return summary; + } + + public String getContent() { + return content; + } + + public List<String> getTechStack() { + return techStack; + } + + public List<String> getLinks() { + return links; + } + + public LocalDate getPeriodStart() { + return periodStart; + } + + public LocalDate getPeriodEnd() { + return periodEnd; + } + + public Long getVersion() { + return version; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java new file mode 100644 index 00000000..2316e1c7 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java @@ -0,0 +1,35 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterEntity; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.UUID; + +/** + * Pure translation between the {@link Poster} aggregate and its JPA entity. Mirrors {@code + * WorkLogPersistenceMapper}. + */ +public final class PosterPersistenceMapper { + + private PosterPersistenceMapper() {} + + /** + * Canonical UUID string -> PostgreSQL native {@code uuid} (128-bit). Uses {@code java.util.UUID} + * (JDK stdlib) directly, so the persistence adapter carries no id-library dependency. + */ + public static UUID toUuid(PosterId id) { + return UUID.fromString(id.value()); + } + + public static PosterEntity toEntity(Poster p) { + // Carry the domain version so JPA tells a new row (null → insert) from a tracked one. + return new PosterEntity( + toUuid(p.id()), p.title(), p.caption(), p.imageKey(), p.status(), p.version()); + } + + public static Poster toDomain(PosterEntity e) { + PosterId id = PosterId.of(e.getId().toString()); + return Poster.rehydrate( + id, e.getTitle(), e.getCaption(), e.getImageKey(), e.getStatus(), e.getVersion()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java new file mode 100644 index 00000000..43f93472 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java @@ -0,0 +1,54 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; +import java.util.UUID; + +public final class WorkLogPersistenceMapper { + + private WorkLogPersistenceMapper() {} + + /** + * Canonical UUID string -> PostgreSQL native {@code uuid} (128-bit). Uses {@code java.util.UUID} + * (JDK stdlib) directly, so the persistence adapter carries no id-library dependency. See README. + */ + public static UUID toUuid(WorkLogId id) { + return UUID.fromString(id.value()); + } + + public static WorkLogEntity toEntity(WorkLog w) { + // Carry the domain version so JPA tells a new row (null → insert) from a tracked one. + return new WorkLogEntity( + toUuid(w.id()), + w.owner().value(), + w.title(), + w.category(), + w.status(), + w.summary(), + w.content(), + w.techStack(), + w.links(), + w.period().start(), + w.period().end(), + w.version()); + } + + public static WorkLog toDomain(WorkLogEntity e) { + WorkLogId id = WorkLogId.of(e.getId().toString()); + return WorkLog.rehydrate( + id, + WorkLogOwner.of(e.getOwner()), + e.getTitle(), + e.getCategory(), + e.getStatus(), + e.getSummary(), + e.getContent(), + e.getTechStack(), + e.getLinks(), + new Period(e.getPeriodStart(), e.getPeriodEnd()), + e.getVersion()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java new file mode 100644 index 00000000..8d876ca2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterEntity; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PosterJpaRepository extends JpaRepository<PosterEntity, UUID> { + + // Flat equality filter (the only filter syntax the contract allows), kept in the data layer. + Page<PosterEntity> findByStatus(PosterStatus status, Pageable pageable); + + boolean existsByTitleIgnoreCase(String title); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java new file mode 100644 index 00000000..b4b6bdbb --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java @@ -0,0 +1,95 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterEntity; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.PosterPersistenceMapper; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +@Repository +public class PosterRepositoryAdapter implements PosterRepository { + + private final PosterJpaRepository jpa; + // Audit metadata (time/actor) is stamped here in the persistence adapter; the domain Poster + // carries no audit fields. Mirrors WorkLogRepositoryAdapter. + private final Clock clock; + private final AuditContextPort auditContext; + + public PosterRepositoryAdapter( + PosterJpaRepository jpa, Clock clock, AuditContextPort auditContext) { + this.jpa = jpa; + this.clock = clock; + this.auditContext = auditContext; + } + + @Override + public Poster save(Poster poster) { + PosterEntity entity = PosterPersistenceMapper.toEntity(poster); + Instant now = clock.instant(); + String actor = auditContext.currentActor(); + if (poster.version() == null) { + // INSERT: created_* and updated_* both take now/actor. + entity.initializeAudit(now, actor); + } else { + // UPDATE: carry the persisted created_* forward and bump only updated_*. + PosterEntity existing = + jpa.findById(entity.getId()) + .orElseThrow( + () -> + new IllegalStateException( + "no persisted poster row to carry audit creation from: " + + entity.getId())); + entity.carryCreation(existing.getCreatedAt(), existing.getCreatedBy()); + entity.applyModification(now, actor); + } + return PosterPersistenceMapper.toDomain(jpa.save(entity)); + } + + @Override + public Optional<Poster> findById(PosterId id) { + return jpa.findById(PosterPersistenceMapper.toUuid(id)).map(PosterPersistenceMapper::toDomain); + } + + @Override + public PosterPage findPage( + int page, int size, PosterSortField sortField, boolean ascending, PosterStatus status) { + int safeSize = size <= 0 ? 20 : size; + int safePage = Math.max(0, page); + Sort sort = + sortField == null + ? Sort.unsorted() + : Sort.by(ascending ? Sort.Direction.ASC : Sort.Direction.DESC, sortField.property()); + PageRequest pageRequest = PageRequest.of(safePage, safeSize, sort); + Page<PosterEntity> result = + status == null ? jpa.findAll(pageRequest) : jpa.findByStatus(status, pageRequest); + List<Poster> items = result.map(PosterPersistenceMapper::toDomain).getContent(); + return new PosterPage(items, result.getTotalElements()); + } + + @Override + public boolean existsById(PosterId id) { + return jpa.existsById(PosterPersistenceMapper.toUuid(id)); + } + + @Override + public boolean existsByTitle(String title) { + return jpa.existsByTitleIgnoreCase(title); + } + + @Override + public void deleteById(PosterId id) { + jpa.deleteById(PosterPersistenceMapper.toUuid(id)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java new file mode 100644 index 00000000..588738d4 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java @@ -0,0 +1,26 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface WorkLogJpaRepository extends JpaRepository<WorkLogEntity, UUID> { + + // Flat equality filter (the only filter syntax the contract allows), kept in the data layer. + Page<WorkLogEntity> findByCategory(WorkCategory category, Pageable pageable); + + long countByCategory(WorkCategory category); + + // CQRS-lite projection read: a JPQL SELECT-new selects only the summary columns into + // WorkLogSummaryRow, skipping aggregate hydration and lazy @ElementCollection joins. + // SELECT-new is preferred over a closed interface projection. See README. + @Query( + "select new dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository.WorkLogSummaryRow(" + + "e.id, e.title, e.category, e.periodStart, e.periodEnd) from WorkLogEntity e") + List<WorkLogSummaryRow> findRecentSummaryRows(Pageable pageable); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java new file mode 100644 index 00000000..5d2e90ff --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java @@ -0,0 +1,91 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.WorkLogPersistenceMapper; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +@Repository +public class WorkLogRepositoryAdapter implements WorkLogRepository { + + private final WorkLogJpaRepository jpa; + // Audit metadata (time/actor) is stamped here in the persistence adapter; the domain + // WorkLog carries no audit fields. See README. + private final Clock clock; + private final AuditContextPort auditContext; + + public WorkLogRepositoryAdapter( + WorkLogJpaRepository jpa, Clock clock, AuditContextPort auditContext) { + this.jpa = jpa; + this.clock = clock; + this.auditContext = auditContext; + } + + @Override + public WorkLog save(WorkLog workLog) { + WorkLogEntity entity = WorkLogPersistenceMapper.toEntity(workLog); + Instant now = clock.instant(); + String actor = auditContext.currentActor(); + if (workLog.version() == null) { + // INSERT: created_* and updated_* both take now/actor. + entity.initializeAudit(now, actor); + } else { + // UPDATE: carry the persisted created_* forward and bump only updated_*. + WorkLogEntity existing = + jpa.findById(entity.getId()) + .orElseThrow( + () -> + new IllegalStateException( + "no persisted work_log row to carry audit creation from: " + + entity.getId())); + entity.carryCreation(existing.getCreatedAt(), existing.getCreatedBy()); + entity.applyModification(now, actor); + } + return WorkLogPersistenceMapper.toDomain(jpa.save(entity)); + } + + @Override + public Optional<WorkLog> findById(WorkLogId id) { + return jpa.findById(WorkLogPersistenceMapper.toUuid(id)) + .map(WorkLogPersistenceMapper::toDomain); + } + + @Override + public WorkLogPage findPage( + int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) { + int safeSize = size <= 0 ? 20 : size; + int safePage = Math.max(0, page); + Sort sort = + sortField == null + ? Sort.unsorted() + : Sort.by(ascending ? Sort.Direction.ASC : Sort.Direction.DESC, sortField.property()); + PageRequest pageRequest = PageRequest.of(safePage, safeSize, sort); + Page<WorkLogEntity> result = + category == null ? jpa.findAll(pageRequest) : jpa.findByCategory(category, pageRequest); + List<WorkLog> items = result.map(WorkLogPersistenceMapper::toDomain).getContent(); + return new WorkLogPage(items, result.getTotalElements()); + } + + @Override + public boolean existsById(WorkLogId id) { + return jpa.existsById(WorkLogPersistenceMapper.toUuid(id)); + } + + @Override + public void deleteById(WorkLogId id) { + jpa.deleteById(WorkLogPersistenceMapper.toUuid(id)); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java new file mode 100644 index 00000000..b5840a12 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java @@ -0,0 +1,39 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.application.port.WorkLogSummaryQueryPort; +import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; +import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +/** + * Persistence adapter for the WorkLog summary projection read. Implements {@link + * WorkLogSummaryQueryPort} via the JPQL {@code SELECT new} query and maps the storage-native {@link + * WorkLogSummaryRow} (UUID id) to {@link WorkLogSummary} (canonical UUID string id). The + * storage-native {@code UUID} never escapes this adapter. See README. + */ +@Repository +public class WorkLogSummaryQueryAdapter implements WorkLogSummaryQueryPort { + + private final WorkLogJpaRepository jpa; + + public WorkLogSummaryQueryAdapter(WorkLogJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public List<WorkLogSummary> findRecentSummaries(int limit) { + int safeLimit = limit <= 0 ? 1 : limit; + PageRequest pageRequest = + PageRequest.of(0, safeLimit, Sort.by(Sort.Direction.DESC, "periodStart")); + return jpa.findRecentSummaryRows(pageRequest).stream() + .map(WorkLogSummaryQueryAdapter::toSummary) + .toList(); + } + + private static WorkLogSummary toSummary(WorkLogSummaryRow row) { + return new WorkLogSummary( + row.id().toString(), row.title(), row.category(), row.periodStart(), row.periodEnd()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java new file mode 100644 index 00000000..335ecbc2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import java.time.LocalDate; +import java.util.UUID; + +/** + * Persistence-side read row for the WorkLog summary projection: the target of a JPQL {@code SELECT + * new} in {@link WorkLogJpaRepository#findRecentSummaryRows}, mirroring the storage shape (native + * {@code UUID} id). Kept inside the persistence package so the storage-native {@code UUID} never + * escapes the adapter. See README. + */ +public record WorkLogSummaryRow( + UUID id, String title, WorkCategory category, LocalDate periodStart, LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java new file mode 100644 index 00000000..c48cf698 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java @@ -0,0 +1,4 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; + +/** Raw external provider shape — never crosses into domain. Package-private. */ +record RawRepoStatsResponse(String fullName, int stargazers, String pushedAt, String echoedToken) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java new file mode 100644 index 00000000..32834f5c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; + +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import dev.caskeleton.shared.error.MappingException; + +/** Anti-Corruption Layer mapper: normalize, mask, select public fields. See README. */ +final class RepoStatsAclMapper { + + private RepoStatsAclMapper() {} + + static RepoStats toDomain(RawRepoStatsResponse raw) { + if (raw == null || raw.fullName() == null || raw.fullName().isBlank()) { + throw new MappingException("repo provider: missing 'fullName'"); + } + // normalization: lower-case full name. masking: echoedToken is dropped (never reaches domain). + // public-field selection: only fullName/stargazers/pushedAt reach RepoStats. + return new RepoStats( + raw.fullName().toLowerCase(), Math.max(0, raw.stargazers()), raw.pushedAt()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java new file mode 100644 index 00000000..a5af09cf --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java @@ -0,0 +1,33 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; + +import dev.caskeleton.sample.portfolio.application.port.RepoStatsPort; +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import org.springframework.stereotype.Component; + +/** + * Outbound adapter for repository stats. The actual HTTP fetch is abstracted ({@link #fetchRaw}) so + * the template stays infra-light; the raw response passes through the ACL before any domain type is + * produced. See README. + */ +@Component +public class RepoStatsPortClient implements RepoStatsPort { + + @Override + public RepoStats fetch(String repoUrl) { + return RepoStatsAclMapper.toDomain(fetchRaw(repoUrl)); + } + + /** Abstracted external fetch — stubbed for the template. */ + protected RawRepoStatsResponse fetchRaw(String repoUrl) { + return new RawRepoStatsResponse( + "caskeleton/" + safeName(repoUrl), 0, "1970-01-01T00:00:00Z", "REDACTED"); + } + + private static String safeName(String repoUrl) { + if (repoUrl == null || repoUrl.isBlank()) { + return "unknown"; + } + int slash = repoUrl.lastIndexOf('/'); + return slash >= 0 && slash < repoUrl.length() - 1 ? repoUrl.substring(slash + 1) : repoUrl; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java new file mode 100644 index 00000000..8a05b275 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java @@ -0,0 +1,12 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Objects; + +public record ArchivePosterCommand(PosterId id) implements Command { + + public ArchivePosterCommand { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java new file mode 100644 index 00000000..9b9fbb8a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java @@ -0,0 +1,15 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import java.util.List; + +/** + * Atomic batch-create intent: all items succeed together or the whole batch rolls back. Wraps the + * per-item {@link CreateWorkLogCommand}s so the inbound port stays a single {@link Command}. + */ +public record BatchCreateWorkLogsCommand(List<CreateWorkLogCommand> items) implements Command { + + public BatchCreateWorkLogsCommand { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java new file mode 100644 index 00000000..abb4387c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java @@ -0,0 +1,15 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** + * Self-validating input model for creating a poster: fail fast on a null required field, regardless + * of the calling adapter. {@code caption} is optional (may be null → normalised to empty). + */ +public record CreatePosterCommand(String title, String caption) implements Command { + + public CreatePosterCommand { + Objects.requireNonNull(title, "title"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java new file mode 100644 index 00000000..47ac5d2d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java @@ -0,0 +1,31 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +public record CreateWorkLogCommand( + String owner, + String title, + WorkCategory category, + String summary, + String content, + List<String> techStack, + List<String> links, + LocalDate periodStart, + LocalDate periodEnd) + implements Command { + + // Self-validating input model: fail fast on null required fields, regardless of the + // calling adapter. `owner` is validated in the use case; summary/content/periodEnd are + // optional. See README. + public CreateWorkLogCommand { + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(periodStart, "periodStart"); + techStack = techStack == null ? List.of() : List.copyOf(techStack); + links = links == null ? List.of() : List.copyOf(links); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java new file mode 100644 index 00000000..b016cf04 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java @@ -0,0 +1,12 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Objects; + +public record DeletePosterCommand(PosterId id) implements Command { + + public DeletePosterCommand { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java new file mode 100644 index 00000000..908bd259 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import java.util.Objects; + +public record DeleteWorkLogCommand(WorkLogId id) implements Command { + + // Self-validating input model: a delete with no target id fails fast at construction. See README. + public DeleteWorkLogCommand { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java new file mode 100644 index 00000000..644b0387 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java @@ -0,0 +1,12 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Objects; + +public record PublishPosterCommand(PosterId id) implements Command { + + public PublishPosterCommand { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java new file mode 100644 index 00000000..97487c86 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.shared.request.Patch; +import java.util.Objects; + +/** + * Partial update of a poster. {@code Patch} distinguishes the three PATCH states (absent / explicit + * null / value). A null id or null patch is a programming error — fail fast; use {@link + * Patch#absent()} for "no change", never {@code null}. Mirrors {@code UpdateWorkLogCommand}. + */ +public record UpdatePosterCommand(PosterId id, Patch<String> title, Patch<String> caption) + implements Command { + + public UpdatePosterCommand { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(caption, "caption"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java new file mode 100644 index 00000000..570a5d86 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java @@ -0,0 +1,29 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import dev.caskeleton.shared.request.Patch; +import java.util.Objects; + +public record UpdateWorkLogCommand( + WorkLogId id, + Patch<String> title, + Patch<String> summary, + Patch<String> content, + Patch<WorkCategory> category, + Patch<WorkLogStatus> status) + implements Command { + + // Self-validating input model: a null id or null patch is a programming error — fail + // fast at construction; use Patch.absent() for "no change", never null. See README. + public UpdateWorkLogCommand { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(summary, "summary"); + Objects.requireNonNull(content, "content"); + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(status, "status"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java new file mode 100644 index 00000000..a9b14960 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import dev.caskeleton.application.command.Command; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Objects; + +/** + * Uploads an image for a poster: the raw bytes plus their MIME type. The use case stores them under + * an object-storage key and records the key on the aggregate. + */ +@SuppressWarnings("ArrayRecordComponent") // raw image bytes destined for object storage +public record UploadPosterImageCommand(PosterId id, byte[] content, String contentType) + implements Command { + + public UploadPosterImageCommand { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(content, "content"); + Objects.requireNonNull(contentType, "contentType"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java new file mode 100644 index 00000000..67057673 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java @@ -0,0 +1,109 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.sample.portfolio.domain.poster.PosterArchived; +import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; +import dev.caskeleton.sample.portfolio.domain.poster.PosterDeleted; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageAttached; +import dev.caskeleton.sample.portfolio.domain.poster.PosterPublished; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import java.time.Clock; +import java.time.Instant; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +/** + * Publishes Poster lifecycle domain events to the transactional outbox. MUST be invoked from within + * a use case's {@code TransactionPort.inWrite(...)} block so the append participates in the same + * write transaction (no dual-write). Factors out the outbox plumbing shown once in {@code + * CreateWorkLogUseCase}, because Poster emits five lifecycle events. {@code eventId} (a UUIDv7 from + * {@link OutboxEventIdFactory}) doubles as the idempotency key; {@code correlationId} comes from + * MDC, falling back to {@code eventId} off-HTTP. + */ +@Component +public class PosterEventPublisher { + + private static final String MDC_CORRELATION_ID = "correlation_id"; + + private final OutboxAppendPort outbox; + private final OutboxEventIdFactory eventIdFactory; + private final Clock clock; + + public PosterEventPublisher( + OutboxAppendPort outbox, OutboxEventIdFactory eventIdFactory, Clock clock) { + this.outbox = outbox; + this.eventIdFactory = eventIdFactory; + this.clock = clock; + } + + public void publishCreated(PosterCreated e) { + append( + "poster.created", + e.posterId().value(), + "{" + field("posterId", e.posterId().value()) + "," + field("title", e.title()) + "}"); + } + + public void publishImageAttached(PosterImageAttached e) { + append( + "poster.image-attached", + e.posterId().value(), + "{" + + field("posterId", e.posterId().value()) + + "," + + field("imageKey", e.imageKey()) + + "}"); + } + + public void publishPublished(PosterPublished e) { + append("poster.published", e.posterId().value(), onlyId(e.posterId().value())); + } + + public void publishArchived(PosterArchived e) { + append("poster.archived", e.posterId().value(), onlyId(e.posterId().value())); + } + + public void publishDeleted(PosterDeleted e) { + append("poster.deleted", e.posterId().value(), onlyId(e.posterId().value())); + } + + private void append(String eventType, String aggregateId, String payload) { + String eventId = eventIdFactory.newEventId(); + String correlationId = MDC.get(MDC_CORRELATION_ID); + if (correlationId == null || correlationId.isBlank()) { + correlationId = eventId; + } + outbox.append( + new NewOutboxEvent( + eventId, eventType, aggregateId, payload, Instant.now(clock), correlationId, eventId)); + } + + private static String onlyId(String posterId) { + return "{" + field("posterId", posterId) + "}"; + } + + private static String field(String key, String value) { + return "\"" + key + "\":\"" + escape(value) + "\""; + } + + /** Escapes a JSON string value per RFC 8259 §7 (reverse solidus, quote, C0 controls). */ + private static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') { + sb.append("\\\\"); + } else if (c == '"') { + sb.append("\\\""); + } else if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java new file mode 100644 index 00000000..90bcb746 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java @@ -0,0 +1,10 @@ +package dev.caskeleton.sample.portfolio.application.event; + +/** + * Integration (wire) event derived from the {@code WorkLogReserved} domain event: the + * boundary-owned, serialisable shape an outbound adapter would publish (e.g. to Kafka). It lives in + * the application layer because translating a domain fact into a wire contract is an application + * concern; the domain stays unaware of the wire. See README. + */ +public record WorkLogReservedIntegrationEvent( + String workLogId, String category, String occurredOn) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java new file mode 100644 index 00000000..b2665240 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java @@ -0,0 +1,75 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; + +/** + * Maps the transport-free {@code WorkLogReserved} domain event to a {@link + * WorkLogReservedIntegrationEvent} at the application boundary, and serialises it to a hand-rolled + * JSON payload string for the transactional outbox. See README. + */ +public final class WorkLogReservedIntegrationEventMapper { + + /** Stable logical event type name; becomes the broker topic. See README. */ + public static final String EVENT_TYPE = "worklog.reserved"; + + private WorkLogReservedIntegrationEventMapper() {} + + /** + * Maps a {@link WorkLogReserved} domain event to its integration event form, flattening value + * objects to primitive strings. + * + * @param event the domain event; must not be {@code null} + * @return the integration event record + */ + public static WorkLogReservedIntegrationEvent toIntegrationEvent(WorkLogReserved event) { + return new WorkLogReservedIntegrationEvent( + event.workLogId().value(), event.category().name(), event.periodStart().toString()); + } + + /** + * Serialises a {@link WorkLogReservedIntegrationEvent} to a JSON string for the outbox {@code + * payload} field (hand-rolled, no Jackson/Gson dependency). See README. + * + * @param event the integration event to serialise; must not be {@code null} + * @return a JSON object string, e.g. {@code + * {"workLogId":"0190bd6e-7c3e-7abc-8def-0123456789ab","category":"BACKEND","occurredOn":"2025-01-01"}} + */ + public static String toJson(WorkLogReservedIntegrationEvent event) { + return "{" + + jsonField("workLogId", event.workLogId()) + + "," + + jsonField("category", event.category()) + + "," + + jsonField("occurredOn", event.occurredOn()) + + "}"; + } + + /** Renders a JSON key-value pair, escaping the value per RFC 8259 §7. */ + static String jsonField(String key, String value) { + return "\"" + key + "\":\"" + escape(value) + "\""; + } + + /** + * Escapes a string value for embedding in a JSON string literal. Covers the mandatory escapes in + * RFC 8259 §7: reverse solidus, quotation mark, and the C0 control characters (U+0000–U+001F). + */ + static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') { + sb.append("\\\\"); + } else if (c == '"') { + sb.append("\\\""); + } else if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java new file mode 100644 index 00000000..07fcc554 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.application.exception; + +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; + +/** Raised when a poster lookup misses. Carries no error code — the web handler maps it. */ +public class PosterNotFoundException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public PosterNotFoundException(PosterId id) { + super("Poster not found: " + id.value()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java new file mode 100644 index 00000000..4edaffa2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java @@ -0,0 +1,11 @@ +package dev.caskeleton.sample.portfolio.application.exception; + +/** Raised when creating a poster whose title is already taken. Web handler maps it to 409. */ +public class PosterTitleAlreadyExistsException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public PosterTitleAlreadyExistsException(String title) { + super("Poster title already in use: " + title); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java new file mode 100644 index 00000000..632fed23 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java @@ -0,0 +1,12 @@ +package dev.caskeleton.sample.portfolio.application.exception; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; + +public class WorkLogNotFoundException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public WorkLogNotFoundException(WorkLogId id) { + super("WorkLog not found: " + id.value()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java new file mode 100644 index 00000000..ea92b559 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.sample.portfolio.application.port; + +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; + +/** Outbound port: fetch normalized repository statistics for a repo URL. */ +public interface RepoStatsPort { + RepoStats fetch(String repoUrl); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java new file mode 100644 index 00000000..4fc4fe28 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.application.port; + +import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; +import java.util.List; + +/** + * Application read/query port for WorkLog projections. By convention a read port returning a + * projection (not an aggregate) ends with {@code QueryPort}, and an ArchUnit rule keeps such a port + * from leaking domain aggregate / JPA / web types. The read-model bypass is an opt-in optimisation, + * not a forced default. See README. + */ +public interface WorkLogSummaryQueryPort { + + /** Returns the most recent WorkLog summaries (newest period first), at most {@code limit}. */ + List<WorkLogSummary> findRecentSummaries(int limit); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java new file mode 100644 index 00000000..5382cde2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java @@ -0,0 +1,54 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.ArchivePosterCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterArchived; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +/** + * Archives a poster (→ {@code ARCHIVED}, terminal). Emits a {@code poster.archived} outbox event. + */ +@Service +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class ArchivePosterUseCase implements CommandUseCase<ArchivePosterCommand, Poster> { + + private final PosterRepository repository; + private final PosterEventPublisher events; + private final TransactionPort tx; + + public ArchivePosterUseCase( + PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { + this.repository = repository; + this.events = events; + this.tx = tx; + } + + @Override + public Poster handle(ArchivePosterCommand cmd) { + return tx.inWrite( + () -> { + Poster p = + repository + .findById(cmd.id()) + .orElseThrow(() -> new PosterNotFoundException(cmd.id())); + p.archive(); + Poster saved = repository.save(p); + events.publishArchived(new PosterArchived(saved.id())); + return saved; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java new file mode 100644 index 00000000..5ed0390c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java @@ -0,0 +1,61 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.CreatePosterCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.exception.PosterTitleAlreadyExistsException; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; +import dev.caskeleton.sample.portfolio.domain.poster.PosterIdFactory; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +/** + * Creates a new {@link Poster} (server-assigned id via {@link PosterIdFactory}) and appends a + * {@code poster.created} outbox event in the same write transaction. Rejects a duplicate title. + * Mirrors {@code CreateWorkLogUseCase}. + */ +@Service +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class CreatePosterUseCase implements CommandUseCase<CreatePosterCommand, Poster> { + + private final PosterRepository repository; + private final PosterIdFactory idFactory; + private final PosterEventPublisher events; + private final TransactionPort tx; + + public CreatePosterUseCase( + PosterRepository repository, + PosterIdFactory idFactory, + PosterEventPublisher events, + TransactionPort tx) { + this.repository = repository; + this.idFactory = idFactory; + this.events = events; + this.tx = tx; + } + + @Override + public Poster handle(CreatePosterCommand cmd) { + return tx.inWrite( + () -> { + if (repository.existsByTitle(cmd.title())) { + throw new PosterTitleAlreadyExistsException(cmd.title()); + } + Poster saved = + repository.save(Poster.create(idFactory.newId(), cmd.title(), cmd.caption())); + events.publishCreated(new PosterCreated(saved.id(), saved.title())); + return saved; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java new file mode 100644 index 00000000..dd24480f --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java @@ -0,0 +1,53 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.PosterDeleted; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +/** + * Deletes a poster — the destructive, admin-tier operation, gated by {@code poster:close} (granted + * only to {@code admin}). Emits a {@code poster.deleted} outbox event. Mirrors {@code + * DeleteWorkLogUseCase}. + */ +@Service +@RequiresPermission("poster:close") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class DeletePosterUseCase implements CommandUseCase<DeletePosterCommand, Void> { + + private final PosterRepository repository; + private final PosterEventPublisher events; + private final TransactionPort tx; + + public DeletePosterUseCase( + PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { + this.repository = repository; + this.events = events; + this.tx = tx; + } + + @Override + public Void handle(DeletePosterCommand cmd) { + return tx.inWrite( + () -> { + if (!repository.existsById(cmd.id())) { + throw new PosterNotFoundException(cmd.id()); + } + events.publishDeleted(new PosterDeleted(cmd.id())); + repository.deleteById(cmd.id()); + return null; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java new file mode 100644 index 00000000..46acbe1a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java @@ -0,0 +1,35 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.application.query.GetPosterQuery; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +@Service +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class GetPosterUseCase implements QueryUseCase<GetPosterQuery, Poster> { + + private final PosterRepository repository; + private final TransactionPort tx; + + public GetPosterUseCase(PosterRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public Poster handle(GetPosterQuery q) { + return tx.inRead( + () -> repository.findById(q.id()).orElseThrow(() -> new PosterNotFoundException(q.id()))); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java new file mode 100644 index 00000000..afbb9f45 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java @@ -0,0 +1,34 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.query.ListPostersQuery; +import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +@Service +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class ListPostersUseCase implements QueryUseCase<ListPostersQuery, PosterPage> { + + private final PosterRepository repository; + private final TransactionPort tx; + + public ListPostersUseCase(PosterRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public PosterPage handle(ListPostersQuery q) { + return tx.inRead( + () -> repository.findPage(q.page(), q.size(), q.sortField(), q.ascending(), q.status())); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java new file mode 100644 index 00000000..29efe89a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java @@ -0,0 +1,55 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.PublishPosterCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterPublished; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +/** + * Publishes a poster ({@code DRAFT → PUBLISHED}); the aggregate rejects publishing without an + * attached image. Emits a {@code poster.published} outbox event. + */ +@Service +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class PublishPosterUseCase implements CommandUseCase<PublishPosterCommand, Poster> { + + private final PosterRepository repository; + private final PosterEventPublisher events; + private final TransactionPort tx; + + public PublishPosterUseCase( + PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { + this.repository = repository; + this.events = events; + this.tx = tx; + } + + @Override + public Poster handle(PublishPosterCommand cmd) { + return tx.inWrite( + () -> { + Poster p = + repository + .findById(cmd.id()) + .orElseThrow(() -> new PosterNotFoundException(cmd.id())); + p.publish(); + Poster saved = repository.save(p); + events.publishPublished(new PosterPublished(saved.id())); + return saved; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java new file mode 100644 index 00000000..79c8af9d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java @@ -0,0 +1,55 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.UpdatePosterCommand; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +/** + * Partial update of a poster's title/caption using {@code Patch} 3-state semantics. Mirrors {@code + * UpdateWorkLogUseCase}. + */ +@Service +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UpdatePosterUseCase implements CommandUseCase<UpdatePosterCommand, Poster> { + + private final PosterRepository repository; + private final TransactionPort tx; + + public UpdatePosterUseCase(PosterRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public Poster handle(UpdatePosterCommand cmd) { + return tx.inWrite( + () -> { + Poster p = + repository + .findById(cmd.id()) + .orElseThrow(() -> new PosterNotFoundException(cmd.id())); + if (cmd.title().hasValue()) { + p.rename(cmd.title().value()); + } + if (cmd.caption().hasValue()) { + p.changeCaption(cmd.caption().value()); + } else if (cmd.caption().isExplicitNull()) { + p.changeCaption(null); + } + return repository.save(p); + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java new file mode 100644 index 00000000..ed5f5b6d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java @@ -0,0 +1,69 @@ +package dev.caskeleton.sample.portfolio.application.poster; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; +import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterImageAttached; +import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; +import org.springframework.stereotype.Service; + +/** + * Uploads a poster's image to object storage AND records its key on the aggregate — the single + * collaboration that ties object storage to the domain (an image is what lets a poster be + * published). Stores the bytes under {@code posters/{id}/image}, then {@code attachImage}, save, + * and a {@code poster.image-attached} outbox event, all in one write transaction. + */ +@Service +@RequiresPermission("poster:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +public class UploadPosterImageUseCase + implements CommandUseCase<UploadPosterImageCommand, StoredObject> { + + private final PosterRepository repository; + private final ObjectStoragePort objectStorage; + private final PosterEventPublisher events; + private final TransactionPort tx; + + public UploadPosterImageUseCase( + PosterRepository repository, + ObjectStoragePort objectStorage, + PosterEventPublisher events, + TransactionPort tx) { + this.repository = repository; + this.objectStorage = objectStorage; + this.events = events; + this.tx = tx; + } + + @Override + public StoredObject handle(UploadPosterImageCommand cmd) { + return tx.inWrite( + () -> { + Poster p = + repository + .findById(cmd.id()) + .orElseThrow(() -> new PosterNotFoundException(cmd.id())); + StoredObject stored = + objectStorage.put( + "posters/" + cmd.id().value() + "/image", cmd.content(), cmd.contentType()); + p.attachImage(stored.key()); + repository.save(p); + events.publishImageAttached(new PosterImageAttached(p.id(), stored.key())); + return stored; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java new file mode 100644 index 00000000..4f401151 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java @@ -0,0 +1,12 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import java.util.Objects; + +public record GetPosterQuery(PosterId id) implements Query { + + public GetPosterQuery { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java new file mode 100644 index 00000000..6dbb1d7b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java @@ -0,0 +1,12 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.query.Query; +import java.util.Objects; + +public record GetRepoStatsQuery(String repoUrl) implements Query { + + // Self-validating input model: a null repoUrl fails fast at construction. See README. + public GetRepoStatsQuery { + Objects.requireNonNull(repoUrl, "repoUrl"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java new file mode 100644 index 00000000..61c3bff2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import java.util.Objects; + +public record GetWorkLogQuery(WorkLogId id) implements Query { + + // Self-validating input model: a lookup with no id fails fast at construction. See README. + public GetWorkLogQuery { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java new file mode 100644 index 00000000..80bf5700 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; + +/** + * List query carrying validated pagination, an optional allow-listed sort field, and an optional + * flat status filter. {@code sortField}/{@code status} are {@code null} when not supplied. Mirrors + * {@code ListWorkLogsQuery}. + */ +public record ListPostersQuery( + int page, int size, PosterSortField sortField, boolean ascending, PosterStatus status) + implements Query {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java new file mode 100644 index 00000000..51b9b85a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java @@ -0,0 +1,11 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.query.Query; + +/** + * Read intent for the recent-WorkLog summary projection. + * + * @param limit maximum number of summaries to return; the use case / adapter clamps non-positive + * values to a safe minimum. + */ +public record ListRecentWorkLogSummariesQuery(int limit) implements Query {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java new file mode 100644 index 00000000..496b8052 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.application.query.Query; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; + +/** + * List query carrying validated pagination, an optional allow-listed sort field, and an optional + * flat equality filter. {@code sortField}/{@code category} are {@code null} when not supplied. + */ +public record ListWorkLogsQuery( + int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) + implements Query {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java new file mode 100644 index 00000000..733576d6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.application.query; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import java.time.LocalDate; + +/** + * Application-layer read projection for a WorkLog list view (CQRS-lite "query bypass"): carries + * only the columns a list view needs, not the full aggregate. A projection DTO is an application + * type — never a domain aggregate, JPA entity, or web DTO (referencing a domain value type like + * {@link WorkCategory} is fine). The {@code id} is the canonical 36-char UUID string, converted + * from the storage-native {@code UUID} inside the adapter. See README. + */ +public record WorkLogSummary( + String id, String title, WorkCategory category, LocalDate periodStart, LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java new file mode 100644 index 00000000..25d9b9ad --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java @@ -0,0 +1,68 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Atomic sync batch create: every item runs inside a single {@code tx.inWrite} unit of work, so any + * item failure rolls the whole batch back (one 4xx, never partial success). See README. + */ +@Service +@RequiresPermission("worklog:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + bulkWrite = true) +public class BatchCreateWorkLogsUseCase + implements CommandUseCase<BatchCreateWorkLogsCommand, List<WorkLog>> { + + private final WorkLogRepository repository; + private final WorkLogIdFactory idFactory; + private final TransactionPort tx; + + public BatchCreateWorkLogsUseCase( + WorkLogRepository repository, WorkLogIdFactory idFactory, TransactionPort tx) { + this.repository = repository; + this.idFactory = idFactory; + this.tx = tx; + } + + @Override + public List<WorkLog> handle(BatchCreateWorkLogsCommand cmd) { + return tx.inWrite( + () -> { + List<WorkLog> created = new ArrayList<>(cmd.items().size()); + for (CreateWorkLogCommand item : cmd.items()) { + created.add( + repository.save( + WorkLog.create( + idFactory.newId(), + WorkLogOwner.of(item.owner()), + item.title(), + item.category(), + item.summary(), + item.content(), + item.techStack(), + item.links(), + new Period(item.periodStart(), item.periodEnd())))); + } + return created; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java new file mode 100644 index 00000000..b01bf261 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java @@ -0,0 +1,123 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.event.WorkLogReservedIntegrationEvent; +import dev.caskeleton.sample.portfolio.application.event.WorkLogReservedIntegrationEventMapper; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; +import java.time.Clock; +import java.time.Instant; +import org.slf4j.MDC; +import org.springframework.stereotype.Service; + +/** + * Creates a new {@link WorkLog} and appends a {@link WorkLogReserved} outbox event in the same + * write transaction, demonstrating the dual-write prohibition. {@code eventId} is the UUIDv7 from + * {@link OutboxEventIdFactory} and doubles as the idempotency key; {@code correlationId} comes from + * MDC. See README. + */ +@Service +@RequiresPermission("worklog:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class CreateWorkLogUseCase implements CommandUseCase<CreateWorkLogCommand, WorkLog> { + + private static final String MDC_CORRELATION_ID = "correlation_id"; + + private final WorkLogRepository repository; + private final WorkLogIdFactory idFactory; + private final OutboxEventIdFactory eventIdFactory; + private final OutboxAppendPort outboxAppendPort; + private final Clock clock; + private final TransactionPort tx; + + public CreateWorkLogUseCase( + WorkLogRepository repository, + WorkLogIdFactory idFactory, + OutboxEventIdFactory eventIdFactory, + OutboxAppendPort outboxAppendPort, + Clock clock, + TransactionPort tx) { + this.repository = repository; + this.idFactory = idFactory; + this.eventIdFactory = eventIdFactory; + this.outboxAppendPort = outboxAppendPort; + this.clock = clock; + this.tx = tx; + } + + @Override + public WorkLog handle(CreateWorkLogCommand cmd) { + // The server mints the id through the domain WorkLogIdFactory port (see README). + return tx.inWrite( + () -> { + WorkLog saved = + repository.save( + WorkLog.create( + idFactory.newId(), + WorkLogOwner.of(cmd.owner()), + cmd.title(), + cmd.category(), + cmd.summary(), + cmd.content(), + cmd.techStack(), + cmd.links(), + new Period(cmd.periodStart(), cmd.periodEnd()))); + + // Append the outbox event in the SAME transaction as the save (no dual-write). + appendReservedEvent(saved); + + return saved; + }); + } + + /** + * Builds the {@link WorkLogReserved} domain event, maps it to an integration event, serialises + * it, and appends it to the outbox — all within the caller's transaction. + */ + private void appendReservedEvent(WorkLog saved) { + WorkLogReserved domainEvent = + new WorkLogReserved(saved.id(), saved.category(), saved.period().start()); + + WorkLogReservedIntegrationEvent integrationEvent = + WorkLogReservedIntegrationEventMapper.toIntegrationEvent(domainEvent); + + String payload = WorkLogReservedIntegrationEventMapper.toJson(integrationEvent); + + String eventId = eventIdFactory.newEventId(); + Instant occurredAt = Instant.now(clock); + + // correlationId from MDC; fall back to eventId for self-correlation off-HTTP paths. + String correlationId = MDC.get(MDC_CORRELATION_ID); + if (correlationId == null || correlationId.isBlank()) { + correlationId = eventId; + } + + outboxAppendPort.append( + new NewOutboxEvent( + eventId, + WorkLogReservedIntegrationEventMapper.EVENT_TYPE, + saved.id().value(), + payload, + occurredAt, + correlationId, + eventId // idempotencyKey = eventId + )); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java new file mode 100644 index 00000000..d515d64f --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java @@ -0,0 +1,44 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import org.springframework.stereotype.Service; + +// Deleting a work log is the destructive, admin-tier operation, gated by `worklog:close` +// (granted only to `admin`, never `user`). See README. +@Service +@RequiresPermission("worklog:close") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class DeleteWorkLogUseCase implements CommandUseCase<DeleteWorkLogCommand, Void> { + + private final WorkLogRepository repository; + private final TransactionPort tx; + + public DeleteWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public Void handle(DeleteWorkLogCommand cmd) { + return tx.inWrite( + () -> { + if (!repository.existsById(cmd.id())) { + throw new WorkLogNotFoundException(cmd.id()); + } + repository.deleteById(cmd.id()); + return null; + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java new file mode 100644 index 00000000..61adac82 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java @@ -0,0 +1,31 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.port.RepoStatsPort; +import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import org.springframework.stereotype.Service; + +@Service +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.NONE, + externalOutboundAllowed = true) +public class GetRepoStatsUseCase implements QueryUseCase<GetRepoStatsQuery, RepoStats> { + + private final RepoStatsPort repoStatsPort; + + public GetRepoStatsUseCase(RepoStatsPort repoStatsPort) { + this.repoStatsPort = repoStatsPort; + } + + @Override + public RepoStats handle(GetRepoStatsQuery q) { + return repoStatsPort.fetch(q.repoUrl()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java new file mode 100644 index 00000000..b99052f5 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java @@ -0,0 +1,35 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import org.springframework.stereotype.Service; + +@Service +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class GetWorkLogUseCase implements QueryUseCase<GetWorkLogQuery, WorkLog> { + + private final WorkLogRepository repository; + private final TransactionPort tx; + + public GetWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public WorkLog handle(GetWorkLogQuery q) { + return tx.inRead( + () -> repository.findById(q.id()).orElseThrow(() -> new WorkLogNotFoundException(q.id()))); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java new file mode 100644 index 00000000..43362128 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.port.WorkLogSummaryQueryPort; +import dev.caskeleton.sample.portfolio.application.query.ListRecentWorkLogSummariesQuery; +import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Read-model (projection) query use case. Even a projection read goes through a {@link + * QueryUseCase} bean, runs inside {@link TransactionPort#inRead}, and is still {@link + * RepositoryAccess#READ_REPOSITORY} ("projection vs aggregate" is the return shape, orthogonal to + * the repository access level). See README. + */ +@Service +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class ListRecentWorkLogSummariesUseCase + implements QueryUseCase<ListRecentWorkLogSummariesQuery, List<WorkLogSummary>> { + + private final WorkLogSummaryQueryPort queryPort; + private final TransactionPort tx; + + public ListRecentWorkLogSummariesUseCase(WorkLogSummaryQueryPort queryPort, TransactionPort tx) { + this.queryPort = queryPort; + this.tx = tx; + } + + @Override + public List<WorkLogSummary> handle(ListRecentWorkLogSummariesQuery q) { + return tx.inRead(() -> queryPort.findRecentSummaries(q.limit())); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java new file mode 100644 index 00000000..e9e40394 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java @@ -0,0 +1,34 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import dev.caskeleton.sample.portfolio.application.query.ListWorkLogsQuery; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import org.springframework.stereotype.Service; + +@Service +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class ListWorkLogsUseCase implements QueryUseCase<ListWorkLogsQuery, WorkLogPage> { + + private final WorkLogRepository repository; + private final TransactionPort tx; + + public ListWorkLogsUseCase(WorkLogRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public WorkLogPage handle(ListWorkLogsQuery q) { + return tx.inRead( + () -> repository.findPage(q.page(), q.size(), q.sortField(), q.ascending(), q.category())); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java new file mode 100644 index 00000000..2a9c18a2 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java @@ -0,0 +1,76 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import dev.caskeleton.sample.portfolio.application.command.UpdateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import org.springframework.stereotype.Service; + +@Service +@RequiresPermission("worklog:write") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UpdateWorkLogUseCase implements CommandUseCase<UpdateWorkLogCommand, WorkLog> { + + private final WorkLogRepository repository; + private final TransactionPort tx; + + public UpdateWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { + this.repository = repository; + this.tx = tx; + } + + @Override + public WorkLog handle(UpdateWorkLogCommand cmd) { + return tx.inWrite( + () -> { + WorkLog w = + repository + .findById(cmd.id()) + .orElseThrow(() -> new WorkLogNotFoundException(cmd.id())); + if (cmd.title().hasValue()) { + w.rename(cmd.title().value()); + } + if (cmd.summary().hasValue()) { + w.updateSummary(cmd.summary().value()); + } else if (cmd.summary().isExplicitNull()) { + w.updateSummary(null); + } + if (cmd.content().hasValue()) { + w.updateContent(cmd.content().value()); + } else if (cmd.content().isExplicitNull()) { + w.updateContent(null); + } + if (cmd.category().hasValue()) { + w.recategorize(cmd.category().value()); + } + if (cmd.status().hasValue()) { + switch (cmd.status().value()) { + case IN_PROGRESS -> w.startProgress(); + case CLOSED -> w.close(); + case OPEN -> { + if (w.status() != WorkLogStatus.OPEN) { + throw new WorkLogInvariantException( + WorkLogInvariantException.Reason.INVALID_STATUS_TRANSITION); + } + } + default -> + throw new IllegalStateException( + "unexpected work log status: " + cmd.status().value()); + } + } + return repository.save(w); + }); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java new file mode 100644 index 00000000..90f98949 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.bootstrap.context; + +import dev.caskeleton.shared.concurrency.DomainContextPropagator; +import dev.caskeleton.shared.concurrency.ThreadLocalDomainContextPropagator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Sample-local substitute for {@code app-bootstrap}'s {@code DomainContextConfig}: provides the + * {@link DomainContextPropagator} bean (default {@code THREAD_LOCAL} strategy) that the scanned + * {@code DomainContextAuditContextPort} requires. Disposable. See README. + */ +@Configuration +public class SampleDomainContextConfig { + + @Bean + public DomainContextPropagator domainContextPropagator() { + return new ThreadLocalDomainContextPropagator(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java new file mode 100644 index 00000000..869990d9 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java @@ -0,0 +1,31 @@ +package dev.caskeleton.sample.portfolio.bootstrap.idempotency; + +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import java.time.Clock; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code IdempotencyConfig}: supplies the {@code + * Clock}, assembles the {@code IdempotencyExecutor}, and enables scheduling for the idempotency + * reaper. Disposable. See README. + */ +@Configuration +@EnableScheduling +@EnableConfigurationProperties(SampleIdempotencySettings.class) +public class SampleIdempotencyConfig { + + @Bean + public Clock systemClock() { + return Clock.systemUTC(); + } + + @Bean + public IdempotencyExecutor idempotencyExecutor( + IdempotencyStorePort store, Clock clock, SampleIdempotencySettings properties) { + return new IdempotencyExecutor(store, clock, properties.ttl()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java new file mode 100644 index 00000000..f20fa8e1 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java @@ -0,0 +1,34 @@ +package dev.caskeleton.sample.portfolio.bootstrap.idempotency; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code IdempotencySettings}, bound from {@code + * ca-skeleton.idempotency.*}. TTL must be positive and ≤ 72h; reaper interval defaults to 10m. + * Disposable. See README. + */ +@Validated +@ConfigurationProperties(prefix = "ca-skeleton.idempotency") +public record SampleIdempotencySettings(Duration ttl, Duration reaperInterval) { + + private static final Duration MAX_TTL = Duration.ofHours(72); + + public SampleIdempotencySettings { + if (ttl == null) { + ttl = Duration.ofHours(24); + } + if (ttl.isZero() || ttl.isNegative()) { + throw new IllegalArgumentException( + "APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be positive, was " + ttl); + } + if (ttl.compareTo(MAX_TTL) > 0) { + throw new IllegalArgumentException( + "APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be <= 72h (D6), was " + ttl); + } + if (reaperInterval == null || reaperInterval.isZero() || reaperInterval.isNegative()) { + reaperInterval = Duration.ofMinutes(10); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java new file mode 100644 index 00000000..3fe94843 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java @@ -0,0 +1,25 @@ +package dev.caskeleton.sample.portfolio.bootstrap.logging; + +import dev.caskeleton.adapter.outbound.identifier.HmacUserPrincipalPseudonymizer; +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.sample.portfolio.bootstrap.settings.SamplePrivacySettings; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code PseudonymizationConfig}: supplies the {@code + * UserPrincipalPseudonymizerPort} that adapter-web's {@code RequestLoggingFilter} injects. + * Disposable. See README. + */ +@Configuration +@EnableConfigurationProperties(SamplePrivacySettings.class) +public class SamplePseudonymizationConfig { + + @Bean + @ConditionalOnMissingBean + UserPrincipalPseudonymizerPort userPrincipalPseudonymizer(SamplePrivacySettings privacySettings) { + return new HmacUserPrincipalPseudonymizer(privacySettings.saltBytes()); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java new file mode 100644 index 00000000..46fdbede --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java @@ -0,0 +1,30 @@ +package dev.caskeleton.sample.portfolio.bootstrap.management; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Sample-local actuator chain. The standalone sample is intentionally public so the demo can be + * inspected without a configured IdP; production actuator posture remains in app-bootstrap. + */ +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class SampleManagementSecurityConfig { + + /** Actuator security chain ordered at {@code 0} to run BEFORE the main app chain. */ + @Bean + @Order(0) + public SecurityFilterChain sampleActuatorSecurityFilterChain(HttpSecurity http) throws Exception { + http.securityMatcher(EndpointRequest.toAnyEndpoint()) + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java new file mode 100644 index 00000000..696718cc --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java @@ -0,0 +1,87 @@ +package dev.caskeleton.sample.portfolio.bootstrap.metrics; + +import dev.caskeleton.shared.metrics.ForbiddenMetricTags; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.config.MeterFilterReply; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Configuration; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code MetricsContractConfig}: installs the + * cardinality and SLO-distribution {@code MeterFilter}s onto the {@link MeterRegistry} before any + * meters register. Disposable. See README. + */ +@Configuration +public class SampleMetricsContractConfig { + + private static final Logger log = LoggerFactory.getLogger(SampleMetricsContractConfig.class); + + private final ObjectProvider<MeterRegistry> meterRegistryProvider; + + public SampleMetricsContractConfig(ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.meterRegistryProvider = meterRegistryProvider; + } + + @PostConstruct + void installFilters() { + MeterRegistry registry = meterRegistryProvider.getIfAvailable(); + if (registry == null) { + log.debug("sample metrics-contract: no MeterRegistry available; MeterFilter install skipped"); + return; + } + registry.config().meterFilter(new CardinalityFilter()).meterFilter(new DistributionFilter()); + } + + /** Deny meters with high-cardinality tag keys. */ + private static final class CardinalityFilter implements MeterFilter { + @Override + public MeterFilterReply accept(Meter.Id id) { + for (Tag tag : id.getTags()) { + if (ForbiddenMetricTags.isForbidden(tag.getKey())) { + return MeterFilterReply.DENY; + } + } + return MeterFilterReply.NEUTRAL; + } + } + + /** SLO-driven histogram config for owned timer names. */ + private static final class DistributionFilter implements MeterFilter { + + private static final Set<String> SLO_DRIVEN_TIMERS = + Set.of( + "http.server.requests", + "http.server.requests.latency", + "dependency.client.requests", + "db.query.duration", + "jvm.gc.pause"); + + @Override + public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) { + if (!SLO_DRIVEN_TIMERS.contains(id.getName())) { + return config; + } + return DistributionStatisticConfig.builder() + .percentilesHistogram(true) + .percentiles(0.5, 0.9, 0.95, 0.99) + .serviceLevelObjectives( + (double) Duration.ofMillis(100).toNanos(), + (double) Duration.ofMillis(500).toNanos(), + (double) Duration.ofSeconds(1).toNanos(), + (double) Duration.ofSeconds(5).toNanos()) + .minimumExpectedValue((double) Duration.ofMillis(1).toNanos()) + .maximumExpectedValue((double) Duration.ofSeconds(10).toNanos()) + .build() + .merge(config); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java new file mode 100644 index 00000000..fde89e9d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java @@ -0,0 +1,46 @@ +package dev.caskeleton.sample.portfolio.bootstrap.persistence; + +import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; +import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; +import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository; +import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; +import jakarta.persistence.EntityManager; +import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Sample-local replacement for {@code PostgreSqlPersistenceConfig}. Exists to break a + * Flyway/EntityManager initialization cycle by registering the {@code + * FlywayConfigurationCustomizer} as a {@code static @Bean}. Disposable. See README. + */ +@Configuration(proxyBeanMethods = false) +@Import(PersistenceJpaConfig.class) +public class SamplePostgreSqlPersistenceConfig { + + /** + * {@code static @Bean} (called without instantiating the owning class, so no + * {@code @PersistenceContext} injection during Flyway init). Sets both migration locations: + * production {@code db/migration/postgresql} + sample {@code db/sample-migration}. {@code + * locations(...)} replaces rather than appends, so this — not {@code spring.flyway.locations} in + * application.yml — is the effective source of truth. See README. + */ + @Bean + public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() { + return configuration -> + configuration.locations( + "classpath:db/migration/postgresql", "classpath:db/sample-migration"); + } + + @Bean + public OutboxClaimRepository outboxClaimRepository(EntityManager entityManager) { + return new PostgreSqlOutboxClaimRepository(entityManager); + } + + @Bean + public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() { + return new PostgreSqlSqlStateErrorMapping(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java new file mode 100644 index 00000000..60608714 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java @@ -0,0 +1,29 @@ +package dev.caskeleton.sample.portfolio.bootstrap.scan; + +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.filter.TypeFilter; + +/** + * Component-scan exclusion filter that rejects {@code @Configuration} classes nested inside test + * classes (binary name contains {@code $} and the enclosing class name ends with {@code Test}), + * preventing {@code BeanDefinitionOverrideException} during the full-context boot. Disposable. See + * README. + */ +public class TestEnclosedConfigurationFilter implements TypeFilter { + + @Override + public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) { + String className = metadataReader.getClassMetadata().getClassName(); + int dollarIndex = className.lastIndexOf('$'); + if (dollarIndex < 0) { + return false; // top-level class — not a nested class + } + String enclosingName = className.substring(0, dollarIndex); + // Strip any further nesting to reach the top-level class name segment. + int lastDot = enclosingName.lastIndexOf('.'); + String simpleEnclosingName = + (lastDot >= 0) ? enclosingName.substring(lastDot + 1) : enclosingName; + return simpleEnclosingName.endsWith("Test"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java new file mode 100644 index 00000000..e15af510 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java @@ -0,0 +1,30 @@ +package dev.caskeleton.sample.portfolio.bootstrap.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Sample-only HTTP security posture. The WorkLog sample has no login surface, so every demo URL is + * reachable without an Authorization header while production modules continue to use adapter-web's + * JWT and method-security configuration. + */ +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class SamplePublicAccessSecurityConfig { + + @Bean + @Order(-100) + public SecurityFilterChain samplePublicAccessSecurityFilterChain(HttpSecurity http) + throws Exception { + http.securityMatcher("/**") + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java new file mode 100644 index 00000000..f9d419c6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java @@ -0,0 +1,33 @@ +package dev.caskeleton.sample.portfolio.bootstrap.settings; + +import java.nio.charset.StandardCharsets; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code PrivacySettings}, bound from {@code + * ca-skeleton.privacy.*}. Supplies the HMAC salt; a blank salt falls back to a dev sentinel so + * local/test runs start. Disposable. See README. + */ +@ConfigurationProperties(prefix = "ca-skeleton.privacy") +public record SamplePrivacySettings(String pseudonymizationSalt) { + + private static final Logger log = LoggerFactory.getLogger(SamplePrivacySettings.class); + + static final String DEV_SENTINEL_SALT = "__LOCAL_DEV_pseudonymization_salt"; + + public SamplePrivacySettings { + if (pseudonymizationSalt == null || pseudonymizationSalt.isBlank()) { + log.warn( + "APP_PRIVACY_PSEUDONYMIZATION_SALT is blank; using a dev sentinel salt. " + + "Set a real secret-manager value before production."); + pseudonymizationSalt = DEV_SENTINEL_SALT; + } + } + + /** The salt as UTF-8 bytes for the HMAC key. */ + public byte[] saltBytes() { + return pseudonymizationSalt.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java new file mode 100644 index 00000000..9cb62c88 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java @@ -0,0 +1,33 @@ +package dev.caskeleton.sample.portfolio.bootstrap.tracing; + +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import io.micrometer.tracing.Span; +import io.micrometer.tracing.Tracer; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code MicrometerSpanErrorRecorder}: records an + * exception on the current span with an {@code error.code} tag. Disposable. See README. + */ +public final class SampleMicrometerSpanErrorRecorder implements SpanErrorRecorder { + + private final Tracer tracer; + + public SampleMicrometerSpanErrorRecorder(Tracer tracer) { + this.tracer = tracer; + } + + @Override + public void recordException(Throwable error, String errorCode) { + if (error == null) { + return; + } + Span span = tracer.currentSpan(); + if (span == null) { + return; + } + span.error(error); + if (errorCode != null) { + span.tag("error.code", errorCode); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java new file mode 100644 index 00000000..a8e16bae --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java @@ -0,0 +1,83 @@ +package dev.caskeleton.sample.portfolio.bootstrap.tracing; + +import dev.caskeleton.shared.tracing.SpanErrorRecorder; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.tracing.Tracer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code TracingConfig}: registers the {@link + * SpanErrorRecorder} bean and the sampling-rate gauge. Disposable. See README. + */ +@Configuration +@EnableConfigurationProperties(SampleTracingSettings.class) +public class SampleTracingConfig { + + private static final Logger log = LoggerFactory.getLogger(SampleTracingConfig.class); + + private final SampleTracingSettings properties; + private final Environment environment; + private final ObjectProvider<MeterRegistry> meterRegistryProvider; + + public SampleTracingConfig( + SampleTracingSettings properties, + Environment environment, + ObjectProvider<MeterRegistry> meterRegistryProvider) { + this.properties = properties; + this.environment = environment; + this.meterRegistryProvider = meterRegistryProvider; + } + + /** Registers the {@code tracing.sampling.rate} gauge on startup. */ + @Bean + public SampleTracingSamplingRateGaugeRegistrar sampleTracingSamplingRateGaugeRegistrar() { + String activeProfile = resolveActiveProfile(); + double effectiveRate = + new SampleTracingSampleRateResolver().resolve(activeProfile, properties.sampleRate()); + registerGauge(activeProfile, effectiveRate); + return new SampleTracingSamplingRateGaugeRegistrar(activeProfile, effectiveRate); + } + + /** Real {@link SpanErrorRecorder} bean when a Tracer is present; NOOP otherwise. */ + @Bean + @ConditionalOnMissingBean(SpanErrorRecorder.class) + SpanErrorRecorder sampleSpanErrorRecorder(ObjectProvider<Tracer> tracerProvider) { + Tracer tracer = tracerProvider.getIfAvailable(); + if (tracer == null) { + return SpanErrorRecorder.NOOP; + } + return new SampleMicrometerSpanErrorRecorder(tracer); + } + + private String resolveActiveProfile() { + String[] profiles = environment.getActiveProfiles(); + return (profiles != null && profiles.length > 0) ? profiles[0] : "local"; + } + + private void registerGauge(String activeProfile, double effectiveRate) { + MeterRegistry registry = meterRegistryProvider.getIfAvailable(); + if (registry == null) { + return; + } + try { + Gauge.builder("tracing.sampling.rate", () -> effectiveRate) + .tag("profile", activeProfile != null ? activeProfile : "unknown") + .description("Effective distributed-tracing sample rate for the active profile") + .register(registry); + } catch (RuntimeException ex) { + log.warn("sample tracing: failed to register gauge tracing.sampling.rate", ex); + } + } + + /** Value holder exposing the active profile and resolved rate for test inspection. */ + public record SampleTracingSamplingRateGaugeRegistrar( + String activeProfile, double effectiveSampleRate) {} +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java new file mode 100644 index 00000000..be612fae --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java @@ -0,0 +1,51 @@ +package dev.caskeleton.sample.portfolio.bootstrap.tracing; + +import java.util.Locale; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code TracingSampleRateResolver}: applies the + * per-profile default sample rate (prod=0.01, staging=0.10, dev/local=1.0). Disposable. See README. + */ +final class SampleTracingSampleRateResolver { + + /** + * Returns the per-profile default sample rate (D6 SSOT). + * + * @param profile Spring active profile name; {@code null}/blank treated as "anything else" + * @return effective default rate for the profile + */ + static double defaultRateForProfile(String profile) { + if (profile == null) { + return 1.0; + } + return switch (profile.trim().toLowerCase(Locale.ROOT)) { + case "prod" -> 0.01; + case "staging" -> 0.10; + case "dev" -> 1.0; + case "local" -> 1.0; + default -> 1.0; + }; + } + + /** + * Resolves the effective sample rate, letting a configured override win over the per-profile + * default. + * + * @param profile active Spring profile + * @param configuredRate raw string from {@code APP_TRACING_SAMPLE_RATE}; may be blank + * @return effective sample rate in [0.0, 1.0] + */ + double resolve(String profile, String configuredRate) { + if (configuredRate != null && !configuredRate.isBlank()) { + try { + double v = Double.parseDouble(configuredRate.trim()); + if (v >= 0.0 && v <= 1.0) { + return v; + } + } catch (NumberFormatException ignored) { + // fall through to profile default + } + } + return defaultRateForProfile(profile); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java new file mode 100644 index 00000000..a31e9eb5 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java @@ -0,0 +1,38 @@ +package dev.caskeleton.sample.portfolio.bootstrap.tracing; + +import java.util.Map; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code TracingSamplingEnvironmentPostProcessor}: + * bridges the resolved sample rate to {@code management.tracing.sampling.probability} so the OTel + * sampler matches the gauge. Disposable. See README. + */ +public class SampleTracingSamplingEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String NATIVE_KEY = "management.tracing.sampling.probability"; + private static final String CA_SAMPLE_RATE_KEY = "ca-skeleton.tracing.sample-rate"; + + private final SampleTracingSampleRateResolver resolver = new SampleTracingSampleRateResolver(); + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, SpringApplication application) { + // User-explicit setting takes priority. + if (environment.containsProperty(NATIVE_KEY)) { + return; + } + String[] profiles = environment.getActiveProfiles(); + String profile = (profiles != null && profiles.length > 0) ? profiles[0] : "local"; + String configured = environment.getProperty(CA_SAMPLE_RATE_KEY); + double rate = resolver.resolve(profile, configured); + environment + .getPropertySources() + .addLast( + new MapPropertySource( + "sampleTracingSamplingBridge", Map.of(NATIVE_KEY, String.valueOf(rate)))); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java new file mode 100644 index 00000000..f1763584 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java @@ -0,0 +1,76 @@ +package dev.caskeleton.sample.portfolio.bootstrap.tracing; + +import java.net.URI; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Sample-local copy of {@code app-bootstrap}'s {@code TracingSettings}, bound from {@code + * ca-skeleton.tracing.*}. Blank sampleRate = per-profile default; non-blank must be a float in + * [0.0, 1.0]; blank otlpEndpoint = exporter off, non-blank must be a valid URL. Disposable. See + * README. + */ +@ConfigurationProperties(prefix = "ca-skeleton.tracing") +@Validated +public record SampleTracingSettings(boolean enabled, String sampleRate, Exporter exporter) { + + /** Nested properties for the exporter seam. */ + public record Exporter(String otlpEndpoint) { + + public Exporter { + if (otlpEndpoint == null) { + otlpEndpoint = ""; + } + } + + /** {@code true} when an exporter endpoint is configured (non-blank). */ + public boolean isConfigured() { + return otlpEndpoint != null && !otlpEndpoint.isBlank(); + } + } + + /** Compact canonical constructor — validation: float_between_0_and_1 + url_or_empty. */ + public SampleTracingSettings { + if (sampleRate == null) { + sampleRate = ""; + } + if (exporter == null) { + exporter = new Exporter(""); + } + // blank = "per-profile default" signal + if (!sampleRate.isBlank()) { + validateSampleRate(sampleRate); + } + validateOtlpEndpoint(exporter.otlpEndpoint()); + } + + private static void validateSampleRate(String raw) { + try { + double v = Double.parseDouble(raw.trim()); + if (v < 0.0 || v > 1.0) { + throw new IllegalStateException( + "APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0]; got: " + raw); + } + } catch (NumberFormatException ex) { + throw new IllegalStateException( + "APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0]; got: " + raw, ex); + } + } + + private static void validateOtlpEndpoint(String endpoint) { + if (endpoint == null || endpoint.isBlank()) { + return; + } + try { + URI uri = URI.create(endpoint.trim()); + String scheme = uri.getScheme(); + if (scheme == null || scheme.isBlank()) { + throw new IllegalStateException( + "OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL with a scheme; got: " + endpoint); + } + } catch (IllegalArgumentException ex) { + throw new IllegalStateException( + "OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL or empty; got: " + endpoint, ex); + } + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java new file mode 100644 index 00000000..13c7c93b --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java @@ -0,0 +1,144 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.stereotype.AggregateRoot; +import java.util.Objects; + +/** + * A promotional poster. Aggregate root for the Poster aggregate; framework-neutral POJO (no + * Spring/JPA). State changes only through intention-revealing methods (no public setters), and + * invariant violations surface as {@link PosterInvariantException}. + * + * <p>The distinctive rule: a poster cannot be {@link #publish() published} until an image has been + * {@link #attachImage(String) attached} — this is what ties the object-storage upload to a domain + * invariant. The id is server-assigned (minted by a {@code PosterIdFactory} in the use case, never + * by the domain); {@code version} is {@code null} until the store persists it. Mirrors {@code + * WorkLog}. + */ +@AggregateRoot +public class Poster { + + private final PosterId id; + private String title; + private String caption; + private String imageKey; // object-storage key; null until an image is uploaded + private PosterStatus status; + + /** Optimistic-lock version; {@code null} until the store persists it. */ + private final Long version; + + private Poster( + PosterId id, + String title, + String caption, + String imageKey, + PosterStatus status, + Long version) { + this.id = Objects.requireNonNull(id, "id is required"); + this.title = requireValidTitle(title); + this.caption = caption == null ? "" : caption; + this.imageKey = imageKey; // nullable by design + this.status = Objects.requireNonNull(status, "status is required"); + this.version = version; + } + + /** + * Builds a new poster with a server-assigned identifier. Starts in {@link PosterStatus#DRAFT} + * with no image; version is {@code null} until the store persists it. + */ + public static Poster create(PosterId id, String title, String caption) { + return new Poster(id, title, caption, null, PosterStatus.DRAFT, null); + } + + /** Rehydrates a persisted poster (no invariant re-derivation beyond construction checks). */ + public static Poster rehydrate( + PosterId id, + String title, + String caption, + String imageKey, + PosterStatus status, + Long version) { + return new Poster(id, title, caption, imageKey, status, version); + } + + public void rename(String newTitle) { + this.title = requireValidTitle(newTitle); + } + + public void changeCaption(String newCaption) { + this.caption = newCaption == null ? "" : newCaption; + } + + /** Records the object-storage key of the uploaded image. */ + public void attachImage(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("imageKey is required"); + } + this.imageKey = key; + } + + /** {@code DRAFT → PUBLISHED}. Requires an attached image (the domain invariant). */ + public void publish() { + if (imageKey == null || imageKey.isBlank()) { + throw new PosterInvariantException(PosterInvariantException.Reason.IMAGE_REQUIRED); + } + transitionTo(PosterStatus.PUBLISHED); + } + + public void archive() { + transitionTo(PosterStatus.ARCHIVED); + } + + private void transitionTo(PosterStatus next) { + Objects.requireNonNull(next, "status is required"); + if (status == next) { + return; + } + if (!status.canTransitionTo(next)) { + throw new PosterInvariantException(PosterInvariantException.Reason.INVALID_STATUS_TRANSITION); + } + this.status = next; + } + + /** + * Enforces the title invariant on the sole construction/mutation path. A null or blank title is + * client-facing validation, so it surfaces as a domain invariant ({@link + * PosterInvariantException} with {@code TITLE_BLANK}) — which the web layer maps to a 400 — + * rather than an {@code NullPointerException} (which would read as a server bug / 500). + * Server-controlled refs (id, status) still use {@link Objects#requireNonNull} because a null + * there is a genuine programming error, not client input. + */ + private static String requireValidTitle(String title) { + if (title == null || title.isBlank()) { + throw new PosterInvariantException(PosterInvariantException.Reason.TITLE_BLANK); + } + return title; + } + + public PosterId id() { + return id; + } + + public String title() { + return title; + } + + public String caption() { + return caption; + } + + public String imageKey() { + return imageKey; + } + + public boolean hasImage() { + return imageKey != null && !imageKey.isBlank(); + } + + public PosterStatus status() { + return status; + } + + public Long version() { + return version; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java new file mode 100644 index 00000000..d2dc6000 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.util.Objects; + +/** Domain event: a {@link Poster} was archived. Transport-free; carries domain data only. */ +@DomainEvent +public record PosterArchived(PosterId posterId) { + + public PosterArchived { + Objects.requireNonNull(posterId, "posterId is required"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java new file mode 100644 index 00000000..011eb60c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.util.Objects; + +/** + * Domain event: a {@link Poster} was created. Transport-free — carries domain data only, no + * broker/wire/HTTP type. The application boundary turns it into an integration event. Mirrors + * {@code WorkLogReserved}. + */ +@DomainEvent +public record PosterCreated(PosterId posterId, String title) { + + public PosterCreated { + Objects.requireNonNull(posterId, "posterId is required"); + Objects.requireNonNull(title, "title is required"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java new file mode 100644 index 00000000..ee4725ec --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.util.Objects; + +/** Domain event: a {@link Poster} was deleted. Transport-free; carries domain data only. */ +@DomainEvent +public record PosterDeleted(PosterId posterId) { + + public PosterDeleted { + Objects.requireNonNull(posterId, "posterId is required"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java new file mode 100644 index 00000000..c45b8181 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java @@ -0,0 +1,29 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.identifier.ResourceId; +import dev.caskeleton.domain.stereotype.ValueObject; +import java.util.regex.Pattern; + +/** + * Resource identifier for a {@link Poster}: a 36-character canonical UUID (RFC 9562 UUIDv7). Pure + * value object — validates the canonical form only; case normalisation happens at the web boundary. + * Mirrors {@code WorkLogId}. + */ +@ValueObject +public record PosterId(String value) implements ResourceId<PosterId> { + + /** Canonical UUID form (RFC 9562): 8-4-4-4-12 hexadecimal groups. */ + private static final Pattern PATTERN = + Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + public PosterId { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid PosterId format: " + value); + } + } + + public static PosterId of(String value) { + return new PosterId(value); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java new file mode 100644 index 00000000..4548f525 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java @@ -0,0 +1,9 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.identifier.IdFactory; + +/** + * Domain port that mints {@link PosterId}s. Implemented by an infrastructure adapter; injected into + * the create use case. Mirrors {@code WorkLogIdFactory}. + */ +public interface PosterIdFactory extends IdFactory<PosterId> {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java new file mode 100644 index 00000000..d4a4c012 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java @@ -0,0 +1,17 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.util.Objects; + +/** + * Domain event: an image was attached to a {@link Poster}. Transport-free; carries domain data + * only. + */ +@DomainEvent +public record PosterImageAttached(PosterId posterId, String imageKey) { + + public PosterImageAttached { + Objects.requireNonNull(posterId, "posterId is required"); + Objects.requireNonNull(imageKey, "imageKey is required"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java new file mode 100644 index 00000000..371f8278 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java @@ -0,0 +1,29 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +/** + * Raised when a {@link Poster} aggregate invariant is violated. Carries a safe, noun-form {@link + * Reason} and knows nothing about error codes, HTTP status, or logging — the application/web layer + * translates {@code reason()}. Mirrors {@code WorkLogInvariantException}. + */ +public final class PosterInvariantException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Safe, client-presentable reason for an invariant violation (noun form). */ + public enum Reason { + TITLE_BLANK, + INVALID_STATUS_TRANSITION, + IMAGE_REQUIRED + } + + private final Reason reason; + + public PosterInvariantException(Reason reason) { + super("Poster invariant violated: " + reason); + this.reason = reason; + } + + public Reason reason() { + return reason; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java new file mode 100644 index 00000000..84a0b590 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java @@ -0,0 +1,15 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import java.util.List; + +/** + * One page of posters plus the total element count across all pages. {@code total} feeds the + * response {@code meta.page.total}; {@code items} is never {@code null}. Mirrors {@code + * WorkLogPage}. + */ +public record PosterPage(List<Poster> items, long total) { + + public PosterPage { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java new file mode 100644 index 00000000..7120c65a --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java @@ -0,0 +1,13 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.util.Objects; + +/** Domain event: a {@link Poster} was published. Transport-free; carries domain data only. */ +@DomainEvent +public record PosterPublished(PosterId posterId) { + + public PosterPublished { + Objects.requireNonNull(posterId, "posterId is required"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java new file mode 100644 index 00000000..ec9ff4a9 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java @@ -0,0 +1,24 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import java.util.Optional; + +/** Outbound port for Poster persistence. Implemented in adapter-persistence. */ +public interface PosterRepository { + Poster save(Poster poster); + + Optional<Poster> findById(PosterId id); + + /** + * Returns one page of posters plus the total count. {@code sortField} / {@code status} may be + * {@code null} (unsorted / unfiltered); the total is computed against the same filter. Mirrors + * {@code WorkLogRepository#findPage}. + */ + PosterPage findPage( + int page, int size, PosterSortField sortField, boolean ascending, PosterStatus status); + + boolean existsById(PosterId id); + + boolean existsByTitle(String title); + + void deleteById(PosterId id); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java new file mode 100644 index 00000000..b06ca6c9 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java @@ -0,0 +1,32 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +/** + * Allow-listed sortable fields for the poster list endpoint. A closed enum (not an open string + * passed to the ORM) is what keeps the {@code ?sort=field,direction} contract safe. Mirrors {@code + * WorkLogSortField}. + */ +public enum PosterSortField { + TITLE("title"), + STATUS("status"); + + private final String property; + + PosterSortField(String property) { + this.property = property; + } + + /** Persistence property name this logical field maps to. */ + public String property() { + return property; + } + + /** Resolves a client-supplied sort field name, or {@code null} if not sortable. */ + public static PosterSortField fromRequest(String field) { + for (PosterSortField f : values()) { + if (f.property.equalsIgnoreCase(field)) { + return f; + } + } + return null; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java new file mode 100644 index 00000000..aaa7dd45 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java @@ -0,0 +1,24 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +/** + * Lifecycle state of a {@link Poster}. Transitions are enforced by the aggregate (see {@link + * #canTransitionTo}): {@code DRAFT → PUBLISHED → ARCHIVED}, with {@code DRAFT → ARCHIVED} allowed + * and {@code ARCHIVED} terminal. + */ +public enum PosterStatus { + /** Created but not yet publicly visible. */ + DRAFT, + /** Publicly visible. */ + PUBLISHED, + /** Retired; terminal state. */ + ARCHIVED; + + /** Whether a direct transition from this state to {@code target} is permitted. */ + public boolean canTransitionTo(PosterStatus target) { + return switch (this) { + case DRAFT -> target == PUBLISHED || target == ARCHIVED; + case PUBLISHED -> target == ARCHIVED; + case ARCHIVED -> false; + }; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java new file mode 100644 index 00000000..12e74e97 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +/** + * Domain port that mints opaque string identifiers for outbox events. Kept distinct from {@link + * WorkLogIdFactory} so the aggregate id type does not constrain the event id shape; the returned + * value is used as both {@code eventId} and {@code idempotencyKey}. See README. + */ +public interface OutboxEventIdFactory { + + /** + * Mints a new, globally unique event identifier. + * + * @return a non-null, non-blank string suitable for use as a UUIDv7 event id + */ + String newEventId(); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java new file mode 100644 index 00000000..0ff1c393 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java @@ -0,0 +1,21 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import dev.caskeleton.domain.stereotype.ValueObject; +import java.time.LocalDate; +import java.util.Objects; + +/** Work period; {@code end == null} means ongoing. */ +@ValueObject +public record Period(LocalDate start, LocalDate end) { + + public Period { + Objects.requireNonNull(start, "start is required"); + if (end != null && end.isBefore(start)) { + throw new IllegalArgumentException("end must not be before start"); + } + } + + public boolean isOngoing() { + return end == null; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java new file mode 100644 index 00000000..355a193f --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java @@ -0,0 +1,4 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +/** Normalized repository statistics produced by the outbound ACL. */ +public record RepoStats(String fullName, int stars, String lastPushedIso) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java new file mode 100644 index 00000000..5f00fade --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java @@ -0,0 +1,9 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +/** Engineering work categories surfaced on the portfolio board. */ +public enum WorkCategory { + INFRASTRUCTURE, + DATABASE, + BACKEND, + PLATFORM +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java new file mode 100644 index 00000000..a5cd5860 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java @@ -0,0 +1,243 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import dev.caskeleton.domain.stereotype.AggregateRoot; +import java.util.List; +import java.util.Objects; + +/** + * A portfolio board entry describing one piece of engineering work. Aggregate root for the WorkLog + * aggregate; framework-neutral POJO (no Spring/JPA). State changes only through intention-revealing + * methods (no public setters), and invariant violations surface as {@link + * WorkLogInvariantException}. See README for the design rationale. + */ +@AggregateRoot +public class WorkLog { + + private static final WorkLogOwner DEFAULT_OWNER = WorkLogOwner.of("sample-owner"); + + private final WorkLogId id; + private final WorkLogOwner owner; + private String title; + private WorkCategory category; + private WorkLogStatus status; + private String summary; + private String content; + private List<String> techStack; + private List<String> links; + private Period period; + + /** Optimistic-lock version; {@code null} until the store persists it. See README. */ + private final Long version; + + private WorkLog( + WorkLogId id, + WorkLogOwner owner, + String title, + WorkCategory category, + WorkLogStatus status, + String summary, + String content, + List<String> techStack, + List<String> links, + Period period, + Long version) { + this.id = Objects.requireNonNull(id, "id is required"); + this.owner = Objects.requireNonNull(owner, "owner is required"); + this.title = requireValidTitle(title); + this.category = Objects.requireNonNull(category, "category is required"); + this.status = Objects.requireNonNull(status, "status is required"); + this.summary = summary == null ? "" : summary; + this.content = content == null ? "" : content; + this.techStack = techStack == null ? List.of() : List.copyOf(techStack); + this.links = links == null ? List.of() : List.copyOf(links); + this.period = Objects.requireNonNull(period, "period is required"); + this.version = version; + } + + /** + * Builds a new WorkLog with a server-assigned identifier (minted by a {@link WorkLogIdFactory} in + * the use case, never by the domain). Version is {@code null} until the store persists it. See + * README. + */ + public static WorkLog create( + WorkLogId id, + String title, + WorkCategory category, + String summary, + String content, + List<String> techStack, + List<String> links, + Period period) { + return create(id, DEFAULT_OWNER, title, category, summary, content, techStack, links, period); + } + + public static WorkLog create( + WorkLogId id, + WorkLogOwner owner, + String title, + WorkCategory category, + String summary, + String content, + List<String> techStack, + List<String> links, + Period period) { + return new WorkLog( + id, + owner, + title, + category, + WorkLogStatus.OPEN, + summary, + content, + techStack, + links, + period, + null); + } + + public static WorkLog rehydrate( + WorkLogId id, + String title, + WorkCategory category, + String summary, + String content, + List<String> techStack, + List<String> links, + Period period, + Long version) { + return rehydrate( + id, + DEFAULT_OWNER, + title, + category, + WorkLogStatus.OPEN, + summary, + content, + techStack, + links, + period, + version); + } + + public static WorkLog rehydrate( + WorkLogId id, + WorkLogOwner owner, + String title, + WorkCategory category, + WorkLogStatus status, + String summary, + String content, + List<String> techStack, + List<String> links, + Period period, + Long version) { + return new WorkLog( + id, owner, title, category, status, summary, content, techStack, links, period, version); + } + + public void rename(String newTitle) { + ensureEditable(); + this.title = requireValidTitle(newTitle); + } + + /** + * Enforces the title invariant on the sole construction/mutation path: required (null → {@link + * NullPointerException}) and non-blank (whitespace-only → {@link WorkLogInvariantException} with + * {@code TITLE_BLANK}). See README. + */ + private static String requireValidTitle(String title) { + Objects.requireNonNull(title, "title is required"); + if (title.isBlank()) { + throw new WorkLogInvariantException(WorkLogInvariantException.Reason.TITLE_BLANK); + } + return title; + } + + public void recategorize(WorkCategory newCategory) { + ensureEditable(); + this.category = Objects.requireNonNull(newCategory, "category is required"); + } + + public void updateSummary(String newSummary) { + ensureEditable(); + this.summary = newSummary == null ? "" : newSummary; + } + + public void updateContent(String newContent) { + ensureEditable(); + this.content = newContent == null ? "" : newContent; + } + + public void startProgress() { + transitionTo(WorkLogStatus.IN_PROGRESS); + } + + public void close() { + transitionTo(WorkLogStatus.CLOSED); + } + + private void transitionTo(WorkLogStatus next) { + Objects.requireNonNull(next, "status is required"); + if (status == next) { + return; + } + boolean allowed = + (status == WorkLogStatus.OPEN && next == WorkLogStatus.IN_PROGRESS) + || (status == WorkLogStatus.IN_PROGRESS && next == WorkLogStatus.CLOSED); + if (!allowed) { + throw new WorkLogInvariantException( + WorkLogInvariantException.Reason.INVALID_STATUS_TRANSITION); + } + this.status = next; + } + + private void ensureEditable() { + if (status == WorkLogStatus.CLOSED) { + throw new WorkLogInvariantException(WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION); + } + } + + public WorkLogId id() { + return id; + } + + public WorkLogOwner owner() { + return owner; + } + + public String title() { + return title; + } + + public WorkCategory category() { + return category; + } + + public WorkLogStatus status() { + return status; + } + + public String summary() { + return summary; + } + + public String content() { + return content; + } + + public List<String> techStack() { + return techStack; + } + + public List<String> links() { + return links; + } + + public Period period() { + return period; + } + + public Long version() { + return version; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java new file mode 100644 index 00000000..dcbaf4a6 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java @@ -0,0 +1,29 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import dev.caskeleton.domain.identifier.ResourceId; +import dev.caskeleton.domain.stereotype.ValueObject; +import java.util.regex.Pattern; + +/** + * Resource identifier for a {@link WorkLog}: a 36-character canonical UUID (RFC 9562 UUIDv7). Pure + * value object — validates the canonical form only; case normalisation happens at the web boundary. + * See README. + */ +@ValueObject +public record WorkLogId(String value) implements ResourceId<WorkLogId> { + + /** Canonical UUID form (RFC 9562): 8-4-4-4-12 hexadecimal groups. */ + private static final Pattern PATTERN = + Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + public WorkLogId { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid WorkLogId format: " + value); + } + } + + public static WorkLogId of(String value) { + return new WorkLogId(value); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java new file mode 100644 index 00000000..bb44e177 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java @@ -0,0 +1,9 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import dev.caskeleton.domain.identifier.IdFactory; + +/** + * Domain port that mints {@link WorkLogId}s. Implemented by an infrastructure adapter; injected + * into the create use case. See README. + */ +public interface WorkLogIdFactory extends IdFactory<WorkLogId> {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java new file mode 100644 index 00000000..6e7d025e --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java @@ -0,0 +1,29 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +/** + * Raised when a {@link WorkLog} aggregate invariant is violated. Carries a safe, noun-form {@link + * Reason} and knows nothing about error codes, HTTP status, or logging — the application/web layer + * translates {@code reason()}. See README. + */ +public final class WorkLogInvariantException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Safe, client-presentable reason for an invariant violation (noun form). */ + public enum Reason { + TITLE_BLANK, + INVALID_STATUS_TRANSITION, + CLOSED_WORKLOG_MUTATION + } + + private final Reason reason; + + public WorkLogInvariantException(Reason reason) { + super("WorkLog invariant violated: " + reason); + this.reason = reason; + } + + public Reason reason() { + return reason; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java new file mode 100644 index 00000000..39ebf877 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import dev.caskeleton.domain.stereotype.ValueObject; +import java.util.Objects; + +@ValueObject +public record WorkLogOwner(String value) { + + public static WorkLogOwner of(String value) { + String normalized = Objects.requireNonNull(value, "owner is required").trim(); + if (normalized.isBlank()) { + throw new IllegalArgumentException("owner is required"); + } + return new WorkLogOwner(normalized); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java new file mode 100644 index 00000000..ec592dff --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java @@ -0,0 +1,14 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import java.util.List; + +/** + * One page of WorkLogs plus the total element count across all pages. {@code total} feeds the + * response {@code meta.page.total}; {@code items} is never {@code null}. + */ +public record WorkLogPage(List<WorkLog> items, long total) { + + public WorkLogPage { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java new file mode 100644 index 00000000..4c0c8084 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java @@ -0,0 +1,22 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import java.util.Optional; + +/** Outbound port for WorkLog persistence. Implemented in adapter-persistence. */ +public interface WorkLogRepository { + WorkLog save(WorkLog workLog); + + Optional<WorkLog> findById(WorkLogId id); + + /** + * Returns one page of WorkLogs plus the total count. {@code sortField} / {@code category} may be + * {@code null} (unsorted / unfiltered); the total is computed against the same filter. See + * README. + */ + WorkLogPage findPage( + int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category); + + boolean existsById(WorkLogId id); + + void deleteById(WorkLogId id); +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java new file mode 100644 index 00000000..445d6650 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java @@ -0,0 +1,20 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import dev.caskeleton.domain.stereotype.DomainEvent; +import java.time.LocalDate; +import java.util.Objects; + +/** + * Domain event: a {@link WorkLog} period was reserved. Transport-free — carries domain data only, + * no broker/wire/HTTP type. The application boundary turns it into an integration event. See + * README. + */ +@DomainEvent +public record WorkLogReserved(WorkLogId workLogId, WorkCategory category, LocalDate periodStart) { + + public WorkLogReserved { + Objects.requireNonNull(workLogId, "workLogId is required"); + Objects.requireNonNull(category, "category is required"); + Objects.requireNonNull(periodStart, "periodStart is required"); + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java new file mode 100644 index 00000000..f6b41a3c --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java @@ -0,0 +1,31 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +/** + * Allow-listed sortable fields for the WorkLog list endpoint. A closed enum (not an open string + * passed to the ORM) is what keeps the {@code ?sort=field,direction} contract safe. See README. + */ +public enum WorkLogSortField { + TITLE("title"), + PERIOD_START("periodStart"); + + private final String property; + + WorkLogSortField(String property) { + this.property = property; + } + + /** Persistence property name this logical field maps to. */ + public String property() { + return property; + } + + /** Resolves a client-supplied sort field name, or {@code null} if not sortable. */ + public static WorkLogSortField fromRequest(String field) { + for (WorkLogSortField f : values()) { + if (f.property.equalsIgnoreCase(field)) { + return f; + } + } + return null; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java new file mode 100644 index 00000000..be7ee51d --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java @@ -0,0 +1,7 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +public enum WorkLogStatus { + OPEN, + IN_PROGRESS, + CLOSED +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java new file mode 100644 index 00000000..5a7843aa --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java @@ -0,0 +1,2 @@ +/** Sample fixture module. Production modules must not depend on this package. */ +package dev.caskeleton.sample.portfolio; diff --git a/src/sample-portfolio/src/main/resources/META-INF/spring.factories b/src/sample-portfolio/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..f660ca1b --- /dev/null +++ b/src/sample-portfolio/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.EnvironmentPostProcessor=\ +dev.caskeleton.sample.portfolio.bootstrap.tracing.SampleTracingSamplingEnvironmentPostProcessor diff --git a/src/sample-portfolio/src/main/resources/application.yml b/src/sample-portfolio/src/main/resources/application.yml new file mode 100644 index 00000000..ee302dc3 --- /dev/null +++ b/src/sample-portfolio/src/main/resources/application.yml @@ -0,0 +1,247 @@ +# ============================================================================= +# sample-portfolio standalone application configuration. +# Mirrors app-bootstrap/application.yml for the settings that scanned beans require. +# Values are driven by env vars so the same src/.env file used by app-bootstrap works here. +# ============================================================================= + +spring: + autoconfigure: + # SampleManagementSecurityConfig provides the actuator SecurityFilterChain directly. + # ManagementWebSecurityAutoConfiguration is excluded because it expects an HttpSecurity + # prototype bean supplied by SpringSecurityFilterChainBuilder, which is absent in narrow + # @SpringBootConfiguration test slices that exclude SecurityAutoConfiguration. Excluding + # the auto-config here prevents it from being activated when such test slices load + # application.yml (which enables management endpoint exposure and triggers the auto-config). + exclude: org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration + application: + name: ${APP_NAME:sample-portfolio} + mvc: + problemdetails: + # D1/D5: RFC 7807 ProblemDetail rejected in favour of custom envelope. Pin OFF. + enabled: false + web: + error: + include-stacktrace: ${APP_SERVER_ERROR_INCLUDE_STACKTRACE:never} + include-message: ${APP_SERVER_ERROR_INCLUDE_MESSAGE:never} + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + datasource: + url: ${APP_DATASOURCE_URL:jdbc:postgresql://localhost:5432/ca_skeleton} + username: ${APP_DATASOURCE_USERNAME:ca_skeleton} + password: ${APP_DATASOURCE_PASSWORD:ca_skeleton} + driver-class-name: ${APP_DATASOURCE_DRIVER:org.postgresql.Driver} + hikari: + maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE:10} + minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE:2} + connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT:30000} + idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT:600000} + max-lifetime: ${APP_DATASOURCE_POOL_MAX_LIFETIME:1800000} + keepalive-time: 120000 + leak-detection-threshold: 30000 + validation-timeout: 3000 + initialization-fail-timeout: 1 + flyway: + baseline-on-migrate: false + out-of-order: true + clean-disabled: true + # NOTE: this property is overridden at runtime by the static + # SamplePostgreSqlPersistenceConfig.postgreSqlFlywayLocationCustomizer @Bean, which + # sets both "classpath:db/migration/postgresql" (V1, V3, V4) and + # "classpath:db/sample-migration" (V2 work_log). The FlywayConfigurationCustomizer + # replaces whatever is declared here, so this entry is documentation-only. + locations: classpath:db/migration/postgresql,classpath:db/sample-migration + jpa: + hibernate: + ddl-auto: ${APP_DATASOURCE_DDL_AUTO:update} + show-sql: ${APP_DATASOURCE_SHOW_SQL:false} + open-in-view: ${APP_DATASOURCE_OPEN_IN_VIEW:false} + properties: + hibernate: + format_sql: ${APP_DATASOURCE_FORMAT_SQL:false} + # spring.security.oauth2.resourceserver.jwt is intentionally absent. + # The custom JwtDecoderConfig (adapter-web) builds its JwtDecoder from + # ca-skeleton.security.issuer-uri via SecuritySettings — not from Spring Boot's + # OAuth2ResourceServer auto-config. Declaring issuer-uri here would activate + # OAuth2SecurityFilterChainConfiguration, which requires HttpSecurity and therefore + # SecurityAutoConfiguration — breaking narrow @WebMvcTest slices that exclude Security. + main: + banner-mode: ${SPRING_BANNER_MODE:off} + lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION:false} + log-startup-info: ${SPRING_MAIN_LOG_STARTUP_INFO:true} + threads: + virtual: + enabled: ${SPRING_THREADS_VIRTUAL_ENABLED:false} + jackson: + deserialization: + fail-on-unknown-properties: ${SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES:true} + fail-on-null-for-primitives: ${SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES:true} + fail-on-ignored-properties: ${SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES:true} + datatype: + enum: + read-unknown-enum-values-as-null: ${SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL:false} + datetime: + write-dates-as-timestamps: ${SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS:false} + lifecycle: + timeout-per-shutdown-phase: ${APP_SERVER_SHUTDOWN_TIMEOUT:30s} + +# --------------------------------------------------------------------------- +# Actuator / management +# --------------------------------------------------------------------------- +management: + server: + port: ${MANAGEMENT_SERVER_PORT:9001} + endpoints: + web: + exposure: + include: health,prometheus,info,loggers + exclude: env,configprops,heapdump,threaddump,shutdown + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + group: + liveness: + include: livenessState + readiness: + # 'db' contributor is omitted here; narrow test slices that exclude DataSource + # auto-config do not have a 'db' health contributor and HealthEndpointGroupMembershipValidator + # would throw NoSuchHealthContributorException. Add 'db' via application-local.yml / + # environment-specific config when a real database is present. + include: readinessState + startup: + include: readinessState + shutdown: + access: none + info: + build: + enabled: true + +server: + port: ${APP_SERVER_PORT:8080} + shutdown: ${APP_SERVER_SHUTDOWN:graceful} + forward-headers-strategy: ${APP_SERVER_FORWARD_HEADERS_STRATEGY:framework} + tomcat: + threads: + max: ${APP_SERVER_TOMCAT_MAX_THREADS:200} + min-spare: ${APP_SERVER_TOMCAT_MIN_SPARE_THREADS:10} + accept-count: ${APP_SERVER_TOMCAT_ACCEPT_COUNT:100} + max-connections: ${APP_SERVER_TOMCAT_MAX_CONNECTIONS:8192} + connection-timeout: ${APP_SERVER_TOMCAT_CONNECTION_TIMEOUT:20s} + compression: + enabled: ${APP_SERVER_COMPRESSION_ENABLED:true} + min-response-size: ${APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE:1024} +logging: + level: + root: ${APP_LOG_LEVEL_ROOT:INFO} + dev.caskeleton: ${APP_LOG_LEVEL_APP:DEBUG} + org.springframework: ${APP_LOG_LEVEL_SPRING:INFO} + org.springframework.web: ${APP_LOG_LEVEL_WEB:INFO} + org.hibernate.SQL: ${APP_LOG_LEVEL_SQL:WARN} + +# --------------------------------------------------------------------------- +# ca-skeleton module-scoped settings. +# These are bound by @ConfigurationProperties in the respective modules. +# --------------------------------------------------------------------------- +ca-skeleton: + bootstrap: + app-name: ${APP_NAME:sample-portfolio} + runtime: + error-detail-exposure-enabled: ${APP_ERROR_DETAIL_EXPOSURE_ENABLED:false} + log-body-capture-enabled: ${APP_LOG_BODY_CAPTURE_ENABLED:false} + multi-instance-enabled: ${APP_MULTI_INSTANCE_ENABLED:false} + lock: + wait-time: 3s + lease-ttl: 30s + presentation: + api-base-path: ${PRESENTATION_API_BASE_PATH:/api} + rate-limit: + enabled: ${APP_RATE_LIMIT_ENABLED:true} + limit: 100 + window: 1s + algorithm: fixed-window + idempotency: + ttl: ${APP_IDEMPOTENCY_TTL:24h} + reaper-interval: 10m + security: + issuer-uri: ${APP_SECURITY_JWT_ISSUER:http://localhost:8080/auth/realms/sample} + audience: ${APP_SECURITY_JWT_AUDIENCE:} + public-paths: ${SECURITY_PUBLIC_PATHS:/api/healthcheck} + authz: + role-permissions: + user: worklog:read,worklog:write,poster:write + admin: worklog:read,worklog:write,worklog:close,poster:write,poster:close + cors: + enabled: ${APP_SECURITY_CORS_ENABLED:true} + allowed-origins: ${APP_SECURITY_CORS_ORIGINS:http://localhost:3000} + allowed-methods: ${APP_SECURITY_CORS_ALLOWED_METHODS:} + allowed-headers: ${APP_SECURITY_CORS_ALLOWED_HEADERS:*} + allow-credentials: ${APP_SECURITY_CORS_ALLOW_CREDENTIALS:true} + max-age-seconds: ${APP_SECURITY_CORS_MAX_AGE:3600} + logging: + file: + enabled: ${APP_LOG_FILE_ENABLED:false} + path: ${APP_LOG_FILE_PATH:logs/sample-portfolio.json} + max-size: ${APP_LOG_FILE_MAX_SIZE:100MB} + max-history: ${APP_LOG_FILE_MAX_HISTORY:14} + total-size-cap: ${APP_LOG_FILE_TOTAL_SIZE_CAP:3GB} + async: + enabled: ${APP_LOG_ASYNC_ENABLED:true} + queue-size: ${APP_LOG_ASYNC_QUEUE_SIZE:512} + discarding-threshold: ${APP_LOG_ASYNC_DISCARDING_THRESHOLD:20} + json: + timezone: ${APP_LOG_JSON_TIMEZONE:UTC} + timestamp-pattern: ${APP_LOG_JSON_TIMESTAMP_PATTERN:"yyyy-MM-dd'T'HH:mm:ss.SSSXXX"} + include-caller-data: ${APP_LOG_JSON_INCLUDE_CALLER_DATA:false} + logger-name-length: ${APP_LOG_JSON_LOGGER_NAME_LENGTH:0} + sampling-rate: ${APP_LOG_SAMPLING_RATE:1.0} + privacy: + pseudonymization-salt: ${APP_PRIVACY_PSEUDONYMIZATION_SALT:} + tracing: + enabled: ${APP_TRACING_ENABLED:true} + sample-rate: ${APP_TRACING_SAMPLE_RATE:} + exporter: + otlp-endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:} + # Outbox relay disabled for lean demo (WorkLog demo does not need active relay). + outbox: + relay-enabled: false + poll-interval: PT5S + batch-size: 20 + in-flight-timeout: PT5M + reaper-interval: PT10M + published-retention: P7D + +# --------------------------------------------------------------------------- +# Optional integration adapter toggles (all disabled for lean demo). +# --------------------------------------------------------------------------- +app: + cache: + redis: + enabled: ${APP_CACHE_REDIS_ENABLED:false} + messaging: + broker: ${APP_MESSAGING_BROKER:} + kafka: + brokers: ${APP_MESSAGING_KAFKA_BROKERS:} + notification: + slack: + provider: ${APP_NOTIFICATION_SLACK_PROVIDER:} + email: + provider: ${APP_NOTIFICATION_EMAIL_PROVIDER:} + outbound: + http: + connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT:2s} + read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT:5s} + global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT:10s} + retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} + retry: + max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} + initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms} + backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0} + circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false} + circuit-breaker: + failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50} + sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100} + minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100} + wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s} + permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10} + response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB} diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql new file mode 100644 index 00000000..0b5e7e26 --- /dev/null +++ b/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql @@ -0,0 +1,65 @@ +-- feature-persistence-auditing-contract (D3/D6) — sample WorkLog aggregate schema, the +-- reference table that carries the audit-metadata columns. +-- +-- The four audit columns (created_at / updated_at / created_by / updated_by) are the +-- deliverable of this branch: they map the adapter-persistence AuditableEntity +-- @MappedSuperclass that WorkLogEntity inherits (D6 — a domain aggregate persistence +-- entity opts in; the infra/immutable idempotency_record table in V1 does NOT, D6). The +-- domain WorkLog model carries none of these fields (D2); WorkLogRepositoryAdapter stamps +-- them via the Clock bean (D4) and AuditContextPort actor with "system" fallback (D5). +-- +-- created_* are immutable after INSERT (the entity maps them updatable=false); updated_* +-- move on every modification. version (optimistic-lock) is owned by +-- feature-persistence-failure-baseline / feature-transaction-concurrency-contract, not by +-- this audit contract (D3 out-of-scope), and is mapped here only as the aggregate's column. +-- +-- Location: db/sample-migration (NOT the default db/migration). Flyway scans +-- `classpath:db/migration` recursively across every classpath root; sample-portfolio is +-- absent from the Gradle runtime classpath but present on IDE/test classpaths +-- (app-bootstrap testImplementation). Keeping this file in db/migration made the resolved +-- migration set differ per launcher against the same long-lived dev DB — once production +-- V3 was applied without V2, Flyway validation failed in BOTH directions (resolved-not- +-- applied from the IDE, applied-not-resolved from Gradle; out-of-order=false is pinned by +-- FLYWAY-C5). The sibling location keeps every launcher resolving only the production +-- migrations (V1, V3). To actually run this file, add +-- `spring.flyway.locations: classpath:db/migration,classpath:db/sample-migration` +-- in a sample-enabled deployment; local dev relies on ddl-auto=update for the sample +-- schema instead. + +CREATE TABLE work_log ( + id uuid NOT NULL, + owner varchar(256) NOT NULL, + title varchar(255), + category varchar(255), + status varchar(32) NOT NULL, + summary varchar(500), + content varchar(10000), + period_start date, + period_end date, + version bigint, + -- audit metadata (feature-persistence-auditing-contract D3) + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + created_by varchar(256) NOT NULL, + updated_by varchar(256) NOT NULL, + CONSTRAINT pk_work_log PRIMARY KEY (id) +); + +-- @ElementCollection tables (WorkLogEntity techStack / links). These are owned-value +-- tables, not aggregates, so they carry no audit columns (D6). +CREATE TABLE work_log_tech_stack ( + work_log_id uuid NOT NULL, + tech varchar(255), + CONSTRAINT fk_work_log_tech_stack_work_log + FOREIGN KEY (work_log_id) REFERENCES work_log (id) +); + +CREATE TABLE work_log_link ( + work_log_id uuid NOT NULL, + url varchar(255), + CONSTRAINT fk_work_log_link_work_log + FOREIGN KEY (work_log_id) REFERENCES work_log (id) +); + +CREATE INDEX ix_work_log_tech_stack_work_log_id ON work_log_tech_stack (work_log_id); +CREATE INDEX ix_work_log_link_work_log_id ON work_log_link (work_log_id); diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V6__poster.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V6__poster.sql new file mode 100644 index 00000000..8ed795e2 --- /dev/null +++ b/src/sample-portfolio/src/main/resources/db/sample-migration/V6__poster.sql @@ -0,0 +1,30 @@ +-- Sample Poster aggregate schema (second sample aggregate, alongside work_log in V2). +-- +-- Demonstrates the object-storage + domain-invariant collaboration: image_key holds the +-- object-storage key of the uploaded image, and the domain forbids publishing a poster that +-- has none. Like work_log, this is a domain aggregate entity, so it opts into the four audit +-- columns from the adapter-persistence AuditableEntity @MappedSuperclass (PosterEntity inherits +-- it); the domain Poster model carries none of them — PosterRepositoryAdapter stamps them via +-- the Clock bean and AuditContextPort actor ("system" fallback). created_* are immutable after +-- INSERT; updated_* move on every modification. version is the optimistic-lock column. +-- +-- Location: db/sample-migration (a sibling of db/migration/postgresql). The sample +-- application.yml activates both locations, so the merged Flyway timeline is V1..V6. + +CREATE TABLE poster ( + id uuid NOT NULL, + title varchar(255) NOT NULL, + caption varchar(2000), + image_key varchar(512), + status varchar(32) NOT NULL, + version bigint, + -- audit metadata (feature-persistence-auditing-contract D3) + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + created_by varchar(256) NOT NULL, + updated_by varchar(256) NOT NULL, + CONSTRAINT pk_poster PRIMARY KEY (id) +); + +CREATE INDEX ix_poster_status ON poster (status); +CREATE INDEX ix_poster_title ON poster (title); diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java new file mode 100644 index 00000000..ae33a299 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java @@ -0,0 +1,84 @@ +package dev.caskeleton.sample.portfolio; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.ApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Boot-loop driver for the LEAN sample-portfolio standalone startup. + * + * <p>Boots a full {@link SamplePortfolioApplication} context using the {@code test} Spring profile + * and a real Testcontainers Postgres. Asserts that the full migration set — including the sample's + * work_log table (V2 from {@code db/sample-migration}) — is applied on standalone boot. This guards + * against {@link + * dev.caskeleton.sample.portfolio.bootstrap.persistence.SamplePostgreSqlPersistenceConfig} + * accidentally dropping sample migrations when it configures Flyway locations. + * + * <p>Testcontainers is enabled via {@code @ActiveProfiles("test")} combined with a disposable + * Postgres container whose connection details override the placeholder datasource URL defined in + * {@code application-test.yml}. + */ +@SpringBootTest( + classes = SamplePortfolioApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Testcontainers(disabledWithoutDocker = true) +class SampleApplicationContextTest { + + @Container @ServiceConnection + static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); + + @Autowired ApplicationContext context; + + @Autowired JdbcTemplate jdbcTemplate; + + @Test + void contextLoads() { + assertThat(context).isNotNull(); + } + + /** + * Asserts that the work_log table (V2 — {@code db/sample-migration/V2__work_log.sql}) was applied + * by Flyway on standalone boot. Without this assertion the bug where {@link + * dev.caskeleton.sample.portfolio.bootstrap.persistence.SamplePostgreSqlPersistenceConfig} + * overwrites the configured locations (dropping {@code db/sample-migration}) would be invisible + * to this test. + */ + @Test + void workLogTableIsMigrated() { + Integer count = + jdbcTemplate.queryForObject( + "SELECT count(*) FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_name = 'work_log'", + Integer.class); + assertThat(count) + .as("work_log table must exist — V2 sample-migration must be applied") + .isEqualTo(1); + } + + /** + * Asserts that {@code flyway_schema_history} records V2 as a successfully applied migration + * script (description contains "work_log" case-insensitively). + */ + @Test + void flywaySchemaHistoryContainsV2WorkLogMigration() { + Integer applied = + jdbcTemplate.queryForObject( + "SELECT count(*) FROM flyway_schema_history " + + "WHERE version = '2' AND success = true", + Integer.class); + assertThat(applied) + .as("flyway_schema_history must contain a successful V2 migration") + .isEqualTo(1); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java new file mode 100644 index 00000000..e9b365b8 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; + +/** + * Minimal {@code @SpringBootConfiguration} bootstrap so {@code @WebMvcTest} has an entry point when + * sample-portfolio runs its own tests (the real application lives in {@code app-bootstrap}, which + * is not on sample-portfolio's test classpath). + * + * <p>No {@code @ComponentScan}: each {@code @WebMvcTest} declares the controller it tests via the + * {@code controllers} attribute and explicitly {@code @Import}s the advice / configuration beans it + * needs, keeping the test slice tight and avoiding accidental loading of persistence / outbound + * adapters whose JPA beans would fail without a database. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +public class SamplePortfolioTestApplication {} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java new file mode 100644 index 00000000..6625ebeb --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java @@ -0,0 +1,171 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; +import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * Gate #10 (OpenAPI drift) of the contract-verification suite — the release-blocking drift gate + * that {@code OpenApiSnapshotTest} (feature-api-contract-baseline D10, the producer) defers to this + * branch. Boots the same datasource-free slice and compares the runtime springdoc {@code + * /v3/api-docs} against a committed, canonicalized snapshot resource; any drift between the actual + * response surface and the committed contract fails the build + * (feature-contract-verification-test-suite §5, §테스트 계약 "OpenAPI snapshot과 실제 response envelope가 + * drift되면 build 실패"). + * + * <p>Mechanism = committed-snapshot equality (the chosen alternative; semantic additive/breaking + * classification stays the feature-api-compatibility-deprecation-contract layer). Both sides are + * canonicalized (map keys sorted, pretty-printed) so the diff reflects a real surface change, not + * springdoc emission ordering. Regenerate the baseline after an intentional API change with {@code + * ./gradlew openapiCheckSnapshot -PapproveOpenApiChange} (sets {@code + * -Dopenapi.snapshot.write=true}). + */ +@SpringBootTest( + classes = OpenApiDriftContractTest.OpenApiDriftTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "management.server.port=0") +@AutoConfigureTestRestTemplate +class OpenApiDriftContractTest { + + private static final String SNAPSHOT_RESOURCE = "/openapi/worklogs-openapi-snapshot.json"; + private static final String SNAPSHOT_SOURCE_PATH = + "src/test/resources/openapi/worklogs-openapi-snapshot.json"; + private static final String WRITE_FLAG = "openapi.snapshot.write"; + + /** Sorts map keys so the snapshot is stable regardless of springdoc emission order. */ + private static final ObjectMapper CANONICAL = + JsonMapper.builder() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + @LocalServerPort int port; + + @Autowired TestRestTemplate rest; + + // The controller is the OpenAPI source; its collaborators are mocked so the slice needs no + // datasource / security (mirrors OpenApiSnapshotTest). + @MockitoBean CreateWorkLogUseCase createUseCase; + @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; + @MockitoBean UpdateWorkLogUseCase updateUseCase; + @MockitoBean DeleteWorkLogUseCase deleteUseCase; + @MockitoBean GetWorkLogUseCase getUseCase; + @MockitoBean ListWorkLogsUseCase listUseCase; + @MockitoBean GetRepoStatsUseCase repoStatsUseCase; + + @Test + void runtimeOpenapiDocMatchesCommittedSnapshot() throws IOException { + ResponseEntity<String> response = + rest.getForEntity("http://localhost:" + port + "/v3/api-docs", String.class); + assertThat(response.getStatusCode().is2xxSuccessful()) + .as("springdoc must serve the generated OpenAPI document") + .isTrue(); + + String canonical = canonicalize(response.getBody()); + + if (Boolean.getBoolean(WRITE_FLAG)) { + writeSnapshot(canonical); + return; // refresh mode: baseline (re)generated; no assertion + } + + String committed = readCommittedSnapshot(); + if (committed == null) { + fail( + "No committed OpenAPI snapshot on the classpath (" + + SNAPSHOT_RESOURCE + + "). Generate the " + + "baseline with: ./gradlew openapiCheckSnapshot -PapproveOpenApiChange"); + } + + assertThat(canonical) + .as( + "the runtime springdoc /v3/api-docs drifted from the committed OpenAPI snapshot — if " + + "this change is intended, regenerate with " + + "./gradlew openapiCheckSnapshot -PapproveOpenApiChange") + .isEqualTo(canonicalize(committed)); + } + + @SuppressWarnings("unchecked") + private static String canonicalize(String json) throws IOException { + Object tree = CANONICAL.readValue(json, Object.class); + // Drop the environment-specific `servers` block: springdoc fills it with the + // RANDOM_PORT test URL (http://localhost:<random>), which is harness noise. The drift + // gate tracks the API surface (paths / components / schemas), not the runtime base URL. + if (tree instanceof Map<?, ?> map) { + ((Map<String, Object>) map).remove("servers"); + } + return CANONICAL.writeValueAsString(tree); + } + + private String readCommittedSnapshot() throws IOException { + try (InputStream in = getClass().getResourceAsStream(SNAPSHOT_RESOURCE)) { + return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void writeSnapshot(String canonical) throws IOException { + Path target = moduleDir().resolve(SNAPSHOT_SOURCE_PATH); + Files.createDirectories(target.getParent()); + Files.writeString(target, canonical + System.lineSeparator()); + } + + /** The sample-portfolio module dir (Gradle runs tests with the module dir as the working dir). */ + private static Path moduleDir() { + for (Path dir = Paths.get("").toAbsolutePath(); dir != null; dir = dir.getParent()) { + if (Files.isRegularFile(dir.resolve("build.gradle")) + && Files.isDirectory(dir.resolve("src/test/java/dev/caskeleton/sample/portfolio"))) { + return dir; + } + } + throw new IllegalStateException( + "sample-portfolio module dir not found from " + Paths.get("").toAbsolutePath()); + } + + @SpringBootConfiguration + @EnableAutoConfiguration( + exclude = { + SecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class + }) + @Import(WorkLogController.class) + static class OpenApiDriftTestApp {} +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java new file mode 100644 index 00000000..bda2f3b8 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.operation.SampleOperationStore; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * feature-api-contract-baseline D17 — LRO wire contract: {@code POST /worklogs:export} returns 202 + * + {@code Location} + envelope {@code data.{operationId,statusUrl}} with a PENDING status, and + * {@code GET /operations/{id}} returns the operation with a status from the 5-value enum (here + * terminal SUCCEEDED). + */ +@WebMvcTest( + controllers = OperationsController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({ + OperationsController.class, + SampleOperationStore.class, + GlobalExceptionHandler.class, + EnvelopeBodyAdvice.class +}) +class OperationsControllerWireTest { + + @Autowired MockMvc mvc; + @MockitoBean PresentationSettings presentationSettings; + + @Test + void exportReturns202LocationPendingThenPollReachesTerminalStatus() throws Exception { + when(presentationSettings.apiBasePath()).thenReturn(""); + + MvcResult accepted = + mvc.perform(post("/worklogs:export")) + .andExpect(status().isAccepted()) + .andExpect(header().exists("Location")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.operationId").isNotEmpty()) + .andExpect(jsonPath("$.data.statusUrl").isNotEmpty()) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andReturn(); + + String operationId = + JsonPath.read(accepted.getResponse().getContentAsString(), "$.data.operationId"); + + mvc.perform(get("/operations/" + operationId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.operationId").value(operationId)) + .andExpect(jsonPath("$.data.status").value("SUCCEEDED")) + .andExpect(jsonPath("$.data.result.exportedCount").value(0)); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java new file mode 100644 index 00000000..ed8e0dc0 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java @@ -0,0 +1,242 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.application.storage.StoredObject; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.PosterIdSerializer; +import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; +import dev.caskeleton.sample.portfolio.application.poster.ArchivePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.CreatePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.DeletePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.GetPosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.ListPostersUseCase; +import dev.caskeleton.sample.portfolio.application.poster.PublishPosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.UpdatePosterUseCase; +import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterInvariantException; +import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import java.net.URI; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest( + controllers = PosterController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({ + PosterController.class, + GlobalExceptionHandler.class, + DomainExceptionHandler.class, + EnvelopeBodyAdvice.class, + JacksonNullableConfig.class, + PosterIdSerializer.class +}) +@TestPropertySource( + properties = { + "spring.jackson.deserialization.fail-on-unknown-properties=true", + "spring.jackson.deserialization.fail-on-null-for-primitives=true" + }) +class PosterControllerWireTest { + + private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + + @Autowired MockMvc mvc; + @MockitoBean CreatePosterUseCase createUseCase; + @MockitoBean UpdatePosterUseCase updateUseCase; + @MockitoBean UploadPosterImageUseCase uploadImageUseCase; + @MockitoBean PublishPosterUseCase publishUseCase; + @MockitoBean ArchivePosterUseCase archiveUseCase; + @MockitoBean DeletePosterUseCase deleteUseCase; + @MockitoBean GetPosterUseCase getUseCase; + @MockitoBean ListPostersUseCase listUseCase; + + private Poster poster(String title, PosterStatus status, String imageKey) { + return Poster.rehydrate(PosterId.of(ID), title, "7월 한정", imageKey, status, 0L); + } + + // ---- list ---- + + @Test + void listIsWrappedInEnvelopeWithPageMeta() throws Exception { + when(listUseCase.handle(any())) + .thenReturn(new PosterPage(List.of(poster("여름 세일", PosterStatus.DRAFT, null)), 1)); + mvc.perform(get("/posters")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].id").value(ID)) + .andExpect(jsonPath("$.data[0].title").value("여름 세일")) + .andExpect(jsonPath("$.meta.page.total").value(1)); + } + + @Test + void emptyListIsDataArrayNotNullWithZeroTotal() throws Exception { + when(listUseCase.handle(any())).thenReturn(new PosterPage(List.of(), 0)); + mvc.perform(get("/posters")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)) + .andExpect(jsonPath("$.meta.page.total").value(0)); + } + + @Test + void sizeOverCapIs400Validation() throws Exception { + mvc.perform(get("/posters").param("size", "101")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); + } + + @Test + void nativeSortSyntaxIsEchoedInMeta() throws Exception { + when(listUseCase.handle(any())) + .thenReturn(new PosterPage(List.of(poster("a", PosterStatus.DRAFT, null)), 1)); + mvc.perform(get("/posters").param("sort", "title,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.page.sort").value("title,desc")); + } + + // ---- get ---- + + @Test + void getReturnsPoster() throws Exception { + when(getUseCase.handle(any())) + .thenReturn(poster("여름 세일", PosterStatus.PUBLISHED, "posters/x/image")); + mvc.perform(get("/posters/" + ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(ID)) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")) + .andExpect(jsonPath("$.data.imageKey").value("posters/x/image")); + } + + @Test + void getMissingReturns404PosterNotFound() throws Exception { + when(getUseCase.handle(any())).thenThrow(new PosterNotFoundException(PosterId.of(ID))); + mvc.perform(get("/posters/" + ID)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("POSTER_NOT_FOUND")); + } + + @Test + void getWithMalformedIdReturns400BadParameter() throws Exception { + mvc.perform(get("/posters/not-a-valid-uuid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("BAD_PARAMETER")); + } + + @Test + void getAcceptsMixedCaseUuidPathAndNormalizes() throws Exception { + when(getUseCase.handle(any())).thenReturn(poster("여름 세일", PosterStatus.DRAFT, null)); + mvc.perform(get("/posters/" + ID.toUpperCase(Locale.ROOT))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(ID)); + } + + // ---- create / update ---- + + @Test + void createReturns201WithBody() throws Exception { + when(createUseCase.handle(any())).thenReturn(poster("여름 세일", PosterStatus.DRAFT, null)); + mvc.perform( + post("/posters") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"여름 세일\",\"caption\":\"7월 한정\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("여름 세일")); + } + + @Test + void createWithBlankTitleFailsValidation() throws Exception { + mvc.perform( + post("/posters").contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); + } + + @Test + void patchReplacesTitle() throws Exception { + when(updateUseCase.handle(any())).thenReturn(poster("새 제목", PosterStatus.DRAFT, null)); + mvc.perform( + patch("/posters/" + ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"새 제목\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("새 제목")); + } + + // ---- lifecycle: publish (the object-storage domain invariant) / archive ---- + + @Test + void publishWithoutImageReturns400ImageRequired() throws Exception { + when(publishUseCase.handle(any())) + .thenThrow(new PosterInvariantException(PosterInvariantException.Reason.IMAGE_REQUIRED)); + mvc.perform(post("/posters/" + ID + "/publish")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("POSTER_IMAGE_REQUIRED")); + } + + @Test + void publishReturnsPublishedPoster() throws Exception { + when(publishUseCase.handle(any())) + .thenReturn(poster("여름 세일", PosterStatus.PUBLISHED, "posters/x/image")); + mvc.perform(post("/posters/" + ID + "/publish")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + } + + @Test + void archiveReturnsArchivedPoster() throws Exception { + when(archiveUseCase.handle(any())) + .thenReturn(poster("여름 세일", PosterStatus.ARCHIVED, "posters/x/image")); + mvc.perform(post("/posters/" + ID + "/archive")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + // ---- image upload (multipart) ---- + + @Test + void uploadImageReturnsStoredObjectReceipt() throws Exception { + when(uploadImageUseCase.handle(any())) + .thenReturn( + new StoredObject("posters/" + ID + "/image", 3L, "image/png", URI.create("file:///x"))); + MockMultipartFile file = + new MockMultipartFile("file", "banner.png", "image/png", new byte[] {1, 2, 3}); + mvc.perform(multipart("/posters/" + ID + "/image").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.key").value("posters/" + ID + "/image")) + .andExpect(jsonPath("$.data.contentType").value("image/png")); + } + + // ---- delete ---- + + @Test + void deleteReturns204() throws Exception { + mvc.perform(delete("/posters/" + ID)).andExpect(status().isNoContent()); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java new file mode 100644 index 00000000..43f6b912 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java @@ -0,0 +1,447 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.WorkLogIdSerializer; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import java.time.LocalDate; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest( + controllers = WorkLogController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({ + WorkLogController.class, + GlobalExceptionHandler.class, + DomainExceptionHandler.class, + EnvelopeBodyAdvice.class, + JacksonNullableConfig.class, + WorkLogIdSerializer.class +}) +@TestPropertySource( + properties = { + "spring.jackson.deserialization.fail-on-unknown-properties=true", + "spring.jackson.deserialization.fail-on-null-for-primitives=true", + "spring.jackson.deserialization.fail-on-ignored-properties=true", + "spring.jackson.datatype.enum.read-unknown-enum-values-as-null=false" + }) +class WorkLogControllerWireTest { + + private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; // D19 fixture + + @Autowired MockMvc mvc; + @MockitoBean CreateWorkLogUseCase createUseCase; + @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; + @MockitoBean UpdateWorkLogUseCase updateUseCase; + @MockitoBean DeleteWorkLogUseCase deleteUseCase; + @MockitoBean GetWorkLogUseCase getUseCase; + @MockitoBean ListWorkLogsUseCase listUseCase; + @MockitoBean GetRepoStatsUseCase repoStatsUseCase; + + private WorkLog sample(String title) { + // version 0 → ETag W/"0" at the boundary + return WorkLog.rehydrate( + WorkLogId.of(ID), + title, + WorkCategory.DATABASE, + "s", + "c", + List.of("postgres"), + List.of("https://example.com"), + new Period(LocalDate.of(2025, 1, 1), null), + 0L); + } + + // ---- D7/D18/D20/D21 list ---- + + @Test + void listIsWrappedInEnvelopeWithPageMeta() throws Exception { + when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("DB 튜닝")), 1)); + mvc.perform(get("/worklogs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].title").value("DB 튜닝")) + .andExpect(jsonPath("$.data[0].id").value(ID)) + .andExpect(jsonPath("$.meta.page.number").value(0)) + .andExpect(jsonPath("$.meta.page.size").value(20)) + .andExpect(jsonPath("$.meta.page.total").value(1)); + } + + @Test + void emptyListIsDataArrayNotNullWithZeroTotal() throws Exception { + when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(), 0)); + mvc.perform(get("/worklogs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)) + .andExpect(jsonPath("$.meta.page.total").value(0)); + } + + @Test + void sizeOverCapIs400ValidationWithFieldAndCode() throws Exception { + mvc.perform(get("/worklogs").param("size", "101")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.error.details.field").value("size")) + .andExpect(jsonPath("$.error.details.code").value("SIZE_EXCEEDS_MAX")); + } + + @Test + void nativeSortSyntaxIsAcceptedAndEchoedInMeta() throws Exception { + when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("a")), 1)); + mvc.perform(get("/worklogs").param("sort", "title,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.page.sort").value("title,desc")); + } + + @Test + void jsonapiPrefixSortSyntaxIsRejected400() throws Exception { + mvc.perform(get("/worklogs").param("sort", "-title")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); + } + + @Test + void flatCategoryFilterIsAccepted() throws Exception { + when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("a")), 1)); + mvc.perform(get("/worklogs").param("category", "DATABASE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + // ---- D15 conditional requests ---- + + @Test + void getEmitsEtagHeader() throws Exception { + when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); + mvc.perform(get("/worklogs/" + ID)) + .andExpect(status().isOk()) + .andExpect(header().string("ETag", "W/\"0\"")) + .andExpect(jsonPath("$.data.id").value(ID)); + } + + @Test + void getWithMatchingIfNoneMatchReturns304NoBody() throws Exception { + when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); + mvc.perform(get("/worklogs/" + ID).header("If-None-Match", "W/\"0\"")) + .andExpect(status().isNotModified()); + } + + @Test + void patchWithStaleIfMatchReturns412() throws Exception { + when(getUseCase.handle(any())).thenReturn(sample("current")); + mvc.perform( + patch("/worklogs/" + ID) + .header("If-Match", "W/\"99\"") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"x\"}")) + .andExpect(status().isPreconditionFailed()) + .andExpect(jsonPath("$.error.code").value("PRECONDITION_FAILED")); + } + + @Test + void patchWithMatchingIfMatchAppliesUpdate() throws Exception { + when(getUseCase.handle(any())).thenReturn(sample("current")); + when(updateUseCase.handle(any())).thenReturn(sample("new title")); + mvc.perform( + patch("/worklogs/" + ID) + .header("If-Match", "W/\"0\"") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"new title\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("new title")); + } + + // ---- D13 HEAD mirrors GET ---- + + @Test + void headOnGetEndpointIsSupportedNot405() throws Exception { + // D13: a GET-supporting endpoint MUST also answer HEAD (Spring MVC mirrors it), + // i.e. 200 — never 405/404. (Body stripping is the container's job and is not + // reproduced by MockMvc, so we assert the mirrored status, not an empty body.) + when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); + mvc.perform(head("/worklogs/" + ID)).andExpect(status().isOk()); + } + + // ---- D21 filter DSL is not parsed (silently ignored) ---- + + @Test + void filterDslIsIgnoredNotParsed() throws Exception { + when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("a")), 1)); + mvc.perform( + get("/worklogs").param("filter", "status==OPEN")) // RSQL/DSL → unknown param, ignored + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + // ---- D3 Idempotency-Key accepted (server-tolerant) on POST ---- + + @Test + void postAcceptsIdempotencyKeyHeader() throws Exception { + when(createUseCase.handle(any())).thenReturn(sample("a")); + mvc.perform( + post("/worklogs") + .header("Idempotency-Key", "key-abc") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + // ---- existing id / validation / mapping behaviours (paths renamed to /worklogs) ---- + + @Test + void getMissingReturns404WorklogNotFound() throws Exception { + when(getUseCase.handle(any())).thenThrow(new WorkLogNotFoundException(WorkLogId.of(ID))); + mvc.perform(get("/worklogs/" + ID)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("WORKLOG_NOT_FOUND")); + } + + @Test + void getWithMalformedIdReturns400BadParameter() throws Exception { + mvc.perform(get("/worklogs/not-a-valid-uuid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("BAD_PARAMETER")); + } + + @Test + void getAcceptsMixedCaseUuidPathAndNormalizesD3() throws Exception { + when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); + mvc.perform(get("/worklogs/" + ID.toUpperCase(Locale.ROOT))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(ID)); + } + + @Test + void createWithBlankTitleFailsValidation() throws Exception { + mvc.perform( + post("/worklogs") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); + } + + @Test + void createWithUnknownFieldIsRejectedB1() throws Exception { + mvc.perform( + post("/worklogs") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"x\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\",\"oops\":\"y\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.error.details.cause").value("UnrecognizedPropertyException")); + } + + @Test + void createWithUnmappableLinkRoutesToMappingFailedB3() throws Exception { + mvc.perform( + post("/worklogs") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"x\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\",\"links\":[\"not a uri\"]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("MAPPING_FAILED")); + } + + @Test + void patchReplacesOnlyPresentFieldsB2() throws Exception { + when(updateUseCase.handle(any())).thenReturn(sample("new title")); + mvc.perform( + patch("/worklogs/" + ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"new title\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("new title")) + .andExpect(jsonPath("$.data.id").value(ID)); + } + + @Test + void patchCanAdvanceStatusAndResponseExposesMinimumModel() throws Exception { + WorkLog started = sample("status"); + started.startProgress(); + when(updateUseCase.handle(any())).thenReturn(started); + + mvc.perform( + patch("/worklogs/" + ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"IN_PROGRESS\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.data.owner").doesNotExist()); + } + + @Test + void closedWorklogUpdateReturnsConflictEnvelope() throws Exception { + when(updateUseCase.handle(any())) + .thenThrow( + new WorkLogInvariantException( + WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION)); + + mvc.perform( + patch("/worklogs/" + ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"late edit\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("WORKLOG_CONFLICT")); + } + + @Test + void patchWithExplicitNullSummaryIsAcceptedB2() throws Exception { + when(updateUseCase.handle(any())).thenReturn(sample("t")); + mvc.perform( + patch("/worklogs/" + ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"summary\":null}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + void repoStatsEndpointReturnsDomainViaEnvelope() throws Exception { + when(repoStatsUseCase.handle(any())) + .thenReturn(new RepoStats("owner/repo", 42, "2025-01-01T00:00:00Z")); + mvc.perform(get("/worklogs/repoStats").param("repoUrl", "https://github.com/owner/repo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.stars").value(42)); + } + + @Test + void createCapturesAuthenticatedPrincipalAsOwner() throws Exception { + TestingAuthenticationToken token = + new TestingAuthenticationToken( + new AuthenticatedPrincipal("sub-7", "u@example.com", Set.of("user")), "n/a"); + token.setAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(token); + ArgumentCaptor<CreateWorkLogCommand> captor = + ArgumentCaptor.forClass(CreateWorkLogCommand.class); + when(createUseCase.handle(captor.capture())).thenReturn(sample("t")); + try { + mvc.perform( + post("/worklogs") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"t\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) + .andExpect(status().isOk()); + assertThat(captor.getValue().owner()).isEqualTo("sub-7"); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + void createResponseDoesNotExposeOwner() throws Exception { + // privacy: owner is the raw principal id; it must not leak in the response payload + // (pseudonymization is delegated to feature-data-retention-privacy-contract). + when(createUseCase.handle(any())).thenReturn(sample("t")); + mvc.perform( + post("/worklogs") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"t\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.owner").doesNotExist()); + } + + // ---- D23 atomic batch create ---- + + @Test + void batchCreateAllOkReturnsArray() throws Exception { + when(batchCreateUseCase.handle(any())).thenReturn(List.of(sample("a"), sample("b"))); + mvc.perform( + post("/worklogs:batchCreate") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"requests\":[" + + "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}," + + "{\"title\":\"b\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.length()").value(2)); + } + + @Test + void batchOverSizeCapIs400() throws Exception { + String item = "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}"; + String oversized = + "{\"requests\":[" + + IntStream.rangeClosed(1, WorkLogController.MAX_BATCH_SIZE + 1) + .mapToObj(i -> item) + .collect(Collectors.joining(",")) + + "]}"; + mvc.perform( + post("/worklogs:batchCreate") + .contentType(MediaType.APPLICATION_JSON) + .content(oversized)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); + } + + @Test + void batchCreateIsAtomicOneBadItemFailsWholeBatch() throws Exception { + // a single unmappable link fails mapping before any create → whole batch 400, no partial + // success (D23) + mvc.perform( + post("/worklogs:batchCreate") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"requests\":[" + + "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}," + + "{\"title\":\"b\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\",\"links\":[\"bad uri\"]}]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("MAPPING_FAILED")); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java new file mode 100644 index 00000000..884fca00 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.date; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * feature-api-contract-baseline D24 — every response carries a {@code Date} header. The servlet + * container (Tomcat) emits it by default and nothing in the skeleton disables it; this boots a real + * container and checks the header across a success (200) and an error (404) response — the + * status-matrix that a mock MVC test cannot exercise (MockMvc never goes through Tomcat). + */ +@SpringBootTest( + classes = DateHeaderContractTest.DateProbeApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "management.server.port=0") +@AutoConfigureTestRestTemplate +class DateHeaderContractTest { + + @LocalServerPort int port; + + @Autowired TestRestTemplate rest; + + @Test + void successResponseCarriesADateHeader() { + ResponseEntity<String> response = + rest.getForEntity("http://localhost:" + port + "/__date/ok", String.class); + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getHeaders().getFirst("Date")).isNotBlank(); + } + + @Test + void errorResponseAlsoCarriesADateHeader() { + ResponseEntity<String> response = + rest.getForEntity("http://localhost:" + port + "/__date/does-not-exist", String.class); + assertThat(response.getStatusCode().value()).isEqualTo(404); + assertThat(response.getHeaders().getFirst("Date")).isNotBlank(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration( + exclude = { + SecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class + }) + static class DateProbeApp { + + @RestController + static class DateProbeController { + @GetMapping("/__date/ok") + String ok() { + return "ok"; + } + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java new file mode 100644 index 00000000..a8c24228 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; +import org.junit.jupiter.api.Test; + +/** + * B5 alternative pattern: {@link BasicPolymorphicTypeValidator} allowlist. + * + * <p>The branch note records two acceptable polymorphic-deserialization shapes: + * + * <ol> + * <li>{@code @JsonTypeInfo(use = NAME)} + {@code @JsonSubTypes} on a sealed interface — see + * {@link SamplePolymorphicRequest} + {@link SamplePolymorphicRequestTest}. + * <li><b>{@link BasicPolymorphicTypeValidator} allowlist registered on the {@link + * ObjectMapper}</b> — this test. + * </ol> + * + * <p>Either shape blocks the CVE-2019-14379 RCE vector by refusing to deserialize subtypes that are + * not on the allowlist. We pin both because the second is the choice when the polymorphic root + * cannot carry annotations (e.g., third-party types). + */ +class BasicPolymorphicTypeValidatorAllowlistTest { + + interface Animal {} + + public static final class Cat implements Animal { + public String name; + + public Cat() {} + + public Cat(String name) { + this.name = name; + } + } + + public static final class Dog implements Animal { + public String name; + + public Dog() {} + + public Dog(String name) { + this.name = name; + } + } + + public static final class ForbiddenAnimal implements Animal { + public String name; + } + + private ObjectMapper allowlistMapper() { + // Allowlist only Cat and Dog as polymorphic subtypes of Animal. The validator + // refuses every other subclass by default — the security guarantee. + PolymorphicTypeValidator ptv = + BasicPolymorphicTypeValidator.builder() + .allowIfSubType(Cat.class) + .allowIfSubType(Dog.class) + .build(); + + ObjectMapper mapper = new ObjectMapper(); + mapper.activateDefaultTyping( + ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); + return mapper; + } + + @Test + void allowlistedCatDeserializes() throws Exception { + ObjectMapper mapper = allowlistMapper(); + String json = + """ + ["dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.BasicPolymorphicTypeValidatorAllowlistTest$Cat", + {"name":"Whiskers"}] + """; + + Animal animal = mapper.readValue(json, Animal.class); + + assertThat(animal).isInstanceOf(Cat.class); + assertThat(((Cat) animal).name).isEqualTo("Whiskers"); + } + + @Test + void allowlistedDogDeserializes() throws Exception { + ObjectMapper mapper = allowlistMapper(); + String json = + """ + ["dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.BasicPolymorphicTypeValidatorAllowlistTest$Dog", + {"name":"Rex"}] + """; + + Animal animal = mapper.readValue(json, Animal.class); + + assertThat(animal).isInstanceOf(Dog.class); + assertThat(((Dog) animal).name).isEqualTo("Rex"); + } + + @Test + void nonAllowlistedSubtypeIsRejected() { + ObjectMapper mapper = allowlistMapper(); + String json = + """ + ["dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.BasicPolymorphicTypeValidatorAllowlistTest$ForbiddenAnimal", + {"name":"trojan"}] + """; + + assertThatThrownBy(() -> mapper.readValue(json, Animal.class)) + .as( + "B5: a type id outside the BasicPolymorphicTypeValidator allowlist must be " + + "refused — without this guarantee, CVE-2019-14379-class gadget chains " + + "would be deserializable") + .isInstanceOf(JsonMappingException.class); + } + + @Test + void arbitraryJdkClassIsRejectedEvenWithoutExplicitAttack() { + ObjectMapper mapper = allowlistMapper(); + // java.io.File is a classic gadget-class candidate. Any non-allowlisted + // class must be refused regardless of whether it would actually exploit. + String json = + """ + ["java.io.File", "/tmp/evil"] + """; + + assertThatThrownBy(() -> mapper.readValue(json, Animal.class)) + .as("B5: arbitrary JDK classes must be refused by the allowlist") + .isInstanceOf(JsonMappingException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java new file mode 100644 index 00000000..6592831e --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import org.junit.jupiter.api.Test; + +/** + * B5: prove that the {@code @JsonTypeInfo} + {@code @JsonSubTypes} allowlist accepts each declared + * subtype, rejects unknown discriminator values, and rejects payloads missing the discriminator + * entirely. + * + * <p>Without this test the sealed/allowlist pattern is "documented but not pinned" — a future + * maintainer removing one annotation would not see the regression until a security review. + */ +class SamplePolymorphicRequestTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void allowlistedTextSubtypeDeserializes() throws Exception { + String json = + """ + {"kind":"text","title":"hello","body":"world"} + """; + + SamplePolymorphicRequest req = mapper.readValue(json, SamplePolymorphicRequest.class); + + assertThat(req).isInstanceOf(SamplePolymorphicRequest.Text.class); + assertThat(((SamplePolymorphicRequest.Text) req).title()).isEqualTo("hello"); + assertThat(((SamplePolymorphicRequest.Text) req).body()).isEqualTo("world"); + } + + @Test + void allowlistedImageSubtypeDeserializes() throws Exception { + String json = + """ + {"kind":"image","title":"avatar","imageUrl":"https://example.test/a.png"} + """; + + SamplePolymorphicRequest req = mapper.readValue(json, SamplePolymorphicRequest.class); + + assertThat(req).isInstanceOf(SamplePolymorphicRequest.Image.class); + assertThat(((SamplePolymorphicRequest.Image) req).imageUrl()) + .isEqualTo("https://example.test/a.png"); + } + + @Test + void unknownSubtypeIdIsRejected() { + String json = + """ + {"kind":"video","title":"x","url":"y"} + """; + + assertThatThrownBy(() -> mapper.readValue(json, SamplePolymorphicRequest.class)) + .as( + "B5: discriminator outside the @JsonSubTypes allowlist must throw, " + + "preventing arbitrary-class deserialization") + .isInstanceOf(InvalidTypeIdException.class); + } + + @Test + void missingDiscriminatorIsRejected() { + String json = + """ + {"title":"hello","body":"world"} + """; + + assertThatThrownBy(() -> mapper.readValue(json, SamplePolymorphicRequest.class)) + .as( + "B5: payloads without the type discriminator must not silently bind " + + "to a default subtype") + .isInstanceOf(JsonMappingException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java new file mode 100644 index 00000000..0bad86f4 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java @@ -0,0 +1,75 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.response.BulkEnvelope; +import dev.caskeleton.shared.response.BulkItemResult; +import dev.caskeleton.shared.response.ResponseMeta; +import java.util.List; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; + +/** + * B8: pin the three bulk-endpoint envelope shapes. + * + * <p>The branch note's failure condition explicitly forbids {@code success: true} on any + * partial-failure response. The "all failed" case and the "partial" case must take the same + * envelope branch ({@code BATCH_PARTIAL_FAILURE}) so callers always inspect {@code details[]} when + * {@code success == false}. + */ +class BulkEnvelopeTest { + + @Test + void allSuccessProducesSuccessTrueEnvelope() { + BulkEnvelope<String> env = + BulkEnvelope.allOk(List.of("a", "b", "c"), new ResponseMeta("req-1", "trace-1", "corr-1")); + + assertThat(env.success()).isTrue(); + assertThat(env.error()).as("success envelope must not carry error info").isNull(); + assertThat(env.results()).containsExactly("a", "b", "c"); + assertThat(env.meta().traceId()).isEqualTo("trace-1"); + } + + @Test + void partialFailureProducesBatchPartialFailureCodeWithDetails() { + BulkEnvelope<String> env = + BulkEnvelope.partial( + List.of( + BulkItemResult.ok(0, "id-1"), + BulkItemResult.error(1, "DUPLICATE_EMAIL", "email already taken"), + BulkItemResult.ok(2, "id-3")), + new ResponseMeta("req-2", "trace-2", "corr-2")); + + assertThat(env.success()).as("partial failure must NOT report success = true").isFalse(); + assertThat(env.results()).as("partial envelope must carry details[], not results[]").isNull(); + assertThat(env.error().code()).isEqualTo("BATCH_PARTIAL_FAILURE"); + assertThat(env.error().retryable()) + .as("BATCH_PARTIAL_FAILURE is per-item; not a transient retryable signal") + .isFalse(); + assertThat(env.error().details()) + .asInstanceOf(InstanceOfAssertFactories.list(BulkItemResult.class)) + .extracting(BulkItemResult::index, BulkItemResult::status) + .containsExactly(Tuple.tuple(0, "ok"), Tuple.tuple(1, "error"), Tuple.tuple(2, "ok")); + } + + @Test + void allFailuresTakeTheSameBranchAsPartial() { + BulkEnvelope<String> env = + BulkEnvelope.partial( + List.of( + BulkItemResult.error(0, "VALIDATION_FAILED", "name blank"), + BulkItemResult.error(1, "VALIDATION_FAILED", "name blank")), + new ResponseMeta("req-3", "trace-3", "corr-3")); + + assertThat(env.success()).isFalse(); + assertThat(env.error().code()).isEqualTo("BATCH_PARTIAL_FAILURE"); + assertThat(env.error().details()) + .as( + "all-failure case must use the same envelope shape as partial — " + + "callers should not have to handle a third variant") + .asInstanceOf(InstanceOfAssertFactories.list(BulkItemResult.class)) + .hasSize(2) + .allMatch(d -> "error".equals(d.status())); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java new file mode 100644 index 00000000..7afd4ef3 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java @@ -0,0 +1,26 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.shared.response.Envelope; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +class DomainExceptionHandlerTest { + + private final DomainExceptionHandler handler = new DomainExceptionHandler(); + + @Test + void worklogNotFoundMapsTo404Envelope() { + ResponseEntity<Envelope<Void>> response = + handler.handleWorkLogNotFound( + new WorkLogNotFoundException(WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"))); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().error().code()).isEqualTo("WORKLOG_NOT_FOUND"); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java new file mode 100644 index 00000000..35006e18 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** Keeps sample-owned error-code registry checks inside the sample fixture module. */ +class PortfolioErrorCodeRegistryMappingTest { + + private static Map<String, Integer> registryHttpStatusByCode; + + @BeforeAll + static void loadRegistry() throws Exception { + Path registry = locateRegistry(); + Assumptions.assumeTrue( + registry != null, + "docs/registries/error-codes.yaml not found; sample registry check skipped"); + registryHttpStatusByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map<String, Object> root = new Yaml().load(in); + @SuppressWarnings("unchecked") + List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors"); + for (Map<String, Object> row : errors) { + registryHttpStatusByCode.put( + (String) row.get("code"), ((Number) row.get("http_status")).intValue()); + } + } + } + + @Test + void sampleErrorCodesPresentInTheRegistryMatchTheirHttpStatus() { + for (PortfolioErrorCode code : PortfolioErrorCode.values()) { + Integer registryStatus = registryHttpStatusByCode.get(code.code()); + if (registryStatus != null) { + assertThat(code.httpStatus()) + .as("sample enum %s httpStatus must match error-codes.yaml", code.code()) + .isEqualTo(registryStatus); + } + } + } + + private static Path locateRegistry() { + Path directory = Path.of("").toAbsolutePath(); + for (int depth = 0; depth < 6 && directory != null; depth++) { + Path candidate = directory.resolve("docs/registries/error-codes.yaml"); + if (Files.isRegularFile(candidate)) { + return candidate; + } + directory = directory.getParent(); + } + return null; + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java new file mode 100644 index 00000000..d1c14e6d --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java @@ -0,0 +1,16 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.error.Category; +import org.junit.jupiter.api.Test; + +class PortfolioErrorCodeTest { + + @Test + void worklogNotFoundIsNotFoundCategory404() { + assertThat(PortfolioErrorCode.WORKLOG_NOT_FOUND.category()).isEqualTo(Category.NOT_FOUND); + assertThat(PortfolioErrorCode.WORKLOG_NOT_FOUND.httpStatus()).isEqualTo(404); + assertThat(PortfolioErrorCode.WORKLOG_NOT_FOUND.retryable()).isFalse(); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java new file mode 100644 index 00000000..9f4581af --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.filter.RequestLoggingFilter; +import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; +import java.net.URI; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * B6 end-to-end pin. Boots a real Tomcat with {@code spring.threads.virtual.enabled=true}, sends an + * HTTP request to a self-contained {@link ContextProbeController} probe endpoint, and verifies + * that: + * + * <ul> + * <li>the request actually runs on a virtual carrier ({@code virtualThread = true} in the + * response); + * <li>the {@code requestId} the inbound filter set on MDC is visible to the controller (i.e., the + * filter ran on the same virtual thread and the MDC value propagated through the filter chain + * into the handler); + * <li>when the client supplies {@code X-Request-Id}, the filter honours it and the controller + * sees the same value. + * </ul> + * + * <p>Auto-configuration is narrowed to skip security so the demo endpoint is reachable without a + * JWT — security is owned by a sibling branch and is not the contract under test here. + */ +@SpringBootTest( + classes = VirtualThreadMdcE2ETest.TestBootstrap.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.threads.virtual.enabled=true", + "spring.jackson.deserialization.fail-on-unknown-properties=true", + "management.server.port=0" + }) +@AutoConfigureTestRestTemplate +class VirtualThreadMdcE2ETest { + + private static final ParameterizedTypeReference<Map<String, Object>> ENVELOPE_TYPE = + new ParameterizedTypeReference<>() {}; + + @LocalServerPort int port; + + @Autowired TestRestTemplate http; + + @Test + void filterPropagatesRequestIdToControllerUnderVirtualThreads() { + ResponseEntity<Map<String, Object>> response = + http.exchange( + URI.create("http://localhost:" + port + "/test-probe/context"), + HttpMethod.GET, + null, + ENVELOPE_TYPE); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + @SuppressWarnings("unchecked") + Map<String, Object> body = (Map<String, Object>) response.getBody().get("data"); + assertThat(body).as("envelope.data must be the controller payload").isNotNull(); + + assertThat(body.get("virtualThread")) + .as( + "Tomcat must dispatch on a virtual thread when " + + "spring.threads.virtual.enabled=true") + .isEqualTo("true"); + + assertThat(body.get("requestId")) + .as( + "requestId set by RequestLoggingFilter must reach the controller — " + + "B6 propagation on a virtual carrier") + .isNotNull() + .isNotEqualTo("null"); + + String requestIdHeader = response.getHeaders().getFirst("X-Request-Id"); + assertThat(requestIdHeader) + .as("the filter echoes the requestId back as a header") + .isEqualTo(body.get("requestId")); + } + + @Test + void filterHonoursClientSuppliedRequestIdUnderVirtualThreads() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Request-Id", "client-supplied-abc"); + ResponseEntity<Map<String, Object>> response = + http.exchange( + new RequestEntity<>( + headers, + HttpMethod.GET, + URI.create("http://localhost:" + port + "/test-probe/context")), + ENVELOPE_TYPE); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + @SuppressWarnings("unchecked") + Map<String, Object> body = (Map<String, Object>) response.getBody().get("data"); + assertThat(body.get("requestId")).isEqualTo("client-supplied-abc"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration( + exclude = { + SecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + DataJpaRepositoriesAutoConfiguration.class + }) + @Import({ + ContextProbeController.class, + GlobalExceptionHandler.class, + DomainExceptionHandler.class, + EnvelopeBodyAdvice.class, + JacksonNullableConfig.class, + RequestLoggingFilter.class + }) + static class TestBootstrap { + + /** + * RequestLoggingFilter now requires a pseudonymizer; a simple stub suffices here (this pin + * tests MDC propagation, not pseudonymization). + */ + @Bean + UserPrincipalPseudonymizerPort userPrincipalPseudonymizer() { + return raw -> (raw == null || raw.isBlank()) ? null : "pseudo-" + raw; + } + } + + /** + * Self-contained probe controller. Reads MDC and the current carrier thread inside the handler + * (i.e., after the {@link RequestLoggingFilter} chain), mirroring the behaviour the B6 contract + * requires. Defined inside the test so the kept B6 e2e pin does not depend on any demo controller + * slated for removal. + */ + @RestController + static class ContextProbeController { + + @GetMapping("/test-probe/context") + public Map<String, String> context() { + return Map.of( + "requestId", String.valueOf(MDC.get("request_id")), + "traceId", String.valueOf(MDC.get("trace_id")), + "virtualThread", String.valueOf(Thread.currentThread().isVirtual())); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java new file mode 100644 index 00000000..7b3ee444 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +/** + * B6 contract pin for virtual-thread context propagation. + * + * <p>The branch note rejects {@link InheritableThreadLocal} and requires SLF4J 2.0+ MDC + {@code + * RequestContextHolder} for {@code requestId} / {@code traceId} propagation. SLF4J 2.0's {@code + * MDCAdapter} is virtual-thread aware (the value lives on each virtual thread's own {@code + * ThreadLocal}, not on the carrier), which gives the two guarantees this test pins: + * + * <ol> + * <li><b>Same-thread propagation</b>: an MDC value set early in a virtual thread is visible to + * subsequent code on that same virtual thread — i.e., a filter on a virtual thread can + * populate {@code requestId} and the controller body (still on that virtual thread) reads it + * back. + * <li><b>No silent inheritance</b>: a *new* virtual thread spawned inside the request thread does + * NOT inherit MDC. This is the property {@link InheritableThreadLocal} would break, which is + * why the {@code NO_INHERITABLE_THREAD_LOCAL} ArchUnit rule forbids it. Code that crosses + * thread boundaries must capture MDC explicitly (Micrometer Context Propagation). + * </ol> + * + * <p>The two guarantees are exactly what filter-based MDC propagation depends on under virtual + * threads. If a future SLF4J upgrade or JVM change breaks either, this test fails before the change + * reaches production. + */ +class VirtualThreadMdcPropagationTest { + + @Test + void mdcValueSetInAVirtualThreadIsVisibleLaterOnThatSameThread() throws InterruptedException { + CountDownLatch done = new CountDownLatch(1); + AtomicReference<String> observed = new AtomicReference<>(); + AtomicReference<Boolean> ranOnVirtualThread = new AtomicReference<>(); + + Thread vt = + Thread.startVirtualThread( + () -> { + try { + MDC.put("request_id", "req-abc"); + // Simulated handler body — still on the same virtual thread. + ranOnVirtualThread.set(Thread.currentThread().isVirtual()); + observed.set(MDC.get("request_id")); + } finally { + MDC.clear(); + done.countDown(); + } + }); + + assertThat(done.await(2, TimeUnit.SECONDS)) + .as("virtual thread must complete within timeout") + .isTrue(); + vt.join(); + assertThat(ranOnVirtualThread.get()) + .as("test precondition: the body really ran on a virtual thread") + .isTrue(); + assertThat(observed.get()) + .as("same-thread MDC propagation under virtual threads") + .isEqualTo("req-abc"); + } + + @Test + void mdcIsNotSilentlyInheritedByAFreshlySpawnedVirtualThread() throws InterruptedException { + AtomicReference<String> childMdcValue = new AtomicReference<>("not-set"); + + try { + MDC.put("request_id", "parent-req"); + + Thread child = + Thread.startVirtualThread( + () -> { + // Fresh virtual thread spawned without explicit context capture. + childMdcValue.set(MDC.get("request_id")); + }); + child.join(2_000); + } finally { + MDC.clear(); + } + + assertThat(childMdcValue.get()) + .as( + "MDC must not propagate by inheritance (B6) — code crossing thread " + + "boundaries must capture context explicitly via Micrometer Context " + + "Propagation or equivalent. InheritableThreadLocal would break this " + + "contract.") + .isNull(); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java new file mode 100644 index 00000000..d32e520e --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java @@ -0,0 +1,23 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; + +class WorkLogIdSerializerTest { + + private final ObjectMapper mapper = + JsonMapper.builder() + .addModule(new SimpleModule().addSerializer(WorkLogId.class, new WorkLogIdSerializer())) + .build(); + + @Test + void serializesAsBareUuidStringNotRecordObject() throws Exception { + String json = mapper.writeValueAsString(WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab")); + assertThat(json).isEqualTo("\"0190bd6e-7c3e-7abc-8def-0123456789ab\""); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java new file mode 100644 index 00000000..8388a879 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java @@ -0,0 +1,86 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; +import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * feature-api-contract-baseline D10 — OpenAPI producer pin. Boots a real Tomcat with springdoc on + * the classpath and asserts the generated machine-readable contract at {@code /v3/api-docs} is + * served and reflects the actual {@link WorkLogController} surface (so a hand-maintained / stale + * schema cannot pass). The release-blocking <em>drift gate</em> against a committed snapshot is + * owned by feature-contract-verification-test-suite (planned). + */ +@SpringBootTest( + classes = OpenApiSnapshotTest.OpenApiTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "management.server.port=0") +@AutoConfigureTestRestTemplate +class OpenApiSnapshotTest { + + @LocalServerPort int port; + + @Autowired TestRestTemplate rest; + + // The controller is the OpenAPI source; its collaborators are mocked so the slice + // needs no datasource / security. + @MockitoBean CreateWorkLogUseCase createUseCase; + @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; + @MockitoBean UpdateWorkLogUseCase updateUseCase; + @MockitoBean DeleteWorkLogUseCase deleteUseCase; + @MockitoBean GetWorkLogUseCase getUseCase; + @MockitoBean ListWorkLogsUseCase listUseCase; + @MockitoBean GetRepoStatsUseCase repoStatsUseCase; + + @Test + void apiDocsAreGeneratedAndDescribeTheWorklogsContract() { + ResponseEntity<String> response = + rest.getForEntity("http://localhost:" + port + "/v3/api-docs", String.class); + + assertThat(response.getStatusCode().is2xxSuccessful()) + .as("springdoc must serve the generated OpenAPI document (D10 producer)") + .isTrue(); + String body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body).contains("\"openapi\""); // OAS 3.x document + assertThat(body).contains("/worklogs"); // real controller surface, not a stale schema + assertThat(body).contains("/worklogs:batchCreate"); // AIP-136 colon-verb custom method (D23) + } + + @SpringBootConfiguration + @EnableAutoConfiguration( + exclude = { + SecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class + }) + @Import(WorkLogController.class) + static class OpenApiTestApp {} +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java new file mode 100644 index 00000000..34b5ecbb --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java @@ -0,0 +1,57 @@ +package dev.caskeleton.sample.portfolio.adapter.inbound.web.versioning; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * feature-api-contract-baseline D2 — the API version prefix is applied to every controller mapping + * by {@link PresentationWebConfig}. With the prefix set to {@code /v1}, an endpoint mapped at + * {@code /probe} is reachable only at {@code /v1/probe}; the unversioned path 404s. This pins that + * an unversioned public endpoint cannot exist (the prefix is applied centrally, not + * per-controller). + */ +@WebMvcTest( + controllers = VersioningPrefixTest.VersionedProbeController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({PresentationWebConfig.class, VersioningPrefixTest.VersionedProbeController.class}) +@EnableConfigurationProperties(PresentationSettings.class) +@TestPropertySource(properties = "ca-skeleton.presentation.api-base-path=/v1") +class VersioningPrefixTest { + + @Autowired MockMvc mvc; + + @Test + void endpointIsReachableUnderTheVersionPrefix() throws Exception { + mvc.perform(get("/v1/probe")).andExpect(status().isOk()); + } + + @Test + void unversionedPathIsNotExposed() throws Exception { + mvc.perform(get("/probe")).andExpect(status().isNotFound()); + } + + @RestController + static class VersionedProbeController { + + @GetMapping("/probe") + Map<String, String> probe() { + return Map.of("probe", "ok"); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java new file mode 100644 index 00000000..c53534d1 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import org.junit.jupiter.api.Test; + +class UuidWorkLogIdFactoryTest { + + private final UuidWorkLogIdFactory factory = new UuidWorkLogIdFactory(); + + @Test + void newIdProducesAValidCanonicalWorklogId() { + WorkLogId id = factory.newId(); + // WorkLogId's constructor enforces the canonical UUID pattern, so a successful + // construction proves the generated value is canonical. + assertThat(id.value()) + .matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + } + + @Test + void successiveIdsAreDistinct() { + assertThat(factory.newId()).isNotEqualTo(factory.newId()); + } + + @Test + void monotonicFactoryProducesStrictlyIncreasingIds() { + // getTimeOrderedEpochPlus1 guarantees each UUIDv7 sorts strictly after the previous one + // (time-ordered timestamp + monotonic counter). Because the canonical string is big-endian + // lowercase hex with hyphens at fixed positions, lexicographic order matches numeric order. + // Looping makes the property observable within a single millisecond and would fail loudly if + // the adapter were swapped to a non-monotonic generator — a two-element check would pass ~50% + // of the time in that regression. + String previous = factory.newId().value(); + for (int i = 0; i < 1_000; i++) { + String current = factory.newId().value(); + assertThat(current).isGreaterThan(previous); + previous = current; + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java new file mode 100644 index 00000000..824a1702 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java @@ -0,0 +1,39 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +class WorkLogPersistenceMapperTest { + + private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + @Test + void roundTripPreservesFieldsIncludingIdViaUuid() { + WorkLog w = + WorkLog.create( + ID, + "t", + WorkCategory.BACKEND, + "s", + "c", + List.of("java"), + List.of("https://x"), + new Period(LocalDate.of(2025, 1, 1), null)); + WorkLog back = WorkLogPersistenceMapper.toDomain(WorkLogPersistenceMapper.toEntity(w)); + assertThat(back.id()).isEqualTo(ID); // UUID string -> uuid -> UUID string is lossless (D10) + assertThat(back.title()).isEqualTo("t"); + assertThat(back.techStack()).containsExactly("java"); + assertThat(back.period().isOngoing()).isTrue(); + assertThat(back.status()).isEqualTo(WorkLogStatus.OPEN); + assertThat(back.owner()).isEqualTo(WorkLogOwner.of("sample-owner")); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java new file mode 100644 index 00000000..daa08195 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java @@ -0,0 +1,130 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; +import dev.caskeleton.sample.portfolio.domain.poster.Poster; +import dev.caskeleton.sample.portfolio.domain.poster.PosterId; +import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; +import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; +import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Persistence-adapter INTEGRATION test: boots a disposable Postgres via Testcontainers and drives + * the real Flyway schema (V6 {@code poster}) + JPA mapping, so regressions a mock cannot see fail + * here: column mapping, {@code @Enumerated(STRING)}, the UUID string<->native-uuid + * conversion, audit stamping, and {@code @Version} assignment. {@code ddl-auto=validate} makes + * Hibernate verify {@code PosterEntity} against the Flyway-built schema (schema-drift gate). + * Mirrors {@code WorkLogRepositoryAdapterIntegrationTest}; skipped automatically when no Docker + * daemon is present. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers(disabledWithoutDocker = true) +@Import({ + JpaConfig.class, + PosterRepositoryAdapter.class, + PosterRepositoryAdapterIntegrationTest.TestAuditConfig.class +}) +@TestPropertySource( + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration,classpath:db/sample-migration", + "spring.jpa.hibernate.ddl-auto=validate" + }) +class PosterRepositoryAdapterIntegrationTest { + + @Container @ServiceConnection + static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); + + private static final PosterId ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + @Autowired PosterRepositoryAdapter adapter; + + private Poster sample() { + return Poster.create(ID, "여름 세일 배너", "7월 한정"); + } + + @Test + void saveThenFindByIdRoundTripsThroughRealPostgres(@Autowired TestEntityManager em) { + Poster saved = adapter.save(sample()); + assertThat(saved.version()).isNotNull(); // @Version assigned by Hibernate on INSERT + + em.flush(); + em.clear(); + + Optional<Poster> found = adapter.findById(ID); + assertThat(found).isPresent(); + Poster p = found.get(); + assertThat(p.id()).isEqualTo(ID); // UUID string <-> native uuid + assertThat(p.title()).isEqualTo("여름 세일 배너"); + assertThat(p.status()).isEqualTo(PosterStatus.DRAFT); // @Enumerated(STRING) + assertThat(p.imageKey()).isNull(); + } + + @Test + void attachImageThenPublishIsPersisted(@Autowired TestEntityManager em) { + adapter.save(sample()); + em.flush(); + em.clear(); + + Poster loaded = adapter.findById(ID).orElseThrow(); + loaded.attachImage("posters/" + ID.value() + "/image"); + loaded.publish(); + adapter.save(loaded); // UPDATE path: reload existing audit + bump version + em.flush(); + em.clear(); + + Poster reread = adapter.findById(ID).orElseThrow(); + assertThat(reread.status()).isEqualTo(PosterStatus.PUBLISHED); + assertThat(reread.imageKey()).isEqualTo("posters/" + ID.value() + "/image"); + } + + @Test + void findPageAppliesStatusFilterAndExistsByTitleIsCaseInsensitive( + @Autowired TestEntityManager em) { + adapter.save(sample()); + em.flush(); + em.clear(); + + assertThat(adapter.findPage(0, 20, PosterSortField.TITLE, true, PosterStatus.DRAFT).total()) + .isEqualTo(1); + // A non-matching status filter runs the real derived query and returns nothing. + assertThat(adapter.findPage(0, 20, null, true, PosterStatus.PUBLISHED).total()).isZero(); + + assertThat(adapter.existsByTitle("여름 세일 배너")).isTrue(); + assertThat(adapter.existsByTitle("존재하지 않는 제목")).isFalse(); + } + + @TestConfiguration + static class TestAuditConfig { + + @Bean + Clock clock() { + return Clock.fixed(Instant.parse("2026-06-15T00:00:00Z"), ZoneOffset.UTC); + } + + @Bean + AuditContextPort auditContext() { + return () -> "it-actor"; + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java new file mode 100644 index 00000000..2477a77a --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java @@ -0,0 +1,134 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; +import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * Persistence-adapter INTEGRATION test (Hombergs Ch7): the book maps a persistence adapter to an + * integration test against a real database, not a mock. The sibling {@code + * WorkLogRepositoryAdapterTest} mocks {@code WorkLogJpaRepository} to verify the audit-stamping + * logic; this test boots a disposable Postgres via Testcontainers and drives the real Flyway schema + * + JPA mapping, so regressions a mock cannot see fail here: column mapping, + * {@code @Enumerated(STRING)}, the {@code @ElementCollection} join tables, the UUID + * string<->native-uuid conversion, and {@code @Version} assignment. + * + * <p>Flyway is pointed at both production migrations (V1, V3 in {@code db/migration}) and the + * sample's {@code db/sample-migration} (V2 {@code work_log}); {@code ddl-auto=validate} makes + * Hibernate verify {@code WorkLogEntity} against that Flyway-built schema (schema-drift gate). + * Skipped automatically when no Docker daemon is present. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers(disabledWithoutDocker = true) +@Import({ + JpaConfig.class, + WorkLogRepositoryAdapter.class, + WorkLogRepositoryAdapterIntegrationTest.TestAuditConfig.class +}) +@TestPropertySource( + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration,classpath:db/sample-migration", + "spring.jpa.hibernate.ddl-auto=validate" + }) +class WorkLogRepositoryAdapterIntegrationTest { + + @Container @ServiceConnection + static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); + + private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + @Autowired WorkLogRepositoryAdapter adapter; + + private WorkLog sample() { + return WorkLog.create( + ID, + "DB 튜닝", + WorkCategory.DATABASE, + "summary", + "content", + List.of("postgres", "jpa"), + List.of("https://example.test/pr/1"), + new Period(LocalDate.of(2025, 1, 1), LocalDate.of(2025, 2, 1))); + } + + @Test + void saveThenFindByIdRoundTripsThroughRealPostgres(@Autowired TestEntityManager em) { + WorkLog saved = adapter.save(sample()); + assertThat(saved.version()).isNotNull(); // @Version assigned by Hibernate on INSERT + + // Force a real SELECT instead of the persistence-context L1 cache so the column / + // enum / collection / UUID string<->uuid mapping is actually round-tripped through Postgres. + em.flush(); + em.clear(); + + Optional<WorkLog> found = adapter.findById(ID); + assertThat(found).isPresent(); + WorkLog w = found.get(); + assertThat(w.id()).isEqualTo(ID); // UUID string <-> native uuid + assertThat(w.title()).isEqualTo("DB 튜닝"); + assertThat(w.category()).isEqualTo(WorkCategory.DATABASE); // @Enumerated(STRING) + assertThat(w.techStack()).containsExactly("postgres", "jpa"); // @ElementCollection join table + assertThat(w.links()).containsExactly("https://example.test/pr/1"); + assertThat(w.period().start()).isEqualTo(LocalDate.of(2025, 1, 1)); + assertThat(w.period().end()).isEqualTo(LocalDate.of(2025, 2, 1)); + } + + @Test + void findPageReadsPersistedRowsAndAppliesCategoryFilter(@Autowired TestEntityManager em) { + adapter.save(sample()); + em.flush(); + em.clear(); + + WorkLogPage matching = + adapter.findPage(0, 20, WorkLogSortField.TITLE, true, WorkCategory.DATABASE); + assertThat(matching.total()).isEqualTo(1); + assertThat(matching.items()).hasSize(1); + assertThat(matching.items().get(0).id()).isEqualTo(ID); + + // A non-matching category filter runs the real derived query and returns nothing. + assertThat(adapter.findPage(0, 20, null, true, WorkCategory.BACKEND).total()).isZero(); + } + + @TestConfiguration + static class TestAuditConfig { + + @Bean + Clock clock() { + return Clock.fixed(Instant.parse("2026-06-15T00:00:00Z"), ZoneOffset.UTC); + } + + @Bean + AuditContextPort auditContext() { + return () -> "it-actor"; + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java new file mode 100644 index 00000000..6450dd3a --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java @@ -0,0 +1,151 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; +import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.WorkLogPersistenceMapper; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +class WorkLogRepositoryAdapterTest { + + private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final Instant NOW = Instant.parse("2026-06-10T12:00:00Z"); + private static final Instant EARLIER = Instant.parse("2026-01-01T00:00:00Z"); + + private final WorkLogJpaRepository jpa = Mockito.mock(WorkLogJpaRepository.class); + private final AuditContextPort auditContext = Mockito.mock(AuditContextPort.class); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + private final WorkLogRepositoryAdapter adapter = + new WorkLogRepositoryAdapter(jpa, clock, auditContext); + + private WorkLog sample() { + return WorkLog.create( + ID, + "t", + WorkCategory.DATABASE, + "s", + "c", + List.of(), + List.of(), + new Period(LocalDate.of(2025, 1, 1), null)); + } + + private WorkLog persisted(long version) { + return WorkLog.rehydrate( + ID, + "t", + WorkCategory.DATABASE, + "s", + "c", + List.of(), + List.of(), + new Period(LocalDate.of(2025, 1, 1), null), + version); + } + + @Test + void saveMapsToEntityAndBack() { + WorkLog w = sample(); + when(jpa.save(any(WorkLogEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + assertThat(adapter.save(w).id()).isEqualTo(w.id()); + } + + @Test + void saveNewStampsCreatedAndUpdatedAuditWithClockAndActor() { + // D1 Manual path / D4 Clock / D5 actor: a never-persisted aggregate (null version) + // is an INSERT — created_* and updated_* both take now + actor. + when(auditContext.currentActor()).thenReturn("alice"); + when(jpa.save(any(WorkLogEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + adapter.save(sample()); + + ArgumentCaptor<WorkLogEntity> saved = ArgumentCaptor.forClass(WorkLogEntity.class); + verify(jpa).save(saved.capture()); + // no existing-row read for an insert + verify(jpa, never()).findById(any()); + assertThat(saved.getValue().getCreatedAt()).isEqualTo(NOW); + assertThat(saved.getValue().getUpdatedAt()).isEqualTo(NOW); + assertThat(saved.getValue().getCreatedBy()).isEqualTo("alice"); + assertThat(saved.getValue().getUpdatedBy()).isEqualTo("alice"); + } + + @Test + void saveUpdatePreservesCreatedAndBumpsUpdatedAudit() { + // D2/D3: an already-persisted aggregate (non-null version) is an UPDATE — created_* + // is carried from the persisted row, only updated_* moves to now + actor. + when(auditContext.currentActor()).thenReturn("bob"); + WorkLogEntity existing = WorkLogPersistenceMapper.toEntity(persisted(1L)); + existing.initializeAudit(EARLIER, "alice"); + when(jpa.findById(WorkLogPersistenceMapper.toUuid(ID))).thenReturn(Optional.of(existing)); + when(jpa.save(any(WorkLogEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + adapter.save(persisted(1L)); + + ArgumentCaptor<WorkLogEntity> saved = ArgumentCaptor.forClass(WorkLogEntity.class); + verify(jpa).save(saved.capture()); + assertThat(saved.getValue().getCreatedAt()).isEqualTo(EARLIER); + assertThat(saved.getValue().getCreatedBy()).isEqualTo("alice"); + assertThat(saved.getValue().getUpdatedAt()).isEqualTo(NOW); + assertThat(saved.getValue().getUpdatedBy()).isEqualTo("bob"); + } + + @Test + void findByIdConvertsIdStringToUuidAndMapsOptional() { + WorkLog w = sample(); + when(jpa.findById(WorkLogPersistenceMapper.toUuid(ID))) + .thenReturn(Optional.of(WorkLogPersistenceMapper.toEntity(w))); + assertThat(adapter.findById(ID)).isPresent(); + } + + @Test + void findPageDelegatesSortedPagerequestAndMapsTotal() { + WorkLog w = sample(); + when(jpa.findAll(any(Pageable.class))) + .thenReturn( + new PageImpl<>( + List.of(WorkLogPersistenceMapper.toEntity(w)), PageRequest.of(2, 5), 11)); + var result = adapter.findPage(2, 5, WorkLogSortField.TITLE, false, null); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).id()).isEqualTo(w.id()); + assertThat(result.total()).isEqualTo(11); + ArgumentCaptor<Pageable> cap = ArgumentCaptor.forClass(Pageable.class); + verify(jpa).findAll(cap.capture()); + assertThat(cap.getValue().getPageNumber()).isEqualTo(2); + assertThat(cap.getValue().getPageSize()).isEqualTo(5); + assertThat(cap.getValue().getSort().getOrderFor("title").getDirection()) + .isEqualTo(Sort.Direction.DESC); + } + + @Test + void findPageWithCategoryFilterDelegatesToFindByCategory() { + WorkLog w = sample(); + when(jpa.findByCategory(eq(WorkCategory.DATABASE), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(WorkLogPersistenceMapper.toEntity(w)))); + var result = adapter.findPage(0, 20, null, true, WorkCategory.DATABASE); + assertThat(result.items()).hasSize(1); + verify(jpa).findByCategory(eq(WorkCategory.DATABASE), any(Pageable.class)); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java new file mode 100644 index 00000000..1362415f --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java @@ -0,0 +1,63 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +class WorkLogSummaryQueryAdapterTest { + + private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + private static final UUID ROW_UUID = UUID.fromString(ID); + + private final WorkLogJpaRepository jpa = Mockito.mock(WorkLogJpaRepository.class); + private final WorkLogSummaryQueryAdapter adapter = new WorkLogSummaryQueryAdapter(jpa); + + @Test + void mapsRowUuidBackToCanonicalUuidString() { + when(jpa.findRecentSummaryRows(any(Pageable.class))) + .thenReturn( + List.of( + new WorkLogSummaryRow( + ROW_UUID, + "Tuned Hikari pool", + WorkCategory.DATABASE, + LocalDate.of(2025, 1, 1), + null))); + + List<WorkLogSummary> result = adapter.findRecentSummaries(5); + + assertThat(result).hasSize(1); + WorkLogSummary summary = result.get(0); + assertThat(summary.id()).isEqualTo(ID); + assertThat(summary.title()).isEqualTo("Tuned Hikari pool"); + assertThat(summary.category()).isEqualTo(WorkCategory.DATABASE); + assertThat(summary.periodStart()).isEqualTo(LocalDate.of(2025, 1, 1)); + assertThat(summary.periodEnd()).isNull(); + } + + @Test + void buildsPagerequestWithClampedLimitSortedByPeriodDesc() { + when(jpa.findRecentSummaryRows(any(Pageable.class))).thenReturn(List.of()); + + adapter.findRecentSummaries(0); // non-positive → clamped to 1 + + ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class); + Mockito.verify(jpa).findRecentSummaryRows(captor.capture()); + Pageable pageable = captor.getValue(); + assertThat(pageable.getPageNumber()).isZero(); + assertThat(pageable.getPageSize()).isEqualTo(1); + assertThat(pageable.getSort().getOrderFor("periodStart").getDirection()) + .isEqualTo(Sort.Direction.DESC); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java new file mode 100644 index 00000000..09e11207 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import dev.caskeleton.shared.error.MappingException; +import org.junit.jupiter.api.Test; + +class RepoStatsAclMapperTest { + + @Test + void mapsAndNormalizes() { + RepoStats s = + RepoStatsAclMapper.toDomain( + new RawRepoStatsResponse("Owner/Repo", 1200, "2025-01-02T03:04:05Z", "secret-token")); + assertThat(s.fullName()).isEqualTo("owner/repo"); + assertThat(s.stars()).isEqualTo(1200); + assertThat(s.lastPushedIso()).isEqualTo("2025-01-02T03:04:05Z"); + } + + @Test + void missingFullNameThrowsMappingException() { + assertThatThrownBy( + () -> RepoStatsAclMapper.toDomain(new RawRepoStatsResponse(null, 0, "x", "t"))) + .isInstanceOf(MappingException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java new file mode 100644 index 00000000..11b87e1b --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.sample.portfolio.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; +import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.shared.request.Patch; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Self-validating input models (Hombergs Ch4): commands/queries enforce their own invariants at + * construction so the contract holds for ANY inbound adapter — not just the web edge whose jakarta + * validation a non-web (e.g. messaging) adapter would bypass. + */ +class CommandSelfValidationTest { + + private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + private static final LocalDate START = LocalDate.of(2025, 1, 1); + + @Test + void createRejectsNullRequiredScalars() { + assertThatThrownBy( + () -> + new CreateWorkLogCommand( + "o", null, WorkCategory.DATABASE, "s", "c", List.of(), List.of(), START, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> + new CreateWorkLogCommand( + "o", "t", null, "s", "c", List.of(), List.of(), START, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> + new CreateWorkLogCommand( + "o", "t", WorkCategory.DATABASE, "s", "c", List.of(), List.of(), null, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void createDefaultsNullCollectionsToEmptyImmutable() { + CreateWorkLogCommand cmd = + new CreateWorkLogCommand( + "o", "t", WorkCategory.DATABASE, "s", "c", null, null, START, null); + assertThat(cmd.techStack()).isEmpty(); + assertThat(cmd.links()).isEmpty(); + assertThatThrownBy(() -> cmd.techStack().add("x")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void updateRejectsNullIdAndNullPatches() { + assertThatThrownBy( + () -> + new UpdateWorkLogCommand( + null, + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> + new UpdateWorkLogCommand( + ID, null, Patch.absent(), Patch.absent(), Patch.absent(), Patch.absent())) + .isInstanceOf(NullPointerException.class); + } + + @Test + void deleteGetAndRepostatsRejectNull() { + assertThatThrownBy(() -> new DeleteWorkLogCommand(null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new GetWorkLogQuery(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new GetRepoStatsQuery(null)).isInstanceOf(NullPointerException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java new file mode 100644 index 00000000..5734c614 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java @@ -0,0 +1,134 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.outbox.NewOutboxEvent; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Contract test (spec D7) — at-least-once delivery consumer dedupe obligation. + * + * <p>This test demonstrates the <em>executable contract</em> that any consumer of {@code + * worklog.reserved} events MUST honour: given identical {@code idempotencyKey} envelopes delivered + * multiple times (at-least-once delivery is the structural guarantee of the polling outbox), + * exactly one processing side-effect must occur. + * + * <h2>Scope</h2> + * + * <ul> + * <li>The <em>dedupe mechanism</em> (in-memory {@link Set} below) is intentionally minimal — a + * real consumer uses a persistent store with TTL. Storage and TTL policy is owned by the + * consumer implementation branch (out of scope here). + * <li>This test exercises the <em>shape of the contract</em>: same {@code idempotencyKey} → skip, + * different key → process. It does not test distributed consistency or TTL expiry. + * </ul> + * + * <h2>Relationship to I12</h2> + * + * <p>{@code idempotencyKey = eventId} by default (I12 — per-event deduplication). A consumer that + * keys dedupe on the envelope's {@code idempotencyKey} therefore deduplicates at the event level, + * not the use-case call level. + */ +class WorkLogReservedConsumerDedupeContractTest { + + /** + * Minimal fake consumer with in-memory idempotencyKey tracking. + * + * <p>Real implementations replace the {@link HashSet} with a persistent store (Redis / DB {@code + * idempotency_record}) and a configurable TTL. The dedupe logic shape ({@code if (seen.add(key)) + * process()}) is identical. + */ + static class FakeWorkLogReservedConsumer { + + private final Set<String> processedKeys = new HashSet<>(); + final List<String> processedEventIds = new ArrayList<>(); + + /** + * Processes the envelope. If {@code idempotencyKey} was already seen, the message is silently + * skipped (at-least-once → exactly-once effect). + */ + void consume(NewOutboxEvent envelope) { + if (!processedKeys.add(envelope.idempotencyKey())) { + // already processed — discard duplicate + return; + } + processedEventIds.add(envelope.eventId()); + } + + int processedCount() { + return processedEventIds.size(); + } + } + + // ---- contract tests ---- + + @Test + void sameIdempotencyKeyDeliveredFiveTimesProducesExactlyOneEffect() { + // D7: at-least-once delivery + consumer dedupe obligation + FakeWorkLogReservedConsumer consumer = new FakeWorkLogReservedConsumer(); + + NewOutboxEvent envelope = sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + // Simulate at-least-once delivery: same envelope received 5 times + for (int i = 0; i < 5; i++) { + consumer.consume(envelope); + } + + assertThat(consumer.processedCount()) + .as("same idempotencyKey delivered 5 times must produce exactly 1 processing effect (D7)") + .isEqualTo(1); + } + + @Test + void distinctIdempotencyKeysEachProcessedOnce() { + FakeWorkLogReservedConsumer consumer = new FakeWorkLogReservedConsumer(); + + consumer.consume(sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789aa")); + consumer.consume(sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789bb")); + consumer.consume(sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789cc")); + + assertThat(consumer.processedCount()).isEqualTo(3); + } + + @Test + void mixedDeliveryOnlyDeduplicatesMatchingKey() { + FakeWorkLogReservedConsumer consumer = new FakeWorkLogReservedConsumer(); + + String keyA = "0190bd6e-7c3e-7abc-8def-0123456789aa"; + String keyB = "0190bd6e-7c3e-7abc-8def-0123456789bb"; + + // keyA delivered twice, keyB once + consumer.consume(sampleEnvelope(keyA)); + consumer.consume(sampleEnvelope(keyA)); // duplicate — must be skipped + consumer.consume(sampleEnvelope(keyB)); + + assertThat(consumer.processedCount()).isEqualTo(2); + assertThat(consumer.processedEventIds).containsExactly(keyA, keyB); + } + + @Test + void envelopeIdempotencyKeyEqualsEventIdPerI12() { + // I12: idempotencyKey = eventId by default + NewOutboxEvent envelope = sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789ab"); + assertThat(envelope.idempotencyKey()).isEqualTo(envelope.eventId()); + } + + // ---- builder ---- + + private static NewOutboxEvent sampleEnvelope(String id) { + return new NewOutboxEvent( + id, + WorkLogReservedIntegrationEventMapper.EVENT_TYPE, + "0190bd6e-7c3e-7abc-8def-0123456789ab", // aggregateId + "{\"workLogId\":\"" + id + "\",\"category\":\"BACKEND\",\"occurredOn\":\"2025-01-01\"}", + Instant.parse("2025-01-01T00:00:00Z"), + id, // correlationId + id // idempotencyKey = eventId (I12) + ); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java new file mode 100644 index 00000000..746f96c3 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java @@ -0,0 +1,92 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +/** + * Verifies the JSON serialisation produced by {@link WorkLogReservedIntegrationEventMapper#toJson} + * (feature-domain-event-outbox-contract D11 + D12). + * + * <p>Hand-rolled serialisation: no Jackson dependency. Checks field presence, ordering, and RFC + * 8259 §7 escape correctness. + */ +class WorkLogReservedIntegrationEventMapperJsonTest { + + private static final WorkLogReserved SAMPLE_EVENT = + new WorkLogReserved( + WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), + WorkCategory.BACKEND, + LocalDate.of(2025, 1, 1)); + + @Test + void toJsonProducesExpectedShape() { + WorkLogReservedIntegrationEvent ie = + WorkLogReservedIntegrationEventMapper.toIntegrationEvent(SAMPLE_EVENT); + + String json = WorkLogReservedIntegrationEventMapper.toJson(ie); + + assertThat(json) + .isEqualTo( + "{\"workLogId\":\"0190bd6e-7c3e-7abc-8def-0123456789ab\"," + + "\"category\":\"BACKEND\"," + + "\"occurredOn\":\"2025-01-01\"}"); + } + + @Test + void toJsonIsValidParseableJsonObject() { + WorkLogReservedIntegrationEvent ie = + WorkLogReservedIntegrationEventMapper.toIntegrationEvent(SAMPLE_EVENT); + String json = WorkLogReservedIntegrationEventMapper.toJson(ie); + + assertThat(json).startsWith("{").endsWith("}"); + assertThat(json).contains("\"workLogId\""); + assertThat(json).contains("\"category\""); + assertThat(json).contains("\"occurredOn\""); + } + + @Test + void escapeHandlesBackslashAndDoubleQuote() { + String input = "path\\to\"value"; + String escaped = WorkLogReservedIntegrationEventMapper.escape(input); + assertThat(escaped).isEqualTo("path\\\\to\\\"value"); + } + + @Test + void escapeHandlesControlCharacters() { + // Tab (0x09), newline (0x0A), carriage return (0x0D) are < 0x20 + String input = "a\tb\nc"; + String escaped = WorkLogReservedIntegrationEventMapper.escape(input); + assertThat(escaped).isEqualTo("a\\u0009b\\u000ac"); + } + + @Test + void escapeNullReturnsEmptyString() { + assertThat(WorkLogReservedIntegrationEventMapper.escape(null)).isEqualTo(""); + } + + @Test + void eventTypeConstantIsStable() { + // I9: eventType becomes the broker topic — must not change silently + assertThat(WorkLogReservedIntegrationEventMapper.EVENT_TYPE).isEqualTo("worklog.reserved"); + } + + @Test + void toJsonContainsNoPiiFieldNames() { + // EventPayloadPiiContractTest (Task E) also enforces this statically; + // this test makes it explicit for the sample payload shape. + WorkLogReservedIntegrationEvent ie = + WorkLogReservedIntegrationEventMapper.toIntegrationEvent(SAMPLE_EVENT); + String json = WorkLogReservedIntegrationEventMapper.toJson(ie); + + assertThat(json.toLowerCase()) + .doesNotContain("email") + .doesNotContain("password") + .doesNotContain("token") + .doesNotContain("secret"); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java new file mode 100644 index 00000000..735c5386 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java @@ -0,0 +1,32 @@ +package dev.caskeleton.sample.portfolio.application.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +/** + * Verifies the Claims-To-Verify flow: a transport-free domain event is convertible to an + * integration event at the application boundary (feature-domain-modeling-guardrails D4/D8). + */ +class WorkLogReservedIntegrationEventMapperTest { + + @Test + void mapsDomainEventToIntegrationEventFlatteningValueObjects() { + WorkLogReserved domainEvent = + new WorkLogReserved( + WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), + WorkCategory.BACKEND, + LocalDate.of(2025, 1, 1)); + + WorkLogReservedIntegrationEvent integrationEvent = + WorkLogReservedIntegrationEventMapper.toIntegrationEvent(domainEvent); + + assertThat(integrationEvent.workLogId()).isEqualTo("0190bd6e-7c3e-7abc-8def-0123456789ab"); + assertThat(integrationEvent.category()).isEqualTo("BACKEND"); + assertThat(integrationEvent.occurredOn()).isEqualTo("2025-01-01"); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java new file mode 100644 index 00000000..7e102c12 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java @@ -0,0 +1,263 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.event.WorkLogReservedIntegrationEventMapper; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +/** + * Verifies that {@link CreateWorkLogUseCase} appends a {@code WorkLogReserved} outbox event inside + * the same {@code tx.inWrite} block as the aggregate save (feature-domain-event-outbox-contract + * D2). + * + * <p>Uses hand-rolled fakes — no Spring context, no JPA. + */ +class CreateWorkLogOutboxTest { + + // ---- fakes ---- + + static final WorkLogId FIXED_ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + static final WorkLogIdFactory IDS = () -> FIXED_ID; + static final String FIXED_EVENT_ID = "0190bd6e-7c3e-7abc-8def-0123456789ff"; + static final OutboxEventIdFactory EVENT_IDS = () -> FIXED_EVENT_ID; + static final Clock FIXED_CLOCK = + Clock.fixed(Instant.parse("2025-06-01T10:00:00Z"), ZoneOffset.UTC); + + static class FakeRepo implements WorkLogRepository { + final List<WorkLog> store = new ArrayList<>(); + + public WorkLog save(WorkLog w) { + store.add(w); + return w; + } + + public Optional<WorkLog> findById(WorkLogId id) { + return store.stream().filter(x -> x.id().equals(id)).findFirst(); + } + + public WorkLogPage findPage( + int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) { + return new WorkLogPage(List.of(), 0); + } + + public boolean existsById(WorkLogId id) { + return findById(id).isPresent(); + } + + public void deleteById(WorkLogId id) { + store.removeIf(x -> x.id().equals(id)); + } + } + + static class CapturingOutboxPort implements OutboxAppendPort { + final List<NewOutboxEvent> appended = new ArrayList<>(); + boolean appendCalledInTx = false; + + void markInTx() { + appendCalledInTx = true; + } + + @Override + public void append(NewOutboxEvent event) { + appended.add(event); + } + } + + /** + * A {@link TransactionPort} that tracks whether {@code outboxAppendPort.append} was called inside + * the {@code inWrite} callback — used to assert D2 (same-tx dual-write guarantee). + */ + static class TrackingTx implements TransactionPort { + private final CapturingOutboxPort outboxPort; + boolean appendHappenedInsideTx = false; + + TrackingTx(CapturingOutboxPort outboxPort) { + this.outboxPort = outboxPort; + } + + @Override + public <T> T inWrite(Supplier<T> action) { + int beforeSize = outboxPort.appended.size(); + T result = action.get(); + // If the outbox port was called during the action, the size increased + appendHappenedInsideTx = outboxPort.appended.size() > beforeSize; + return result; + } + + @Override + public <T> T inRead(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inNew(Supplier<T> action) { + return action.get(); + } + } + + // ---- helpers ---- + + private CreateWorkLogCommand createCmd() { + return new CreateWorkLogCommand( + "owner1", + "Test Work", + WorkCategory.BACKEND, + "summary", + "content", + List.of("java"), + List.of(), + LocalDate.of(2025, 1, 1), + null); + } + + // ---- tests ---- + + @Test + void createAppendsOutboxEventWithCorrectEventType() { + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TrackingTx tx = new TrackingTx(outbox); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + assertThat(outbox.appended).hasSize(1); + assertThat(outbox.appended.get(0).eventType()) + .isEqualTo(WorkLogReservedIntegrationEventMapper.EVENT_TYPE); + } + + @Test + void createAppendsOutboxEventInsideTxInWriteBlock() { + // D2: append must happen inside the same tx.inWrite as the aggregate save + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TrackingTx tx = new TrackingTx(outbox); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + assertThat(tx.appendHappenedInsideTx) + .as("OutboxAppendPort.append must be called inside tx.inWrite (D2)") + .isTrue(); + } + + @Test + void createOutboxEventHasAggregateIdMatchingWorklogId() { + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + assertThat(outbox.appended).hasSize(1); + assertThat(outbox.appended.get(0).aggregateId()).isEqualTo(created.id().value()); + } + + @Test + void createOutboxEventIdempotencyKeyEqualsEventId() { + // I12: idempotencyKey = eventId + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + NewOutboxEvent event = outbox.appended.get(0); + assertThat(event.idempotencyKey()) + .as("idempotencyKey must equal eventId (I12)") + .isEqualTo(event.eventId()); + } + + @Test + void createOutboxEventOccurredAtUsesInjectedClock() { + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + assertThat(outbox.appended.get(0).occurredAt()) + .isEqualTo(Instant.parse("2025-06-01T10:00:00Z")); + } + + @Test + void createOutboxEventPayloadContainsWorklogIdAndCategory() { + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + String payload = outbox.appended.get(0).payload(); + assertThat(payload).contains(FIXED_ID.value()); + assertThat(payload).contains("BACKEND"); + } + + @Test + void createOutboxEventCorrelationIdFallsBackToEventIdWhenNoMdc() { + // When no MDC correlation_id is present (non-HTTP path), correlationId = eventId + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + + NewOutboxEvent event = outbox.appended.get(0); + assertThat(event.correlationId()) + .as("self-correlation fallback: correlationId = eventId when MDC absent") + .isEqualTo(event.eventId()); + } + + @Test + void createOutboxEventUsesMdcCorrelationIdWhenPresent() { + FakeRepo repo = new FakeRepo(); + CapturingOutboxPort outbox = new CapturingOutboxPort(); + TransactionPort tx = plainTx(); + + MDC.put("correlation_id", "test-corr-123"); + try { + new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, FIXED_CLOCK, tx).handle(createCmd()); + } finally { + MDC.remove("correlation_id"); + } + + assertThat(outbox.appended.get(0).correlationId()).isEqualTo("test-corr-123"); + } + + // Plain pass-through tx (no tracking) + private static TransactionPort plainTx() { + return new TransactionPort() { + public <T> T inWrite(Supplier<T> a) { + return a.get(); + } + + public <T> T inRead(Supplier<T> a) { + return a.get(); + } + + public <T> T inNew(Supplier<T> a) { + return a.get(); + } + }; + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java new file mode 100644 index 00000000..29123aa0 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.sample.portfolio.application.port.RepoStatsPort; +import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; +import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; +import org.junit.jupiter.api.Test; + +class GetRepoStatsUseCaseTest { + + @Test + void delegatesToPort() { + RepoStatsPort port = repoUrl -> new RepoStats("owner/repo", 7, "2025-01-01T00:00:00Z"); + RepoStats result = new GetRepoStatsUseCase(port).handle(new GetRepoStatsQuery("x")); + assertThat(result.stars()).isEqualTo(7); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java new file mode 100644 index 00000000..cb6a566f --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.port.WorkLogSummaryQueryPort; +import dev.caskeleton.sample.portfolio.application.query.ListRecentWorkLogSummariesQuery; +import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import java.time.LocalDate; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class ListRecentWorkLogSummariesUseCaseTest { + + /** Records whether the read happened inside {@link TransactionPort#inRead} (D4 boundary). */ + private static final class RecordingTransactionPort implements TransactionPort { + boolean inReadCalled; + + @Override + public <T> T inWrite(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inRead(Supplier<T> action) { + inReadCalled = true; + return action.get(); + } + + @Override + public <T> T inNew(Supplier<T> action) { + return action.get(); + } + } + + @Test + void delegatesToQueryPortInsideReadTransaction() { + WorkLogSummary summary = + new WorkLogSummary( + "0190bd6e-7c3e-7abc-8def-0123456789ab", + "Tuned Hikari pool", + WorkCategory.DATABASE, + LocalDate.of(2025, 1, 1), + null); + WorkLogSummaryQueryPort port = limit -> List.of(summary); + RecordingTransactionPort tx = new RecordingTransactionPort(); + + List<WorkLogSummary> result = + new ListRecentWorkLogSummariesUseCase(port, tx) + .handle(new ListRecentWorkLogSummariesQuery(10)); + + assertThat(result).containsExactly(summary); + assertThat(tx.inReadCalled) + .as("projection read must run inside TransactionPort.inRead (D4 default)") + .isTrue(); + } + + @Test + void forwardsTheRequestedLimitToThePort() { + int[] seenLimit = {-1}; + WorkLogSummaryQueryPort port = + limit -> { + seenLimit[0] = limit; + return List.of(); + }; + + new ListRecentWorkLogSummariesUseCase(port, new RecordingTransactionPort()) + .handle(new ListRecentWorkLogSummariesQuery(7)); + + assertThat(seenLimit[0]).isEqualTo(7); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java new file mode 100644 index 00000000..3ee8d6a4 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java @@ -0,0 +1,294 @@ +package dev.caskeleton.sample.portfolio.application.worklog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.github.f4b6a3.uuid.UuidCreator; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.command.UpdateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; +import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; +import dev.caskeleton.sample.portfolio.application.query.ListWorkLogsQuery; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; +import dev.caskeleton.shared.request.Patch; +import java.time.Clock; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class WorkLogUseCasesTest { + + /** A valid UUID guaranteed absent from an empty FakeRepo. */ + private static final WorkLogId ABSENT_ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + /** Server-assigned id source (D5) — distinct monotonic UUIDv7s per create. */ + private static final WorkLogIdFactory IDS = + () -> WorkLogId.of(UuidCreator.getTimeOrderedEpochPlus1().toString()); + + static class FakeRepo implements WorkLogRepository { + final List<WorkLog> store = new ArrayList<>(); + + public WorkLog save(WorkLog w) { + store.removeIf(x -> x.id().equals(w.id())); + store.add(w); + return w; + } + + public Optional<WorkLog> findById(WorkLogId id) { + return store.stream().filter(x -> x.id().equals(id)).findFirst(); + } + + public WorkLogPage findPage( + int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) { + int safeSize = size <= 0 ? 20 : size; + List<WorkLog> filtered = + store.stream().filter(x -> category == null || x.category() == category).toList(); + List<WorkLog> pageItems = + filtered.stream().skip((long) Math.max(0, page) * safeSize).limit(safeSize).toList(); + return new WorkLogPage(pageItems, filtered.size()); + } + + public boolean existsById(WorkLogId id) { + return findById(id).isPresent(); + } + + public void deleteById(WorkLogId id) { + store.removeIf(x -> x.id().equals(id)); + } + } + + static final TransactionPort TX = + new TransactionPort() { + public <T> T inWrite(Supplier<T> a) { + return a.get(); + } + + public <T> T inRead(Supplier<T> a) { + return a.get(); + } + + public <T> T inNew(Supplier<T> a) { + return a.get(); + } + }; + + /** No-op outbox port — existing tests focus on use-case behaviour, not outbox wiring. */ + static final OutboxAppendPort NO_OP_OUTBOX = e -> {}; + + /** Deterministic event-id stub for existing tests. */ + static final OutboxEventIdFactory STUB_EVENT_IDS = () -> "01FAKEEVENTIDTSV4RRFFQ6900"; + + static final Clock UTC_CLOCK = Clock.systemUTC(); + + private CreateWorkLogCommand createCmd(String title) { + return new CreateWorkLogCommand( + "sample-owner", + title, + WorkCategory.DATABASE, + "s", + "c", + List.of("postgres"), + List.of(), + LocalDate.of(2025, 1, 1), + null); + } + + @Test + void createThenGetReturnsSaved() { + FakeRepo repo = new FakeRepo(); + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + .handle(createCmd("DB 튜닝")); + WorkLog got = new GetWorkLogUseCase(repo, TX).handle(new GetWorkLogQuery(created.id())); + assertThat(got.title()).isEqualTo("DB 튜닝"); + } + + @Test + void createSetsOwnerFromCommandPrincipal() { + FakeRepo repo = new FakeRepo(); + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + .handle( + new CreateWorkLogCommand( + "sub-7", + "owner demo", + WorkCategory.DATABASE, + "s", + "c", + List.of("postgres"), + List.of(), + LocalDate.of(2025, 1, 1), + null)); + assertThat(created.owner().value()).isEqualTo("sub-7"); + } + + @Test + void getMissingThrowsNotFound() { + FakeRepo repo = new FakeRepo(); + assertThatThrownBy(() -> new GetWorkLogUseCase(repo, TX).handle(new GetWorkLogQuery(ABSENT_ID))) + .isInstanceOf(WorkLogNotFoundException.class); + } + + @Test + void updateAppliesPatchThreeState() { + FakeRepo repo = new FakeRepo(); + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + .handle(createCmd("old")); + WorkLog updated = + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + created.id(), + Patch.of("new title"), + Patch.absent(), + Patch.of("new content"), + Patch.absent(), + Patch.absent())); + assertThat(updated.title()).isEqualTo("new title"); + assertThat(updated.content()).isEqualTo("new content"); + } + + @Test + void updateCanAdvanceStatusButClosedWorklogRejectsLaterMutation() { + FakeRepo repo = new FakeRepo(); + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + .handle(createCmd("status demo")); + WorkLog inProgress = + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + created.id(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.of(WorkLogStatus.IN_PROGRESS))); + WorkLog closed = + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + inProgress.id(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.of(WorkLogStatus.CLOSED))); + + assertThat(closed.status()).isEqualTo(WorkLogStatus.CLOSED); + assertThatThrownBy( + () -> + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + closed.id(), + Patch.of("late edit"), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent()))) + .isInstanceOf(WorkLogInvariantException.class) + .extracting(ex -> ((WorkLogInvariantException) ex).reason()) + .isEqualTo(WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION); + } + + @Test + void updateRejectsRevertingStatusToOpen() { + FakeRepo repo = new FakeRepo(); + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + .handle(createCmd("status revert")); + WorkLog inProgress = + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + created.id(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.of(WorkLogStatus.IN_PROGRESS))); + + assertThatThrownBy( + () -> + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + inProgress.id(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.absent(), + Patch.of(WorkLogStatus.OPEN)))) + .isInstanceOf(WorkLogInvariantException.class) + .extracting(ex -> ((WorkLogInvariantException) ex).reason()) + .isEqualTo(WorkLogInvariantException.Reason.INVALID_STATUS_TRANSITION); + } + + @Test + void listReturnsPageWithTotal() { + FakeRepo repo = new FakeRepo(); + CreateWorkLogUseCase create = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX); + create.handle(createCmd("a")); + create.handle(createCmd("b")); + var page = + new ListWorkLogsUseCase(repo, TX).handle(new ListWorkLogsQuery(0, 10, null, true, null)); + assertThat(page.items()).hasSize(2); + assertThat(page.total()).isEqualTo(2); + } + + @Test + void batchCreatePersistsEveryItemAtomically() { + FakeRepo repo = new FakeRepo(); + var batch = + new BatchCreateWorkLogsUseCase(repo, IDS, TX) + .handle(new BatchCreateWorkLogsCommand(List.of(createCmd("one"), createCmd("two")))); + assertThat(batch).hasSize(2); + assertThat(repo.store).hasSize(2); + } + + @Test + void updateExplicitNullClearsNullableField() { + FakeRepo repo = new FakeRepo(); + WorkLog created = + new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX) + .handle(createCmd("t")); + WorkLog updated = + new UpdateWorkLogUseCase(repo, TX) + .handle( + new UpdateWorkLogCommand( + created.id(), + Patch.absent(), + Patch.ofNull(), + Patch.absent(), + Patch.absent(), + Patch.absent())); + assertThat(updated.summary()).isEqualTo(""); // updateSummary(null) normalizes to "" + } + + @Test + void deleteMissingThrowsNotFound() { + FakeRepo repo = new FakeRepo(); + assertThatThrownBy( + () -> new DeleteWorkLogUseCase(repo, TX).handle(new DeleteWorkLogCommand(ABSENT_ID))) + .isInstanceOf(WorkLogNotFoundException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java new file mode 100644 index 00000000..48460e32 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java @@ -0,0 +1,224 @@ +package dev.caskeleton.sample.portfolio.authz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; +import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.Import; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * End-to-end demonstration of the authorization contract (§5): real, method-secured use case beans + * are invoked through the Spring AOP proxy with an authenticated principal in the {@code + * SecurityContext}, proving the 3-tier model — public ⊂ authenticated ⊂ authorized — in code. + * + * <p>Enforcement runs at the method-security layer ({@link MethodSecurityConfig}), so this test + * deliberately bypasses the HTTP filter chain (no JWT decoding / issuer needed) and exercises the + * {@code @RequiresPermission} → {@code AuthorizationPort} → {@code RolePermissionRegistry} path + * directly. The fixture mirrors the production role bundles: {@code user} = {worklog:read, + * worklog:write}; {@code admin} additionally holds {@code worklog:close}. + */ +@SpringBootTest( + classes = WorkLogAuthorizationContractTest.AuthzTestConfig.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE) +class WorkLogAuthorizationContractTest { + + private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + + @Autowired CreateWorkLogUseCase createUseCase; + @Autowired DeleteWorkLogUseCase deleteUseCase; + + @MockitoBean WorkLogRepository repository; + @MockitoBean WorkLogIdFactory idFactory; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateWithRoles(String... roles) { + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles)); + TestingAuthenticationToken token = new TestingAuthenticationToken(principal, "n/a"); + token.setAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(token); + } + + private static CreateWorkLogCommand createCommand() { + return new CreateWorkLogCommand( + "sub-1", + "authz demo", + WorkCategory.DATABASE, + "s", + "c", + List.of("postgres"), + List.of(), + LocalDate.of(2025, 1, 1), + null); + } + + @Test + void authenticatedUserWithWritePermissionMayCreate() { + authenticateWithRoles("user"); + when(idFactory.newId()).thenReturn(WorkLogId.of(ID)); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + WorkLog created = createUseCase.handle(createCommand()); + + assertThat(created).isNotNull(); + verify(repository).save(any()); + } + + @Test + void authenticatedUserWithoutClosePermissionIsDeniedDelete() { + // tier-3: authenticated ≠ authorized. `user` lacks `worklog:close`. + authenticateWithRoles("user"); + + assertThatThrownBy(() -> deleteUseCase.handle(new DeleteWorkLogCommand(WorkLogId.of(ID)))) + .isInstanceOf(AccessDeniedException.class); + // enforcement happens before the use-case body — the repository is never touched. + verify(repository, never()).deleteById(any()); + verify(repository, never()).existsById(any()); + } + + @Test + void adminWithClosePermissionMayDelete() { + authenticateWithRoles("admin"); + when(repository.existsById(any())).thenReturn(true); + + assertThatCode(() -> deleteUseCase.handle(new DeleteWorkLogCommand(WorkLogId.of(ID)))) + .doesNotThrowAnyException(); + + verify(repository).deleteById(any()); + } + + @Test + void unauthenticatedCallerIsDeniedFailClosed() { + // No authentication in the context. Method security is a defense-in-depth backstop: + // it rejects the missing authentication outright (AuthenticationException) rather than + // reaching a permission check. In production the HTTP filter chain + // (.anyRequest().authenticated()) returns 401 before a request ever reaches here; this + // proves the use case is never silently invoked without an authenticated principal. + assertThatThrownBy(() -> deleteUseCase.handle(new DeleteWorkLogCommand(WorkLogId.of(ID)))) + .isInstanceOf(AuthenticationException.class); + verify(repository, never()).deleteById(any()); + } + + // Force CGLIB class proxies, mirroring Spring Boot's AOP default + // (spring.aop.proxy-target-class=true). The production app gets this from + // AopAutoConfiguration; this isolated slice has no auto-config, so without it the + // method-security advisor would create JDK interface proxies that are not assignable + // to the concrete *UseCase types the controller (and this test) inject. + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(MethodSecurityConfig.class) + static class AuthzTestConfig { + + @Bean + RolePermissionRegistry rolePermissionRegistry() { + return new RolePermissionRegistry( + new RolePermissionPolicy( + Map.of( + "user", List.of("worklog:read", "worklog:write"), + "admin", List.of("worklog:read", "worklog:write", "worklog:close")))); + } + + @Bean + AuthorizationAdapter authorizationAdapter(RolePermissionRegistry registry) { + return new AuthorizationAdapter(registry); + } + + /** Pass-through transaction boundary — the contract under test is authorization, not tx. */ + @Bean + TransactionPort transactionPort() { + return new TransactionPort() { + @Override + public <T> T inWrite(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inRead(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inNew(Supplier<T> action) { + return action.get(); + } + }; + } + + @Bean + OutboxEventIdFactory outboxEventIdFactory() { + return () -> "0190c9a2-1b3c-7def-8abc-1234567890ab"; + } + + @Bean + OutboxAppendPort outboxAppendPort() { + // No-op for authorization tests — the contract under test is auth, not outbox. + return (NewOutboxEvent e) -> {}; + } + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + CreateWorkLogUseCase createWorkLogUseCase( + WorkLogRepository repository, + WorkLogIdFactory idFactory, + OutboxEventIdFactory eventIdFactory, + OutboxAppendPort outboxAppendPort, + Clock clock, + TransactionPort tx) { + return new CreateWorkLogUseCase( + repository, idFactory, eventIdFactory, outboxAppendPort, clock, tx); + } + + @Bean + DeleteWorkLogUseCase deleteWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { + return new DeleteWorkLogUseCase(repository, tx); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java new file mode 100644 index 00000000..e796c752 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java @@ -0,0 +1,176 @@ +package dev.caskeleton.sample.portfolio.authz; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; +import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.WorkLogIdSerializer; +import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Negative + positive authorization E2E over the <em>real</em> {@link WorkLogController} DELETE + * endpoint (feature-authentication-authorization-contract §4/§5 — full HTTP path). + * + * <p>This is the path the unit/slice tests could not cover on their own: an HTTP request → MVC + * dispatch → method-security interception of the proxied use case → application {@code + * AuthorizationDeniedException} → Spring {@code AccessDeniedException} → {@link + * GlobalExceptionHandler#handleForbidden} → {@code AUTHZ_INSUFFICIENT_PERMISSION} 403 envelope on + * the wire. The negative case ({@code user} cannot delete) and the positive control ({@code admin} + * can) together prove the wiring end to end. + * + * <p>The HTTP security filter chain is intentionally out of scope here (no JWT decoding): the + * authenticated principal is placed directly in the {@code SecurityContext}, which the + * method-security interceptor reads on the synchronous request thread. In production the filter + * chain authenticates first; this test isolates the <em>authorization</em> leg. + */ +@WebMvcTest( + controllers = WorkLogController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class) +@AutoConfigureMockMvc(addFilters = false) +@Import({ + WorkLogController.class, + GlobalExceptionHandler.class, + EnvelopeBodyAdvice.class, + JacksonNullableConfig.class, + WorkLogIdSerializer.class, + WorkLogAuthorizationE2ETest.AuthzE2EConfig.class +}) +class WorkLogAuthorizationE2ETest { + + private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + + @Autowired MockMvc mvc; + + // The DeleteWorkLogUseCase @Bean is real (proxied); its repository is a reset-between-tests mock. + @MockitoBean WorkLogRepository repository; + + // controller constructor collaborators that the DELETE path does not exercise + @MockitoBean CreateWorkLogUseCase createUseCase; + @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; + @MockitoBean UpdateWorkLogUseCase updateUseCase; + @MockitoBean GetWorkLogUseCase getUseCase; + @MockitoBean ListWorkLogsUseCase listUseCase; + @MockitoBean GetRepoStatsUseCase repoStatsUseCase; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateWithRoles(String... roles) { + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles)); + TestingAuthenticationToken token = new TestingAuthenticationToken(principal, "n/a"); + token.setAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(token); + } + + @Test + void userDeletingAWorklogGets403InsufficientPermissionEnvelope() throws Exception { + authenticateWithRoles("user"); // has worklog:write, NOT worklog:close + + mvc.perform(delete("/worklogs/{id}", ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("AUTHZ_INSUFFICIENT_PERMISSION")) + .andExpect(jsonPath("$.error.category").value("AUTHZ")) + .andExpect(jsonPath("$.error.retryable").value(false)); + + // enforcement happened before the use-case body — the repository is untouched. + verify(repository, never()).deleteById(any()); + } + + @Test + void adminDeletingAWorklogSucceedsWith204() throws Exception { + authenticateWithRoles("admin"); // has worklog:close + when(repository.existsById(any())).thenReturn(true); + + mvc.perform(delete("/worklogs/{id}", ID)).andExpect(status().isNoContent()); + + verify(repository).deleteById(any()); + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(MethodSecurityConfig.class) + static class AuthzE2EConfig { + + @Bean + RolePermissionRegistry rolePermissionRegistry() { + return new RolePermissionRegistry( + new RolePermissionPolicy( + Map.of( + "user", List.of("worklog:read", "worklog:write"), + "admin", List.of("worklog:read", "worklog:write", "worklog:close")))); + } + + @Bean + AuthorizationAdapter authorizationAdapter(RolePermissionRegistry registry) { + return new AuthorizationAdapter(registry); + } + + @Bean + TransactionPort transactionPort() { + return new TransactionPort() { + @Override + public <T> T inWrite(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inRead(Supplier<T> action) { + return action.get(); + } + + @Override + public <T> T inNew(Supplier<T> action) { + return action.get(); + } + }; + } + + @Bean + DeleteWorkLogUseCase deleteWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { + return new DeleteWorkLogUseCase(repository, tx); + } + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java new file mode 100644 index 00000000..ed124115 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java @@ -0,0 +1,116 @@ +package dev.caskeleton.sample.portfolio.bootstrap.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.caskeleton.adapter.inbound.web.auth.SecurityConfig; +import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; +import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.sample.portfolio.SamplePortfolioApplication; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; +import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.WorkLogIdSerializer; +import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; +import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; +import dev.caskeleton.sample.portfolio.domain.worklog.Period; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(classes = SamplePublicAccessSecurityConfigTest.TestBootstrap.class) +@AutoConfigureMockMvc +class SamplePublicAccessSecurityConfigTest { + + private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + + @Autowired MockMvc mvc; + + @MockitoBean CreateWorkLogUseCase createUseCase; + @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; + @MockitoBean UpdateWorkLogUseCase updateUseCase; + @MockitoBean DeleteWorkLogUseCase deleteUseCase; + @MockitoBean GetWorkLogUseCase getUseCase; + @MockitoBean ListWorkLogsUseCase listUseCase; + @MockitoBean GetRepoStatsUseCase repoStatsUseCase; + + @Test + void sampleWorklogWriteEndpointIsReachableWithoutAuthorizationHeader() throws Exception { + when(createUseCase.handle(any())).thenReturn(sample("public demo")); + + mvc.perform( + post("/worklogs") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"title\":\"public demo\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) + .andExpect(status().isOk()); + } + + @Test + void sampleCompositionRootDoesNotImportProductionJwtOrMethodSecurity() { + ComponentScan componentScan = + AnnotationUtils.findAnnotation(SamplePortfolioApplication.class, ComponentScan.class); + + assertThat(componentScan).isNotNull(); + assertThat(componentScan.excludeFilters()) + .flatExtracting(filter -> List.of(filter.classes())) + .contains(SecurityConfig.class, MethodSecurityConfig.class); + } + + private static WorkLog sample(String title) { + return WorkLog.rehydrate( + WorkLogId.of(ID), + title, + WorkCategory.DATABASE, + "summary", + "content", + List.of("postgres"), + List.of(), + new Period(LocalDate.of(2025, 1, 1), null), + 0L); + } + + @SpringBootConfiguration + @EnableAutoConfiguration( + exclude = { + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class + }) + @Import({ + WorkLogController.class, + SamplePublicAccessSecurityConfig.class, + GlobalExceptionHandler.class, + DomainExceptionHandler.class, + EnvelopeBodyAdvice.class, + JacksonNullableConfig.class, + WorkLogIdSerializer.class + }) + static class TestBootstrap {} +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java new file mode 100644 index 00000000..7222aa84 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java @@ -0,0 +1,26 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class PosterIdTest { + + private static final String VALID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; + + @Test + void acceptsCanonicalUuid() { + assertThat(PosterId.of(VALID).value()).isEqualTo(VALID); + } + + @Test + void rejectsMalformedOrNull() { + assertThatThrownBy(() -> PosterId.of("not-a-uuid")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PosterId.of(null)).isInstanceOf(IllegalArgumentException.class); + // an unhyphenated UUID is not canonical — the VO validates the canonical 8-4-4-4-12 form. + assertThatThrownBy(() -> PosterId.of("0190bd6e7c3e7abc8def0123456789ab")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java new file mode 100644 index 00000000..fe6468e3 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.sample.portfolio.domain.poster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class PosterTest { + + private static final PosterId ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + private Poster sample() { + return Poster.create(ID, "여름 세일 배너", "7월 한정"); + } + + @Test + void createStartsInDraftWithNoImageAndNullVersion() { + Poster p = sample(); + assertThat(p.id()).isEqualTo(ID); + assertThat(p.title()).isEqualTo("여름 세일 배너"); + assertThat(p.status()).isEqualTo(PosterStatus.DRAFT); + assertThat(p.hasImage()).isFalse(); + assertThat(p.version()).isNull(); + } + + @Test + void nullTitleViolatesInvariantNotNpe() { + // client-facing validation must surface as a domain invariant (→ 400), never an NPE (→ 500). + assertThatThrownBy(() -> Poster.create(ID, null, "c")) + .isInstanceOf(PosterInvariantException.class) + .extracting(ex -> ((PosterInvariantException) ex).reason()) + .isEqualTo(PosterInvariantException.Reason.TITLE_BLANK); + } + + @Test + void blankTitleViolatesInvariant() { + assertThatThrownBy(() -> Poster.create(ID, " ", "c")) + .isInstanceOf(PosterInvariantException.class) + .extracting(ex -> ((PosterInvariantException) ex).reason()) + .isEqualTo(PosterInvariantException.Reason.TITLE_BLANK); + } + + @Test + void renameChangesTitleAndChangeCaptionNormalizesNull() { + Poster p = sample(); + p.rename("새 제목"); + p.changeCaption(null); + assertThat(p.title()).isEqualTo("새 제목"); + assertThat(p.caption()).isEmpty(); + } + + @Test + void publishWithoutImageIsRejected() { + Poster p = sample(); + assertThatThrownBy(p::publish) + .isInstanceOf(PosterInvariantException.class) + .extracting(ex -> ((PosterInvariantException) ex).reason()) + .isEqualTo(PosterInvariantException.Reason.IMAGE_REQUIRED); + } + + @Test + void attachImageThenPublishTransitionsToPublished() { + Poster p = sample(); + p.attachImage("posters/x/image"); + p.publish(); + assertThat(p.status()).isEqualTo(PosterStatus.PUBLISHED); + assertThat(p.hasImage()).isTrue(); + } + + @Test + void draftCanBeArchivedDirectly() { + Poster p = sample(); + p.archive(); + assertThat(p.status()).isEqualTo(PosterStatus.ARCHIVED); + } + + @Test + void archivedIsTerminalSoRepublishIsRejected() { + Poster p = sample(); + p.attachImage("posters/x/image"); + p.publish(); + p.archive(); + assertThatThrownBy(p::publish) + .isInstanceOf(PosterInvariantException.class) + .extracting(ex -> ((PosterInvariantException) ex).reason()) + .isEqualTo(PosterInvariantException.Reason.INVALID_STATUS_TRANSITION); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java new file mode 100644 index 00000000..8a35fc72 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class PeriodTest { + + @Test + void ongoingPeriodHasNullEnd() { + Period p = new Period(LocalDate.of(2025, 1, 1), null); + assertThat(p.isOngoing()).isTrue(); + } + + @Test + void endBeforeStartIsRejected() { + assertThatThrownBy(() -> new Period(LocalDate.of(2025, 5, 1), LocalDate.of(2025, 1, 1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("end"); + } + + @Test + void startIsRequired() { + assertThatThrownBy(() -> new Period(null, null)).isInstanceOf(NullPointerException.class); + } + + // feature-business-rule-validation-contract C2 / D2: the business invariant + // ("end must not be before start") is enforced inside the domain value object's + // canonical constructor. Period is an immutable record, so the compact canonical + // constructor is the SOLE construction path and there is no setter to bypass it — + // no application-service or persistence path can hand out an invariant-violating + // Period. This is the "intentional bypass test" the branch note's 테스트 계약 requires. + @Test + void invariantIsEnforcedOnTheSoleConstructionPathAndCannotBeBypassed() { + assertThatThrownBy(() -> new Period(LocalDate.of(2030, 12, 31), LocalDate.of(2030, 1, 1))) + .as("invariant must reject end-before-start on the sole (record) construction path (C2)") + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java new file mode 100644 index 00000000..28a934a9 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java @@ -0,0 +1,75 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Assume; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.jqwik.api.constraints.StringLength; + +/** + * Property-based proof that the {@code @ValueObject} {@link WorkLogId} enforces its format + * invariant on the sole construction path, across many generated inputs + * (feature-domain-modeling-guardrails Claims To Verify — VO invariant). A single example-based test + * can only show the invariant holds for the cases the author happened to pick; jqwik exercises the + * whole input space. + */ +class WorkLogIdPropertyTest { + + private static final String UUID_PATTERN = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + + /** Canonical UUID form (RFC 9562): 32 hex chars grouped 8-4-4-4-12. */ + @Provide + Arbitrary<String> canonicalUuids() { + return Arbitraries.strings() + .withChars("0123456789abcdefABCDEF") + .ofLength(32) + .map(WorkLogIdPropertyTest::hyphenate); + } + + @Property + void acceptsEveryCanonicalUuid(@ForAll("canonicalUuids") String value) { + assertThat(WorkLogId.of(value).value()).isEqualTo(value); + } + + @Property + void rejectsAnyNonCanonicalInput(@ForAll @StringLength(min = 0, max = 40) String value) { + // Filter out the (vanishingly rare) randomly-canonical strings so this property + // only asserts the rejection path. + Assume.that(!value.matches(UUID_PATTERN)); + + assertThatThrownBy(() -> WorkLogId.of(value)).isInstanceOf(IllegalArgumentException.class); + } + + /** 32 non-hex letters hyphenated to the UUID shape — always invalid (g-z are not hex). */ + @Provide + Arbitrary<String> nonHexUuidShapedStrings() { + return Arbitraries.strings() + .withChars("ghijklmnopqrstuvwxyz") + .ofLength(32) + .map(WorkLogIdPropertyTest::hyphenate); + } + + @Property + void rejectsNonHexLetters(@ForAll("nonHexUuidShapedStrings") String value) { + assertThatThrownBy(() -> WorkLogId.of(value)).isInstanceOf(IllegalArgumentException.class); + } + + /** Inserts hyphens into 32 chars to form the canonical 8-4-4-4-12 UUID layout. */ + private static String hyphenate(String hex) { + return hex.substring(0, 8) + + "-" + + hex.substring(8, 12) + + "-" + + hex.substring(12, 16) + + "-" + + hex.substring(16, 20) + + "-" + + hex.substring(20, 32); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java new file mode 100644 index 00000000..39259a09 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class WorkLogIdTest { + + private static final String VALID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; // D19 fixture + + @Test + void acceptsCanonicalUuid() { + assertThat(WorkLogId.of(VALID).value()).isEqualTo(VALID); + } + + @Test + void rejectsNull() { + assertThatThrownBy(() -> WorkLogId.of(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsUppercaseHexVariant() { + // The VO regex is case-insensitive over hex; canonical lowercasing happens at the web edge. + assertThat(WorkLogId.of(VALID.toUpperCase()).value()).isEqualTo(VALID.toUpperCase()); + } + + @Test + void rejectsNonHexLetters() { + assertThatThrownBy(() -> WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789zz")) // z is not hex + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsWrongLength() { + assertThatThrownBy(() -> WorkLogId.of("0190bd6e-7c3e")) // too short + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsUnhyphenatedUuid() { + assertThatThrownBy( + () -> WorkLogId.of("0190bd6e7c3e7abc8def0123456789ab")) // no 8-4-4-4-12 groups + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsCrockfordUlidFormat() { + // the previous 26-char Crockford ULID form is no longer a valid id + assertThatThrownBy(() -> WorkLogId.of("01ARZ3NDEKTSV4RRFFQ69G5FAV")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java new file mode 100644 index 00000000..a212b481 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java @@ -0,0 +1,63 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.shared.error.ApiErrorCode; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Verifies the WorkLog aggregate invariant surfaces as a domain exception carrying a safe reason + * (feature-domain-modeling-guardrails D3/D2): the domain neither logs nor knows operational error + * codes. + */ +class WorkLogInvariantTest { + + private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + private static WorkLog sample(String title) { + return WorkLog.create( + ID, + title, + WorkCategory.BACKEND, + "s", + "c", + List.of(), + List.of(), + new Period(LocalDate.of(2025, 1, 1), null)); + } + + @Test + void blankTitleIsRejectedWithASafeReasonOnCreate() { + assertThatThrownBy(() -> sample(" ")) + .isInstanceOf(WorkLogInvariantException.class) + .extracting(ex -> ((WorkLogInvariantException) ex).reason()) + .isEqualTo(WorkLogInvariantException.Reason.TITLE_BLANK); + } + + @Test + void renameToBlankIsRejected() { + WorkLog w = sample("ok"); + assertThatThrownBy(() -> w.rename("")).isInstanceOf(WorkLogInvariantException.class); + } + + @Test + void nullTitleStaysANullCheckNotAnInvariantViolation() { + // The "required" check (NPE) and the "must not be blank" invariant are distinct. + assertThatThrownBy(() -> sample(null)).isInstanceOf(NullPointerException.class); + } + + // D2: the domain invariant exception carries only a safe domain reason — it must NOT + // implement the operational ApiErrorCode contract. Translating reason() into an + // error.category / log line is the application/web layer's responsibility. + @Test + void invariantExceptionDoesNotKnowOperationalErrorCodes() { + WorkLogInvariantException ex = + new WorkLogInvariantException(WorkLogInvariantException.Reason.TITLE_BLANK); + + assertThat(ex).isNotInstanceOf(ApiErrorCode.class); + assertThat(ex.reason()).isEqualTo(WorkLogInvariantException.Reason.TITLE_BLANK); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java new file mode 100644 index 00000000..893e22a9 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.sample.portfolio.domain.worklog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +class WorkLogTest { + + private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + private WorkLog sample() { + return WorkLog.create( + ID, + "DB 쿼리 튜닝", + WorkCategory.DATABASE, + "slow query 개선", + "...", + List.of("postgres"), + List.of(), + new Period(LocalDate.of(2025, 1, 1), null)); + } + + @Test + void createKeepsAssignedIdAndFields() { + WorkLog w = sample(); + assertThat(w.id()).isEqualTo(ID); + assertThat(w.title()).isEqualTo("DB 쿼리 튜닝"); + assertThat(w.category()).isEqualTo(WorkCategory.DATABASE); + } + + @Test + void titleIsRequired() { + assertThatThrownBy( + () -> + WorkLog.create( + ID, + null, + WorkCategory.DATABASE, + "s", + "c", + List.of(), + List.of(), + new Period(LocalDate.now(), null))) + .isInstanceOf(NullPointerException.class); + } + + @Test + void renameChangesTitle() { + WorkLog w = sample(); + w.rename("새 제목"); + assertThat(w.title()).isEqualTo("새 제목"); + } + + @Test + void createStartsOpenAndKeepsOwner() { + WorkLog w = sample(); + assertThat(w.status()).isEqualTo(WorkLogStatus.OPEN); + assertThat(w.owner()).isEqualTo(WorkLogOwner.of("sample-owner")); + } + + @Test + void statusTransitionIsOpenToInProgressToClosed() { + WorkLog w = sample(); + w.startProgress(); + assertThat(w.status()).isEqualTo(WorkLogStatus.IN_PROGRESS); + + w.close(); + assertThat(w.status()).isEqualTo(WorkLogStatus.CLOSED); + } + + @Test + void closedWorklogRejectsContentMutation() { + WorkLog w = sample(); + w.startProgress(); + w.close(); + + assertThatThrownBy(() -> w.rename("닫힌 작업 수정")) + .isInstanceOf(WorkLogInvariantException.class) + .extracting(ex -> ((WorkLogInvariantException) ex).reason()) + .isEqualTo(WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION); + } +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java new file mode 100644 index 00000000..e6c7642b --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.fixtures; + +import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; + +/** + * Shared sample fixtures (feature-resource-identifier-contract §8/D19). + * + * <p>{@link #WORK_LOG_ID} is the canonical reference UUID cross-cited by + * feature-api-contract-baseline (URL path variable example) and the project note's §17 Sample + * Domain Fixture / §22 Sample-portfolio Contract Matrix. + */ +public final class SamplePortfolioFixture { + + /** D19: canonical 36-char UUID (RFC 9562 UUIDv7) reference value. */ + public static final WorkLogId WORK_LOG_ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); + + private SamplePortfolioFixture() {} +} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java new file mode 100644 index 00000000..536fd198 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java @@ -0,0 +1,18 @@ +package dev.caskeleton.sample.portfolio.testinfra; + +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** Creates sample integration-test databases with reuse off unless local opt-in is explicit. */ +public final class PostgreSqlTestContainer { + + public static final String IMAGE = "postgres:16-alpine"; + private static final String REUSE_ENV = "TESTCONTAINERS_REUSE_ENABLE"; + + private PostgreSqlTestContainer() {} + + @SuppressWarnings("resource") + public static PostgreSQLContainer create() { + boolean localReuse = Boolean.parseBoolean(System.getenv(REUSE_ENV)); + return new PostgreSQLContainer(IMAGE).withReuse(localReuse); + } +} diff --git a/src/sample-portfolio/src/test/resources/application-test.yml b/src/sample-portfolio/src/test/resources/application-test.yml new file mode 100644 index 00000000..1e51a66e --- /dev/null +++ b/src/sample-portfolio/src/test/resources/application-test.yml @@ -0,0 +1,200 @@ +# ============================================================================= +# Test profile overrides for SampleApplicationContextTest and any future +# sample-portfolio slice tests that need the full context. +# +# Testcontainers provides a real Postgres; datasource properties are registered +# via @DynamicPropertySource in SampleApplicationContextTest. +# All ${ENV_VAR} placeholders without a .env file are resolved here with safe defaults. +# ============================================================================= + +spring: + application: + name: sample-portfolio-test + mvc: + problemdetails: + enabled: false + web: + error: + include-stacktrace: never + include-message: never + # Testcontainers datasource is set via @DynamicPropertySource in SampleApplicationContextTest. + # Provide safe non-networked defaults for any other test that does not use Testcontainers. + datasource: + url: jdbc:postgresql://localhost:5432/sample_test + username: sample_test + password: sample_test + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 5 + minimum-idle: 1 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + keepalive-time: 120000 + leak-detection-threshold: 30000 + validation-timeout: 3000 + initialization-fail-timeout: 1 + flyway: + baseline-on-migrate: false + out-of-order: false + clean-disabled: true + locations: classpath:db/migration,classpath:db/sample-migration + jpa: + hibernate: + ddl-auto: none + show-sql: false + open-in-view: false + properties: + hibernate: + format_sql: false + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8081/realms/sample-test + audiences: sample-test + main: + banner-mode: off + lazy-initialization: false + log-startup-info: false + threads: + virtual: + enabled: false + jackson: + deserialization: + fail-on-unknown-properties: true + fail-on-null-for-primitives: true + fail-on-ignored-properties: true + datatype: + enum: + read-unknown-enum-values-as-null: false + datetime: + write-dates-as-timestamps: false + lifecycle: + timeout-per-shutdown-phase: 30s + +management: + # Random port to avoid conflicts with the production default 9001. + server: + port: 0 + +server: + port: 0 + shutdown: immediate + forward-headers-strategy: none + tomcat: + threads: + max: 10 + min-spare: 2 + accept-count: 10 + max-connections: 100 + connection-timeout: 20s + compression: + enabled: false + min-response-size: 1024 +logging: + level: + root: WARN + dev.caskeleton: WARN + org.springframework: WARN + org.springframework.web: WARN + org.hibernate.SQL: WARN + +ca-skeleton: + bootstrap: + app-name: sample-portfolio-test + runtime: + error-detail-exposure-enabled: false + log-body-capture-enabled: false + multi-instance-enabled: false + lock: + wait-time: 3s + lease-ttl: 30s + presentation: + api-base-path: /api + rate-limit: + enabled: false + limit: 100 + window: 1s + algorithm: fixed-window + idempotency: + ttl: 24h + reaper-interval: 10m + security: + issuer-uri: http://localhost:8081/realms/sample-test + audience: sample-test + public-paths: /api/healthcheck + authz: + role-permissions: + user: worklog:read,worklog:write + admin: worklog:read,worklog:write,worklog:close + cors: + enabled: true + allowed-origins: http://localhost:3000 + allowed-methods: "" + allowed-headers: "*" + allow-credentials: true + max-age-seconds: 3600 + logging: + file: + enabled: false + path: logs/sample-portfolio-test.json + max-size: 100MB + max-history: 14 + total-size-cap: 3GB + async: + enabled: false + queue-size: 512 + discarding-threshold: 20 + json: + timezone: UTC + timestamp-pattern: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" + include-caller-data: false + logger-name-length: 0 + sampling-rate: 1.0 + privacy: + pseudonymization-salt: __LOCAL_DEV_test_salt + tracing: + enabled: true + sample-rate: "" + exporter: + otlp-endpoint: "" + outbox: + relay-enabled: false + poll-interval: PT5S + batch-size: 20 + in-flight-timeout: PT5M + reaper-interval: PT10M + published-retention: P7D + +app: + cache: + redis: + enabled: false + messaging: + broker: "" + kafka: + brokers: "" + notification: + slack: + provider: "" + email: + provider: "" + outbound: + http: + connect-timeout: 2s + read-timeout: 5s + global-call-timeout: 10s + retry-enabled: false + retry: + max-attempts: 3 + initial-backoff: 100ms + backoff-multiplier: 2.0 + circuit-breaker-enabled: false + circuit-breaker: + failure-rate-threshold: 50 + sliding-window-size: 100 + minimum-number-of-calls: 100 + wait-duration-in-open-state: 60s + permitted-calls-in-half-open: 10 + response-size-limit: 10MB diff --git a/src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json b/src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json new file mode 100644 index 00000000..8ea1e665 --- /dev/null +++ b/src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json @@ -0,0 +1,523 @@ +{ + "components" : { + "schemas" : { + "ApiError" : { + "properties" : { + "category" : { + "type" : "string" + }, + "code" : { + "type" : "string" + }, + "details" : { + "type" : "object" + }, + "message" : { + "type" : "string" + }, + "retryable" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "BatchCreateRequest" : { + "properties" : { + "requests" : { + "items" : { + "$ref" : "#/components/schemas/CreateWorkLogRequest" + }, + "maxItems" : 1000, + "minItems" : 0, + "type" : "array" + } + }, + "type" : "object" + }, + "CreateWorkLogRequest" : { + "properties" : { + "category" : { + "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], + "type" : "string" + }, + "content" : { + "type" : "string" + }, + "links" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "periodEnd" : { + "format" : "date", + "type" : "string" + }, + "periodOrdered" : { + "type" : "boolean" + }, + "periodStart" : { + "format" : "date", + "type" : "string" + }, + "summary" : { + "type" : "string" + }, + "techStack" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "title" : { + "type" : "string" + } + }, + "type" : "object" + }, + "EnvelopeListWorkLogSummaryResponse" : { + "properties" : { + "data" : { + "items" : { + "$ref" : "#/components/schemas/WorkLogSummaryResponse" + }, + "type" : "array" + }, + "error" : { + "$ref" : "#/components/schemas/ApiError" + }, + "meta" : { + "$ref" : "#/components/schemas/ResponseMeta" + }, + "success" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "JsonNullableString" : { + "properties" : { + "present" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "JsonNullableWorkCategory" : { + "properties" : { + "present" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "JsonNullableWorkLogStatus" : { + "properties" : { + "present" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "PageMeta" : { + "properties" : { + "number" : { + "format" : "int32", + "type" : "integer" + }, + "size" : { + "format" : "int32", + "type" : "integer" + }, + "sort" : { + "type" : "string" + }, + "total" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, + "RepoStatsResponse" : { + "properties" : { + "fullName" : { + "type" : "string" + }, + "lastPushedIso" : { + "type" : "string" + }, + "stars" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, + "ResponseMeta" : { + "properties" : { + "correlationId" : { + "type" : "string" + }, + "page" : { + "$ref" : "#/components/schemas/PageMeta" + }, + "requestId" : { + "type" : "string" + }, + "traceId" : { + "type" : "string" + } + }, + "type" : "object" + }, + "UpdateWorkLogRequest" : { + "properties" : { + "category" : { + "$ref" : "#/components/schemas/JsonNullableWorkCategory" + }, + "content" : { + "$ref" : "#/components/schemas/JsonNullableString" + }, + "status" : { + "$ref" : "#/components/schemas/JsonNullableWorkLogStatus" + }, + "summary" : { + "$ref" : "#/components/schemas/JsonNullableString" + }, + "title" : { + "$ref" : "#/components/schemas/JsonNullableString" + } + }, + "type" : "object" + }, + "WorkLogId" : { + "properties" : { + "value" : { + "type" : "string" + } + }, + "type" : "object" + }, + "WorkLogResponse" : { + "properties" : { + "category" : { + "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], + "type" : "string" + }, + "content" : { + "type" : "string" + }, + "id" : { + "$ref" : "#/components/schemas/WorkLogId" + }, + "links" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "periodEnd" : { + "format" : "date", + "type" : "string" + }, + "periodStart" : { + "format" : "date", + "type" : "string" + }, + "status" : { + "enum" : [ "OPEN", "IN_PROGRESS", "CLOSED" ], + "type" : "string" + }, + "summary" : { + "type" : "string" + }, + "techStack" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "title" : { + "type" : "string" + } + }, + "type" : "object" + }, + "WorkLogSummaryResponse" : { + "properties" : { + "category" : { + "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], + "type" : "string" + }, + "id" : { + "$ref" : "#/components/schemas/WorkLogId" + }, + "periodEnd" : { + "format" : "date", + "type" : "string" + }, + "periodStart" : { + "format" : "date", + "type" : "string" + }, + "status" : { + "enum" : [ "OPEN", "IN_PROGRESS", "CLOSED" ], + "type" : "string" + }, + "title" : { + "type" : "string" + } + }, + "type" : "object" + } + } + }, + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "openapi" : "3.1.0", + "paths" : { + "/worklogs" : { + "get" : { + "operationId" : "list", + "parameters" : [ { + "in" : "query", + "name" : "page", + "required" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "in" : "query", + "name" : "size", + "required" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "in" : "query", + "name" : "sort", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "in" : "query", + "name" : "category", + "required" : false, + "schema" : { + "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/EnvelopeListWorkLogSummaryResponse" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + }, + "post" : { + "operationId" : "create", + "parameters" : [ { + "in" : "header", + "name" : "Idempotency-Key", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CreateWorkLogRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/WorkLogResponse" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + } + }, + "/worklogs/repoStats" : { + "get" : { + "operationId" : "repoStats", + "parameters" : [ { + "in" : "query", + "name" : "repoUrl", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RepoStatsResponse" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + } + }, + "/worklogs/{id}" : { + "delete" : { + "operationId" : "delete", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + }, + "get" : { + "operationId" : "getOne", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "in" : "header", + "name" : "If-None-Match", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/WorkLogResponse" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + }, + "patch" : { + "operationId" : "patch", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "in" : "header", + "name" : "If-Match", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/UpdateWorkLogRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/WorkLogResponse" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + } + }, + "/worklogs:batchCreate" : { + "post" : { + "operationId" : "batchCreate", + "parameters" : [ { + "in" : "header", + "name" : "Idempotency-Key", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/BatchCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/WorkLogResponse" + }, + "type" : "array" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "work-log-controller" ] + } + } + } +} diff --git a/src/settings.gradle b/src/settings.gradle new file mode 100644 index 00000000..6fd4b8b2 --- /dev/null +++ b/src/settings.gradle @@ -0,0 +1,26 @@ +import groovy.json.JsonSlurper + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'ca-skeleton' + +File repositoryRoot = settingsDir.parentFile +File moduleRegistryFile = new File(repositoryRoot, '.harness/project/modules.yaml') +if (!moduleRegistryFile.isFile()) { + throw new GradleException("Missing module registry: ${moduleRegistryFile}") +} + +def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile) +if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) { + throw new GradleException("Module registry has no modules: ${moduleRegistryFile}") +} + +moduleRegistry.modules.each { module -> + if (!(module.gradle_path instanceof String) || !(module.source_path instanceof String)) { + throw new GradleException("Each registry module needs string gradle_path and source_path") + } + include module.gradle_path + project(module.gradle_path).projectDir = new File(repositoryRoot, module.source_path) +} diff --git a/src/shared-contract/CLAUDE.md b/src/shared-contract/CLAUDE.md new file mode 100644 index 00000000..db3868d5 --- /dev/null +++ b/src/shared-contract/CLAUDE.md @@ -0,0 +1,38 @@ +# shared-contract — skeleton-wide operational contract + +## Registered identity + +- Module ID: `shared-contract` +- Gradle path: `:shared-contract` +- Focused test: `./gradlew :shared-contract:test --console=plain` +- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. +- Registry SSOT: `.harness/project/modules.yaml`. + +Package root: `dev.caskeleton.shared`. + +Design decisions previously kept as code comments (error-code classification, response +envelope shape, metric cardinality bounds, tracing seam, domain-context propagation) live in +[README.md](README.md). This file stays the SSOT for module rules. + +## Responsibility +- Response envelope (`response/Envelope`, `response/ApiError`, `response/BulkEnvelope`, `response/BulkItemResult`). +- Error code contract (`error/ApiErrorCode` interface, `error/OperationalError` enum). +- Cross-cutting mapper sentinel (`error/MappingException`). +- Request value contract (`request/Patch`). +- Tracing contract types (`tracing/TraceParent`, `tracing/BaggageAllowlist`, + `tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist, + and the span-error-recording seam (feature-distributed-tracing-contract; the OTel + tracer runtime is a fork-activated seam, so these stay Java-stdlib-only). + +## Allowed +- Java standard library ONLY. No Spring, no Jackson, no JPA imports. + +## Forbidden +- business/domain concept (domain error codes belong in the consuming module). +- framework imports (HTTP status is expressed as transport-neutral `int`; the web module maps it). + +## Test +```bash +cd src +./gradlew :shared-contract:test +``` diff --git a/src/shared-contract/README.md b/src/shared-contract/README.md new file mode 100644 index 00000000..4409d688 --- /dev/null +++ b/src/shared-contract/README.md @@ -0,0 +1,489 @@ +# shared-contract — 설계 결정 참조 + +스켈레톤 전역 운영 계약(operational contract) 모듈. 패키지 루트: `dev.caskeleton.shared`. + +모듈 책임·허용/금지 의존(Java 표준 라이브러리 only)·테스트 명령 같은 **모듈 규칙**은 +[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 +모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 아래 설명은 별도 +추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다. + +이 모듈은 한 가지 규칙을 끝까지 지킨다: **Java 표준 라이브러리만 쓴다.** Spring·Jackson·JPA· +Micrometer·OTel 같은 프레임워크 타입을 import 하지 않는다. 그래서 모든 계층(application·web· +persistence·outbound)이 프레임워크 충돌 없이 이 타입들을 공유할 수 있고, HTTP status 같은 +전송 개념도 프레임워크 타입이 아니라 평범한 `int` 로 표현한다(매핑은 web 어댑터가 한다). + +--- + +## error — 에러 코드 계약 + +### ApiErrorCode (인터페이스) +- **클라이언트에 노출되는, 안정적이고 기계가 읽을 수 있는 에러 코드 계약.** 스켈레톤 공통 코드는 + `OperationalError` 에 있고, 포크한 프로젝트는 이 인터페이스를 구현해(보통 enum) 자기 도메인 코드를 + 더한다. +- **`httpStatus()` 가 프레임워크 타입이 아니라 평범한 `int` 인 이유:** 이 모듈을 프레임워크 중립으로 + 유지하기 위해서다(stdlib-only 규칙). 실제 전송 status 타입으로의 매핑은 web 어댑터가 한다. +- **`retryable` 의 의미:** `true` 면 같은 입력이 나중에 성공할 수 있다는 뜻(일시적 인프라 장애 / + rate limit). `false` 면 입력 자체를 바꿔야 한다. + +### Category (enum) +- **운영 에러 분류 10-value SSOT enum.** 응답에 `error.category` 로 노출되어, 클라이언트가 모든 + 코드를 일일이 열거하지 않고도 굵직하게 분기할 수 있게 해 준다. +- **분류(identity)만 담는다.** 코드별 HTTP status·`retryable` 은 `ApiErrorCode` 구현과 + `error-codes.yaml` 에 있지 여기 있지 않다 — `retryable` 은 **코드별** 값이며 category 에서 + 계산하지 않는다. + +### OperationalError (enum) +스켈레톤 공통 운영/전송/보안 에러 코드의 집합이다. `USER_NOT_FOUND` 같은 **도메인 전용 코드는 +여기 두지 않고** 그 코드를 쓰는 모듈에 둔다. + +- **출처/SSOT:** 모든 코드의 status/category/`retryable` 값은 레지스트리 + `docs/registries/error-codes.yaml` 를 그대로 미러링한다. 코드 추가/변경은 레지스트리가 먼저다. + +**전송 형태(transport-shape) 코드의 분류 — 프로젝트 선택** +- `METHOD_NOT_ALLOWED`(405), `UNSUPPORTED_MEDIA_TYPE`(415), `NOT_ACCEPTABLE`(406), + `PAYLOAD_TOO_LARGE`(413), `URI_TOO_LONG`(414) 는 "요청 형태가 잘못됐다" 류의 전송 계층 에러다. + 10-value enum 에는 이들을 위한 전용 category 가 없어, 가장 가까운 "클라이언트 요청 형태" 버킷인 + `VALIDATION` 에 넣었다. 이를 강제하는 외부 표준은 없다(프로젝트 결정). +- `PRECONDITION_FAILED`(412) 는 `If-Match` 검증 실패, 즉 낙관적 동시성 충돌의 HTTP 표현이다. + 그래서 persistence 계층의 `DB_SERIALIZATION_FAILURE`/`DB_DEADLOCK` 와 같은 식구인 `CONFLICT` + 로 분류한다. + +**보안 코드 — 굵은(coarse) 폴백 vs 세분화(fine-grained)** +- `UNAUTHENTICATED`/`INVALID_TOKEN`/`FORBIDDEN` 은 굵은 폴백 코드다. 시큐리티 필터를 거치지 않는 + 경로(예: 컨트롤러에서 직접 던진 `AccessDeniedException`)를 위해 남겨 둔다. +- `AUTH_*` 세분화 코드는 보안 운영 베이스라인의 AuthN/AuthZ 결정 매트릭스 구현이다. 굵은 3-way + 매핑 대신 레지스트리가 선언한 세분화 코드를 쓰도록 정리한 것으로, 런타임에 리소스 서버의 + AuthenticationEntryPoint / AccessDeniedHandler 가 방출한다. +- `AUTH_KID_UNKNOWN` 은 `retryable=true`: 키 회전(key rotation) 중 알 수 없는 JWKS `kid` 는 키 + 세트가 새로고침되면 저절로 풀린다(레지스트리에서 `false→true` 로 바뀐 이력 있음, Retry-After 5s). +- `AUTH_JWKS_UNAVAILABLE` 은 JWKS 엔드포인트 장애 = 일시적 의존성 실패 → 503, retryable. +- `INTERNAL_AUTH_MISCONFIGURATION` 은 `retryable=false`: 보호돼야 할 엔드포인트가 public 으로 + 새어 나가는 것은 **배포 시점 설정 버그**이지 일시적 장애가 아니다. 같은 요청을 다시 보내도 (재배포 + 전까지) 절대 풀리지 않으므로, "INTERNAL 은 retryable" 이라는 일반 휴리스틱에서 의도적으로 벗어나 + false 로 둔다. + +**rate-limit / idempotency** +- `RATE_LIMIT_EXCEEDED` 는 `retryable=true` 이며 Retry-After 헤더와 짝을 이룬다. +- `IDEMPOTENT_IN_FLIGHT`/`IDEMPOTENT_REQUEST_MISMATCH` 는 결정적인 클라이언트 결과(다시 + poll 하거나 본문을 고쳐야 함)라 `retryable=false`. + +**ADAPTER_DISABLED — 어댑터 런타임 fail-fast** +- 비활성(`app.<domain>.<adapter>.enabled=false`, 기본값) 상태인 선택적 어댑터(Kafka/Redis/Slack/ + Google Email)의 use-case 경로가 호출됐을 때 던지는 런타임 fail-fast 코드다(`AdapterDisabledException` + 매핑 결과). +- 기동 시점의 `REQUIRED_ADAPTER_DISABLED`(owner: 마이그레이션/기동 계약, exit code 72)와 **의도적으로 + 다른 코드**다. 런타임 호출과 기동 검증은 서로 다른 lifecycle 이라, 코드를 재사용하면 두 의미가 + 뭉개진다. `INTERNAL_AUTH_MISCONFIGURATION` 과 마찬가지로 결정적 설정/프로그래밍 버그이지 일시적 + 장애가 아니므로 `retryable=false` — 여전히 비활성인 어댑터를 다시 호출해도 풀리지 않는다. + +**persistence — SQLState → 코드 분류** +- `DB_*` 코드는 adapter-persistence 의 `PersistenceExceptionTranslator` 가 raw Spring + `DataAccessException`/SQLState 를 벗겨 낸 뒤 방출하는 프레임워크 중립 코드다. JPA 예외 자체는 web + 계층까지 절대 도달하지 않는다. +- **`PERSISTENCE` 라는 category 는 없다.** 10-value Category 가 SSOT 이므로 DB 실패도 기존 분류에 + 녹여 넣는다: 연결 끊김/타임아웃 → `TRANSIENT_DEPENDENCY`, 직렬화/데드락/유니크 → `CONFLICT`, + null/FK/check → `DATA_INTEGRITY`. + +| 코드 | SQLState | category | HTTP | retryable | 메모 | +|---|---|---|---|---|---| +| `DB_UNAVAILABLE` | 08* | TRANSIENT_DEPENDENCY | 503 | ✅ | 연결 실패 | +| `DB_SERIALIZATION_FAILURE` | 40001 | CONFLICT | 409 | ✅ | | +| `DB_DEADLOCK` | 40P01 | CONFLICT | 409 | ✅ | backoff 후 재시도 | +| `DB_NULL_VIOLATION` | 23502 | DATA_INTEGRITY | 409 | ❌ | | +| `DB_FK_VIOLATION` | 23503 | DATA_INTEGRITY | 409 | ❌ | | +| `DB_UNIQUE_VIOLATION` | 23505 | CONFLICT | 409 | ❌ | 비즈니스 매핑 | +| `DB_CHECK_VIOLATION` | 23514 | DATA_INTEGRITY | 409 | ❌ | | +| `DB_IDLE_IN_TX_TIMEOUT` | 25P03 | TRANSIENT_DEPENDENCY | 503 | ✅ | | +| `DB_QUERY_CANCELED` | 57014 | TRANSIENT_DEPENDENCY | 503 | ❌ | | + +**outbound HTTP — upstream 실패 분류** +- `DEPENDENCY_*` 코드는 adapter-outbound 의 `OutboundHttpErrorMapper` 가 upstream HTTP/네트워크 + 실패를 분류한 뒤 방출한다. raw upstream 응답은 web 계층까지 도달하지 않는다. +- **알려진 미해결 엣지:** 408(Request Timeout)·429(Too Many Requests)는 의미상 재시도 가능하지만, + 레지스트리 SSOT 는 **모든 upstream 4xx 를 `PERMANENT_DEPENDENCY`(`retryable=false`)로 분류**한다. + 이는 의도적 결정이며, 바꾸려면 레지스트리 갱신과 어댑터 변경을 함께 해야 한다. + +| 코드 | category | HTTP | retryable | 트리거 | +|---|---|---|---|---| +| `DEPENDENCY_TIMEOUT` | TRANSIENT_DEPENDENCY | 504 | ✅ | upstream read/global-call timeout | +| `DEPENDENCY_CONNECT_FAILED` | TRANSIENT_DEPENDENCY | 503 | ✅ | TCP connect 거부/타임아웃 | +| `DEPENDENCY_DNS_FAILED` | TRANSIENT_DEPENDENCY | 503 | ✅ | 이름 해석 실패 | +| `DEPENDENCY_4XX_CLIENT` | PERMANENT_DEPENDENCY | 502 | ❌ | upstream 이 요청 거부(4xx) | +| `DEPENDENCY_5XX_SERVER` | TRANSIENT_DEPENDENCY | 502 | ✅ | upstream 서버 에러(5xx) | +| `DEPENDENCY_CIRCUIT_OPEN` | TRANSIENT_DEPENDENCY | 503 | ✅ | 서킷 브레이커 open / shutdown 거부 fail-fast | + +**transactional outbox** +- outbox 상태 머신: `PENDING → IN_FLIGHT → PUBLISHED | FAILED | DEAD`. +- `OUTBOX_PUBLISH_FAILED`: 일시적 발행 실패 → row 가 FAILED 로 가고 backoff 재시도. 메시지 + 브로커/relay 가 일시적 의존성이라 `TRANSIENT_DEPENDENCY`, `retryable=true`(retry_after 30s). +- `OUTBOX_DEAD_LETTER`: 최대 재시도 소진 → row 가 DEAD(DLQ)로. 수동 개입이 필요하고 같은 발행을 + 다시 해도 풀리지 않으므로 `retryable=false`(`INTERNAL_AUTH_MISCONFIGURATION`/`ADAPTER_DISABLED` + 와 같은 논리: 결정적 종료 상태이지 일시적 장애가 아님). + +**background job / async executor** +- 이 계약이 소유하는 재시도/DLQ 어휘이며 outbox/outbound 계약이 가져다 쓴다. +- `JOB_EXECUTOR_REJECTED`: bounded executor 포화(AbortPolicy 거부). 스레드 풀+큐가 다 차서 생긴 + 일시적 용량 부족으로, 부하가 빠지면 풀린다 → `TRANSIENT_DEPENDENCY`/503, retryable(Retry-After 5s). +- `JOB_TIMEOUT`: 실행 중 job 이 예산(19s graceful-shutdown interrupt 포함)을 초과 → 일시적, + 다음 cycle 에 재시도 → `TRANSIENT_DEPENDENCY`/500, retryable. +- `JOB_DEAD_LETTER`: 재시도 소진 → DLQ 종료 상태. 수동 개입 필요, 같은 job 을 다시 해도 풀리지 + 않음 → `INTERNAL`/500, `retryable=false`(`OUTBOX_DEAD_LETTER` 와 같은 논리). + +**distributed lock** +- `LOCK_ACQUISITION_TIMEOUT`: 분산 락 provider(`JdbcLockRegistry`/in-process `LockRegistry`)가 + 제한된 `waitTime` 안에 락을 얻지 못했을 때 방출(무한 블로킹 없이 try-lock + 유한 waitTime, D5). +- 일반 500 이 아니라 `CONFLICT`+`retryable=true` 인 이유: 락 경합은 일시적이다 — 보유자가 + 임계 구역을 떠나거나 lease TTL 이 만료되면 같은 요청이 락을 얻는다. `DB_DEADLOCK`/ + `DB_SERIALIZATION_FAILURE` 와 같은 재시도 가능 `CONFLICT`(409) 식구다. 이건 효율용 락 타임아웃 + (D6)이며, 정합성 자체는 이 코드가 아니라 DB 제약이 지킨다. + +**기타 운영 코드** +- `JVM_OOM`: JVM OutOfMemoryError 분류. JVM 이 죽는 + 종료성 장애(`ExitOnOutOfMemoryError`, exit 137)라 재시도해도 안 풀림 → `retryable=false` + (`INTERNAL`/500). 로그의 `error.code=JVM_OOM` 유무로 kubelet OOMKill 과 구별한다. +- `ACTUATOR_FORBIDDEN`: 운영 환경에서 위험한 + actuator 엔드포인트(env/configprops/heapdump/threaddump/shutdown) 접근 → 403(`AUTHZ`/403/false, + 로그 WARN). + +### MappingException +- **경계 mapper 의 sentinel.** request→command/query 변환, response shaping, outbound ACL 등 어떤 + 경계 mapper 에서든 payload 가 구조적으로는 멀쩡한데 의미상 매핑이 불가능할 때 던진다. +- web 어댑터의 기본 `GlobalExceptionHandler` 가 이 타입을 잡아 `MAPPING_FAILED`(400)로 보낸다 — + `INTERNAL_ERROR` 로 새어 나가지 않게 하려는 것. +- **`shared.error` 에 사는 이유:** 어떤 모듈(web/persistence/outbound ACL mapper)이든 cross-adapter + 의존 없이 던질 수 있게 하기 위해서다. + +### AdapterDisabledException +- **integration-adapter-templates Layer 3 의 런타임 fail-fast sentinel.** 비활성 상태인 선택적 + 어댑터(Kafka/Redis/Slack/Google Email)의 use-case 경로가 호출되면 던진다. +- **정상 경로에선 도달할 수 없다:** Layer 1(`@ConditionalOnProperty` bean-gating)이 비활성일 때 실제 + 어댑터 bean 을 아예 등록하지 않으므로 호출될 수 없다. 이 예외는 Layer 1/Layer 2 를 우회한 호출에 + 대한 **최후의 방어선**이다 — 조용한 no-op 이나 타임아웃 대기 없이 즉시 실패해서, 비활성 의존성이 + 상태를 오염시키거나 멈추게 두지 않고 바로 표면화한다. +- web 어댑터가 이 타입을 `ADAPTER_DISABLED`(500, retryable=false)로 매핑한다 — 기동 시점의 + `REQUIRED_ADAPTER_DISABLED` 와는 절대 섞지 않는 별개 런타임 코드. `shared.error` 거주 이유는 + `MappingException` 과 동일하다. + +### DependencyFailureException +- **분류된 outbound HTTP(의존성) 실패의 프레임워크 중립 운반체**. adapter-outbound 의 `OutboundHttpErrorMapper` 가 raw 네트워크/HTTP 예외를 잡아 upstream + 실패 매트릭스로 분류한 뒤, 안정적 `ApiErrorCode`(`DEPENDENCY_*`)와 upstream 의존성 이름을 담아 + 이 타입으로 감싸 다시 던진다. +- **`diagnosticMessage` 는 서버 로그 전용이며 upstream raw 응답 본문을 절대 담아선 안 된다.** 테스트 + 계약: "upstream raw error body 가 response/log 에 노출되면 실패". web 어댑터는 `errorCode()` + + 고정된 client-safe 메시지로 매핑하므로 upstream status/body/header/raw 예외 클래스가 API + 클라이언트에 닿지 않는다. +- **stdlib-only 유지:** `ApiErrorCode` 는 프레임워크 중립이고 cause 는 평범한 `Throwable` 이라 + no-Spring 규칙을 지킨다. `adapter-outbound` 가 던지고 `adapter-web` 이 잡되 금지된 cross-adapter + 의존을 만들지 않도록 `shared.error` 에 둔다. + +### PersistenceFailureException +- **분류된 persistence 실패의 프레임워크 중립 운반체**. + adapter-persistence 의 `PersistenceExceptionTranslator` 가 raw Spring `DataAccessException` 을 + 잡아 SQLState 를 9행 매트릭스로 분류한 뒤, 안정적 `ApiErrorCode`(`DB_*`)만 담아 이 타입으로 + 감싸 다시 던진다. +- raw `DataAccessException` 은 **서버 로그용 cause 로만** 보존한다. web 어댑터가 `errorCode()` + + 고정 client-safe 메시지로 매핑하므로 SQLState·제약/인덱스 이름·SQL 조각·JPA/Spring 예외 클래스가 + 클라이언트에 닿지 않는다(D1, business-rule-validation C7/D9). +- `shared.error` 거주·stdlib-only 이유는 `DependencyFailureException` 과 동일하다. + +--- + +## response — 응답 envelope 계약 + +### Envelope +- **스켈레톤 공통 단일 아이템 응답 envelope.** 성공과 실패가 한 모양을 공유한다: 최상위 `success` + 플래그, `data`(성공) 또는 `error`(실패) 필드, 그리고 request/trace/correlation id 를 담는 `meta`. + `data`/`error` 중 정확히 하나만 non-null 이다. +- **RFC 7807 ProblemDetail 을 대체한다**(boundary D5/D6). `meta` 객체는 예전의 평평한 `traceId` + 필드를 대체하며, success/error 대칭은 유지하면서 요청 진단 정보를 더 풍부하게 담는다. + +### ApiError +- **`success=false` 일 때 Envelope 안에 들어가는 에러 payload.** +- D5 가 RFC 7807 ProblemDetail 을 거부하고, D10 이 `category`(10-value Category enum)를 **1급 + 필드**로 추가했다 — 클라이언트가 모든 코드를 열거하지 않고도 굵게 분기할 수 있게. +- 필드 의미: `code`(기계가독 식별자, 클라이언트는 `message` 가 아니라 이걸로 분기), `category`(굵은 + 운영 버킷), `message`(client-facing 사유 — stack trace·내부 ID 금지), `retryable`(운영 메타를 + 1급으로 끌어올림), `details`(코드별 polymorphic — VALIDATION 이면 field error, BATCH_PARTIAL_FAILURE + 면 per-item 결과, 아니면 null). + +### ResponseMeta +- **envelope `meta` 객체로 노출되는 per-response correlation 메타**(D19/G2). camelCase JSON 이 + wire form 이고, 로그/MDC form 은 snake_case(`request_id`/`trace_id`/`correlation_id`)이며, 그 + 변환(projection)은 adapter-web 에서 한다(이 모듈은 프레임워크 중립). +- **D7: 실제 응답에서 `traceId` 는 절대 null 이 아니다** — tracing 이 꺼져 있으면 어댑터가 생성한 + opaque id 를 채운다. `span_id` 는 로그 전용이라 여기 의도적으로 노출하지 않는다 + (`mdc-keys.yaml` 에서 `envelope_field: null`). +- `page`는 collection 응답에 pagination 메타를 싣고 단일 아이템 + 응답에선 null 이다. 3-arg 생성자는 단일 아이템 호출부를 그대로 두고, list 컨트롤러는 `withPage()` + 로 pagination 을 붙인다. + +### PageMeta +- **envelope `meta.page` 로 노출되는 pagination 메타**(D7/D18). collection 응답에만 있고 단일 아이템 + 엔 null. +- 필드: `number`(0-indexed 페이지 번호, Spring `Pageable` 과 동일, `0`=첫 페이지), `size`(1..100 cap + 적용 후 실제 page size), `total`(전체 element 수 — 빈 collection 은 `0`, 이때 `data` 는 `[]` 이지 + null 이 아님), `sort`(Spring native 형식 `"field,direction"`, 미정렬이면 null). +- 프레임워크 중립: JSON 필드명은 camelCase(envelope wire SSOT), Jackson import 없음. + +### BulkEnvelope +- **bulk 엔드포인트 응답 envelope**(B8). `ApiError`/`ResponseMeta` 를 재사용하되 `data`(단일 값)를 + `results`(리스트)로 바꾼다. +- `success=true` 는 **모든** 아이템이 성공했을 때만이다. 하나라도 실패하면 → `success=false` + + `error.code=BATCH_PARTIAL_FAILURE` + per-item `details`. `results` 는 전부 성공일 때만 non-null. + +### BulkItemResult +- **bulk 엔드포인트의 `error.details[]` 안에 들어가는 per-item 결과.** +- 성공: `status="ok"` + `id`. 실패: `status="error"` + `code` + `message`. +- 디버그 payload 를 두지 않아 envelope 를 로그/전송해도 안전하다(B8 + D3 마스킹). + +--- + +## operation — 장기 실행 작업(LRO) 계약 + +### Operation +- **장기 실행 작업(long-running-operation) 폴링 본문**. + `GET /v1/operations/{id}` 가 `Envelope` 의 `data` 로 반환한다. +- 필드: `operationId`(opaque server id, 202 응답 `Location` 헤더의 마지막 segment 이기도 함, + never null), `status`(`OperationStatus`, terminal 까지 클라이언트가 polling), `statusUrl`(self + link), `result`(`SUCCEEDED` 일 때만, AIP151-C3), `error`(`FAILED` 일 때만, AIP151-C5, 스켈레톤 + `ApiError` 모양 재사용). +- 프레임워크 중립. id 발급과 202/Location 배선은 web 어댑터가 한다. + +### OperationStatus +- **LRO lifecycle status**(D17). +- **프로젝트 선택:** 이 5-value 어휘는 Google AIP-151 의 `done`/`response`/`error` + 이진 모델(AIP151-C4/C3/C5)을 프로젝트 내부용으로 투영한 것이다 — AIP-151 자체에는 enum 이 없다. + 매핑: `PENDING`=접수됐으나 미시작, `RUNNING`=`done=false` 진행 중, `SUCCEEDED`=`done=true`+ + `response`, `FAILED`=`done=true`+`error`, `CANCELLED`=`done=true`+취소. + +--- + +## request — 요청 값 계약 + +### Patch +- **PATCH 커맨드 필드용 스켈레톤 공통 3-state 값**(B2). 한 필드에 대한 호출자의 의도를 표현한다: + `absent()`=필드 생략(변경 없음), `ofNull()`=명시적 `null`(값 비우기), `of(value)`=값으로 교체. +- web 어댑터가 JSON `JsonNullable<T>`(Jackson 을 아는, openapi-generator 산출물)를 이 Jackson-free + 타입으로 매핑한 뒤 use case 를 호출한다 → `application-core` 는 wire 표현을 절대 보지 않는다. + +--- + +## security — 권한 계약 + +### Permission +- **`resource:action` 으로 이름 붙인 권한 집행 단위**. +- **문법:** 2-segment, 소문자, colon 으로 구분(예: `worklog:close`). AWS IAM 의 `service:Action` + 관례(IAM-NAMING-C1)와 Curity 의 `resource:action` 산업 관행(CURITY-SCOPE-C2)을 따른다. +- **점(dot) 형태 `service.resource.verb`(Google IAM)를 거부하는 이유:** Java 패키지명과 시각적으로 + 헷갈리고, 이 단일 서비스 스켈레톤엔 필요 없는 service prefix 를 중복시키기 때문이다. 멀티 서비스용 + `service:resource:action` 문법은 의도적인 향후 확장이라, 3-segment 값을 오늘 조용히 받아들이지 않고 + 명시적으로 거부한다. +- `AuthorizationPort` 계약이 소비하는 as-built 값 객체다. `shared-contract`(Java-only, 프레임워크 + 자유)에 두어, application 계층은 Spring Security 타입 없이 계약을 표현하고 web 어댑터는 같은 + 타입으로 role→permission 을 해석한다. + +--- + +## metrics — 메트릭/알림 계약 + +> 이 패키지의 구체 수치(cardinality 상한, P1/P2/P3 임계치 등)는 대부분 +> **프로젝트 선택** — 외부 표준에서 유도한 게 아니라 이 스켈레톤이 합의한 운영 가정이며, +> 트래픽 패턴/SLO 데이터에 따라 개정 대상인 "문서화된 기본값"이다. 모든 클래스는 stdlib-only(Spring/ +> Jackson/Micrometer 의존 없음). + +### MetricNaming +- **Micrometer dot.case 메트릭 이름의 작명/단위 접미사 규칙**(D2/D3 + §3). +- **이름 규칙(D2):** 소문자 영문+숫자만, 단일 점으로 segment 구분, 각 segment 는 영문자로 시작 + (예: `http.server.requests`). 정확한 규칙은 `VALID_NAME_PATTERN` 정규식에 있다. +- **단위 접미사(D2):** 이름의 마지막 segment 로 `seconds`/`bytes`/`total` 중 하나를 붙인다 + (timer=seconds, byte 게이지=bytes, counter=total). 이 집합 밖의 접미사는 금지. +- **Prometheus base-name 변환(§3):** dot.case 이름의 `.` 을 `_` 로 바꾼다 + (`http.server.requests` → `http_server_requests`). +- **INFERENCE / 운영 메모:** Spring Boot 3 Prometheus exporter 는 런타임에 추가 접미사 + (`_seconds_bucket`/`_count`/`_sum` 등)를 더 붙인다 — 그건 exporter 의 소관이고 이 base-name 변환의 + 범위 밖이다. 실제 방출되는 이름은 `/actuator/prometheus` 로 검증할 것. + +### CardinalityBounds +- **스켈레톤에 등록되는 메트릭의 tag 별 cardinality 상한**(§Cardinality Bounds 표의 Java mirror). + `limitFor(key)` 로 magic number 하드코딩 없이 상한을 조회한다(MeterFilter 설정, cardinality + 계약 테스트). +- 상수값(프로젝트 선택): `STATUS_CODE`=7(1xx~5xx + ok/other), `URI_TEMPLATE`=200 + (라우트는 `/users/{id}` 처럼 템플릿 정규화 필수, raw path 는 `ForbiddenMetricTags` 로 금지), + `DEPENDENCY_NAME`=50, `ERROR_CODE`=100, `TENANT_ID`=1000, `RESILIENCE4J_OUTCOME`=5 + (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED). +- **`ERROR_CODE`=100 은 `error-codes.yaml` 의 행 수 상한과 동기화한다.** 레지스트리가 100행을 넘으면 + 이 상수와 레지스트리 메모를 함께 갱신해야 한다. +- **`TENANT_ID`=1000:** 메트릭 라벨은 bounded mapping-table ID 나 cohort bucket 을 써야 한다 — + raw UUID tenant 식별자는 라벨로 금지. 1001번째 tenant 부터는 bucket folding 이 자동 적용된다. +- **tag-key 매핑 quirk 2가지**(`limitFor` 에서 명시 처리): (1) HTTP 메트릭 레지스트리 행의 tag 이름은 + `status_code` 가 아니라 `status` 다 — 혼동을 막으려 `status`/`status_code` 둘 다 `STATUS_CODE`(7)로 + 매핑. (2) Resilience4j bound 는 `outcome` tag(5값)에 적용 — 상수명은 명료성을 위해 + `RESILIENCE4J_OUTCOME` 이지만 lookup 키는 레지스트리 tag 이름인 `outcome` 다. 다른 메트릭의 + `outcome` tag 는 실제 cardinality 가 다를 수 있으나(db 3, outbox 4), lookup 은 보수적 출발점으로 + Resilience4j bound 를 돌려준다. + +### ForbiddenMetricTags +- **스켈레톤 전역에서 금지된 high-cardinality 메트릭 tag 키**(D8): `user_id`, `request_id`, + `raw_url`, `raw_query`, `raw_header_value`, `ip_address`. +- **왜 금지하나(D8):** label key-value 조합 하나마다 Prometheus 에 새 time series 가 생긴다. user + 식별자·raw URL·IP 처럼 **무한히 늘어나는(unbounded)** 값을 tag 로 쓰면 time series 가 수백만 개로 + 폭증해 애플리케이션과 Prometheus 서버 메모리를 잡아먹는다. 근거: Prometheus 공식 best practice + (PROM-CARD-C1/C2 — "모든 unique label 조합 = 새 time series"; user ID/email/unbounded set 을 명시), + Micrometer `HighCardinalityTagsDetector`(MM-HCARD-C1/C2 — `userId`/`requestId`/`traceId` 가 대표 예시). +- **`request_id` 가 여기선 금지인데 baggage 엔 허용인 이유(의도적 비대칭):** `request_id` 는 + `BaggageAllowlist.ALLOWED` 에 있다 — 분산 추적 correlation 을 위한 정당한 W3C baggage 키이기 + 때문이다. 하지만 **메트릭 라벨**로 쓰면 요청당 time series 1개씩, 수백만 개가 된다. 요청별 correlation + 은 메트릭 tag 가 아니라 분산 추적(trace ID/exemplar)으로 해야 한다. 나중에 읽는 사람이 이걸 + "고친다"며 forbidden 목록에서 `request_id` 를 빼지 말 것 — 비대칭은 의도적이다. +- **프로젝트 선택:** 명백한 user/request ID 예시(PROM-CARD-C2, MM-HCARD-C2) 외의 + 멤버(`raw_url`/`raw_query`/`raw_header_value`/`ip_address`)는 "unbounded set" 원칙을 HTTP 특유의 + 출처에 적용한 운영 가정이다. 이를 명시적으로 열거하는 외부 표준은 없다. + +### AlertSeverity +- **메트릭 계약의 알림 심각도 분류**(D7 / §P1/P2/P3). `key()` 가 돌려주는 소문자 형태(`p1`/`p2`/`p3`)가 + 레지스트리 `alert_severity_thresholds` 키와 일치하며, `fromKey()` 의 정규 입력이다(대소문자 무시). +- **임계치 수치는 provisional, 프로젝트 선택** — 이 스켈레톤이 합의한 운영 가정이지 + 외부 표준이 아니다. 정식 SLO 채택 시 재검토할 것(D5 burn-rate migration path). + - **P1**(릴리스 차단): error rate >5% 5분 OR >10% 1분; p99 latency >5s 5분; 필수 의존성 unavailable >2분. + - **P2**(on-call 즉시 대응): error rate >1% 10분; p99 latency >1s 10분; 선택 의존성 degraded >5분. + - **P3**(업무 시간 대응): error rate >0.1% 1시간; p99 latency >500ms 30분; 평소의 10× spike. + +--- + +## tracing — 분산 추적 계약 + +> tracing 패키지는 **OTel/Micrometer 런타임이 포크에서 활성화되는 seam**이라는 전제로 설계됐다 +>. 그래서 계약 타입 자체는 stdlib-only(Spring/OTel/ +> Micrometer 의존 없음)로 두어, adapter-web·adapter-outbound 가 tracer 라이브러리에 컴파일타임으로 +> 묶이지 않고도 헤더 모양/기록 seam 에 의존할 수 있게 한다. + +### TraceParent +- **W3C `traceparent` 헤더의 불변(immutable) 값 타입**(D5/D7). 형식: + `00-<32자리 소문자 hex>-<16자리 소문자 hex>-<2자리 소문자 hex>`. +- **strict W3C 검증**(`parse`/`of` 가 적용): version=`00`, 정확히 4개의 dash 구분 필드, trace-id 는 + 32자리 소문자 hex 이며 all-zero 금지, parent-id(span-id)는 16자리 소문자 hex 이며 all-zero 금지, + trace-flags 는 2자리 소문자 hex(값 자유, `sampled` = 최하위 비트). +- **실패 처리 정책:** `parse` 는 위반 시 `Optional.empty()` 를 돌려줄 뿐 예외를 던지지 않는다. + `of` 는 invalid 입력이면 예외를 던진다. silent normalization 은 하지 않으므로 호출자가 소문자로 + 넣어야 한다. + +### BaggageAllowlist +- **스켈레톤의 W3C/OTel baggage allowlist**(D2/D8). +- **D8:** W3C `baggage` 헤더에는 `tenant_id` 와 `request_id` 만 허용한다. 그 밖의 모든 키는 downstream + 으로 전파하기 전에 제거한다 — W3C Baggage spec §4.1 의 trust-boundary 규칙과 OTel Baggage API 의 + "untrusted process" 제거 의무(D2)에 따른 것. + +### SpanErrorRecorder +- **tracer 라이브러리에 결합하지 않고 span error 를 기록하기 위한 D12 seam.** adapter-web + (`GlobalExceptionHandler`)·adapter-outbound 가 컴파일타임 tracer 의존 없이 span error 를 일관되게 + 기록할 수 있게 한다. +- **`NOOP` 기본값:** tracer 가 classpath 에 없는 스켈레톤 기본 상태를 위한 no-op 구현. Micrometer + Tracing 을 활성화하는 포크는 `app-bootstrap` composition root 에서 이 bean 을 교체한다. +- **D12 구현 계약 (실제 tracer 를 배선하는 포크가 지켜야 할 것):** + 1. `Observation.error(throwable)` 또는 동등한 OTel `span.recordException(throwable)` 을 호출해 error + lifecycle event 를 내보내고 예외를 현재 span 에 붙인다. + 2. span status 를 별도 호출 `span.setStatus(StatusCode.ERROR)` 로 ERROR 로 만든다 — OTel Trace + API 명세(OTEL-TAPI-C4)상 `recordException` 은 AddEvent 만 하지 status 를 바꾸지 않는다. + 3. `error.code` span attribute 를 전달받은 `errorCode` 문자열로 붙인다. 주의: `error.code` 는 + ca-tmpl 레지스트리 attribute 이름이지, OTel semantic-convention 의 `exception.type`/ + `exception.message`/`exception.stacktrace` 가 아니다. + 4. 전체 stack trace 는 현재 span 이 sampled 일 때만 붙인다. unsampled span 에는 `error.code` + attribute 만 붙이고 stack trace 부착은 금지 — cardinality/데이터 볼륨 오버헤드를 피하기 위한 + ca-tmpl 운영 결정(D12 프로젝트 선택, 외부 spec 근거 없음). + +--- + +## concurrency — 런타임 컨텍스트 전파 계약 + +도메인/비즈니스 컨텍스트(예: 비즈니스 식별자)를 +virtual-thread 와 `StructuredTaskScope.fork()` 경계 너머로 전파하는 계약이다. + +### 설계 골격 — "Default + 교체 가능한 추상화" (rate-limit 패턴) +동작하는 기본 구현을 제공하되 strategy seam 뒤에 두어, 호출부를 건드리지 않고 교체/대체할 수 있게 한다. +`RateLimiter` / `RateLimitAlgorithm` / `RateLimiterFactory` 와 정확히 같은 구조다: + +- **`DomainContextPropagator`** — port(인터페이스). +- **`ThreadLocalDomainContextPropagator`** — 기본 구현(plain `ThreadLocal`, 명시적 capture/restore, + virtual-thread 안전, `InheritableThreadLocal` 미사용). +- **`DomainContextStrategy`** — 선택 가능한 strategy enum(기본 `THREAD_LOCAL`; `MICROMETER`/ + `SCOPED_VALUE` 는 주석으로만 남긴 향후 strategy). +- **`DomainContextPropagatorFactory`** — 유일한 확장점(단일 `switch`). +- **`DomainContextKey`** — 도메인이 공급하는 per-value 확장점(키), `RateLimitKeyResolver` 와 유사. + +Spring 배선(`DomainContextSettings` + `DomainContextConfig`, `ca-skeleton.domain-context.strategy` +바인딩)은 `app-bootstrap` 에 있고, 설정이 없으면 strategy 는 `THREAD_LOCAL` 로 기본 동작한다. + +### 무엇을 일부러 ship 하지 않았나 (그리고 왜) +- **도메인 키:** 스켈레톤엔 도메인이 없으므로 `DomainContextKey` 상수를 ship 하지 않고, 기본적으로 + 흐르는 값도 없다. 도메인은 필요해질 때 자기 키를 선언한다(그 전까지 seam 은 관측 가능한 동작이 + 없다 — route 가 없는 `RateLimitKeyResolver` 와 같다). +- **`ScopedValue`/`StructuredTaskScope` strategy:** Java 21 preview API 이고 빌드에 `--enable-preview` + 가 없어(빌드 사실 C1) 운영에서 컴파일되지 않는다. `domain_context_propagation_primitives_stay_unshipped` + ArchUnit 규칙(app-bootstrap `CleanArchitectureTest`)이, strategy seam 을 통해 활성화되기 전까지 + 이를 운영 코드에서 배제한다. + +### 경계 → 메커니즘 위임 맵 (S1, 참고용) +각 경계의 전파는 형제 계약이 소유한다. 이 계약은 **도메인 컨텍스트** 핸드오프와 통합 view 만 소유한다: + +| 경계 | 메커니즘 | 소유자 | +|---|---|---| +| inbound HTTP 필터 | `request_id`/`correlation_id`/`trace_id` MDC(SLF4J 2.0+) | B6(boundary-validation) | +| outbound HTTP/메시지 | W3C `traceparent`/`tracestate` + baggage | distributed-tracing D5/D7/D8 | +| `@Async` `ThreadPoolTaskExecutor` | `TaskDecorator` 의 MDC 4-key 복사(planned) | background-job D5/D6 | +| virtual-thread carrier | 진단용 MDC, `InheritableThreadLocal` 금지 | B6 | +| `StructuredTaskScope.fork()`/스레드 핸드오프 | 이 패키지 propagator 통한 **도메인 컨텍스트** | 여기서 소유(S2/S3) | + +> `@Async` 경계는 background-job 이 executor 배선을 소유하되, 그 `TaskDecorator` 가 +> `DomainContextPropagator.wrap(Runnable)` 도 호출해 MDC 복사와 함께 도메인 컨텍스트도 운반해야 한다. +> 이 계약은 seam 만 제공한다. + +### DomainContextPropagator (port) +- 도메인/비즈니스 컨텍스트를 스레드·`fork()` 경계 너머 전파하는 **strategy seam**(S2/S3). 애플리케이션 + 코드는 이 인터페이스만 의존하고, Factory 가 설정에서 구체 strategy 를 고른다. strategy 추가 = "새 impl + + enum 값 1개 + factory case 1개", 호출부 변경 없음. +- **진단(diagnostic) 컨텍스트는 여기 두지 않는다.** `request_id`/`trace_id`/`correlation_id` 는 MDC 에 + 살고 inbound 필터가 virtual thread 에서 전파한다(B6 + D11). 이 propagator 는 **도메인 채널** — 별개의 + 관심사다. +- **명시적 핸드오프(S3):** 구현은 스레드 간 암묵적 상속에 의존해선 안 된다(`InheritableThreadLocal` 은 + `no_inheritable_thread_local` ArchUnit 규칙으로 금지). 스레드/`fork()` 경계를 넘는 코드는 + `wrap()` 또는 `capture()`+`restore()` 로 컨텍스트를 명시적으로 재확립한다. + +### DomainContextStrategy (enum) +- `THREAD_LOCAL` 이 기본이자 현재 ship 된 유일한 값이다. 값을 추가하려면 enum 값 + `DomainContextPropagator` + 구현 + Factory case 를 함께 더한다(`RateLimitAlgorithm` 패턴). +- **`THREAD_LOCAL`:** plain `ThreadLocal` + 명시적 capture/restore. virtual-thread 안전(VT 마다 자기 + copy), `no_inheritable_thread_local` 금지 준수(`InheritableThreadLocal` 아님), 추가 의존성 0. +- **향후 strategy(주석으로만 존재):** + - `MICROMETER` — `io.micrometer:context-propagation` 의 `ContextSnapshot`/`ContextRegistry`. 안정적 + API 이고 MDC/tracing 과 같은 채널에 통합된다. 다중 키나 Reactor bridge 가 필요할 때 고른다 + (C7: 활성화 전에 virtual-thread 동작을 확인할 것). + - `SCOPED_VALUE` — Java 21 `ScopedValue`. 불변이며 `StructuredTaskScope` 안에서 자동 상속된다. + PREVIEW: `--enable-preview` 가 필요한데 현재 빌드는 켜지 않는다(C1) → + `domain_context_propagation_primitives_stay_unshipped` ArchUnit 규칙으로 guard 된다. + +### DomainContextPropagatorFactory +- 설정된 strategy 를 만들어 주는 곳(S2). 내부의 단일 `switch` 가 유일한 확장점이다 — strategy 추가 = + enum 값 + 구현 + case, 호출부는 그대로. `RateLimiterFactory` 와 같은 모양. + +### ThreadLocalDomainContextPropagator (기본 구현) +- plain `ThreadLocal` 이 현재 스레드의 도메인 컨텍스트 맵을 들고, 스레드 경계는 명시적 capture/restore + 로 넘긴다(D4/D6). +- **왜 plain `ThreadLocal` 인가:** `InheritableThreadLocal` 이 아니라서 pooled carrier 스레드 너머로 + 조용히 새지 않고 `no_inheritable_thread_local` ArchUnit 규칙(B6)을 지킨다. virtual thread 는 각자 + 자기 copy 를 갖는다(Oracle Java 21 docs, TL-VT-C1) → 같은 스레드 read 는 동작하지만 fork 시 상속은 + 없다 — 그래서 핸드오프가 `wrap()`/`capture()` 로 **명시적**이어야 하는 것이다. +- multi-key 고빈도 변경이나 Reactor bridging 용이 아니다 — 그건 `MICROMETER` strategy 의 일이다. 이 + 기본 구현은 흔한 경우(작은 비즈니스 식별자 집합을 명시적 async 핸드오프 너머 운반)를 노린다. + +### DomainContextKey +- 단일 도메인/비즈니스 컨텍스트 값에 대한 typed·named 키(S2). **확장점**이다: 비즈니스 식별자를 + async/fork 경계 너머 운반해야 하는 도메인이 `DomainContextKey` 상수 하나를 선언하고 `Propagator` 를 + 통해 read/write 한다 — 프로젝트가 `RateLimitKeyResolver` 를 공급하는 것과 비슷하다. 스켈레톤은 + 메커니즘을 ship 하고, 도메인은 키를 공급한다. +- identity 는 `name` 뿐이다 → 같은 이름의 두 키는 같은 slot 을 가리킨다(`type` 은 read 시 구분용). + 키는 `static final` 상수로 두라는 의도이며, 요청마다 새로 만들지 않는다. + +### DomainContextSnapshot +- 스레드/`fork()` 경계 너머 명시적 핸드오프를 위한, 한 스레드 도메인 컨텍스트의 불변 capture(S3). + Micrometer Context Propagation 의 `ContextSnapshot` capture/restore 모양을 본떴다. +- `restore()` 는 반드시 try-with-resources 와 함께 써서 worker 스레드가 오염된 채 남지 않게 한다. diff --git a/src/shared-contract/build.gradle b/src/shared-contract/build.gradle new file mode 100644 index 00000000..8814dece --- /dev/null +++ b/src/shared-contract/build.gradle @@ -0,0 +1,3 @@ +// Skeleton-wide operational contracts only. No business/domain concepts. +dependencies { +} diff --git a/src/shared-contract/gradle.lockfile b/src/shared-contract/gradle.lockfile new file mode 100644 index 00000000..d74791d4 --- /dev/null +++ b/src/shared-contract/gradle.lockfile @@ -0,0 +1,151 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs +com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs:4.10.2=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.google.errorprone:error_prone_annotations:2.47.0=checkstyle +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=checkstyle +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.puppycrawl.tools:checkstyle:13.5.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.21.0=spotbugs +commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath +info.picocli:picocli:4.7.7=checkstyle +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor +io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs +org.antlr:antlr4-runtime:4.13.2=checkstyle +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.maven.doxia:doxia-core:1.12.0=checkstyle +org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle +org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle +org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.xbean:xbean-reflect:3.7=checkstyle +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle +org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle +org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle +org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.dom4j:dom4j:2.2.0=spotbugs +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.javassist:javassist:3.28.0-GA=checkstyle +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath +org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.1.0=spotbugs +org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=spotbugs +org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=spotbugs +org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs +org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +empty=compileClasspath,runtimeClasspath diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/annotation/.gitkeep b/src/shared-contract/src/main/java/dev/caskeleton/shared/annotation/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/annotation/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java new file mode 100644 index 00000000..08dc562a --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java @@ -0,0 +1,56 @@ +package dev.caskeleton.shared.concurrency; + +import java.util.Objects; + +/** + * A typed, named key for a single domain / business-scoped context value — the extension point a + * domain supplies. A domain declares one {@code DomainContextKey} constant and reads/writes it + * through a {@link DomainContextPropagator}. + * + * <p>Identity is the {@code name} only, so two keys with the same name address the same slot + * ({@code type} disambiguates the read). Keys are meant to be {@code static final} constants, not + * created per request. See the module README for the rationale. + * + * @param <T> the type of the value carried under this key + */ +public final class DomainContextKey<T> { + + private final String name; + private final Class<T> type; + + private DomainContextKey(String name, Class<T> type) { + this.name = Objects.requireNonNull(name, "name"); + this.type = Objects.requireNonNull(type, "type"); + if (name.isBlank()) { + throw new IllegalArgumentException("domain context key name must not be blank"); + } + } + + /** Declare a key, e.g. {@code DomainContextKey.of("tenant_id", String.class)}. */ + public static <T> DomainContextKey<T> of(String name, Class<T> type) { + return new DomainContextKey<>(name, type); + } + + public String name() { + return name; + } + + public Class<T> type() { + return type; + } + + @Override + public boolean equals(Object o) { + return o instanceof DomainContextKey<?> other && name.equals(other.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public String toString() { + return "DomainContextKey[" + name + ": " + type.getSimpleName() + "]"; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java new file mode 100644 index 00000000..6bedf7bd --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java @@ -0,0 +1,46 @@ +package dev.caskeleton.shared.concurrency; + +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * Propagates domain / business-scoped context across thread and {@code StructuredTaskScope.fork()} + * boundaries. This is the strategy seam: application code depends only on this interface, and + * {@link DomainContextPropagatorFactory} picks the concrete strategy from configuration. + * + * <p>This is the <em>domain</em> channel only — diagnostic context ({@code request_id}/{@code + * trace_id}/{@code correlation_id}) lives in MDC, a separate concern. Hand-off across threads must + * be explicit (implicit cross-thread inheritance is banned by the {@code + * no_inheritable_thread_local} ArchUnit rule) via {@link #wrap(Runnable)} / {@link #wrap(Callable)} + * or {@link #capture()} + {@link DomainContextSnapshot#restore()}. + * + * <p>See the module README for the rate-limit-style seam rationale and how the {@code @Async} + * {@code TaskDecorator} is expected to use {@link #wrap(Runnable)}. + */ +public interface DomainContextPropagator { + + /** Bind {@code value} under {@code key} for the current thread. */ + <T> void set(DomainContextKey<T> key, T value); + + /** Read the value bound under {@code key} on the current thread, if any. */ + <T> Optional<T> get(DomainContextKey<T> key); + + /** Clear all domain context bound on the current thread. */ + void clear(); + + /** + * Capture the current thread's domain context into an immutable snapshot for an explicit hand-off + * to another thread (e.g. a {@code StructuredTaskScope.fork()}). + */ + DomainContextSnapshot capture(); + + /** + * Wrap {@code task} so that, when it later runs (possibly on another thread), the domain context + * captured <em>now</em> is restored for the duration of the run and reverted afterwards. The + * ergonomic hand-off for executors / virtual threads. + */ + Runnable wrap(Runnable task); + + /** {@link #wrap(Runnable)} for a value-returning task. */ + <V> Callable<V> wrap(Callable<V> task); +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagatorFactory.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagatorFactory.java new file mode 100644 index 00000000..89db0310 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagatorFactory.java @@ -0,0 +1,19 @@ +package dev.caskeleton.shared.concurrency; + +/** + * Builds the configured {@link DomainContextPropagator} strategy. The single {@code switch} below + * is the one extension point: a new strategy adds a {@link DomainContextStrategy} value, a {@link + * DomainContextPropagator} implementation, and a case here — nothing at the call sites changes. + * + * <p>See the module README (mirrors the {@code RateLimiterFactory} pattern). + */ +public final class DomainContextPropagatorFactory { + + private DomainContextPropagatorFactory() {} + + public static DomainContextPropagator create(DomainContextStrategy strategy) { + return switch (strategy) { + case THREAD_LOCAL -> new ThreadLocalDomainContextPropagator(); + }; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextSnapshot.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextSnapshot.java new file mode 100644 index 00000000..1483cf74 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextSnapshot.java @@ -0,0 +1,30 @@ +package dev.caskeleton.shared.concurrency; + +/** + * An immutable capture of a thread's domain context, used for an explicit hand-off across a thread + * or {@code StructuredTaskScope.fork()} boundary. Mirrors the capture/restore shape of Micrometer + * Context Propagation's {@code ContextSnapshot}. + * + * <pre>{@code + * DomainContextSnapshot snapshot = propagator.capture(); // on the caller thread + * // ... hand the snapshot to another thread ... + * try (DomainContextScope ignored = snapshot.restore()) { // on the worker thread + * // domain context is now visible here; reverted on close + * } + * }</pre> + */ +public interface DomainContextSnapshot { + + /** + * Restore this snapshot's context onto the current thread, returning a scope that reverts the + * thread to its previous context when closed. Always use with try-with-resources so the worker + * thread is not left polluted. + */ + DomainContextScope restore(); + + /** The {@link AutoCloseable} returned by {@link #restore()}; {@code close()} reverts. */ + interface DomainContextScope extends AutoCloseable { + @Override + void close(); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextStrategy.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextStrategy.java new file mode 100644 index 00000000..c154d317 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/DomainContextStrategy.java @@ -0,0 +1,20 @@ +package dev.caskeleton.shared.concurrency; + +/** + * Selectable domain-context propagation strategy, bound from {@code + * ca-skeleton.domain-context.strategy}. {@link #THREAD_LOCAL} is the shipped default; add a value + * here together with its {@link DomainContextPropagator} implementation and a {@link + * DomainContextPropagatorFactory} case to introduce a new strategy. + * + * <p>See the module README for the {@code MICROMETER} / {@code SCOPED_VALUE} strategies reserved + * for the future and why they are not shipped yet. + */ +public enum DomainContextStrategy { + + /** + * Plain {@code ThreadLocal} with explicit capture/restore — the default. Virtual-thread safe + * (per-virtual-thread copy), respects the {@code no_inheritable_thread_local} ban, and adds zero + * dependencies. + */ + THREAD_LOCAL +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java new file mode 100644 index 00000000..84bd1367 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java @@ -0,0 +1,90 @@ +package dev.caskeleton.shared.concurrency; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * Default {@link DomainContextPropagator}: a plain {@link ThreadLocal} holding the current thread's + * domain-context map, with explicit capture/restore across thread boundaries. Virtual-thread safe + * (per-virtual-thread copy) and compliant with the {@code no_inheritable_thread_local} ArchUnit + * rule, so context never leaks across pooled carrier threads and is never inherited on fork — which + * is exactly why hand-off must be explicit via {@link #wrap(Runnable)} / {@link #capture()}. + * + * <p>See the module README for why a plain {@code ThreadLocal} was chosen over the + * inheritance-based variant and over the {@code MICROMETER} strategy. + */ +public final class ThreadLocalDomainContextPropagator implements DomainContextPropagator { + + // Plain ThreadLocal — intentionally not the inheritance-based variant + // (no_inheritable_thread_local ban). + private final ThreadLocal<Map<DomainContextKey<?>, Object>> holder = + ThreadLocal.withInitial(HashMap::new); + + @Override + public <T> void set(DomainContextKey<T> key, T value) { + if (value == null) { + holder.get().remove(key); + return; + } + holder.get().put(key, value); + } + + @Override + public <T> Optional<T> get(DomainContextKey<T> key) { + return Optional.ofNullable(holder.get().get(key)).map(key.type()::cast); + } + + @Override + public void clear() { + holder.remove(); + } + + @Override + public DomainContextSnapshot capture() { + // Immutable copy so the snapshot is unaffected by later mutations on this thread. + Map<DomainContextKey<?>, Object> copy = + Collections.unmodifiableMap(new HashMap<>(holder.get())); + return new ThreadLocalSnapshot(copy); + } + + @Override + public Runnable wrap(Runnable task) { + DomainContextSnapshot snapshot = capture(); + return () -> { + try (DomainContextSnapshot.DomainContextScope ignored = snapshot.restore()) { + task.run(); + } + }; + } + + @Override + public <V> Callable<V> wrap(Callable<V> task) { + DomainContextSnapshot snapshot = capture(); + return () -> { + try (DomainContextSnapshot.DomainContextScope ignored = snapshot.restore()) { + return task.call(); + } + }; + } + + /** Snapshot whose {@code restore()} installs the captured map and reverts on close. */ + private final class ThreadLocalSnapshot implements DomainContextSnapshot { + + private final Map<DomainContextKey<?>, Object> captured; + + private ThreadLocalSnapshot(Map<DomainContextKey<?>, Object> captured) { + this.captured = captured; + } + + @Override + public DomainContextScope restore() { + // Save the worker thread's existing context so close() reverts to it, not just clears. + Map<DomainContextKey<?>, Object> previous = new HashMap<>(holder.get()); + holder.set(new HashMap<>(captured)); + return () -> holder.set(previous); + } + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/package-info.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/package-info.java new file mode 100644 index 00000000..b4718dd9 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/concurrency/package-info.java @@ -0,0 +1,18 @@ +/** + * Runtime context propagation contract — propagates domain / business context (e.g. a business + * identifier) across virtual-thread and {@code StructuredTaskScope.fork()} boundaries, behind a + * swappable strategy seam. + * + * <p>The package ships a working default ({@code THREAD_LOCAL}) behind the {@link + * dev.caskeleton.shared.concurrency.DomainContextPropagator} port; the Spring wiring ({@code + * DomainContextSettings} + {@code DomainContextConfig}, bound from {@code + * ca-skeleton.domain-context.strategy}) lives in {@code app-bootstrap} and defaults to {@code + * THREAD_LOCAL} when unconfigured. + * + * <p>See the module README for the full design: the rate-limit-style seam, what stays unshipped + * (domain keys, the {@code ScopedValue} strategy) and why, and the boundary → mechanism + * delegation map. + * + * @see dev.caskeleton.shared + */ +package dev.caskeleton.shared.concurrency; diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java new file mode 100644 index 00000000..7aa2fe49 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java @@ -0,0 +1,46 @@ +package dev.caskeleton.shared.error; + +/** + * Runtime fail-fast sentinel for a disabled optional adapter (Kafka / Redis / Slack / Google Email, + * {@code app.<domain>.<adapter>.enabled=false}) whose use-case path is invoked anyway. The web + * adapter's {@code GlobalExceptionHandler} maps it to {@link OperationalError#ADAPTER_DISABLED} + * (HTTP 500, retryable=false). + * + * <p>See the module README for the Layer-3 last-line-of-defence rationale, why it is distinct from + * the startup-lifecycle {@code REQUIRED_ADAPTER_DISABLED}, and why it lives in {@code + * shared.error}. + */ +public class AdapterDisabledException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String adapterName; + + public AdapterDisabledException(String adapterName) { + super( + "optional adapter '" + + adapterName + + "' is disabled " + + "(app.<domain>." + + adapterName + + ".enabled=false) but its use-case path was invoked " + + "— enable it via its env flag or remove the call (integration-adapter-templates Layer 3)"); + this.adapterName = adapterName; + } + + /** + * Variant for callers whose disable condition is not the canonical {@code + * app.<domain>.<adapter>.enabled} flag (e.g. the cache router's unbound logical-cache-name + * fail-fast). The caller supplies the full remediation message; {@link #adapterName()} still + * identifies the adapter family for error mapping. + */ + public AdapterDisabledException(String adapterName, String detail) { + super(detail); + this.adapterName = adapterName; + } + + /** The disabled adapter's stable name (e.g. {@code kafka}, {@code redis}, {@code slack}). */ + public String adapterName() { + return adapterName; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java new file mode 100644 index 00000000..63c91889 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java @@ -0,0 +1,23 @@ +package dev.caskeleton.shared.error; + +/** + * Transport-agnostic hook a throwable implements so a single inbound-adapter error handler can map + * it to a protocol status without the skeleton naming the feature. The throwable carries a stable + * {@link ApiErrorCode} (the same codes every adapter surfaces); the handler reads {@link + * #errorCode()} and maps its {@link Category} to the protocol's status shape — HTTP status for the + * web adapter, gRPC {@code Status} for the gRPC adapter, a GraphQL error classification, a + * WebSocket close code, and so on. This lets service implementations "just throw" while one handler + * per transport owns the wire mapping. + * + * <p>The shared-contract {@link PersistenceFailureException} / {@link DependencyFailureException} + * carriers implement this directly, so an error surfacing from an outbound adapter maps correctly + * too; an adapter-level throwable (e.g. a gRPC {@code ApiErrorException}) implements it to carry a + * feature's domain {@code ApiErrorCode} to the wire. Only the stable {@link ApiErrorCode#code()} is + * meant to reach the client — never the raw exception message, which may carry a SQLState or + * upstream detail. + */ +public interface ApiErrorCarrier { + + /** The classified, client-facing operational code surfaced on the protocol status. */ + ApiErrorCode errorCode(); +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java new file mode 100644 index 00000000..be0023b6 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java @@ -0,0 +1,24 @@ +package dev.caskeleton.shared.error; + +/** + * Stable, machine-readable error code contract surfaced to API clients. Skeleton-wide operational + * codes live in {@link OperationalError}; each forked project adds its own domain codes by + * implementing this interface (typically an enum). {@code httpStatus()} is a plain int so this + * module stays framework-neutral. + * + * <p>See the module README for why the status is an int and what {@code retryable} means. + */ +public interface ApiErrorCode { + + /** Stable identifier clients switch on (never the human message). */ + String code(); + + /** Coarse-grained operational category surfaced as {@code error.category}. */ + Category category(); + + /** Transport-neutral HTTP status (e.g. 400, 200, 500). */ + int httpStatus(); + + /** Whether the same call may succeed on retry. */ + boolean retryable(); +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/Category.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/Category.java new file mode 100644 index 00000000..ce093d55 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/Category.java @@ -0,0 +1,21 @@ +package dev.caskeleton.shared.error; + +/** + * Skeleton-wide operational error category — the 10-value SSOT enum surfaced in the response as + * {@code error.category}. Identity only: per-code HTTP status and {@code retryable} live on {@link + * ApiErrorCode} implementations and {@code error-codes.yaml}, not here. + * + * <p>See the module README for the rationale. + */ +public enum Category { + VALIDATION, + AUTH, + AUTHZ, + NOT_FOUND, + CONFLICT, + RATE_LIMIT, + TRANSIENT_DEPENDENCY, + PERMANENT_DEPENDENCY, + DATA_INTEGRITY, + INTERNAL +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java new file mode 100644 index 00000000..2495b313 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java @@ -0,0 +1,46 @@ +package dev.caskeleton.shared.error; + +/** + * Framework-neutral carrier for a classified outbound HTTP (dependency) failure. The + * adapter-outbound {@code OutboundHttpErrorMapper} classifies a raw network / HTTP exception and + * rethrows it wrapped in this type, carrying a stable {@link ApiErrorCode} (a {@code DEPENDENCY_*} + * code in {@link OperationalError}) and the upstream dependency name. + * + * <p>The {@code diagnosticMessage} is <strong>SERVER-LOG-ONLY</strong> and must + * <strong>NEVER</strong> contain the upstream raw response body. See the module README for the + * leak-prevention contract and why it lives in {@code shared.error}. + */ +public class DependencyFailureException extends RuntimeException implements ApiErrorCarrier { + + private static final long serialVersionUID = 1L; + + private final transient ApiErrorCode errorCode; + private final String dependencyName; + + /** + * @param errorCode the classified, client-facing code (a {@code DEPENDENCY_*} {@link + * OperationalError}) + * @param dependencyName stable name of the upstream dependency (e.g. {@code "payment-service"}, + * {@code "inventory-api"}) + * @param diagnosticMessage server-log-only structured detail — must NOT contain the upstream raw + * response body; never surfaced to the client + * @param cause the raw network or HTTP exception, kept for the server log + */ + public DependencyFailureException( + ApiErrorCode errorCode, String dependencyName, String diagnosticMessage, Throwable cause) { + super(diagnosticMessage, cause); + this.errorCode = errorCode; + this.dependencyName = dependencyName; + } + + /** The classified operational code surfaced to the client as {@code error.code}. */ + @Override + public ApiErrorCode errorCode() { + return errorCode; + } + + /** The stable name of the upstream dependency that failed. */ + public String dependencyName() { + return dependencyName; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/MappingException.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/MappingException.java new file mode 100644 index 00000000..e833cd43 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/MappingException.java @@ -0,0 +1,22 @@ +package dev.caskeleton.shared.error; + +/** + * Sentinel thrown by any boundary mapper — request → command/query, response shaping, or outbound + * ACL — when the payload is structurally well-formed yet semantically un-mappable. The web + * adapter's {@code GlobalExceptionHandler} catches it and routes it to {@code MAPPING_FAILED} (HTTP + * 400). + * + * <p>See the module README for why it lives in {@code shared.error}. + */ +public class MappingException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public MappingException(String message) { + super(message); + } + + public MappingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/OperationalError.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/OperationalError.java new file mode 100644 index 00000000..363aa56b --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/OperationalError.java @@ -0,0 +1,111 @@ +package dev.caskeleton.shared.error; + +/** + * Skeleton-wide operational / transport / security error codes. Domain-specific codes (e.g. {@code + * USER_NOT_FOUND}) belong in the consuming module, not here. + * + * <p>Every code's status / category / {@code retryable} mirrors the registry SSOT in {@code + * docs/registries/error-codes.yaml}. See the module README for the classification rationale — + * transport-shape bucketing, coarse vs fine-grained security codes, the retryable reasoning per + * group, and the SQLState / dependency / outbox / job mappings. + */ +public enum OperationalError implements ApiErrorCode { + + // operational + VALIDATION_FAILED(Category.VALIDATION, 400, false), + BAD_PARAMETER(Category.VALIDATION, 400, false), + MAPPING_FAILED(Category.VALIDATION, 400, false), + BATCH_PARTIAL_FAILURE(Category.VALIDATION, 200, false), + INTERNAL_ERROR(Category.INTERNAL, 500, true), + // security — coarse fallbacks for non-filter paths + UNAUTHENTICATED(Category.AUTH, 401, false), + INVALID_TOKEN(Category.AUTH, 401, false), + FORBIDDEN(Category.AUTHZ, 403, false), + // security — fine-grained (resource-server entry point / access-denied handler) + AUTH_TOKEN_MISSING(Category.AUTH, 401, false), + AUTH_TOKEN_MALFORMED(Category.AUTH, 401, false), + AUTH_TOKEN_EXPIRED(Category.AUTH, 401, false), + AUTH_TOKEN_INVALID_SIGNATURE(Category.AUTH, 401, false), + AUTH_ISSUER_MISMATCH(Category.AUTH, 401, false), + AUTH_AUDIENCE_MISMATCH(Category.AUTH, 401, false), + AUTH_KID_UNKNOWN(Category.AUTH, 401, true), + AUTH_JWKS_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true), + AUTH_CLAIM_MAPPING_FAILED(Category.AUTH, 401, false), + AUTHZ_INSUFFICIENT_PERMISSION(Category.AUTHZ, 403, false), + AUTHZ_TENANT_MISMATCH(Category.AUTHZ, 403, false), + INTERNAL_AUTH_MISCONFIGURATION(Category.INTERNAL, 500, false), + // transport-shape standard + METHOD_NOT_ALLOWED(Category.VALIDATION, 405, false), + NOT_ACCEPTABLE(Category.VALIDATION, 406, false), + PAYLOAD_TOO_LARGE(Category.VALIDATION, 413, false), + URI_TOO_LONG(Category.VALIDATION, 414, false), + UNSUPPORTED_MEDIA_TYPE(Category.VALIDATION, 415, false), + PRECONDITION_FAILED(Category.CONFLICT, 412, false), + ROUTE_NOT_FOUND(Category.NOT_FOUND, 404, false), + // rate-limit / idempotency + RATE_LIMIT_EXCEEDED(Category.RATE_LIMIT, 429, true), + IDEMPOTENT_IN_FLIGHT(Category.CONFLICT, 409, false), + IDEMPOTENT_REQUEST_MISMATCH(Category.VALIDATION, 422, false), + // adapter runtime lifecycle + ADAPTER_DISABLED(Category.INTERNAL, 500, false), + // persistence (SQLState → code) + DB_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true), // 08* + DB_SERIALIZATION_FAILURE(Category.CONFLICT, 409, true), // 40001 + DB_DEADLOCK(Category.CONFLICT, 409, true), // 40P01 + DB_NULL_VIOLATION(Category.DATA_INTEGRITY, 409, false), // 23502 + DB_FK_VIOLATION(Category.DATA_INTEGRITY, 409, false), // 23503 + DB_UNIQUE_VIOLATION(Category.CONFLICT, 409, false), // 23505 + DB_CHECK_VIOLATION(Category.DATA_INTEGRITY, 409, false), // 23514 + DB_IDLE_IN_TX_TIMEOUT(Category.TRANSIENT_DEPENDENCY, 503, true), // 25P03 + DB_QUERY_CANCELED(Category.TRANSIENT_DEPENDENCY, 503, false), // 57014 + // outbound HTTP dependency + DEPENDENCY_TIMEOUT(Category.TRANSIENT_DEPENDENCY, 504, true), + DEPENDENCY_CONNECT_FAILED(Category.TRANSIENT_DEPENDENCY, 503, true), + DEPENDENCY_DNS_FAILED(Category.TRANSIENT_DEPENDENCY, 503, true), + DEPENDENCY_4XX_CLIENT(Category.PERMANENT_DEPENDENCY, 502, false), + DEPENDENCY_5XX_SERVER(Category.TRANSIENT_DEPENDENCY, 502, true), + DEPENDENCY_CIRCUIT_OPEN(Category.TRANSIENT_DEPENDENCY, 503, true), + // transactional outbox + OUTBOX_PUBLISH_FAILED(Category.TRANSIENT_DEPENDENCY, 500, true), + OUTBOX_DEAD_LETTER(Category.INTERNAL, 500, false), + // background job / async + JOB_EXECUTOR_REJECTED(Category.TRANSIENT_DEPENDENCY, 503, true), + JOB_TIMEOUT(Category.TRANSIENT_DEPENDENCY, 500, true), + JOB_DEAD_LETTER(Category.INTERNAL, 500, false), + // distributed lock + LOCK_ACQUISITION_TIMEOUT(Category.CONFLICT, 409, true), + // container runtime + JVM_OOM(Category.INTERNAL, 500, false), + // actuator security + ACTUATOR_FORBIDDEN(Category.AUTHZ, 403, false); + + private final Category category; + private final int httpStatus; + private final boolean retryable; + + OperationalError(Category category, int httpStatus, boolean retryable) { + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return httpStatus; + } + + @Override + public boolean retryable() { + return retryable; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java new file mode 100644 index 00000000..0e248194 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java @@ -0,0 +1,36 @@ +package dev.caskeleton.shared.error; + +/** + * Framework-neutral carrier for a classified persistence failure. The adapter-persistence {@code + * PersistenceExceptionTranslator} classifies a raw Spring {@code DataAccessException} by SQLState + * and rethrows it wrapped in this type, carrying only a stable {@link ApiErrorCode} (a {@code DB_*} + * code in {@link OperationalError}). The raw exception is kept as the cause for the <em>server</em> + * log only. + * + * <p>See the module README for the leak-prevention contract (SQLState / SQL / exception class never + * reach the client) and why it lives in {@code shared.error}. + */ +public class PersistenceFailureException extends RuntimeException implements ApiErrorCarrier { + + private static final long serialVersionUID = 1L; + + private final transient ApiErrorCode errorCode; + + /** + * @param errorCode the classified, client-facing code (a {@code DB_*} {@link OperationalError}) + * @param diagnosticMessage server-log-only detail (may name the SQLState) — never surfaced to the + * client by the web adapter + * @param cause the raw persistence exception, kept for the server log + */ + public PersistenceFailureException( + ApiErrorCode errorCode, String diagnosticMessage, Throwable cause) { + super(diagnosticMessage, cause); + this.errorCode = errorCode; + } + + /** The classified operational code surfaced to the client as {@code error.code}. */ + @Override + public ApiErrorCode errorCode() { + return errorCode; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/headers/.gitkeep b/src/shared-contract/src/main/java/dev/caskeleton/shared/headers/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/headers/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/AlertSeverity.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/AlertSeverity.java new file mode 100644 index 00000000..724119f0 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/AlertSeverity.java @@ -0,0 +1,63 @@ +package dev.caskeleton.shared.metrics; + +import java.util.Locale; + +/** + * Alert severity classification for the skeleton metrics contract. The lowercase form from {@link + * #key()} matches the registry {@code alert_severity_thresholds} keys ({@code p1}/{@code p2}/{@code + * p3}) and is the canonical input to {@link #fromKey(String)}. Java stdlib only. + * + * <p>The numeric thresholds are provisional operational assumptions, not an external standard. See + * the module README for the per-level thresholds and when to revisit them. + */ +public enum AlertSeverity { + + /** P1 — release-blocking incident. */ + P1, + + /** P2 — on-call immediate response. */ + P2, + + /** P3 — business-hours response. */ + P3; + + /** + * Returns the lowercase registry key for this severity level (e.g. {@code "p1"}, {@code "p2"}, + * {@code "p3"}). + * + * <p>The returned value matches the keys in the {@code alert_severity_thresholds} map of {@code + * docs/registries/metrics.yaml}. + * + * @return lowercase severity key; never {@code null} + */ + public String key() { + return name().toLowerCase(Locale.ROOT); + } + + /** + * Resolves an {@code AlertSeverity} from a registry key string. + * + * <p>Matching is case-insensitive, so {@code "p1"}, {@code "P1"} and {@code "P1"} all resolve to + * {@link #P1}. This allows callers to read directly from YAML keys ({@code p1}/{@code p2}/{@code + * p3}) without pre-normalising the string. + * + * @param key severity key; {@code null} or unknown values throw + * @return the matching {@code AlertSeverity} constant + * @throws IllegalArgumentException if {@code key} is {@code null} or does not match any known + * severity level + */ + public static AlertSeverity fromKey(String key) { + if (key == null) { + throw new IllegalArgumentException( + "Alert severity key must not be null; accepted values: p1, p2, p3"); + } + return switch (key.toLowerCase(Locale.ROOT)) { + case "p1" -> P1; + case "p2" -> P2; + case "p3" -> P3; + default -> + throw new IllegalArgumentException( + "Unknown alert severity key: \"" + key + "\"; accepted values: p1, p2, p3"); + }; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java new file mode 100644 index 00000000..66fbec8f --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java @@ -0,0 +1,66 @@ +package dev.caskeleton.shared.metrics; + +import java.util.Map; +import java.util.OptionalInt; + +/** + * Per-tag cardinality upper bounds for metrics registered in this skeleton — a Java mirror of the + * registry's cardinality table. {@link #limitFor(String)} gives the bound for a known tag key + * without hard-coding magic numbers (used by {@code MeterFilter} config and cardinality contract + * tests). Java stdlib only. + * + * <p>The numeric values are operational assumptions, not an external standard. See the module + * README for the rationale, the {@code error-codes.yaml} sync rule, and the two tag-key mapping + * quirks ({@code status}/{@code status_code} and {@code outcome}). + */ +public final class CardinalityBounds { + + private CardinalityBounds() {} + + /** HTTP status-class tag bound (1xx–5xx + ok/other). */ + public static final int STATUS_CODE = 7; + + /** URI template tag bound — routes must be template-normalised (e.g. {@code /users/{id}}). */ + public static final int URI_TEMPLATE = 200; + + /** Distinct named external dependency tag bound. */ + public static final int DEPENDENCY_NAME = 50; + + /** Error code tag bound — kept in sync with the row count of {@code error-codes.yaml}. */ + public static final int ERROR_CODE = 100; + + /** Tenant id tag bound — bounded mapping-table id / cohort bucket only, no raw UUID. */ + public static final int TENANT_ID = 1000; + + /** Resilience4j {@code outcome} tag bound (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED). */ + public static final int RESILIENCE4J_OUTCOME = 5; + + /** Registry tag name → cardinality bound. See the module README for the mapping quirks. */ + private static final Map<String, Integer> LIMITS = + Map.of( + "status_code", STATUS_CODE, + "status", STATUS_CODE, + "uri_template", URI_TEMPLATE, + "dependency_name", DEPENDENCY_NAME, + "error_code", ERROR_CODE, + "tenant_id", TENANT_ID, + "outcome", RESILIENCE4J_OUTCOME); + + /** + * Returns the cardinality upper bound for a known registry tag key, or {@link + * OptionalInt#empty()} for unknown or {@code null} keys. + * + * <p>Known keys: {@code status_code}, {@code status}, {@code uri_template}, {@code + * dependency_name}, {@code error_code}, {@code tenant_id}, {@code outcome}. + * + * @param tagKey the registry tag name; {@code null} returns empty + * @return the bound as an {@link OptionalInt}, or empty if the key is unknown + */ + public static OptionalInt limitFor(String tagKey) { + if (tagKey == null) { + return OptionalInt.empty(); + } + Integer bound = LIMITS.get(tagKey); + return bound != null ? OptionalInt.of(bound) : OptionalInt.empty(); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java new file mode 100644 index 00000000..1a4e09d5 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java @@ -0,0 +1,60 @@ +package dev.caskeleton.shared.metrics; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; + +/** + * High-cardinality metric tag keys that are globally forbidden in this skeleton. {@link + * #isForbidden} / {@link #firstForbidden} are used by cardinality contract tests and {@code + * MeterFilter} config. Java stdlib only. + * + * <p>See the module README for why each tag is forbidden (unbounded-source time series explosion) + * and the deliberate {@code request_id} asymmetry — forbidden as a metric label here yet allowed as + * W3C baggage. Do not "fix" that asymmetry by removing {@code request_id} from the set. + */ +public final class ForbiddenMetricTags { + + private ForbiddenMetricTags() {} + + /** + * The exhaustive set of metric tag keys that must never appear in any registered metric: + * unbounded identifiers ({@code user_id}, {@code request_id}, {@code ip_address}) and raw HTTP + * sources ({@code raw_url}, {@code raw_query}, {@code raw_header_value}). See the module README + * for the per-tag rationale. + */ + public static final Set<String> FORBIDDEN = + Set.of("user_id", "request_id", "raw_url", "raw_query", "raw_header_value", "ip_address"); + + /** + * Returns {@code true} when {@code tagKey} is in the global forbidden set. + * + * @param tagKey metric tag key to check; {@code null} returns {@code false} + * @return {@code true} if the tag key is forbidden + */ + public static boolean isForbidden(String tagKey) { + return tagKey != null && FORBIDDEN.contains(tagKey); + } + + /** + * Returns the first forbidden tag key found in {@code tagKeys}, or {@link Optional#empty()} if + * none is forbidden. + * + * <p>Iteration order follows the order of the supplied {@link Collection}. A {@code null} + * collection is treated as empty. + * + * @param tagKeys candidate tag keys; may be {@code null} + * @return the first forbidden key found, or empty + */ + public static Optional<String> firstForbidden(Collection<String> tagKeys) { + if (tagKeys == null) { + return Optional.empty(); + } + for (String key : tagKeys) { + if (isForbidden(key)) { + return Optional.of(key); + } + } + return Optional.empty(); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java new file mode 100644 index 00000000..dd8938cf --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java @@ -0,0 +1,85 @@ +package dev.caskeleton.shared.metrics; + +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Naming and unit-suffix conventions for Micrometer dot.case metric names. Validates names ({@link + * #isValidName}) and unit suffixes ({@link #isAllowedUnit}), and converts a dot.case name to its + * Prometheus base name ({@link #toPrometheusName}). Java standard library only. + * + * <p>See the module README for the naming/unit conventions and the operational note on the extra + * suffixes the Prometheus exporter appends at runtime. + */ +public final class MetricNaming { + + private MetricNaming() {} + + /** + * Allowed Micrometer unit suffixes — every registry metric name must end with one of these: + * {@code seconds} (timers), {@code bytes} (byte gauges), {@code total} (counters). + */ + public static final Set<String> ALLOWED_UNITS = Set.of("seconds", "bytes", "total"); + + /** + * Pattern for a valid Micrometer dot.case metric name. + * + * <p>Rules enforced: + * + * <ul> + * <li>Non-empty. + * <li>Lowercase letters ({@code a-z}) and digits ({@code 0-9}) only — no uppercase, no + * underscores, no spaces. + * <li>Segments separated by exactly one dot. + * <li>Each segment starts with a lowercase letter (digits may follow). + * <li>No leading or trailing dot. + * </ul> + */ + private static final Pattern VALID_NAME_PATTERN = + Pattern.compile("[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9]*)*"); + + /** + * Returns {@code true} when {@code name} is a valid Micrometer dot.case metric name. Rules are + * enumerated on {@link #VALID_NAME_PATTERN}. + * + * @return {@code true} if the name satisfies the dot.case convention; {@code false} if {@code + * name} is {@code null} or blank + */ + public static boolean isValidName(String name) { + if (name == null || name.isBlank()) { + return false; + } + return VALID_NAME_PATTERN.matcher(name).matches(); + } + + /** + * Returns {@code true} when {@code unit} is one of the three allowed Micrometer unit suffixes: + * {@code "seconds"}, {@code "bytes"}, or {@code "total"} (case-sensitive). + * + * @param unit candidate unit string; {@code null} returns {@code false} + * @return {@code true} if unit is in {@link #ALLOWED_UNITS} + */ + public static boolean isAllowedUnit(String unit) { + return unit != null && ALLOWED_UNITS.contains(unit); + } + + /** + * Converts a Micrometer dot.case metric name to a Prometheus exposition <em>base name</em> by + * replacing every {@code .} with {@code _} (e.g. {@code "http.server.requests"} → {@code + * "http_server_requests"}). See the module README for the note on exporter-appended suffixes. + * + * @param dottedName a valid Micrometer dot.case name; must pass {@link #isValidName(String)} + * @return the Prometheus base name (dots replaced by underscores) + * @throws IllegalArgumentException if {@code dottedName} is {@code null} or fails {@link + * #isValidName(String)} + */ + public static String toPrometheusName(String dottedName) { + if (!isValidName(dottedName)) { + throw new IllegalArgumentException( + "Invalid Micrometer dot.case metric name: \"" + + dottedName + + "\". Name must match [a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)* ."); + } + return dottedName.replace('.', '_'); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/operation/Operation.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/operation/Operation.java new file mode 100644 index 00000000..c3684a98 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/operation/Operation.java @@ -0,0 +1,40 @@ +package dev.caskeleton.shared.operation; + +import dev.caskeleton.shared.response.ApiError; + +/** + * Long-running-operation polling body (feature-api-contract-baseline D17), returned as the {@code + * data} of an {@link dev.caskeleton.shared.response.Envelope} by {@code GET /v1/operations/{id}}. + * + * <ul> + * <li>{@code operationId} — opaque server id (also the last segment of the 202 {@code Location} + * header). Never {@code null}. + * <li>{@code status} — {@link OperationStatus}; clients poll until terminal ({@code + * SUCCEEDED}/{@code FAILED}/{@code CANCELLED}). + * <li>{@code statusUrl} — self link ({@code /v1/operations/{id}}); mirrors the 202 envelope + * {@code data.statusUrl}. + * <li>{@code result} — present only on {@code SUCCEEDED} (AIP151-C3), else {@code null}. + * <li>{@code error} — present only on {@code FAILED} (AIP151-C5), reusing the skeleton {@link + * ApiError} shape, else {@code null}. + * </ul> + * + * <p>Framework-neutral (no Jackson/Spring). The web adapter mints ids and the 202/Location wiring. + */ +public record Operation<T>( + String operationId, OperationStatus status, String statusUrl, T result, ApiError error) { + + /** Initial 202 acknowledgement body: id + statusUrl + PENDING, no result/error. */ + public static <T> Operation<T> pending(String operationId, String statusUrl) { + return new Operation<>(operationId, OperationStatus.PENDING, statusUrl, null, null); + } + + /** Terminal success: carries the result, no error. */ + public static <T> Operation<T> succeeded(String operationId, String statusUrl, T result) { + return new Operation<>(operationId, OperationStatus.SUCCEEDED, statusUrl, result, null); + } + + /** Terminal failure: carries the error, no result. */ + public static <T> Operation<T> failed(String operationId, String statusUrl, ApiError error) { + return new Operation<>(operationId, OperationStatus.FAILED, statusUrl, null, error); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/operation/OperationStatus.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/operation/OperationStatus.java new file mode 100644 index 00000000..d9004f08 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/operation/OperationStatus.java @@ -0,0 +1,16 @@ +package dev.caskeleton.shared.operation; + +/** + * Long-running-operation (LRO) lifecycle status surfaced by the polling endpoint {@code GET + * /v1/operations/{id}}. + * + * <p>See the module README for how this 5-value vocabulary maps onto the Google AIP-151 + * done/response/error model. + */ +public enum OperationStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/package-info.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/package-info.java new file mode 100644 index 00000000..6ba6b5ee --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/package-info.java @@ -0,0 +1,6 @@ +/** + * Skeleton-wide operational contracts such as response, error, headers, logging, tracing, metrics, + * registries, annotations, and concurrency (runtime context propagation — see {@link + * dev.caskeleton.shared.concurrency}). + */ +package dev.caskeleton.shared; diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/registry/.gitkeep b/src/shared-contract/src/main/java/dev/caskeleton/shared/registry/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/registry/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/request/Patch.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/request/Patch.java new file mode 100644 index 00000000..68387c37 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/request/Patch.java @@ -0,0 +1,85 @@ +package dev.caskeleton.shared.request; + +import java.util.Objects; + +/** + * Skeleton-wide 3-state value for PATCH command fields. Represents the caller's intent for a single + * updatable field: + * + * <ul> + * <li>{@link #absent()} — field omitted, no change; + * <li>{@link #ofNull()} — field present with explicit {@code null}, clear; + * <li>{@link #of(Object)} — field present with a value, replace. + * </ul> + * + * <p>See the module README for how the web adapter maps JSON {@code JsonNullable} into this + * Jackson-free type. + */ +public final class Patch<T> { + + private static final Patch<?> ABSENT = new Patch<>(false, null); + + private final boolean present; + private final T value; + + private Patch(boolean present, T value) { + this.present = present; + this.value = value; + } + + @SuppressWarnings("unchecked") + public static <T> Patch<T> absent() { + return (Patch<T>) ABSENT; + } + + public static <T> Patch<T> ofNull() { + return new Patch<>(true, null); + } + + public static <T> Patch<T> of(T value) { + return new Patch<>(true, value); + } + + public boolean isAbsent() { + return !present; + } + + public boolean isExplicitNull() { + return present && value == null; + } + + public boolean hasValue() { + return present && value != null; + } + + public T value() { + if (!present) { + throw new IllegalStateException("Patch is absent; check isAbsent() first"); + } + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Patch<?> other)) { + return false; + } + return present == other.present && Objects.equals(value, other.value); + } + + @Override + public int hashCode() { + return Objects.hash(present, value); + } + + @Override + public String toString() { + if (!present) { + return "Patch.absent"; + } + return value == null ? "Patch.null" : "Patch[" + value + "]"; + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/response/ApiError.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/ApiError.java new file mode 100644 index 00000000..aa88418d --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/ApiError.java @@ -0,0 +1,30 @@ +package dev.caskeleton.shared.response; + +/** + * Error payload inside an {@link Envelope} when {@code success = false}. + * + * <ul> + * <li>{@code code} — stable, machine-readable identifier. Clients switch on this, never on {@code + * message}. + * <li>{@code category} — coarse operational bucket (Category enum name). + * <li>{@code message} — client-facing reason; never stack traces / internal IDs. + * <li>{@code retryable} — operational metadata, lifted to 1st-class. + * <li>{@code details} — polymorphic per code; field errors for VALIDATION, per-item results for + * BATCH_PARTIAL_FAILURE, else {@code null}. + * </ul> + * + * <p>See the module README for why ProblemDetail is rejected and why {@code category} is a + * 1st-class field. + */ +public record ApiError( + String code, String category, String message, boolean retryable, Object details) { + + public static ApiError of(String code, String category, String message, boolean retryable) { + return new ApiError(code, category, message, retryable, null); + } + + public static ApiError withDetails( + String code, String category, String message, boolean retryable, Object details) { + return new ApiError(code, category, message, retryable, details); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java new file mode 100644 index 00000000..511ebdc8 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java @@ -0,0 +1,33 @@ +package dev.caskeleton.shared.response; + +import dev.caskeleton.shared.error.OperationalError; +import java.util.List; + +/** + * Bulk endpoint response envelope for B8. Reuses {@link ApiError} and {@link ResponseMeta}; + * exchanges {@code data} (single value) for {@code results} (list). + * + * <ul> + * <li>{@code success = true} only when every item succeeded; + * <li>any failure → {@code success = false} + {@code error.code = BATCH_PARTIAL_FAILURE} + + * per-item {@code details}; + * <li>{@code results} non-null only on full success. + * </ul> + */ +public record BulkEnvelope<T>(boolean success, List<T> results, ApiError error, ResponseMeta meta) { + + public static <T> BulkEnvelope<T> allOk(List<T> results, ResponseMeta meta) { + return new BulkEnvelope<>(true, results, null, meta); + } + + public static <T> BulkEnvelope<T> partial(List<BulkItemResult> details, ResponseMeta meta) { + ApiError err = + ApiError.withDetails( + OperationalError.BATCH_PARTIAL_FAILURE.code(), + OperationalError.BATCH_PARTIAL_FAILURE.category().name(), + "One or more items failed; see details[] for the per-item outcome.", + OperationalError.BATCH_PARTIAL_FAILURE.retryable(), + details); + return new BulkEnvelope<>(false, null, err, meta); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/response/BulkItemResult.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/BulkItemResult.java new file mode 100644 index 00000000..1f802f1d --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/BulkItemResult.java @@ -0,0 +1,22 @@ +package dev.caskeleton.shared.response; + +/** + * Per-item result inside a bulk endpoint's {@code error.details[]} array. + * + * <ul> + * <li>{@code status = "ok"} + {@code id} when the item succeeded; + * <li>{@code status = "error"} + {@code code} + {@code message} when failed. + * </ul> + * + * <p>No debug payload — keeps the envelope safe to log/ship. + */ +public record BulkItemResult(int index, String status, String id, String code, String message) { + + public static BulkItemResult ok(int index, String id) { + return new BulkItemResult(index, "ok", id, null, null); + } + + public static BulkItemResult error(int index, String code, String message) { + return new BulkItemResult(index, "error", null, code, message); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/response/Envelope.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/Envelope.java new file mode 100644 index 00000000..fdd1bced --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/Envelope.java @@ -0,0 +1,21 @@ +package dev.caskeleton.shared.response; + +/** + * Skeleton-wide single-item response envelope. Success and failure share one shape: a top-level + * {@code success} flag, a {@code data} field (success) or an {@code error} field (failure), and a + * {@code meta} object carrying request / trace / correlation ids. Exactly one of {@code data} / + * {@code error} is non-null. + * + * <p>See the module README for why this replaces RFC 7807 ProblemDetail and how {@code meta} + * replaced the earlier flat {@code traceId} field. + */ +public record Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta) { + + public static <T> Envelope<T> ok(T data, ResponseMeta meta) { + return new Envelope<>(true, data, null, meta); + } + + public static <T> Envelope<T> failure(ApiError error, ResponseMeta meta) { + return new Envelope<>(false, null, error, meta); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/response/PageMeta.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/PageMeta.java new file mode 100644 index 00000000..cf439eec --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/PageMeta.java @@ -0,0 +1,19 @@ +package dev.caskeleton.shared.response; + +/** + * Pagination metadata surfaced under the envelope {@code meta.page} object. Present only on + * collection (list) responses; {@code null} on single-item ones. + * + * <ul> + * <li>{@code number} — 0-indexed page index (Spring {@code Pageable} parity); {@code 0} = first + * page. + * <li>{@code size} — page size actually applied (after the 1..100 cap). + * <li>{@code total} — total element count across all pages; {@code 0} on an empty collection + * (whose {@code data} is {@code []}, never {@code null}). + * <li>{@code sort} — the applied sort expression in Spring native form ({@code + * "field,direction"}), or {@code null} when unsorted. + * </ul> + * + * <p>Framework-neutral (camelCase wire names, no Jackson). See the module README. + */ +public record PageMeta(int number, int size, long total, String sort) {} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/response/ResponseMeta.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/ResponseMeta.java new file mode 100644 index 00000000..63048052 --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/response/ResponseMeta.java @@ -0,0 +1,26 @@ +package dev.caskeleton.shared.response; + +/** + * Per-response correlation metadata surfaced as the envelope {@code meta} object. camelCase JSON + * field names are the wire form; the log/MDC form is snake_case ({@code request_id}/{@code + * trace_id}/{@code correlation_id}) and the projection happens in {@code adapter-web} (this module + * is framework-neutral). + * + * <p>{@code traceId} is never null on a real response. {@code page} carries pagination on + * collection responses and is {@code null} for a single item; the 3-arg constructor keeps + * single-item call sites unchanged and list controllers attach pagination via {@link + * #withPage(PageMeta)}. See the module README for the rationale (why {@code span_id} is not + * exposed, the null-traceId guarantee). + */ +public record ResponseMeta(String requestId, String traceId, String correlationId, PageMeta page) { + + /** Single-item meta (no pagination) — the common case. */ + public ResponseMeta(String requestId, String traceId, String correlationId) { + this(requestId, traceId, correlationId, null); + } + + /** Returns a copy carrying the given pagination metadata. */ + public ResponseMeta withPage(PageMeta page) { + return new ResponseMeta(requestId, traceId, correlationId, page); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/security/Permission.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/security/Permission.java new file mode 100644 index 00000000..1548592b --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/security/Permission.java @@ -0,0 +1,61 @@ +package dev.caskeleton.shared.security; + +import java.util.Locale; + +/** + * A unit of authorization enforcement, named {@code resource:action} — a 2-segment, lowercase, + * colon-delimited token (e.g. {@code worklog:close}). The as-built value object the {@code + * AuthorizationPort} contract consumes; lives in {@code shared-contract} so the application layer + * can express the contract without any Spring Security type. + * + * <p>See the module README for the grammar rationale — why the AWS IAM {@code service:Action} + * convention is followed and the dotted Google IAM form and 3-segment values are rejected. + */ +public record Permission(String resource, String action) { + + private static final char SEPARATOR = ':'; + + public Permission { + resource = normalise(resource, "resource"); + action = normalise(action, "action"); + } + + /** + * Parses a {@code resource:action} token into a {@link Permission}. Surrounding whitespace is + * trimmed and both segments are lowercased so an identifier that arrives with inconsistent casing + * still resolves to the same permission. + * + * @throws IllegalArgumentException if the token is null/blank or does not contain exactly one + * colon with non-blank segments on each side. + */ + public static Permission parse(String token) { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("permission token must not be null or blank"); + } + String trimmed = token.trim(); + int firstColon = trimmed.indexOf(SEPARATOR); + int lastColon = trimmed.lastIndexOf(SEPARATOR); + if (firstColon < 0 || firstColon != lastColon) { + throw new IllegalArgumentException( + "permission must be a 2-segment 'resource:action' token, got: " + token); + } + return new Permission(trimmed.substring(0, firstColon), trimmed.substring(firstColon + 1)); + } + + /** The canonical {@code resource:action} string form. */ + public String value() { + return resource + SEPARATOR + action; + } + + private static String normalise(String segment, String name) { + if (segment == null || segment.isBlank()) { + throw new IllegalArgumentException("permission " + name + " must not be null or blank"); + } + String cleaned = segment.trim(); + if (cleaned.indexOf(SEPARATOR) >= 0) { + throw new IllegalArgumentException( + "permission " + name + " must not contain ':' — got: " + segment); + } + return cleaned.toLowerCase(Locale.ROOT); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java new file mode 100644 index 00000000..93e724db --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java @@ -0,0 +1,115 @@ +package dev.caskeleton.shared.tracing; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * W3C/OTel baggage allowlist for this skeleton. {@link #filter} / {@link #filterHeader} strip any + * key not in {@link #ALLOWED} before propagating downstream. Java stdlib only. + * + * <p>See the module README for the decision (only {@code tenant_id} and {@code request_id} are + * permitted) and its W3C/OTel trust-boundary basis. + */ +public final class BaggageAllowlist { + + private BaggageAllowlist() {} + + /** Permitted baggage keys; any key not in this set must not cross a trust boundary. */ + public static final Set<String> ALLOWED = Set.of("tenant_id", "request_id"); + + /** + * Returns {@code true} when {@code key} is in the skeleton allowlist. + * + * @param key baggage key; {@code null} returns {@code false} + */ + public static boolean isAllowed(String key) { + return key != null && ALLOWED.contains(key); + } + + /** + * Returns a new map containing only the allowlisted entries from {@code baggage}. + * + * <p>Insertion order is preserved. A {@code null} input is treated as an empty map. + * + * @param baggage source map; may be {@code null} + * @return filtered map; never {@code null} + */ + public static Map<String, String> filter(Map<String, String> baggage) { + if (baggage == null || baggage.isEmpty()) { + return new LinkedHashMap<>(); + } + Map<String, String> result = new LinkedHashMap<>(); + for (Map.Entry<String, String> entry : baggage.entrySet()) { + if (isAllowed(entry.getKey())) { + result.put(entry.getKey(), entry.getValue()); + } + } + return result; + } + + /** + * Parses a W3C {@code baggage} header value into a map. + * + * <p>Format: {@code key1=value1,key2=value2}. Whitespace is trimmed around keys and values. + * Malformed segments (missing {@code =}) are silently skipped. A {@code null} or blank header + * returns an empty map. + * + * @param header raw {@code baggage} header value; may be {@code null} + * @return parsed key-value pairs; never {@code null} + */ + public static Map<String, String> parseHeader(String header) { + Map<String, String> result = new LinkedHashMap<>(); + if (header == null || header.isBlank()) { + return result; + } + String[] segments = header.split(",", -1); + for (String segment : segments) { + int eq = segment.indexOf('='); + if (eq < 0) { + continue; // malformed — skip + } + String key = segment.substring(0, eq).trim(); + String value = segment.substring(eq + 1).trim(); + if (key.isEmpty()) { + continue; // empty key after trim — skip + } + result.put(key, value); + } + return result; + } + + /** + * Renders a map of baggage entries as a W3C {@code baggage} header value. + * + * <p>Format: {@code key1=value1,key2=value2}. Returns {@code ""} for an empty or {@code null} + * map. + * + * @param entries baggage entries; may be {@code null} + * @return rendered header value, or {@code ""} if entries is empty/null + */ + public static String renderHeader(Map<String, String> entries) { + if (entries == null || entries.isEmpty()) { + return ""; + } + return entries.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(",")); + } + + /** + * Convenience method: parse → filter → render in one step. + * + * <p>Strips any baggage key not in the allowlist, then renders the remaining entries. Returns + * {@code ""} when the result is empty (including when {@code header} is {@code null} or blank). + * + * @param header raw {@code baggage} header value; may be {@code null} + * @return filtered header value, or {@code ""} if nothing remains + */ + public static String filterHeader(String header) { + Map<String, String> parsed = parseHeader(header); + Map<String, String> filtered = filter(parsed); + return renderHeader(filtered); + } +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java new file mode 100644 index 00000000..c6d4f62d --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java @@ -0,0 +1,31 @@ +package dev.caskeleton.shared.tracing; + +/** + * Seam for recording span errors without coupling production code to a specific tracer library, so + * adapter-web ({@code GlobalExceptionHandler}) and adapter-outbound can record span errors + * uniformly. Java standard library only; the {@link #NOOP} constant is the default when no tracer + * is on the classpath. + * + * <p>See the module README for why this exists (a fork-activated tracer seam) and the step-by-step + * implementation contract a tracer-backed fork must honour. + */ +public interface SpanErrorRecorder { + + /** + * Records an exception on the current span with the given error code. + * + * @param error the exception to record; implementations should handle {@code null} defensively + * (no-op) + * @param errorCode the ca-tmpl registry {@code error.code} attribute value (e.g. {@code + * "INTERNAL_ERROR"}); may be {@code null} + */ + void recordException(Throwable error, String errorCode); + + /** + * No-operation implementation. + * + * <p>Used in the skeleton template where no tracer is wired. A fork that activates Micrometer + * Tracing replaces this bean at composition-root level in {@code app-bootstrap}. + */ + SpanErrorRecorder NOOP = (error, errorCode) -> {}; +} diff --git a/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/TraceParent.java b/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/TraceParent.java new file mode 100644 index 00000000..7b20240e --- /dev/null +++ b/src/shared-contract/src/main/java/dev/caskeleton/shared/tracing/TraceParent.java @@ -0,0 +1,188 @@ +package dev.caskeleton.shared.tracing; + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Immutable value type for the W3C {@code traceparent} header. Format: {@code 00-<32 lowercase + * hex>-<16 lowercase hex>-<2 lowercase hex>}. Java standard library only (no + * Spring/OTel/Micrometer). + * + * <p>See the module README for why this lives here — the tracer runtime is a fork-activated seam, + * so adapter-web / adapter-outbound depend on the header shape without a tracer library. + * + * <h2>Strict W3C validation rules applied by {@link #parse} and {@link #of}</h2> + * + * <ul> + * <li>Version must be {@code 00}. + * <li>Exactly 4 dash-separated fields. + * <li>trace-id: 32 lowercase hex characters, must not be all zeros. + * <li>parent-id (span-id): 16 lowercase hex characters, must not be all zeros. + * <li>trace-flags: exactly 2 lowercase hex characters; any value accepted; {@code sampled} = + * least-significant bit ({@code flags & 0x01 != 0}). + * </ul> + */ +public final class TraceParent { + + private static final int TRACE_ID_LEN = 32; + private static final int SPAN_ID_LEN = 16; + private static final int FLAGS_LEN = 2; + private static final String VERSION = "00"; + + private static final String ALL_ZERO_TRACE_ID = "00000000000000000000000000000000"; + private static final String ALL_ZERO_SPAN_ID = "0000000000000000"; + + private static final Pattern LOWER_HEX = Pattern.compile("[0-9a-f]+"); + + private final String traceId; + private final String spanId; + private final boolean sampled; + + private TraceParent(String traceId, String spanId, boolean sampled) { + this.traceId = traceId; + this.spanId = spanId; + this.sampled = sampled; + } + + /** 32 lowercase hex characters; never all-zero. */ + public String traceId() { + return traceId; + } + + /** 16 lowercase hex characters; never all-zero. */ + public String spanId() { + return spanId; + } + + /** {@code true} when the least-significant bit of trace-flags is set ({@code 01}). */ + public boolean sampled() { + return sampled; + } + + /** + * Parses a W3C {@code traceparent} header value with strict validation. + * + * <p>Returns {@link Optional#empty()} (never throws) when the header is {@code null}, blank, or + * violates any W3C rule listed in the class javadoc. + * + * @param header raw header value, e.g. {@code "00-4bf9...-00f0...-01"} + * @return non-empty Optional when the header is valid + */ + public static Optional<TraceParent> parse(String header) { + if (header == null || header.isBlank()) { + return Optional.empty(); + } + String[] parts = header.split("-", -1); + if (parts.length != 4) { + return Optional.empty(); + } + String version = parts[0]; + String traceIdPart = parts[1]; + String spanIdPart = parts[2]; + String flagsPart = parts[3]; + + if (!VERSION.equals(version)) { + return Optional.empty(); + } + if (!isValidTraceId(traceIdPart)) { + return Optional.empty(); + } + if (!isValidSpanId(spanIdPart)) { + return Optional.empty(); + } + if (!isValidFlags(flagsPart)) { + return Optional.empty(); + } + boolean sampledBit = (Integer.parseInt(flagsPart, 16) & 0x01) != 0; + return Optional.of(new TraceParent(traceIdPart, spanIdPart, sampledBit)); + } + + /** + * Creates a {@code TraceParent} from validated components. + * + * <p>Both {@code traceId} and {@code spanId} must be lowercase hex of the correct length and must + * not be all zeros. No silent normalization is performed — callers must supply lowercase input. + * + * @param traceId 32 lowercase hex chars, non-zero + * @param spanId 16 lowercase hex chars, non-zero + * @param sampled whether this trace is sampled + * @throws IllegalArgumentException on any validation failure + */ + public static TraceParent of(String traceId, String spanId, boolean sampled) { + if (!isValidTraceId(traceId)) { + throw new IllegalArgumentException( + "Invalid traceId — must be 32 lowercase hex chars, non-zero; got: " + traceId); + } + if (!isValidSpanId(spanId)) { + throw new IllegalArgumentException( + "Invalid spanId — must be 16 lowercase hex chars, non-zero; got: " + spanId); + } + return new TraceParent(traceId, spanId, sampled); + } + + /** + * Renders the canonical W3C {@code traceparent} header value. + * + * <p>Returns {@code "00-<traceId>-<spanId>-01"} when sampled, {@code "00-<traceId>-<spanId>-00"} + * when not sampled. + */ + public String toHeader() { + String flags = sampled ? "01" : "00"; + return VERSION + "-" + traceId + "-" + spanId + "-" + flags; + } + + /** + * Returns {@code true} when {@code id} is exactly 32 lowercase hex characters and is not the + * all-zero value (W3C invalid). + */ + public static boolean isValidTraceId(String id) { + return id != null + && id.length() == TRACE_ID_LEN + && isLowerHex(id) + && !ALL_ZERO_TRACE_ID.equals(id); + } + + /** + * Returns {@code true} when {@code id} is exactly 16 lowercase hex characters and is not the + * all-zero value (W3C invalid). + */ + public static boolean isValidSpanId(String id) { + return id != null + && id.length() == SPAN_ID_LEN + && isLowerHex(id) + && !ALL_ZERO_SPAN_ID.equals(id); + } + + private static boolean isValidFlags(String flags) { + return flags != null && flags.length() == FLAGS_LEN && isLowerHex(flags); + } + + private static boolean isLowerHex(String s) { + if (s == null || s.isEmpty()) { + return false; + } + return LOWER_HEX.matcher(s).matches(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TraceParent other)) { + return false; + } + return sampled == other.sampled && traceId.equals(other.traceId) && spanId.equals(other.spanId); + } + + @Override + public int hashCode() { + return Objects.hash(traceId, spanId, sampled); + } + + @Override + public String toString() { + return "TraceParent{" + toHeader() + "}"; + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/concurrency/DomainContextPropagatorFactoryTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/concurrency/DomainContextPropagatorFactoryTest.java new file mode 100644 index 00000000..d0fc3c9f --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/concurrency/DomainContextPropagatorFactoryTest.java @@ -0,0 +1,26 @@ +package dev.caskeleton.shared.concurrency; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** The factory returns the default strategy's implementation (S2 swap seam). */ +class DomainContextPropagatorFactoryTest { + + @Test + void threadLocalStrategyYieldsTheThreadLocalPropagator() { + DomainContextPropagator propagator = + DomainContextPropagatorFactory.create(DomainContextStrategy.THREAD_LOCAL); + + assertThat(propagator).isInstanceOf(ThreadLocalDomainContextPropagator.class); + } + + @Test + void everyStrategyValueIsConstructible() { + // Guards the factory switch against a new enum value without a case (compile + + // runtime). Today THREAD_LOCAL is the only shipped strategy. + for (DomainContextStrategy strategy : DomainContextStrategy.values()) { + assertThat(DomainContextPropagatorFactory.create(strategy)).isNotNull(); + } + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagatorTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagatorTest.java new file mode 100644 index 00000000..86765262 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagatorTest.java @@ -0,0 +1,154 @@ +package dev.caskeleton.shared.concurrency; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Behaviour pins for the default domain-context propagator + * (feature-runtime-context-propagation-contract D4/D6). These mirror, on the domain channel, the + * guarantees {@code VirtualThreadMdcPropagationTest} pins for the diagnostic channel: same-thread + * visibility, explicit hand-off across threads, and NO silent inheritance. + */ +class ThreadLocalDomainContextPropagatorTest { + + private static final DomainContextKey<String> TENANT = + DomainContextKey.of("tenant_id", String.class); + private static final DomainContextKey<Integer> ATTEMPT = + DomainContextKey.of("attempt", Integer.class); + + private final DomainContextPropagator propagator = new ThreadLocalDomainContextPropagator(); + + @AfterEach + void cleanUp() { + propagator.clear(); + } + + @Test + void setThenGetReturnsTheTypedValueOnTheSameThread() { + propagator.set(TENANT, "acme"); + propagator.set(ATTEMPT, 3); + + assertThat(propagator.get(TENANT)).contains("acme"); + assertThat(propagator.get(ATTEMPT)).contains(3); + } + + @Test + void getIsEmptyWhenUnset() { + assertThat(propagator.get(TENANT)).isEmpty(); + } + + @Test + void settingNullRemovesTheBinding() { + propagator.set(TENANT, "acme"); + propagator.set(TENANT, null); + + assertThat(propagator.get(TENANT)).isEmpty(); + } + + @Test + void clearDropsAllBindings() { + propagator.set(TENANT, "acme"); + propagator.set(ATTEMPT, 1); + + propagator.clear(); + + assertThat(propagator.get(TENANT)).isEmpty(); + assertThat(propagator.get(ATTEMPT)).isEmpty(); + } + + @Test + void snapshotIsNotAffectedByLaterMutations() { + propagator.set(TENANT, "acme"); + DomainContextSnapshot snapshot = propagator.capture(); + + propagator.set(TENANT, "globex"); // mutate after capture + + try (DomainContextSnapshot.DomainContextScope ignored = snapshot.restore()) { + assertThat(propagator.get(TENANT)) + .as("snapshot captured the value at capture() time") + .contains("acme"); + } + } + + @Test + void restoreScopeRevertsToThePreviousContextOnClose() { + DomainContextSnapshot empty = propagator.capture(); // captured before any set → empty + propagator.set(TENANT, "pre-existing"); + + try (DomainContextSnapshot.DomainContextScope ignored = empty.restore()) { + assertThat(propagator.get(TENANT)).as("empty snapshot installed").isEmpty(); + } + + assertThat(propagator.get(TENANT)) + .as("close() reverts to the worker thread's previous context") + .contains("pre-existing"); + } + + @Test + void wrapPropagatesDomainContextIntoAVirtualThread() throws InterruptedException { + propagator.set(TENANT, "acme"); + AtomicReference<String> seenInChild = new AtomicReference<>("unset"); + AtomicReference<Boolean> ranOnVirtual = new AtomicReference<>(); + + Runnable wrapped = + propagator.wrap( + () -> { + ranOnVirtual.set(Thread.currentThread().isVirtual()); + seenInChild.set(propagator.get(TENANT).orElse(null)); + }); + + Thread vt = Thread.ofVirtual().start(wrapped); + vt.join(); + + assertThat(ranOnVirtual.get()).as("precondition: ran on a virtual thread").isTrue(); + assertThat(seenInChild.get()) + .as("wrap() carried domain context across the thread boundary") + .isEqualTo("acme"); + } + + @Test + void contextIsNotSilentlyInheritedWithoutWrap() throws InterruptedException { + propagator.set(TENANT, "acme"); + AtomicReference<String> seenInChild = new AtomicReference<>("unset"); + + // Spawned WITHOUT wrap() — must NOT inherit (the InheritableThreadLocal trap). + Thread vt = + Thread.ofVirtual().start(() -> seenInChild.set(propagator.get(TENANT).orElse(null))); + vt.join(); + + assertThat(seenInChild.get()) + .as("no implicit inheritance — hand-off must be explicit via wrap()/capture()") + .isNull(); + } + + @Test + void wrapClearsTheWorkerThreadAfterRunning() throws InterruptedException { + propagator.set(TENANT, "acme"); + AtomicReference<String> afterRun = new AtomicReference<>("unset"); + + Runnable wrapped = + propagator.wrap( + () -> { + /* uses context */ + }); + + Thread vt = + Thread.ofVirtual() + .start( + () -> { + wrapped.run(); + afterRun.set( + propagator + .get(TENANT) + .orElse(null)); // same worker thread, after the scope closed + }); + vt.join(); + + assertThat(afterRun.get()) + .as("the restore scope reverts the worker thread; no leaked context") + .isNull(); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/contract/.gitkeep b/src/shared-contract/src/test/java/dev/caskeleton/shared/contract/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/contract/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/error/AdapterDisabledExceptionTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/AdapterDisabledExceptionTest.java new file mode 100644 index 00000000..a16bee3f --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/AdapterDisabledExceptionTest.java @@ -0,0 +1,40 @@ +package dev.caskeleton.shared.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class AdapterDisabledExceptionTest { + + @Test + void carriesTheAdapterName() { + AdapterDisabledException ex = new AdapterDisabledException("kafka"); + assertThat(ex.adapterName()).isEqualTo("kafka"); + } + + @Test + void messageNamesTheDisabledAdapter() { + AdapterDisabledException ex = new AdapterDisabledException("redis"); + assertThat(ex.getMessage()).contains("redis").contains("disabled"); + } + + @Test + void isAnUncheckedRuntimeException() { + // Layer 3 must fail fast on a normal invoke path without forcing a checked + // signature onto every adapter method. + assertThat(new AdapterDisabledException("slack")).isInstanceOf(RuntimeException.class); + } + + @Test + void detailOverloadUsesTheCallerSuppliedMessage() { + AdapterDisabledException ex = + new AdapterDisabledException("cache", "no cache backend bound for logical cache 'worklog'"); + assertThat(ex.getMessage()).isEqualTo("no cache backend bound for logical cache 'worklog'"); + } + + @Test + void detailOverloadStillCarriesTheAdapterName() { + AdapterDisabledException ex = new AdapterDisabledException("cache", "custom detail"); + assertThat(ex.adapterName()).isEqualTo("cache"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/error/CategoryTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/CategoryTest.java new file mode 100644 index 00000000..c2b2e5f0 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/CategoryTest.java @@ -0,0 +1,25 @@ +package dev.caskeleton.shared.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CategoryTest { + + @Test + void enumHasExactlyTheTenContractCategories() { + assertThat(Category.values()) + .extracting(Enum::name) + .containsExactly( + "VALIDATION", + "AUTH", + "AUTHZ", + "NOT_FOUND", + "CONFLICT", + "RATE_LIMIT", + "TRANSIENT_DEPENDENCY", + "PERMANENT_DEPENDENCY", + "DATA_INTEGRITY", + "INTERNAL"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/error/DependencyFailureExceptionTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/DependencyFailureExceptionTest.java new file mode 100644 index 00000000..ad09f5b7 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/DependencyFailureExceptionTest.java @@ -0,0 +1,80 @@ +package dev.caskeleton.shared.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DependencyFailureExceptionTest { + + @Test + void carriesTheErrorCode() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_TIMEOUT, + "payment-service", + "read timeout after 5000ms", + null); + assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT); + } + + @Test + void carriesTheDependencyName() { + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_CONNECT_FAILED, + "inventory-service", + "connection refused on port 8080", + null); + assertThat(ex.dependencyName()).isEqualTo("inventory-service"); + } + + @Test + void carriesTheDiagnosticMessageAsExceptionMessage() { + // diagnosticMessage is the server-log-only detail — it is getMessage() on the exception + // and must NEVER be surfaced to the API client (web adapter uses errorCode() instead). + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_DNS_FAILED, + "user-service", + "DNS resolution failed for user-service.internal", + null); + assertThat(ex.getMessage()).isEqualTo("DNS resolution failed for user-service.internal"); + } + + @Test + void carriesTheCause() { + RuntimeException cause = new RuntimeException("raw upstream socket error"); + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_5XX_SERVER, + "notification-service", + "upstream returned HTTP 503", + cause); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void isAnUncheckedRuntimeException() { + // adapter-outbound must throw without a checked signature on every outbound port method. + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_CIRCUIT_OPEN, "external-api", "circuit breaker open", null); + assertThat(ex).isInstanceOf(RuntimeException.class); + } + + @Test + void diagnosticMessageDoesNotCarryRawUpstreamBodyContract() { + // Test contract: an upstream raw response body must NEVER appear in what getMessage() + // returns. The diagnostic message is a structured server-log detail, not a proxy of the + // upstream response body. This test verifies the contract by ensuring the message + // field is exactly the value passed in — not something the constructor appends from + // any upstream body field. + String diagnosticMessage = "classified failure: upstream returned 4xx"; + DependencyFailureException ex = + new DependencyFailureException( + OperationalError.DEPENDENCY_4XX_CLIENT, "billing-service", diagnosticMessage, null); + assertThat(ex.getMessage()).isEqualTo(diagnosticMessage); + // The message must not embed anything beyond what the caller explicitly provided. + assertThat(ex.getMessage()).doesNotContain("upstream raw error body"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java new file mode 100644 index 00000000..a971083e --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java @@ -0,0 +1,394 @@ +package dev.caskeleton.shared.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class OperationalErrorTest { + + @Test + void validationFailedIs400NotRetryable() { + assertThat(OperationalError.VALIDATION_FAILED.code()).isEqualTo("VALIDATION_FAILED"); + assertThat(OperationalError.VALIDATION_FAILED.httpStatus()).isEqualTo(400); + assertThat(OperationalError.VALIDATION_FAILED.retryable()).isFalse(); + } + + @Test + void batchPartialFailureIs200() { + assertThat(OperationalError.BATCH_PARTIAL_FAILURE.httpStatus()).isEqualTo(200); + assertThat(OperationalError.BATCH_PARTIAL_FAILURE.retryable()).isFalse(); + } + + @Test + void internalErrorIs500Retryable() { + assertThat(OperationalError.INTERNAL_ERROR.httpStatus()).isEqualTo(500); + assertThat(OperationalError.INTERNAL_ERROR.retryable()).isTrue(); + } + + @Test + void lockAcquisitionTimeoutIsARetryable409Conflict() { + // feature-distributed-lock-contract D7: distributed-lock acquisition timeout is a + // retryable CONFLICT (409), never a generic 500 — lock contention is transient. + assertThat(OperationalError.LOCK_ACQUISITION_TIMEOUT.code()) + .isEqualTo("LOCK_ACQUISITION_TIMEOUT"); + assertThat(OperationalError.LOCK_ACQUISITION_TIMEOUT.category()).isEqualTo(Category.CONFLICT); + assertThat(OperationalError.LOCK_ACQUISITION_TIMEOUT.httpStatus()).isEqualTo(409); + assertThat(OperationalError.LOCK_ACQUISITION_TIMEOUT.retryable()).isTrue(); + } + + @Test + void implementsApiErrorCodeContract() { + ApiErrorCode code = OperationalError.MAPPING_FAILED; + assertThat(code.code()).isEqualTo("MAPPING_FAILED"); + assertThat(code.httpStatus()).isEqualTo(400); + } + + @Test + void everyCodeReportsACategory() { + for (OperationalError e : OperationalError.values()) { + assertThat(e.category()).as("%s must declare a category", e.name()).isNotNull(); + } + } + + @Test + void categoryAssignmentsMatchTheContractTable() { + assertThat(OperationalError.VALIDATION_FAILED.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.BAD_PARAMETER.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.MAPPING_FAILED.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.BATCH_PARTIAL_FAILURE.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.INTERNAL_ERROR.category()).isEqualTo(Category.INTERNAL); + assertThat(OperationalError.UNAUTHENTICATED.category()).isEqualTo(Category.AUTH); + assertThat(OperationalError.INVALID_TOKEN.category()).isEqualTo(Category.AUTH); + assertThat(OperationalError.FORBIDDEN.category()).isEqualTo(Category.AUTHZ); + assertThat(OperationalError.METHOD_NOT_ALLOWED.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.UNSUPPORTED_MEDIA_TYPE.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.ROUTE_NOT_FOUND.category()).isEqualTo(Category.NOT_FOUND); + } + + @Test + void transportNegotiationAndSizeCodesMapToContractStatuses() { + // feature-api-contract-baseline D9 (406/415), D8 (413/414), D12 (405) + assertThat(OperationalError.NOT_ACCEPTABLE.httpStatus()).isEqualTo(406); + assertThat(OperationalError.NOT_ACCEPTABLE.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.PAYLOAD_TOO_LARGE.httpStatus()).isEqualTo(413); + assertThat(OperationalError.PAYLOAD_TOO_LARGE.category()).isEqualTo(Category.VALIDATION); + assertThat(OperationalError.URI_TOO_LONG.httpStatus()).isEqualTo(414); + assertThat(OperationalError.URI_TOO_LONG.category()).isEqualTo(Category.VALIDATION); + } + + @Test + void preconditionFailedIs412ConflictNotRetryable() { + // feature-api-contract-baseline D15 — If-Match mismatch is an optimistic-lock conflict + assertThat(OperationalError.PRECONDITION_FAILED.httpStatus()).isEqualTo(412); + assertThat(OperationalError.PRECONDITION_FAILED.category()).isEqualTo(Category.CONFLICT); + assertThat(OperationalError.PRECONDITION_FAILED.retryable()).isFalse(); + } + + // ---- feature-business-rule-validation-contract C5 (category × retryable 정합) ---- + + @Test + void deterministicClientErrorCategoriesAreNeverRetryable() { + // Claim 5: VALIDATION / AUTHZ / NOT_FOUND mean "the SAME request can never succeed" + // (malformed input, denied permission, absent resource), so retryable=true would tell + // clients to busy-loop on a request that cannot succeed. AUTH is intentionally EXCLUDED: + // it is context-dependent — e.g. an unknown JWKS `kid` during key rotation is retryable + // once the key set refreshes (registry AUTH_KID_UNKNOWN, retryable=true). + for (OperationalError e : OperationalError.values()) { + if (e.category() == Category.VALIDATION + || e.category() == Category.AUTHZ + || e.category() == Category.NOT_FOUND) { + assertThat(e.retryable()) + .as( + "%s is a deterministic %s client error and must be retryable=false (C5)", + e.name(), e.category()) + .isFalse(); + } + } + } + + @Test + void internalCategoryCodesAreRetryable() { + // Claim 5: a *transient* INTERNAL failure is a server-side fault the same input may + // clear on retry. INTERNAL_AUTH_MISCONFIGURATION is the documented exception: a + // protected endpoint leaking through as public is a deploy-time config bug, not a + // transient fault — the same request keeps hitting the misconfiguration until a + // redeploy, so retrying never helps (registry SSOT pins it retryable=false). + for (OperationalError e : OperationalError.values()) { + if (e.category() == Category.INTERNAL + && e != OperationalError.INTERNAL_AUTH_MISCONFIGURATION + && e != OperationalError.ADAPTER_DISABLED + && e != OperationalError.OUTBOX_DEAD_LETTER + && e != OperationalError.JOB_DEAD_LETTER + && e != OperationalError.JVM_OOM) { + assertThat(e.retryable()) + .as("%s is a transient INTERNAL fault and must be retryable=true (C5)", e.name()) + .isTrue(); + } + } + assertThat(OperationalError.INTERNAL_AUTH_MISCONFIGURATION.retryable()) + .as("INTERNAL_AUTH_MISCONFIGURATION is a deterministic config bug → retryable=false") + .isFalse(); + // ADAPTER_DISABLED, like INTERNAL_AUTH_MISCONFIGURATION, is a deterministic + // config/programming bug (the adapter stays disabled until redeploy), so retry + // never helps → retryable=false (integration-adapter-templates Layer 3 / §Audit A2). + assertThat(OperationalError.ADAPTER_DISABLED.retryable()) + .as("ADAPTER_DISABLED is a deterministic disabled-adapter invoke → retryable=false") + .isFalse(); + // OUTBOX_DEAD_LETTER: the outbox row has exhausted all retry attempts and moved to DEAD + // state. Manual intervention (DLQ drain / runbook) is required; the same automatic + // publish path never clears it → retryable=false (feature-domain-event-outbox-contract D13). + assertThat(OperationalError.OUTBOX_DEAD_LETTER.retryable()) + .as( + "OUTBOX_DEAD_LETTER is a terminal outbox state requiring manual DLQ drain → retryable=false") + .isFalse(); + // JOB_DEAD_LETTER: a background job exhausted its retry attempts and moved to the DLQ. + // Manual intervention (DLQ drain / runbook) is required; the same automatic retry path + // never clears it → retryable=false (feature-background-job-async-contract D4). + assertThat(OperationalError.JOB_DEAD_LETTER.retryable()) + .as("JOB_DEAD_LETTER is a terminal DLQ state requiring manual drain → retryable=false") + .isFalse(); + // JVM_OOM: the JVM dies on OutOfMemoryError (ExitOnOutOfMemoryError → exit 137). + // Retrying the same request never clears the heap pressure; terminal, not transient. + // feature-container-runtime-contract §5 (registry SSOT: INTERNAL/500/false). + assertThat(OperationalError.JVM_OOM.retryable()) + .as("JVM_OOM is a terminal OutOfMemoryError (JVM dies) → retryable=false") + .isFalse(); + } + + // ---- feature-integration-adapter-templates Layer 3 (D4 / §Audit A2) ---- + + @Test + void adapterDisabledIs500InternalNotRetryable() { + assertThat(OperationalError.ADAPTER_DISABLED.code()).isEqualTo("ADAPTER_DISABLED"); + assertThat(OperationalError.ADAPTER_DISABLED.httpStatus()).isEqualTo(500); + assertThat(OperationalError.ADAPTER_DISABLED.category()).isEqualTo(Category.INTERNAL); + assertThat(OperationalError.ADAPTER_DISABLED.retryable()).isFalse(); + } + + // ---- feature-security-operational-baseline AuthN/AuthZ Decision Matrix (12 codes) ---- + + @Test + void fineGrainedAuthCodesAre401Auth() { + for (OperationalError e : + new OperationalError[] { + OperationalError.AUTH_TOKEN_MISSING, + OperationalError.AUTH_TOKEN_MALFORMED, + OperationalError.AUTH_TOKEN_EXPIRED, + OperationalError.AUTH_TOKEN_INVALID_SIGNATURE, + OperationalError.AUTH_ISSUER_MISMATCH, + OperationalError.AUTH_AUDIENCE_MISMATCH, + OperationalError.AUTH_KID_UNKNOWN, + OperationalError.AUTH_CLAIM_MAPPING_FAILED + }) { + assertThat(e.httpStatus()).as("%s must be 401", e.name()).isEqualTo(401); + assertThat(e.category()).as("%s must be AUTH", e.name()).isEqualTo(Category.AUTH); + } + } + + @Test + void kidUnknownIsTheOnlyRetryableAuthCode() { + // AUTH_KID_UNKNOWN is retryable (JWKS refresh during rotation); every other AUTH-category + // classification is a deterministic failure of this request. + assertThat(OperationalError.AUTH_KID_UNKNOWN.retryable()).isTrue(); + assertThat(OperationalError.AUTH_TOKEN_EXPIRED.retryable()).isFalse(); + assertThat(OperationalError.AUTH_TOKEN_INVALID_SIGNATURE.retryable()).isFalse(); + assertThat(OperationalError.AUTH_ISSUER_MISMATCH.retryable()).isFalse(); + assertThat(OperationalError.AUTH_AUDIENCE_MISMATCH.retryable()).isFalse(); + } + + @Test + void jwksUnavailableIs503TransientDependencyRetryable() { + assertThat(OperationalError.AUTH_JWKS_UNAVAILABLE.httpStatus()).isEqualTo(503); + assertThat(OperationalError.AUTH_JWKS_UNAVAILABLE.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.AUTH_JWKS_UNAVAILABLE.retryable()).isTrue(); + } + + @Test + void authzCodesAre403AuthzNotRetryable() { + assertThat(OperationalError.AUTHZ_INSUFFICIENT_PERMISSION.httpStatus()).isEqualTo(403); + assertThat(OperationalError.AUTHZ_INSUFFICIENT_PERMISSION.category()).isEqualTo(Category.AUTHZ); + assertThat(OperationalError.AUTHZ_TENANT_MISMATCH.httpStatus()).isEqualTo(403); + assertThat(OperationalError.AUTHZ_TENANT_MISMATCH.category()).isEqualTo(Category.AUTHZ); + } + + @Test + void publicPathMisconfigurationIs500Internal() { + assertThat(OperationalError.INTERNAL_AUTH_MISCONFIGURATION.httpStatus()).isEqualTo(500); + assertThat(OperationalError.INTERNAL_AUTH_MISCONFIGURATION.category()) + .isEqualTo(Category.INTERNAL); + } + + // ---- feature-rate-limit-idempotency-contract (D1/D7/D8 — error-codes.yaml SSOT) ---- + + @Test + void rateLimitExceededIs429RateLimitRetryable() { + // D1 / error-codes.yaml#RATE_LIMIT_EXCEEDED — retryable=true paired with a Retry-After header. + assertThat(OperationalError.RATE_LIMIT_EXCEEDED.code()).isEqualTo("RATE_LIMIT_EXCEEDED"); + assertThat(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus()).isEqualTo(429); + assertThat(OperationalError.RATE_LIMIT_EXCEEDED.category()).isEqualTo(Category.RATE_LIMIT); + assertThat(OperationalError.RATE_LIMIT_EXCEEDED.retryable()).isTrue(); + } + + @Test + void idempotentInFlightIs409ConflictNotRetryable() { + // D7 / error-codes.yaml#IDEMPOTENT_IN_FLIGHT — client polls for the in-flight result, not + // retries. + assertThat(OperationalError.IDEMPOTENT_IN_FLIGHT.code()).isEqualTo("IDEMPOTENT_IN_FLIGHT"); + assertThat(OperationalError.IDEMPOTENT_IN_FLIGHT.httpStatus()).isEqualTo(409); + assertThat(OperationalError.IDEMPOTENT_IN_FLIGHT.category()).isEqualTo(Category.CONFLICT); + assertThat(OperationalError.IDEMPOTENT_IN_FLIGHT.retryable()).isFalse(); + } + + @Test + void idempotentRequestMismatchIs422ValidationNotRetryable() { + // D8 / error-codes.yaml#IDEMPOTENT_REQUEST_MISMATCH — same key + different body is a client + // bug. + assertThat(OperationalError.IDEMPOTENT_REQUEST_MISMATCH.code()) + .isEqualTo("IDEMPOTENT_REQUEST_MISMATCH"); + assertThat(OperationalError.IDEMPOTENT_REQUEST_MISMATCH.httpStatus()).isEqualTo(422); + assertThat(OperationalError.IDEMPOTENT_REQUEST_MISMATCH.category()) + .isEqualTo(Category.VALIDATION); + assertThat(OperationalError.IDEMPOTENT_REQUEST_MISMATCH.retryable()).isFalse(); + } + + // ---- feature-outbound-http-client-baseline D12 (error-codes.yaml rows 636~711) ---- + + @Test + void dependencyTimeoutIs504TransientDependencyRetryable() { + // error-codes.yaml#DEPENDENCY_TIMEOUT — upstream did not respond in time (read/global-call + // timeout) + assertThat(OperationalError.DEPENDENCY_TIMEOUT.code()).isEqualTo("DEPENDENCY_TIMEOUT"); + assertThat(OperationalError.DEPENDENCY_TIMEOUT.httpStatus()).isEqualTo(504); + assertThat(OperationalError.DEPENDENCY_TIMEOUT.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DEPENDENCY_TIMEOUT.retryable()).isTrue(); + } + + @Test + void dependencyConnectFailedIs503TransientDependencyRetryable() { + // error-codes.yaml#DEPENDENCY_CONNECT_FAILED — TCP connect refused / connect timeout + assertThat(OperationalError.DEPENDENCY_CONNECT_FAILED.code()) + .isEqualTo("DEPENDENCY_CONNECT_FAILED"); + assertThat(OperationalError.DEPENDENCY_CONNECT_FAILED.httpStatus()).isEqualTo(503); + assertThat(OperationalError.DEPENDENCY_CONNECT_FAILED.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DEPENDENCY_CONNECT_FAILED.retryable()).isTrue(); + } + + @Test + void dependencyDnsFailedIs503TransientDependencyRetryable() { + // error-codes.yaml#DEPENDENCY_DNS_FAILED — name resolution failure + assertThat(OperationalError.DEPENDENCY_DNS_FAILED.code()).isEqualTo("DEPENDENCY_DNS_FAILED"); + assertThat(OperationalError.DEPENDENCY_DNS_FAILED.httpStatus()).isEqualTo(503); + assertThat(OperationalError.DEPENDENCY_DNS_FAILED.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DEPENDENCY_DNS_FAILED.retryable()).isTrue(); + } + + @Test + void dependency4xxClientIs502PermanentDependencyNotRetryable() { + // error-codes.yaml#DEPENDENCY_4XX_CLIENT — upstream rejected the request (4xx). + // Known edge: 408/429 are semantically retryable but the registry SSOT classifies all 4xx + // as PERMANENT_DEPENDENCY (retryable=false). See feature-outbound-http-client-baseline D12. + assertThat(OperationalError.DEPENDENCY_4XX_CLIENT.code()).isEqualTo("DEPENDENCY_4XX_CLIENT"); + assertThat(OperationalError.DEPENDENCY_4XX_CLIENT.httpStatus()).isEqualTo(502); + assertThat(OperationalError.DEPENDENCY_4XX_CLIENT.category()) + .isEqualTo(Category.PERMANENT_DEPENDENCY); + assertThat(OperationalError.DEPENDENCY_4XX_CLIENT.retryable()).isFalse(); + } + + @Test + void dependency5xxServerIs502TransientDependencyRetryable() { + // error-codes.yaml#DEPENDENCY_5XX_SERVER — upstream server error + assertThat(OperationalError.DEPENDENCY_5XX_SERVER.code()).isEqualTo("DEPENDENCY_5XX_SERVER"); + assertThat(OperationalError.DEPENDENCY_5XX_SERVER.httpStatus()).isEqualTo(502); + assertThat(OperationalError.DEPENDENCY_5XX_SERVER.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DEPENDENCY_5XX_SERVER.retryable()).isTrue(); + } + + @Test + void dependencyCircuitOpenIs503TransientDependencyRetryable() { + // error-codes.yaml#DEPENDENCY_CIRCUIT_OPEN — circuit breaker open; also reused for + // shutdown-rejection fail-fast (feature-outbound-http-client-baseline plan decision I6) + assertThat(OperationalError.DEPENDENCY_CIRCUIT_OPEN.code()) + .isEqualTo("DEPENDENCY_CIRCUIT_OPEN"); + assertThat(OperationalError.DEPENDENCY_CIRCUIT_OPEN.httpStatus()).isEqualTo(503); + assertThat(OperationalError.DEPENDENCY_CIRCUIT_OPEN.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.DEPENDENCY_CIRCUIT_OPEN.retryable()).isTrue(); + } + + // ---- feature-domain-event-outbox-contract D13 (error-codes.yaml rows 724-749) ---- + + @Test + void outboxPublishFailedIs500TransientDependencyRetryable() { + // D13: transient publish failure → FAILED state + backoff retry. + // error-codes.yaml#OUTBOX_PUBLISH_FAILED — retryable=true with retry_after_seconds=30. + assertThat(OperationalError.OUTBOX_PUBLISH_FAILED.code()).isEqualTo("OUTBOX_PUBLISH_FAILED"); + assertThat(OperationalError.OUTBOX_PUBLISH_FAILED.httpStatus()).isEqualTo(500); + assertThat(OperationalError.OUTBOX_PUBLISH_FAILED.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.OUTBOX_PUBLISH_FAILED.retryable()).isTrue(); + } + + @Test + void outboxDeadLetterIs500InternalNotRetryable() { + // D13: max attempts exhausted → DEAD state; the row moves to DLQ and retrying the same + // publish never clears it without manual intervention. retryable=false (registry SSOT). + // error-codes.yaml#OUTBOX_DEAD_LETTER — category=INTERNAL, retryable=false. + assertThat(OperationalError.OUTBOX_DEAD_LETTER.code()).isEqualTo("OUTBOX_DEAD_LETTER"); + assertThat(OperationalError.OUTBOX_DEAD_LETTER.httpStatus()).isEqualTo(500); + assertThat(OperationalError.OUTBOX_DEAD_LETTER.category()).isEqualTo(Category.INTERNAL); + assertThat(OperationalError.OUTBOX_DEAD_LETTER.retryable()).isFalse(); + } + + // ---- feature-background-job-async-contract D4/D7 (error-codes.yaml L756/770/784) ---- + + @Test + void jobExecutorRejectedIs503TransientDependencyRetryable() { + // D7: bounded executor saturation → AbortPolicy rejection; transient capacity fault that + // clears once load drops. error-codes.yaml#JOB_EXECUTOR_REJECTED — retryable=true, Retry-After + // 5s. + assertThat(OperationalError.JOB_EXECUTOR_REJECTED.code()).isEqualTo("JOB_EXECUTOR_REJECTED"); + assertThat(OperationalError.JOB_EXECUTOR_REJECTED.httpStatus()).isEqualTo(503); + assertThat(OperationalError.JOB_EXECUTOR_REJECTED.category()) + .isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.JOB_EXECUTOR_REJECTED.retryable()).isTrue(); + } + + @Test + void jobTimeoutIs500TransientDependencyRetryable() { + // D4/D8: in-flight job exceeded its budget (incl. 19s graceful-shutdown interrupt) → + // transient, retry-on-next-cycle. error-codes.yaml#JOB_TIMEOUT — retryable=true, Retry-After + // 10s. + assertThat(OperationalError.JOB_TIMEOUT.code()).isEqualTo("JOB_TIMEOUT"); + assertThat(OperationalError.JOB_TIMEOUT.httpStatus()).isEqualTo(500); + assertThat(OperationalError.JOB_TIMEOUT.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY); + assertThat(OperationalError.JOB_TIMEOUT.retryable()).isTrue(); + } + + @Test + void jobDeadLetterIs500InternalNotRetryable() { + // D4: retry attempts exhausted → DLQ terminal state; manual intervention required. + // error-codes.yaml#JOB_DEAD_LETTER — category=INTERNAL, retryable=false. + assertThat(OperationalError.JOB_DEAD_LETTER.code()).isEqualTo("JOB_DEAD_LETTER"); + assertThat(OperationalError.JOB_DEAD_LETTER.httpStatus()).isEqualTo(500); + assertThat(OperationalError.JOB_DEAD_LETTER.category()).isEqualTo(Category.INTERNAL); + assertThat(OperationalError.JOB_DEAD_LETTER.retryable()).isFalse(); + } + + // ---- feature-container-runtime-contract §5 (JVM OutOfMemoryError classification) ---- + + @Test + void jvmOomIs500InternalNotRetryable() { + // feature-container-runtime-contract §5: JVM OOM is a terminal fault — the JVM dies + // (ExitOnOutOfMemoryError → exit 137); retrying the same request never clears it. + // error-codes.yaml#JVM_OOM — category=INTERNAL, http_status=500, retryable=false. + assertThat(OperationalError.JVM_OOM.code()).isEqualTo("JVM_OOM"); + assertThat(OperationalError.JVM_OOM.httpStatus()).isEqualTo(500); + assertThat(OperationalError.JVM_OOM.category()).isEqualTo(Category.INTERNAL); + assertThat(OperationalError.JVM_OOM.retryable()).isFalse(); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/AlertSeverityTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/AlertSeverityTest.java new file mode 100644 index 00000000..e5bbd414 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/AlertSeverityTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.shared.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link AlertSeverity} — feature-metrics-alerting-contract D7. Pure JUnit; no + * Spring context. + */ +class AlertSeverityTest { + + // ---------------------------------------------------------------- + // enum constants + // ---------------------------------------------------------------- + + @Test + void enumHasExactlyP1P2P3() { + assertThat(AlertSeverity.values()) + .containsExactlyInAnyOrder(AlertSeverity.P1, AlertSeverity.P2, AlertSeverity.P3); + } + + // ---------------------------------------------------------------- + // key() + // ---------------------------------------------------------------- + + @Test + void keyReturnsLowercaseP1() { + assertThat(AlertSeverity.P1.key()).isEqualTo("p1"); + } + + @Test + void keyReturnsLowercaseP2() { + assertThat(AlertSeverity.P2.key()).isEqualTo("p2"); + } + + @Test + void keyReturnsLowercaseP3() { + assertThat(AlertSeverity.P3.key()).isEqualTo("p3"); + } + + // ---------------------------------------------------------------- + // fromKey — happy path + // ---------------------------------------------------------------- + + @Test + void fromKeyLowercaseP1Resolves() { + assertThat(AlertSeverity.fromKey("p1")).isEqualTo(AlertSeverity.P1); + } + + @Test + void fromKeyUppercaseP2Resolves() { + assertThat(AlertSeverity.fromKey("P2")).isEqualTo(AlertSeverity.P2); + } + + @Test + void fromKeyMixedCaseP3Resolves() { + assertThat(AlertSeverity.fromKey("P3")).isEqualTo(AlertSeverity.P3); + } + + @Test + void fromKeyAndKeyAreRoundtripConsistent() { + for (AlertSeverity s : AlertSeverity.values()) { + assertThat(AlertSeverity.fromKey(s.key())).isEqualTo(s); + } + } + + // ---------------------------------------------------------------- + // fromKey — error path + // ---------------------------------------------------------------- + + @Test + void fromKeyNullThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> AlertSeverity.fromKey(null)) + .withMessageContaining("null"); + } + + @Test + void fromKeyUnknownKeyThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> AlertSeverity.fromKey("critical")) + .withMessageContaining("critical"); + } + + @Test + void fromKeyEmptyKeyThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> AlertSeverity.fromKey("")); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/CardinalityBoundsTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/CardinalityBoundsTest.java new file mode 100644 index 00000000..c1151a47 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/CardinalityBoundsTest.java @@ -0,0 +1,106 @@ +package dev.caskeleton.shared.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link CardinalityBounds} — feature-metrics-alerting-contract §Cardinality + * Bounds. Pure JUnit; no Spring context. + */ +class CardinalityBoundsTest { + + // ---------------------------------------------------------------- + // named int constants + // ---------------------------------------------------------------- + + @Test + void statusCodeConstantIs7() { + assertThat(CardinalityBounds.STATUS_CODE).isEqualTo(7); + } + + @Test + void uriTemplateConstantIs200() { + assertThat(CardinalityBounds.URI_TEMPLATE).isEqualTo(200); + } + + @Test + void dependencyNameConstantIs50() { + assertThat(CardinalityBounds.DEPENDENCY_NAME).isEqualTo(50); + } + + @Test + void errorCodeConstantIs100() { + assertThat(CardinalityBounds.ERROR_CODE).isEqualTo(100); + } + + @Test + void tenantIdConstantIs1000() { + assertThat(CardinalityBounds.TENANT_ID).isEqualTo(1000); + } + + @Test + void resilience4jOutcomeConstantIs5() { + assertThat(CardinalityBounds.RESILIENCE4J_OUTCOME).isEqualTo(5); + } + + // ---------------------------------------------------------------- + // limitFor — known tag keys + // ---------------------------------------------------------------- + + @Test + void limitForStatusCodeReturns7() { + assertThat(CardinalityBounds.limitFor("status_code")).hasValue(7); + } + + @Test + void limitForStatusAlsoReturns7() { + // registry HTTP row tag is named "status", not "status_code"; both map to 7 + assertThat(CardinalityBounds.limitFor("status")).hasValue(7); + } + + @Test + void limitForUriTemplateReturns200() { + assertThat(CardinalityBounds.limitFor("uri_template")).hasValue(200); + } + + @Test + void limitForDependencyNameReturns50() { + assertThat(CardinalityBounds.limitFor("dependency_name")).hasValue(50); + } + + @Test + void limitForErrorCodeReturns100() { + assertThat(CardinalityBounds.limitFor("error_code")).hasValue(100); + } + + @Test + void limitForTenantIdReturns1000() { + assertThat(CardinalityBounds.limitFor("tenant_id")).hasValue(1000); + } + + @Test + void limitForOutcomeReturns5() { + // resilience4j uses tag named "outcome" + assertThat(CardinalityBounds.limitFor("outcome")).hasValue(5); + } + + // ---------------------------------------------------------------- + // limitFor — unknown / null tag keys + // ---------------------------------------------------------------- + + @Test + void limitForUnknownTagReturnsEmpty() { + assertThat(CardinalityBounds.limitFor("method")).isEmpty(); + } + + @Test + void limitForNullReturnsEmpty() { + assertThat(CardinalityBounds.limitFor(null)).isEmpty(); + } + + @Test + void limitForEmptyStringReturnsEmpty() { + assertThat(CardinalityBounds.limitFor("")).isEmpty(); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/ForbiddenMetricTagsTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/ForbiddenMetricTagsTest.java new file mode 100644 index 00000000..b763de4f --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/ForbiddenMetricTagsTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.shared.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link ForbiddenMetricTags} — feature-metrics-alerting-contract D8. Pure + * JUnit; no Spring context. + */ +class ForbiddenMetricTagsTest { + + // ---------------------------------------------------------------- + // FORBIDDEN constant + // ---------------------------------------------------------------- + + @Test + void forbiddenSetContainsAllSixHighCardinalityTags() { + assertThat(ForbiddenMetricTags.FORBIDDEN) + .containsExactlyInAnyOrder( + "user_id", "request_id", "raw_url", "raw_query", "raw_header_value", "ip_address"); + } + + // ---------------------------------------------------------------- + // isForbidden — true cases + // ---------------------------------------------------------------- + + @Test + void isForbiddenUserIdIsTrue() { + assertThat(ForbiddenMetricTags.isForbidden("user_id")).isTrue(); + } + + @Test + void isForbiddenRequestIdIsTrue() { + assertThat(ForbiddenMetricTags.isForbidden("request_id")).isTrue(); + } + + @Test + void isForbiddenRawUrlIsTrue() { + assertThat(ForbiddenMetricTags.isForbidden("raw_url")).isTrue(); + } + + @Test + void isForbiddenRawQueryIsTrue() { + assertThat(ForbiddenMetricTags.isForbidden("raw_query")).isTrue(); + } + + @Test + void isForbiddenRawHeaderValueIsTrue() { + assertThat(ForbiddenMetricTags.isForbidden("raw_header_value")).isTrue(); + } + + @Test + void isForbiddenIpAddressIsTrue() { + assertThat(ForbiddenMetricTags.isForbidden("ip_address")).isTrue(); + } + + // ---------------------------------------------------------------- + // isForbidden — false cases + // ---------------------------------------------------------------- + + @Test + void isForbiddenNullIsFalse() { + assertThat(ForbiddenMetricTags.isForbidden(null)).isFalse(); + } + + @Test + void isForbiddenAllowedTagIsFalse() { + assertThat(ForbiddenMetricTags.isForbidden("uri_template")).isFalse(); + assertThat(ForbiddenMetricTags.isForbidden("status_code")).isFalse(); + assertThat(ForbiddenMetricTags.isForbidden("tenant_id")).isFalse(); + } + + @Test + void isForbiddenEmptyStringIsFalse() { + assertThat(ForbiddenMetricTags.isForbidden("")).isFalse(); + } + + // ---------------------------------------------------------------- + // firstForbidden — found cases + // ---------------------------------------------------------------- + + @Test + void firstForbiddenReturnsFirstForbiddenKeyFound() { + List<String> tags = Arrays.asList("uri_template", "user_id", "request_id"); + assertThat(ForbiddenMetricTags.firstForbidden(tags)).hasValue("user_id"); + } + + @Test + void firstForbiddenSingleForbiddenTagFound() { + assertThat(ForbiddenMetricTags.firstForbidden(List.of("ip_address"))).hasValue("ip_address"); + } + + // ---------------------------------------------------------------- + // firstForbidden — not found cases + // ---------------------------------------------------------------- + + @Test + void firstForbiddenNoForbiddenTagsReturnsEmpty() { + List<String> tags = List.of("uri_template", "status_code", "method"); + assertThat(ForbiddenMetricTags.firstForbidden(tags)).isEmpty(); + } + + @Test + void firstForbiddenEmptyCollectionReturnsEmpty() { + assertThat(ForbiddenMetricTags.firstForbidden(Collections.emptyList())).isEmpty(); + } + + @Test + void firstForbiddenNullCollectionReturnsEmpty() { + assertThat(ForbiddenMetricTags.firstForbidden(null)).isEmpty(); + } + + // ---------------------------------------------------------------- + // Intentional contrast: request_id is forbidden HERE (metric label) + // but permitted as baggage (BaggageAllowlist.ALLOWED contains "request_id"). + // This test documents that the asymmetry is intentional. + // ---------------------------------------------------------------- + + @Test + void requestIdIsForbiddenAsMetricLabelEvenThoughItIsAllowedBaggage() { + // Metric labels with request_id would create one time series per request — + // millions of series. Use trace/exemplar instead. + assertThat(ForbiddenMetricTags.isForbidden("request_id")).isTrue(); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/MetricNamingTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/MetricNamingTest.java new file mode 100644 index 00000000..78e54358 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/metrics/MetricNamingTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.shared.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link MetricNaming} — feature-metrics-alerting-contract D2/D3. Pure JUnit; no + * Spring context. + */ +class MetricNamingTest { + + // ---------------------------------------------------------------- + // ALLOWED_UNITS constant + // ---------------------------------------------------------------- + + @Test + void allowedUnitsContainsExactlySecondsBytesTotal() { + assertThat(MetricNaming.ALLOWED_UNITS).containsExactlyInAnyOrder("seconds", "bytes", "total"); + } + + // ---------------------------------------------------------------- + // isValidName — valid cases + // ---------------------------------------------------------------- + + @Test + void isValidNameHttpServerRequestsIsValid() { + assertThat(MetricNaming.isValidName("http.server.requests")).isTrue(); + } + + @Test + void isValidNameSingleSegmentIsValid() { + assertThat(MetricNaming.isValidName("jvm")).isTrue(); + } + + @Test + void isValidNameDigitsInSegmentAreValid() { + assertThat(MetricNaming.isValidName("jvm.gc.pause")).isTrue(); + } + + @Test + void isValidNameSegmentWithDigitsAfterLetterIsValid() { + assertThat(MetricNaming.isValidName("http2.requests")).isTrue(); + } + + // ---------------------------------------------------------------- + // isValidName — invalid cases + // ---------------------------------------------------------------- + + @Test + void isValidNameNullIsFalse() { + assertThat(MetricNaming.isValidName(null)).isFalse(); + } + + @Test + void isValidNameBlankIsFalse() { + assertThat(MetricNaming.isValidName(" ")).isFalse(); + } + + @Test + void isValidNameUppercaseIsFalse() { + assertThat(MetricNaming.isValidName("Http.Server")).isFalse(); + } + + @Test + void isValidNameUnderscoreIsFalse() { + assertThat(MetricNaming.isValidName("http_server")).isFalse(); + } + + @Test + void isValidNameSpaceIsFalse() { + assertThat(MetricNaming.isValidName("http server")).isFalse(); + } + + @Test + void isValidNameDoubleDotIsFalse() { + assertThat(MetricNaming.isValidName("http..server")).isFalse(); + } + + @Test + void isValidNameLeadingDotIsFalse() { + assertThat(MetricNaming.isValidName(".http.server")).isFalse(); + } + + @Test + void isValidNameTrailingDotIsFalse() { + assertThat(MetricNaming.isValidName("http.server.")).isFalse(); + } + + @Test + void isValidNameSegmentStartingWithDigitIsFalse() { + assertThat(MetricNaming.isValidName("http.1server")).isFalse(); + } + + @Test + void isValidNameEmptyIsFalse() { + assertThat(MetricNaming.isValidName("")).isFalse(); + } + + // ---------------------------------------------------------------- + // isAllowedUnit + // ---------------------------------------------------------------- + + @Test + void isAllowedUnitSecondsIsTrue() { + assertThat(MetricNaming.isAllowedUnit("seconds")).isTrue(); + } + + @Test + void isAllowedUnitBytesIsTrue() { + assertThat(MetricNaming.isAllowedUnit("bytes")).isTrue(); + } + + @Test + void isAllowedUnitTotalIsTrue() { + assertThat(MetricNaming.isAllowedUnit("total")).isTrue(); + } + + @Test + void isAllowedUnitUppercaseSecondsIsFalse() { + assertThat(MetricNaming.isAllowedUnit("Seconds")).isFalse(); + } + + @Test + void isAllowedUnitNullIsFalse() { + assertThat(MetricNaming.isAllowedUnit(null)).isFalse(); + } + + @Test + void isAllowedUnitUnknownIsFalse() { + assertThat(MetricNaming.isAllowedUnit("milliseconds")).isFalse(); + } + + // ---------------------------------------------------------------- + // toPrometheusName — happy path + // ---------------------------------------------------------------- + + @Test + void toPrometheusNameReplacesDotsWithUnderscores() { + assertThat(MetricNaming.toPrometheusName("http.server.requests")) + .isEqualTo("http_server_requests"); + } + + @Test + void toPrometheusNameSingleSegmentUnchanged() { + assertThat(MetricNaming.toPrometheusName("jvm")).isEqualTo("jvm"); + } + + @Test + void toPrometheusNameDeepNameConvertsAllDots() { + assertThat(MetricNaming.toPrometheusName("jvm.gc.pause")).isEqualTo("jvm_gc_pause"); + } + + // ---------------------------------------------------------------- + // toPrometheusName — error path + // ---------------------------------------------------------------- + + @Test + void toPrometheusNameNullThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> MetricNaming.toPrometheusName(null)); + } + + @Test + void toPrometheusNameInvalidNameThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> MetricNaming.toPrometheusName("Http.Server")); + } + + @Test + void toPrometheusNameUnderscoreNameThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> MetricNaming.toPrometheusName("http_server")); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/operation/OperationTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/operation/OperationTest.java new file mode 100644 index 00000000..ab3a66bb --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/operation/OperationTest.java @@ -0,0 +1,47 @@ +package dev.caskeleton.shared.operation; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.shared.response.ApiError; +import org.junit.jupiter.api.Test; + +/** + * Pins the D17 long-running-operation polling body shape: the 5-value status vocabulary and the + * pending/succeeded/failed factory invariants. + */ +class OperationTest { + + @Test + void statusEnumIsTheFiveValueContractVocabulary() { + assertThat(OperationStatus.values()) + .extracting(Enum::name) + .containsExactly("PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"); + } + + @Test + void pendingCarriesIdAndStatusUrlNoResultNoError() { + Operation<String> op = Operation.pending("01HZX", "/v1/operations/01HZX"); + assertThat(op.operationId()).isEqualTo("01HZX"); + assertThat(op.status()).isEqualTo(OperationStatus.PENDING); + assertThat(op.statusUrl()).isEqualTo("/v1/operations/01HZX"); + assertThat(op.result()).isNull(); + assertThat(op.error()).isNull(); + } + + @Test + void succeededCarriesResultNoError() { + Operation<String> op = Operation.succeeded("01HZX", "/v1/operations/01HZX", "done"); + assertThat(op.status()).isEqualTo(OperationStatus.SUCCEEDED); + assertThat(op.result()).isEqualTo("done"); + assertThat(op.error()).isNull(); + } + + @Test + void failedCarriesErrorNoResult() { + ApiError err = ApiError.of("INTERNAL_ERROR", "INTERNAL", "boom", true); + Operation<String> op = Operation.failed("01HZX", "/v1/operations/01HZX", err); + assertThat(op.status()).isEqualTo(OperationStatus.FAILED); + assertThat(op.result()).isNull(); + assertThat(op.error().code()).isEqualTo("INTERNAL_ERROR"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/response/ApiErrorTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/response/ApiErrorTest.java new file mode 100644 index 00000000..61e397a8 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/response/ApiErrorTest.java @@ -0,0 +1,27 @@ +package dev.caskeleton.shared.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class ApiErrorTest { + + @Test + void ofSetsCategoryAndNullDetails() { + ApiError e = ApiError.of("VALIDATION_FAILED", "VALIDATION", "bad", false); + assertThat(e.code()).isEqualTo("VALIDATION_FAILED"); + assertThat(e.category()).isEqualTo("VALIDATION"); + assertThat(e.message()).isEqualTo("bad"); + assertThat(e.retryable()).isFalse(); + assertThat(e.details()).isNull(); + } + + @Test + void withDetailsCarriesCategoryAndDetails() { + Object details = List.of("x"); + ApiError e = ApiError.withDetails("MAPPING_FAILED", "VALIDATION", "m", false, details); + assertThat(e.category()).isEqualTo("VALIDATION"); + assertThat(e.details()).isEqualTo(details); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/response/EnvelopeTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/response/EnvelopeTest.java new file mode 100644 index 00000000..69e1ccd2 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/response/EnvelopeTest.java @@ -0,0 +1,46 @@ +package dev.caskeleton.shared.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class EnvelopeTest { + + private static final ResponseMeta META = new ResponseMeta("req-1", "trace-1", "corr-1"); + + @Test + void okCarriesDataAndMetaNoError() { + Envelope<String> e = Envelope.ok("payload", META); + assertThat(e.success()).isTrue(); + assertThat(e.data()).isEqualTo("payload"); + assertThat(e.error()).isNull(); + assertThat(e.meta().requestId()).isEqualTo("req-1"); + assertThat(e.meta().traceId()).isEqualTo("trace-1"); + assertThat(e.meta().correlationId()).isEqualTo("corr-1"); + } + + @Test + void failureCarriesErrorAndMetaNoData() { + ApiError err = ApiError.of("INTERNAL_ERROR", "INTERNAL", "boom", true); + Envelope<Void> e = Envelope.failure(err, META); + assertThat(e.success()).isFalse(); + assertThat(e.data()).isNull(); + assertThat(e.error().category()).isEqualTo("INTERNAL"); + assertThat(e.meta().traceId()).isEqualTo("trace-1"); + } + + @Test + void bulkAllOkAndPartialCarryMeta() { + BulkEnvelope<String> ok = BulkEnvelope.allOk(List.of("a"), META); + assertThat(ok.success()).isTrue(); + assertThat(ok.meta().requestId()).isEqualTo("req-1"); + + BulkEnvelope<String> partial = + BulkEnvelope.partial(List.of(BulkItemResult.error(0, "MAPPING_FAILED", "x")), META); + assertThat(partial.success()).isFalse(); + assertThat(partial.error().code()).isEqualTo("BATCH_PARTIAL_FAILURE"); + assertThat(partial.error().category()).isEqualTo("VALIDATION"); + assertThat(partial.meta().correlationId()).isEqualTo("corr-1"); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/response/ResponseMetaPageTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/response/ResponseMetaPageTest.java new file mode 100644 index 00000000..a08f5b85 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/response/ResponseMetaPageTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.shared.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the D7/D18 pagination addition to {@link ResponseMeta}: the legacy 3-arg single-item + * constructor stays page-free, and {@link ResponseMeta#withPage} adds a {@link PageMeta} without + * disturbing the correlation ids. + */ +class ResponseMetaPageTest { + + @Test + void threeArgConstructorLeavesPageNull() { + ResponseMeta meta = new ResponseMeta("req-1", "trace-1", "corr-1"); + assertThat(meta.page()).isNull(); + assertThat(meta.requestId()).isEqualTo("req-1"); + assertThat(meta.traceId()).isEqualTo("trace-1"); + assertThat(meta.correlationId()).isEqualTo("corr-1"); + } + + @Test + void withPageAttachesPageMetaAndKeepsIds() { + ResponseMeta meta = + new ResponseMeta("req-1", "trace-1", "corr-1") + .withPage(new PageMeta(0, 20, 0, "createdAt,desc")); + assertThat(meta.page()).isNotNull(); + assertThat(meta.page().number()).isZero(); + assertThat(meta.page().size()).isEqualTo(20); + assertThat(meta.page().total()).isZero(); + assertThat(meta.page().sort()).isEqualTo("createdAt,desc"); + assertThat(meta.requestId()).isEqualTo("req-1"); + assertThat(meta.correlationId()).isEqualTo("corr-1"); + } + + @Test + void emptyCollectionPageReportsZeroTotal() { + PageMeta page = new PageMeta(0, 20, 0, null); + assertThat(page.total()).isZero(); + assertThat(page.sort()).isNull(); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/security/PermissionTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/security/PermissionTest.java new file mode 100644 index 00000000..6b9bf069 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/security/PermissionTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.shared.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class PermissionTest { + + @Test + void parsesResourceActionIntoComponents() { + Permission p = Permission.parse("worklog:close"); + + assertThat(p.resource()).isEqualTo("worklog"); + assertThat(p.action()).isEqualTo("close"); + assertThat(p.value()).isEqualTo("worklog:close"); + } + + @Test + void valueRoundTripsThroughParse() { + Permission p = Permission.parse("worklog:write"); + + assertThat(Permission.parse(p.value())).isEqualTo(p); + } + + @Test + void normalisesToLowercaseSoLookupIsCaseInsensitive() { + // D6: permission naming is lowercase colon-delimited. A token that arrives + // with mixed case must normalise so registry lookups stay stable. + Permission p = Permission.parse("WorkLog:Close"); + + assertThat(p.resource()).isEqualTo("worklog"); + assertThat(p.action()).isEqualTo("close"); + assertThat(p).isEqualTo(Permission.parse("worklog:close")); + } + + @Test + void trimsSurroundingWhitespace() { + Permission p = Permission.parse(" worklog:read "); + + assertThat(p).isEqualTo(Permission.parse("worklog:read")); + } + + @Test + void constructorRejectsBlankComponents() { + assertThatThrownBy(() -> new Permission("worklog", " ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new Permission(" ", "read")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void parseRejectsNullOrBlank() { + assertThatThrownBy(() -> Permission.parse(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Permission.parse(" ")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void parseRejectsTokensWithoutExactlyOneColon() { + // D6: 2-segment `resource:action`. Zero colons or a 3-segment value is rejected + // until a multi-service `service:resource:action` grammar is intentionally adopted. + assertThatThrownBy(() -> Permission.parse("worklog")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Permission.parse("svc:worklog:close")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Permission.parse("worklog:")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Permission.parse(":read")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/BaggageAllowlistTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/BaggageAllowlistTest.java new file mode 100644 index 00000000..ef0ce40f --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/BaggageAllowlistTest.java @@ -0,0 +1,188 @@ +package dev.caskeleton.shared.tracing; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link BaggageAllowlist} — W3C/OTel baggage allowlist (D2/D8). Pure JUnit; no + * Spring context. + */ +class BaggageAllowlistTest { + + // ---------------------------------------------------------------- + // ALLOWED constant + // ---------------------------------------------------------------- + + @Test + void allowedSetContainsExactlyTenantIdAndRequestId() { + assertThat(BaggageAllowlist.ALLOWED).containsExactlyInAnyOrder("tenant_id", "request_id"); + } + + // ---------------------------------------------------------------- + // isAllowed + // ---------------------------------------------------------------- + + @Test + void isAllowedTenantIdIsTrue() { + assertThat(BaggageAllowlist.isAllowed("tenant_id")).isTrue(); + } + + @Test + void isAllowedRequestIdIsTrue() { + assertThat(BaggageAllowlist.isAllowed("request_id")).isTrue(); + } + + @Test + void isAllowedUnknownKeyIsFalse() { + assertThat(BaggageAllowlist.isAllowed("user_id")).isFalse(); + assertThat(BaggageAllowlist.isAllowed("authorization")).isFalse(); + assertThat(BaggageAllowlist.isAllowed("")).isFalse(); + } + + @Test + void isAllowedNullIsFalse() { + assertThat(BaggageAllowlist.isAllowed(null)).isFalse(); + } + + // ---------------------------------------------------------------- + // filter(Map) + // ---------------------------------------------------------------- + + @Test + void filterRemovesNonAllowlistedKeys() { + Map<String, String> input = new LinkedHashMap<>(); + input.put("tenant_id", "acme"); + input.put("user_id", "secret"); + input.put("request_id", "req-123"); + + Map<String, String> result = BaggageAllowlist.filter(input); + assertThat(result).containsOnlyKeys("tenant_id", "request_id"); + assertThat(result.get("tenant_id")).isEqualTo("acme"); + assertThat(result.get("request_id")).isEqualTo("req-123"); + } + + @Test + void filterEmptyMapReturnsEmpty() { + assertThat(BaggageAllowlist.filter(Map.of())).isEmpty(); + } + + @Test + void filterNullMapReturnsEmpty() { + assertThat(BaggageAllowlist.filter(null)).isEmpty(); + } + + @Test + void filterAllNonAllowedReturnsEmpty() { + Map<String, String> input = Map.of("token", "secret", "password", "pw"); + assertThat(BaggageAllowlist.filter(input)).isEmpty(); + } + + @Test + void filterPreservesInsertionOrder() { + Map<String, String> input = new LinkedHashMap<>(); + input.put("tenant_id", "acme"); + input.put("request_id", "req-1"); + Map<String, String> result = BaggageAllowlist.filter(input); + assertThat(result.keySet()).containsExactly("tenant_id", "request_id"); + } + + // ---------------------------------------------------------------- + // parseHeader + // ---------------------------------------------------------------- + + @Test + void parseHeaderValidTwoEntries() { + Map<String, String> result = BaggageAllowlist.parseHeader("tenant_id=acme,request_id=req-123"); + assertThat(result.get("tenant_id")).isEqualTo("acme"); + assertThat(result.get("request_id")).isEqualTo("req-123"); + } + + @Test + void parseHeaderTrimsWhitespace() { + Map<String, String> result = + BaggageAllowlist.parseHeader(" tenant_id = acme , request_id = req-1 "); + assertThat(result.get("tenant_id")).isEqualTo("acme"); + assertThat(result.get("request_id")).isEqualTo("req-1"); + } + + @Test + void parseHeaderSkipsMalformedSegments() { + // "noequals" has no '=' → skip; valid entry still parsed + Map<String, String> result = BaggageAllowlist.parseHeader("noequals,tenant_id=acme"); + assertThat(result).containsOnlyKeys("tenant_id"); + assertThat(result.get("tenant_id")).isEqualTo("acme"); + } + + @Test + void parseHeaderNullReturnsEmpty() { + assertThat(BaggageAllowlist.parseHeader(null)).isEmpty(); + } + + @Test + void parseHeaderBlankReturnsEmpty() { + assertThat(BaggageAllowlist.parseHeader(" ")).isEmpty(); + } + + @Test + void parseHeaderEmptyKeyAfterTrimSkipped() { + // "=value" → key is empty after trim → skip + Map<String, String> result = BaggageAllowlist.parseHeader("=value,tenant_id=acme"); + assertThat(result).containsOnlyKeys("tenant_id"); + } + + // ---------------------------------------------------------------- + // renderHeader + // ---------------------------------------------------------------- + + @Test + void renderHeaderTwoEntries() { + Map<String, String> entries = new LinkedHashMap<>(); + entries.put("tenant_id", "acme"); + entries.put("request_id", "req-123"); + String header = BaggageAllowlist.renderHeader(entries); + assertThat(header).isEqualTo("tenant_id=acme,request_id=req-123"); + } + + @Test + void renderHeaderSingleEntry() { + Map<String, String> entries = Map.of("tenant_id", "acme"); + String header = BaggageAllowlist.renderHeader(entries); + assertThat(header).isEqualTo("tenant_id=acme"); + } + + @Test + void renderHeaderEmptyMapReturnsEmptyString() { + // Contract: renderHeader returns "" (never null) so a caller can set the + // baggage header value directly without a null guard. + String result = BaggageAllowlist.renderHeader(Map.of()); + assertThat(result).isEmpty(); + } + + // ---------------------------------------------------------------- + // filterHeader — parse → filter → render + // ---------------------------------------------------------------- + + @Test + void filterHeaderRetainsAllowedRemovesForbidden() { + String header = "tenant_id=acme,user_id=secret,request_id=req-1"; + String result = BaggageAllowlist.filterHeader(header); + assertThat(result).contains("tenant_id=acme"); + assertThat(result).contains("request_id=req-1"); + assertThat(result).doesNotContain("user_id"); + } + + @Test + void filterHeaderAllForbiddenReturnsEmptyString() { + String result = BaggageAllowlist.filterHeader("user_id=secret,token=abc"); + assertThat(result).isEmpty(); + } + + @Test + void filterHeaderNullReturnsEmptyString() { + String result = BaggageAllowlist.filterHeader(null); + assertThat(result).isEmpty(); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/SpanErrorRecorderTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/SpanErrorRecorderTest.java new file mode 100644 index 00000000..5d2916a8 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/SpanErrorRecorderTest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.shared.tracing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link SpanErrorRecorder} — D12 span-error-recording seam. Pure JUnit; no + * Spring context. + */ +class SpanErrorRecorderTest { + + // ---------------------------------------------------------------- + // NOOP constant + // ---------------------------------------------------------------- + + @Test + void noopConstantExistsAndIsNotNull() { + assertThat(SpanErrorRecorder.NOOP).isNotNull(); + } + + @Test + void noopRecordExceptionDoesNothingWithoutThrowing() { + // The NOOP impl must silently swallow both parameters — including null + assertThatNoException() + .isThrownBy( + () -> + SpanErrorRecorder.NOOP.recordException( + new RuntimeException("test"), "INTERNAL_ERROR")); + } + + @Test + void noopRecordExceptionWithNullErrorCodeDoesNothing() { + assertThatNoException() + .isThrownBy( + () -> + SpanErrorRecorder.NOOP.recordException(new IllegalArgumentException("bad"), null)); + } + + @Test + void noopRecordExceptionWithNullThrowableDoesNothing() { + assertThatNoException() + .isThrownBy(() -> SpanErrorRecorder.NOOP.recordException(null, "MAPPING_FAILED")); + } + + // ---------------------------------------------------------------- + // Interface contract — custom implementation wiring + // ---------------------------------------------------------------- + + @Test + void customImplementationIsInvokedCorrectly() { + // Verify the interface can be implemented and called; the lambda captures calls. + var capturedError = new Throwable[1]; + var capturedCode = new String[1]; + + SpanErrorRecorder recorder = + (error, errorCode) -> { + capturedError[0] = error; + capturedCode[0] = errorCode; + }; + + RuntimeException ex = new RuntimeException("boom"); + recorder.recordException(ex, "VALIDATION_FAILED"); + + assertThat(capturedError[0]).as("throwable must be forwarded").isSameAs(ex); + assertThat(capturedCode[0]).as("errorCode must be forwarded").isEqualTo("VALIDATION_FAILED"); + } + + @Test + void noopIsInstanceOfSpanErrorRecorder() { + assertThatNoException() + .isThrownBy( + () -> { + SpanErrorRecorder r = SpanErrorRecorder.NOOP; + r.recordException(new Exception("x"), "CODE"); + }); + } +} diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/TraceParentTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/TraceParentTest.java new file mode 100644 index 00000000..83128b22 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/tracing/TraceParentTest.java @@ -0,0 +1,241 @@ +package dev.caskeleton.shared.tracing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** + * TDD unit tests for {@link TraceParent} — W3C traceparent contract (D5/D7). Pure JUnit; no Spring + * context. + */ +class TraceParentTest { + + private static final String VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; + private static final String VALID_SPAN_ID = "00f067aa0ba902b7"; + private static final String VALID_HEADER = "00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-01"; + + // ---------------------------------------------------------------- + // parse — happy path + // ---------------------------------------------------------------- + + @Test + void parseValidSampledHeader() { + Optional<TraceParent> result = TraceParent.parse(VALID_HEADER); + assertThat(result).isPresent(); + TraceParent tp = result.get(); + assertThat(tp.traceId()).isEqualTo(VALID_TRACE_ID); + assertThat(tp.spanId()).isEqualTo(VALID_SPAN_ID); + assertThat(tp.sampled()).isTrue(); + } + + @Test + void parseValidUnsampledHeader() { + String header = "00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00"; + Optional<TraceParent> result = TraceParent.parse(header); + assertThat(result).isPresent(); + assertThat(result.get().sampled()).isFalse(); + } + + @Test + void parseFlags02IsUnsampledLsbZero() { + // flags = 0x02 = 0b00000010 → LSB = 0 → not sampled + String header = "00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-02"; + Optional<TraceParent> result = TraceParent.parse(header); + assertThat(result).isPresent(); + assertThat(result.get().sampled()).isFalse(); + } + + @Test + void parseFlags03IsSampledLsbOne() { + // flags = 0x03 = 0b00000011 → LSB = 1 → sampled + String header = "00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-03"; + Optional<TraceParent> result = TraceParent.parse(header); + assertThat(result).isPresent(); + assertThat(result.get().sampled()).isTrue(); + } + + // ---------------------------------------------------------------- + // parse — reject cases (W3C D5/D7 strict validation) + // ---------------------------------------------------------------- + + @Test + void parseNullReturnsEmpty() { + assertThat(TraceParent.parse(null)).isEmpty(); + } + + @Test + void parseBlankReturnsEmpty() { + assertThat(TraceParent.parse(" ")).isEmpty(); + } + + @Test + void parseWrongVersionReturnsEmpty() { + String header = "01-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseWrongFieldCountReturnsEmpty() { + // only 3 fields + assertThat(TraceParent.parse("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID)).isEmpty(); + // 5 fields + assertThat(TraceParent.parse("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-01-extra")) + .isEmpty(); + } + + @Test + void parseAllZeroTraceIdReturnsEmpty() { + String allZeroTraceId = "00000000000000000000000000000000"; + String header = "00-" + allZeroTraceId + "-" + VALID_SPAN_ID + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseAllZeroSpanIdReturnsEmpty() { + String allZeroSpanId = "0000000000000000"; + String header = "00-" + VALID_TRACE_ID + "-" + allZeroSpanId + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseUppercaseTraceIdReturnsEmpty() { + // W3C: trace-id must be lowercase hex + String upperTraceId = "4BF92F3577B34DA6A3CE929D0E0E4736"; + String header = "00-" + upperTraceId + "-" + VALID_SPAN_ID + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseWrongLengthTraceIdReturnsEmpty() { + // 31 chars instead of 32 + String shortTraceId = "4bf92f3577b34da6a3ce929d0e0e473"; + String header = "00-" + shortTraceId + "-" + VALID_SPAN_ID + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseWrongLengthSpanIdReturnsEmpty() { + // 15 chars instead of 16 + String shortSpanId = "00f067aa0ba902b"; + String header = "00-" + VALID_TRACE_ID + "-" + shortSpanId + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseNonHexTraceIdReturnsEmpty() { + String nonHexTraceId = "4bf92f3577b34da6a3ce929d0e0e47gg"; + String header = "00-" + nonHexTraceId + "-" + VALID_SPAN_ID + "-01"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + @Test + void parseWrongLengthFlagsReturnsEmpty() { + // 3 hex chars for flags instead of 2 + String header = "00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-010"; + assertThat(TraceParent.parse(header)).isEmpty(); + } + + // ---------------------------------------------------------------- + // toHeader + // ---------------------------------------------------------------- + + @Test + void toHeaderSampledProducesCorrectFormat() { + TraceParent tp = TraceParent.of(VALID_TRACE_ID, VALID_SPAN_ID, true); + assertThat(tp.toHeader()).isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-01"); + } + + @Test + void toHeaderUnsampledProducesCorrectFormat() { + TraceParent tp = TraceParent.of(VALID_TRACE_ID, VALID_SPAN_ID, false); + assertThat(tp.toHeader()).isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00"); + } + + @Test + void parseThenToHeaderRoundtrip() { + Optional<TraceParent> parsed = TraceParent.parse(VALID_HEADER); + assertThat(parsed).isPresent(); + assertThat(parsed.get().toHeader()).isEqualTo(VALID_HEADER); + } + + // ---------------------------------------------------------------- + // of — factory validation + // ---------------------------------------------------------------- + + @Test + void ofValidInputsCreatesInstance() { + TraceParent tp = TraceParent.of(VALID_TRACE_ID, VALID_SPAN_ID, true); + assertThat(tp.traceId()).isEqualTo(VALID_TRACE_ID); + assertThat(tp.spanId()).isEqualTo(VALID_SPAN_ID); + assertThat(tp.sampled()).isTrue(); + } + + @Test + void ofAllZeroTraceIdThrows() { + assertThatThrownBy( + () -> TraceParent.of("00000000000000000000000000000000", VALID_SPAN_ID, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ofAllZeroSpanIdThrows() { + assertThatThrownBy(() -> TraceParent.of(VALID_TRACE_ID, "0000000000000000", true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ofNullTraceIdThrows() { + assertThatThrownBy(() -> TraceParent.of(null, VALID_SPAN_ID, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ofWrongLengthTraceIdThrows() { + assertThatThrownBy(() -> TraceParent.of("abc", VALID_SPAN_ID, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ofWrongLengthSpanIdThrows() { + assertThatThrownBy(() -> TraceParent.of(VALID_TRACE_ID, "abc", true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ofUppercaseTraceIdThrows() { + // strict — no silent normalization in of(); must be lowercase + assertThatThrownBy(() -> TraceParent.of(VALID_TRACE_ID.toUpperCase(), VALID_SPAN_ID, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ---------------------------------------------------------------- + // static validators + // ---------------------------------------------------------------- + + @Test + void isValidTraceIdAccepts32LowercaseHexNonZero() { + assertThat(TraceParent.isValidTraceId(VALID_TRACE_ID)).isTrue(); + } + + @Test + void isValidTraceIdRejectsAllZeros() { + assertThat(TraceParent.isValidTraceId("00000000000000000000000000000000")).isFalse(); + } + + @Test + void isValidTraceIdRejectsWrongLength() { + assertThat(TraceParent.isValidTraceId("4bf92f")).isFalse(); + } + + @Test + void isValidSpanIdAccepts16LowercaseHexNonZero() { + assertThat(TraceParent.isValidSpanId(VALID_SPAN_ID)).isTrue(); + } + + @Test + void isValidSpanIdRejectsAllZeros() { + assertThat(TraceParent.isValidSpanId("0000000000000000")).isFalse(); + } +} diff --git a/testcontainers.properties.example b/testcontainers.properties.example new file mode 100644 index 00000000..3a6c6e26 --- /dev/null +++ b/testcontainers.properties.example @@ -0,0 +1,2 @@ +# Local-only opt-in. Copy to ~/.testcontainers.properties; do not enable this in CI. +testcontainers.reuse.enable=true