feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종

설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.

계약과 생성
- src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록
- openapi-generator로 DTO(model)만 생성한다. generateApis 대신
  globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다
- useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이
  깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다
- 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated
  API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다

오류 계약
- StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier)
- StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을
  바꾸지 않기 위해서다
- 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는
  로그 전용이다(ApiErrorCarrier javadoc의 요구)
- 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다

게이트
- TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를
  늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다
- StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증
- StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라
  새 컨트롤러가 자동으로 걸린다
- StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의
  csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를
  테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다

스키마
- V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존
  idempotency_record 재사용)와 범위 밖 6종을 제외했다
- 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway
  세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다

알려진 제약
- getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을
  반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로
  200을 만들지 않았다
- 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다

이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로
작성됐다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:14:52 +09:00
co-authored by Claude Opus 5
parent 697fc740e6
commit 91e6d99654
48 changed files with 9495 additions and 220 deletions
+140
View File
@@ -1,3 +1,51 @@
plugins { id 'org.openapi.generator' }
// ---------------------------------------------------------------------------
// Studio 계약 DTO 생성 — sourceSet 정의와 jar/test classpath 배선 (ADR-004/ADR-006).
// 이 블록은 파일 맨 위, 아래 커스텀 Test 태스크 등록(jpaPersistenceRedactionContractTest,
// webSecurityBoundaryTest)보다 반드시 먼저 와야 한다. 그 둘은
// `classpath = sourceSets.test.runtimeClasspath`로 **eager** 대입한다(Gradle 9.0.0의
// DefaultSourceSet을 디컴파일해 확인 — lazy ConventionMapping이 아니라 단순
// getfield/putfield). 이 배선이 그보다 아래 있으면, 커스텀 태스크는 아직 생성 DTO가
// 안 얹힌 옛 FileCollection 참조를 이미 붙잡은 뒤라 나중에 sourceSet 필드를 새
// composite로 갈아 끼워도 못 본다 — 컴파일이 아니라 테스트 런타임에 NoClassDefFoundError로
// 터진다(발견 당시 실측). 표준 `test` 태스크는 JvmTestSuitePlugin이 lazy
// ConventionMapping Callable로 배선해 괜찮지만(이것도 디컴파일로 확인), register()로 만든
// 이 두 커스텀 Test 태스크는 lazy 배선을 안 타서 못 본다.
//
// openApiGenerate 확장 자체(무엇을 어떤 옵션으로 생성하는지)는 이 파일 아래쪽, 같은 제목의
// 주석 섹션에 그대로 있다 — 여기는 sourceSet 정의와 그 소비자(jar, test classpath)만
// 옮겼다. generatedOpenapiImplementation의 의존성 상속(configurations 블록)과
// compileGeneratedOpenapiJava/checkstyle/spotbugs/spotless 배선도 원래 자리에 남아있다 —
// 전부 lazy(tasks.named/tasks.matching)라 순서 문제가 없다.
// ---------------------------------------------------------------------------
sourceSets {
generatedOpenapi {
java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java'))
}
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
// 넣으면) 아래 generatedOpenapiImplementation.extendsFrom(implementation)과 맞물려
// "컴파일하려면 자기 자신의 산출물이 먼저 있어야 한다"는 순환 태스크 의존성이 생긴다
// (직접 겪음). 그래서 Configuration이 아니라 SourceSet의 compile/runtime classpath
// FileCollection에 직접 이어 붙인다 — 태스크 의존성은 그대로 따라가면서 순환은 없다.
main {
compileClasspath += generatedOpenapi.output
runtimeClasspath += generatedOpenapi.output
}
}
// main.runtimeClasspath로는 부족하다 — 그건 "실행 시 클래스를 찾을 수 있다"는 뜻일 뿐,
// consumer(app-bootstrap 등)가 보는 web 모듈의 runtimeElements(= jar 산출물)에는 여전히
// 생성 DTO가 없다. app-bootstrap은 web을 project dependency로만 물고 web의 build/classes를
// 직접 보지 않으므로, jar 안에 없으면 부팅/요청 시 NoClassDefFoundError로 터진다.
// 같은 이유로 test sourceSet도 main.output만 물려받지 generatedOpenapi.output까지
// 자동으로 따라오지 않는다 — controller 테스트가 컴파일조차 안 된다. 셋 다 명시적으로
// 채워야 한다.
tasks.named('jar') { from sourceSets.generatedOpenapi.output }
sourceSets.test.compileClasspath += sourceSets.generatedOpenapi.output
sourceSets.test.runtimeClasspath += sourceSets.generatedOpenapi.output
// HTTP / web adapters. Depends on application and shared operational contracts.
dependencies {
implementation project(':application-core')
@@ -72,3 +120,95 @@ tasks.register('webSecurityBoundaryTest', Test) {
tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest')
}
// ---------------------------------------------------------------------------
// Studio 계약 DTO 생성 (ADR-004 / ADR-006).
//
// generateApis에 해당하는 효과: 계약이 봉투를 기술하므로 API interface까지 생성하면
// 봉투 wrapper 타입을 반환하게 되고, 그 타입은 dev.caskeleton.shared.response.Envelope가
// 아니라서 EnvelopeBodyAdvice가 한 번 더 감싼다(이중 래핑). controller는 손으로 쓴다.
//
// globalProperties.set(['models': '']): openapi-generator-gradle-plugin 7.18.0의
// openApiGenerate 확장에는 generateApis/generateModels/generateSupportingFiles 프로퍼티가
// 존재하지 않는다(디컴파일로 확인, task-4-report.md 참고) — 대신 CLI --global-property와
// 같은 의미인 globalProperties로 "models만" 생성하게 제한한다.
//
// useOneOfInterfaces=false: discriminator(oneOf) union을 부모 Java interface로 생성하면
// 하위 타입이 그 인터페이스를 구현하는데, 판별 필드가 enum이면(narrowing 여부와 무관하게)
// 인터페이스의 getter는 무조건 String을 반환하고 하위 타입의 getter는 그 프로퍼티의 실제
// 타입(nested enum이든 공유 named enum이든)을 반환해 컴파일이 깨진다. 판별 필드를 하위
// 타입에서 narrowing하지 않고 base의 공유 enum(RecordKind 등)을 그대로 상속하게 계약을
// 고쳐도 동일하게 깨진다는 것까지 스크래치에서 직접 검증했다 — SpringCodegen이
// useOneOfInterfaces=true일 때 discriminator getter를 String으로 고정하는 게 근본
// 원인이라 계약 쪽에서 우회할 수 없다(3개 설정 조합 + 이 검증 전부 task-4-report.md 참고).
//
// useOneOfInterfaces=false는 컴파일은 통과시키지만 대가가 있다: 각 하위 타입이 독립
// 클래스로 생성되고(WorkingCopyInput 등 union 타입과 CaseInput 등 하위 타입 사이에
// Java의 implements 관계가 전혀 없다), 그리고 실측 결과 Jackson 배선도 계약대로 동작하지
// 않는다 — 역직렬화는 InvalidTypeIdException으로 실패하고("Class CaseInput not subtype
// of WorkingCopyInput"), 직렬화는 @JsonIgnoreProperties(value="kind", allowSetters=true)
// 때문에 실제 kind 값 대신 클래스 simple name이 나간다. 즉 생성된 union 클래스는 Jackson
// 양방향 모두 계약을 위반한다. union을 필드 타입으로 쓰는 5개 union(WorkingCopyInput,
// WorkingCopy, Inline, CaseRenderBlock, PublicRenderModel)을 실제로 쓰는 operation은
// Plan 02에서 전략을 정한 뒤 구현한다 — 이번 Task 8/9(getStudioSession,
// listStudioCatalog)는 이 union들을 쓰지 않으므로 막히지 않는다.
// ---------------------------------------------------------------------------
openApiGenerate {
generatorName = 'spring'
inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString()
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'true',
useOneOfInterfaces: 'false',
]
}
// sourceSets.generatedOpenapi 정의와 그걸 소비하는 jar/test classpath 배선은 이 파일
// 맨 위로 옮겼다(리뷰 라운드 2 fix) — 이유는 그 자리의 주석 참고. 여기 남은 건
// generatedOpenapiImplementation의 의존성 상속뿐이다.
configurations {
// 생성 DTO 컴파일에 필요한 의존성(jakarta.validation, swagger-annotations,
// jackson-databind-nullable, spring-web의 @Nullable/@DateTimeFormat 등)은 이미 main의
// implementation에 다 있다 — 따로 중복 선언하지 않고 그대로 물려받는다. main의
// implementation은 generatedOpenapi.output을 포함하지 않으므로(위 참고) 이 확장은
// 순환을 만들지 않는다.
generatedOpenapiImplementation.extendsFrom(implementation)
}
tasks.named('compileGeneratedOpenapiJava', JavaCompile) {
dependsOn 'openApiGenerate'
options.errorprone.enabled = false
// 루트 build.gradle의 tasks.withType(JavaCompile).configureEach가 -Werror와
// -Xlint:deprecation을 이미 넣어 놓은 뒤에 이 설정이 평가되므로(subprojects 블록이
// 먼저, 이 파일이 나중) 여기서 빼는 게 마지막 값으로 남는다.
doFirst {
options.compilerArgs.removeAll(['-Werror', '-Xlint:deprecation'])
}
}
// checkstyle/spotbugs는 sourceSet마다 별도 태스크(checkstyleGeneratedOpenapi,
// spotbugsGeneratedOpenapi)를 만든다 — 그 태스크만 끈다. checkstyleMain/spotbugsMain은
// 생성 코드가 더 이상 main sourceSet에 없으므로 애초에 이 파일들을 보지 않는다.
tasks.matching { it.name == 'checkstyleGeneratedOpenapi' }.configureEach {
dependsOn 'openApiGenerate'
enabled = false
}
tasks.matching { it.name == 'spotbugsGeneratedOpenapi' }.configureEach {
dependsOn 'openApiGenerate'
enabled = false
}
// spotless는 sourceSet과 무관하게 글롭으로 java 파일을 찾으므로 생성 경로를 명시적으로
// 뺀다. spotlessJava가 openApiGenerate보다 먼저 돌면 아직 없는 디렉터리를 글롭 검사하다
// 있으나 마나 한 차이라 dependsOn은 필요 없지만, 생성 전 상태에서 우연히 이전 빌드의 생성물이
// 남아 채점되는 걸 막기 위해 순서를 맞춘다.
tasks.matching { it.name.startsWith('spotless') }.configureEach {
dependsOn 'openApiGenerate'
}
spotless { java { targetExclude('build/generated/**') } }
+103 -103
View File
@@ -1,65 +1,65 @@
# 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
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,generatedOpenapiCompileClasspath,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.auto.service:auto-service-annotations:1.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,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_annotation:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,generatedOpenapiCompileClasspath,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.errorprone:error_prone_annotations:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,generatedOpenapiAnnotationProcessor,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.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,generatedOpenapiAnnotationProcessor,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.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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
commons-logging:commons-logging:1.3.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=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
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,generatedOpenapiAnnotationProcessor,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
@@ -68,21 +68,21 @@ 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-lang3:3.20.0=checkstyle,compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-api:2.25.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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
@@ -93,10 +93,10 @@ 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.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,generatedOpenapiAnnotationProcessor,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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
@@ -109,80 +109,80 @@ org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,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.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,generatedOpenapiCompileClasspath,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.pcollections:pcollections:4.0.1=annotationProcessor,generatedOpenapiAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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:jul-to-slf4j:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-http-converter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-security-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-jackson:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-tomcat-runtime:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-starter:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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-tomcat:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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.boot:spring-boot-webmvc:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core: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.security:spring-security-web:7.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,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
org.yaml:snakeyaml:2.5=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.application.techlog.error.StudioError;
/**
* Studio 실패의 client-safe {@code error.message} 단일 출처.
*
* <p>{@code StudioException#getMessage()}는 Studio use case/facade가 진단용으로 채우는 원문이라 {@code
* ApiErrorCarrier} javadoc이 경고하는 대로 SQLState나 upstream detail을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고, 이 클래스가
* code별 고정 문구만 내보낸다 — 스켈레톤의 {@code ClientSafeErrorMessages}가 {@code OperationalError}에 대해 하는 것과 같은
* 역할을 {@link StudioError}에 대해 한다.
*
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
* {@code StudioErrorRegistryTest}가 그 일치를 고정한다. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE}은 Studio 전용 row가 없고 {@code feature-api-contract-baseline}이 이미 등록한 row를
* 재사용하므로(Task 5 report 5절) 그 row의 영문 문구를 그대로 따른다 — 나머지는 Studio 전용 한국어 문구다.
*
* <p>{@link StudioError}를 exhaustive switch로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — 문구
* 누락이 생길 수 없다.
*/
public final class StudioClientSafeMessages {
private StudioClientSafeMessages() {}
public static String forError(StudioError error) {
return switch (error) {
case AUTHENTICATION_REQUIRED -> "Studio 인증이 필요합니다";
case STUDIO_ACCESS_DENIED -> "이 Studio 리소스에 접근할 권한이 없습니다";
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case VERSION_CONFLICT -> "저장된 version이 더 최신입니다";
case REQUEST_VALIDATION_FAILED -> "요청 형식이 올바르지 않습니다";
case DOCUMENT_VALIDATION_FAILED -> "문서 검증에 실패했습니다";
case VALIDATION_STALE -> "검증 결과가 최신 문서 기준이 아닙니다. 다시 검증해 주세요";
case PREVIEW_NOT_FOUND -> "요청한 미리보기를 찾을 수 없습니다";
case PREVIEW_STALE -> "미리보기가 최신 문서 기준이 아닙니다. 다시 생성해 주세요";
case PREVIEW_EXPIRED -> "미리보기가 만료되었습니다. 다시 생성해 주세요";
case PUBLICATION_NOT_FOUND -> "요청한 게시물을 찾을 수 없습니다";
case PUBLICATION_CONFLICT -> "게시 작업이 다른 변경과 충돌했습니다";
case PUBLICATION_EVENT_NOT_FOUND -> "요청한 게시 이벤트를 찾을 수 없습니다";
case PUBLICATION_SNAPSHOT_NOT_FOUND -> "요청한 게시 스냅샷을 찾을 수 없습니다";
case WARNING_ACKNOWLEDGEMENT_REQUIRED -> "경고 확인이 필요합니다. 확인 후 다시 시도해 주세요";
case IDEMPOTENCY_KEY_REUSED -> "Idempotency 키가 다른 요청에 재사용되었습니다";
case ASSET_NOT_FOUND -> "요청한 자산을 찾을 수 없습니다";
case ASSET_NOT_READY -> "자산 처리가 아직 완료되지 않았습니다";
case ASSET_IN_USE -> "자산이 사용 중이라 이 작업을 수행할 수 없습니다";
case ASSET_QUARANTINED -> "자산이 격리 처리되어 사용할 수 없습니다";
case PAYLOAD_TOO_LARGE -> "Request payload is too large";
case UNSUPPORTED_MEDIA_TYPE -> "Request Content-Type is not supported";
case STUDIO_UNAVAILABLE -> "Studio 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요";
};
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* Studio 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice로 둔다 — 그 파일은
* template sync 대상이다.
*
* <p>{@code error.message}에는 {@link StudioClientSafeMessages}가 주는 code별 고정 문구만 싣는다 — {@link
* StudioException#getMessage()}(진단용 원문)는 SQLState·upstream detail을 실을 수 있어 client-unsafe하다({@code
* ApiErrorCarrier} javadoc). 원문은 버리지 않고 서버 로그에만 남긴다 — {@code GlobalExceptionHandler}의 {@code
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
*
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
* dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code
* studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring
* MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그
* 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이
* 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")
public class StudioExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
@ExceptionHandler(StudioException.class)
public ResponseEntity<Envelope<Void>> handleStudio(StudioException ex) {
StudioError error = ex.studioError();
log.error(
"studio failure classified as {} (category={}, retryable={}): {}",
error.code(),
error.category(),
error.retryable(),
ex.getMessage(),
ex);
return ErrorResponseFactory.envelope(
error, StudioClientSafeMessages.forError(error), ex.details());
}
/**
* 필수 쿼리 파라미터 누락(예: {@code GET /api/v1/studio/catalog}의 {@code type}). {@code
* GlobalExceptionHandler}는 이 예외를 오버라이드하지 않으므로 부모 {@code ResponseEntityExceptionHandler}가 그대로 bare
* {@code ProblemDetail}(content-type {@code application/problem+json})을 만들고, {@code
* EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — ADR-006이 쓰지 않기로 한 RFC 7807이 그대로 나간다(final
* whole-branch review B4). studio 스코프에서 계약 코드 {@link StudioError#REQUEST_VALIDATION_FAILED}(422)로
* 옮긴다.
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return requestValidationFailed(ex.getParameterName(), "Required parameter is missing");
}
/**
* 쿼리 파라미터 타입 불일치(예: {@code type=BOGUS}, {@code limit=abc}). {@code GlobalExceptionHandler}도 이 예외를
* 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — Studio 계약 23종에 없는 코드다. studio 스코프에서 계약 코드로
* 옮긴다.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
*/
private static ResponseEntity<Envelope<Void>> requestValidationFailed(
String parameterName, String message) {
Map<String, Object> fieldError = Map.of("path", "/" + parameterName, "message", message);
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
return ErrorResponseFactory.envelope(
StudioError.REQUEST_VALIDATION_FAILED,
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
details);
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntry;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogPage;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 반환값을 Envelope로 감싸지 않는다 — EnvelopeBodyAdvice가 감싼다.
*
* <p>{@code CatalogEntryType}은 application({@code
* dev.caskeleton.application.techlog.studio.query})과 웹 계약 생성 DTO({@code
* dev.caskeleton.adapter.inbound.web.techlog.studio.api.model})에 같은 이름으로 각각 존재한다 — 한 파일에서 둘 다 단일 타입
* import로 쓰면 컴파일이 깨진다. 이 컨트롤러는 application 쪽을 단일 import로 쓰고(요청 파라미터·use case 입력), 웹 계약 쪽은 {@link
* #toApi}에서 FQN으로만 참조한다(응답 DTO 조립).
*/
@RestController
public class StudioCatalogController {
private final ListCatalogUseCase listCatalog;
public StudioCatalogController(ListCatalogUseCase listCatalog) {
this.listCatalog = listCatalog;
}
@GetMapping("/api/v1/studio/catalog")
public CatalogPage listStudioCatalog(
@RequestParam("type") CatalogEntryType type,
@RequestParam(value = "q", required = false) String q,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
CatalogPageView page = listCatalog.handle(new ListCatalogQuery(type, q, cursor, limit));
CatalogPage body = new CatalogPage();
body.setItems(page.items().stream().map(StudioCatalogController::toApi).toList());
body.setNextCursor(page.nextCursor());
return body;
}
private static CatalogEntry toApi(CatalogEntryView view) {
CatalogEntry entry = new CatalogEntry();
entry.setId(view.id());
entry.setType(
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CatalogEntryType.fromValue(
view.type().name()));
entry.setLabel(view.label());
entry.setDependencyRevision(view.dependencyRevision());
if (view.kind() != null) {
entry.setKind(CatalogEntry.KindEnum.fromValue(view.kind()));
}
entry.setPublicPath(view.publicPath());
return entry;
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import java.util.Set;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 세션은 순수 전송 상태다 — principal과 CSRF 토큰뿐이라 도메인 규칙이 없다. application use case를 끼우지 않는 이유이고,
* application-core는 Spring을 볼 수 없어 SecurityContext에 접근할 수도 없다.
*
* <p>반환값을 {@code Envelope}로 감싸지 않는다. {@code EnvelopeBodyAdvice}가 감싼다.
*
* <p><b>CSRF가 꺼진 auth-mode.</b> {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf ->
* csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 request attribute를
* 아무도 채우지 않는다. 그런데 Spring Security 7.0.0의 {@code CsrfTokenArgumentResolver}는
* {@code @EnableWebSecurity}만 있으면 무조건 등록되고, {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐
* null 체크가 없다 — 그래서 CSRF가 꺼진 모드에서는 {@code csrfToken} 파라미터가 항상 {@code null}이다. Studio는 CSRF 없이 실제로
* 동작할 수 없으므로, 가짜 토큰을 지어내 200을 돌려주는 대신 {@link StudioError#STUDIO_UNAVAILABLE}(503)로 그 사실을 정직하게 보고한다
* — 세션 인프라가 갖춰지면(redis-session + 완전한 CSRF 배선) 코드 변경 없이 200으로 바뀐다. ({@code
* StudioSessionCsrfDisabledTest}가 이 경로를 고정한다.)
*/
@RestController
public class StudioSessionController {
/** studio-v1.yaml {@code StudioSession.csrfHeaderName}은 {@code const} — 이 값만 유효하다. */
private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
/** studio-v1.yaml {@code StudioSession.roles.maxItems}. */
private static final int CONTRACT_MAX_ROLES = 20;
private final String csrfHeaderName;
/**
* {@code csrf-header-name}을 하드코드하지 않고 설정에서 읽되, 계약이 고정한 값과 다르면 부팅 시점에 즉시 실패한다 — 계약의 {@code const}와
* 설정이 갈라지는 건 배포 오류이지 런타임에 조용히 넘어갈 문제가 아니다.
*/
public StudioSessionController(SecuritySettings securitySettings) {
String configured = securitySettings.session().csrfHeaderName();
if (!CONTRACT_CSRF_HEADER_NAME.equals(configured)) {
throw new IllegalStateException(
"ca-skeleton.security.session.csrf-header-name must be \""
+ CONTRACT_CSRF_HEADER_NAME
+ "\" (studio-v1.yaml StudioSession.csrfHeaderName is a contract const) but was"
+ " configured as \""
+ configured
+ "\"");
}
this.csrfHeaderName = configured;
}
@GetMapping("/api/v1/studio/session")
public StudioSession getStudioSession(
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
if (csrfToken == null) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"CSRF token unavailable: CSRF protection is disabled for the active auth-mode");
}
Set<String> roles = Set.copyOf(principal.roles());
if (roles.size() > CONTRACT_MAX_ROLES) {
// 조용히 잘라내면 클라이언트가 실제 권한과 다른 role 집합을 받는다 — IdP 쪽 role 매핑이 잘못됐다는
// 신호를 숨기는 셈이라, 잘라내는 대신 실패시켜 드러낸다.
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"principal role count "
+ roles.size()
+ " exceeds contract max "
+ CONTRACT_MAX_ROLES
+ " (studio-v1.yaml StudioSession.roles.maxItems)");
}
String displayName = displayNameOf(principal);
if (displayName == null || displayName.isBlank()) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"principal has neither a usable email nor idpUserId; cannot satisfy"
+ " StudioSession.displayName minLength 1");
}
StudioSession session = new StudioSession();
session.setAuthenticated(true);
session.setDisplayName(displayName);
session.setRoles(roles);
session.setCsrfToken(csrfToken.getToken());
session.setCsrfHeaderName(csrfHeaderName);
return session;
}
/**
* `displayName`은 계약상 1자 이상이다. profile capability(identity 모듈)가 들어오기 전까지 email을 쓰고, 없으면 IdP
* subject로 대체한다. 둘 다 비어 있으면 {@code getStudioSession}이 실패시킨다(위 참조).
*/
private static String displayNameOf(AuthenticatedPrincipal principal) {
String email = principal.email();
return (email == null || email.isBlank()) ? principal.idpUserId() : email;
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.error;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* final whole-branch review B4: proves the negative for {@link StudioExceptionHandler}'s
* {@code @RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")}
* scoping.
*
* <p>{@link Probe} lives in {@code dev.caskeleton.adapter.inbound.web.error} — outside the {@code
* ...web.techlog} tree the advice is scoped to, standing in for a non-Studio feature (fileserver,
* healthcheck). A missing required parameter on it must still fall through to the inherited {@code
* ResponseEntityExceptionHandler} behaviour (bare {@code ProblemDetail}, {@code
* application/problem+json}) exactly as it did before {@code StudioExceptionHandler} grew {@code
* MissingServletRequestParameterException}/{@code MethodArgumentTypeMismatchException} handlers —
* this branch has no mandate to change fileserver/healthcheck's error shape. If the {@code
* basePackages} scope were ever dropped or widened to cover this package, this probe would start
* seeing {@code REQUEST_VALIDATION_FAILED} at 422 instead and this test would fail.
*/
@WebMvcTest(
controllers = StudioExceptionHandlerScopeTest.Probe.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
StudioExceptionHandlerScopeTest.Probe.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
EnvelopeBodyAdvice.class
})
class StudioExceptionHandlerScopeTest {
@Autowired private MockMvc mvc;
@Test
void missingParameterOnANonStudioControllerKeepsTheUnenvelopedGlobalHandlerBehaviour()
throws Exception {
mvc.perform(get("/probe/non-studio"))
.andExpect(status().is(400))
.andExpect(
content().contentTypeCompatibleWith(MediaType.valueOf("application/problem+json")));
}
@RestController
static class Probe {
@GetMapping("/probe/non-studio")
String probe(@RequestParam("required") String required) {
return required;
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.web.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
class StudioExceptionHandlerTest {
private final StudioExceptionHandler handler = new StudioExceptionHandler();
@Test
void mapsStudioExceptionToFailureEnvelopeWithContractCode() {
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(StudioException.of(StudioError.DOCUMENT_NOT_FOUND, "없음"));
assertThat(response.getStatusCode().value()).isEqualTo(404);
Envelope<Void> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.success()).isFalse();
assertThat(body.error().code()).isEqualTo("DOCUMENT_NOT_FOUND");
assertThat(body.error().category()).isEqualTo("NOT_FOUND");
assertThat(body.error().retryable()).isFalse();
}
/**
* Regression test for the client-safe message leak: the handler must never surface {@link
* StudioException#getMessage()} (diagnostic-only, may carry SQLState/upstream detail) — only
* {@link StudioClientSafeMessages#forError(StudioError)}'s fixed, per-code text.
*/
@Test
void neverLeaksTheRawExceptionMessageAndUsesTheClientSafeTextInstead() {
String rawDiagnosticMessage = "pg constraint fk_document_project violated for id=42";
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(
StudioException.of(StudioError.DOCUMENT_NOT_FOUND, rawDiagnosticMessage));
String message = response.getBody().error().message();
assertThat(message).isNotEqualTo(rawDiagnosticMessage);
assertThat(message)
.isEqualTo(StudioClientSafeMessages.forError(StudioError.DOCUMENT_NOT_FOUND));
}
@Test
void carriesDetailsForConflicts() {
ResponseEntity<Envelope<Void>> response =
handler.handleStudio(
StudioException.withDetails(
StudioError.VERSION_CONFLICT,
"충돌",
Map.of("latestDocument", Map.of("version", 8))));
assertThat(response.getStatusCode().value()).isEqualTo(409);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().error().details()).isNotNull();
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
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.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.function.Supplier;
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.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
/**
* final whole-branch review B4: {@code GET /api/v1/studio/catalog} without its required {@code
* type} query parameter throws {@code MissingServletRequestParameterException}. Neither {@code
* GlobalExceptionHandler} (which we cannot modify — template file) nor the old {@code
* StudioExceptionHandler} handled it, so it fell through to the inherited {@code
* ResponseEntityExceptionHandler} behaviour: a bare {@code ProblemDetail} body with content-type
* {@code application/problem+json}. {@code EnvelopeBodyAdvice#beforeBodyWrite}'s {@code
* MediaType.APPLICATION_JSON.includes(...)} guard then skips wrapping, so a bare RFC 7807 body
* leaks past the envelope — exactly the wire shape ADR-006 says this backend does not use.
*
* <p>{@code ?type=BOGUS} and {@code ?limit=abc} throw {@code MethodArgumentTypeMismatchException}
* instead — {@code GlobalExceptionHandler} does handle that one directly (so the body stays
* enveloped), but with {@code OperationalError.BAD_PARAMETER}, a code outside the 23-code Studio
* contract.
*
* <p>This test pins the fix: a Studio-scoped {@code StudioExceptionHandler} handler moves both
* exceptions to the contract's {@code REQUEST_VALIDATION_FAILED} at 422 (the status the {@code
* StudioError} enum and {@code docs/registries/error-codes.yaml} agree on), enveloped like every
* other Studio failure.
*
* <p>Follows the {@code @WebMvcTest} + hand-built {@code TestBootstrap} slice pattern documented in
* {@link StudioSessionEnvelopeTest} — this module's test source set has no {@code
* CaSkeletonApplication} for {@code @WebMvcTest} to bootstrap from. Security is fully excluded
* (like {@code NoResourceFoundErrorHandlingTest}) since none of these scenarios are
* authentication/authorization-related. {@link ListCatalogUseCase} is real, not mocked (it is a
* {@code final} class and this module's Mockito is not configured with the inline mock maker) —
* built from hand-written fake ports, and its port never runs because request binding fails before
* the controller method body is entered.
*/
@WebMvcTest(
controllers = StudioCatalogController.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
StudioCatalogController.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
EnvelopeBodyAdvice.class,
StudioCatalogBindingErrorEnvelopeTest.TestBeans.class
})
class StudioCatalogBindingErrorEnvelopeTest {
@Autowired private MockMvc mvc;
@Test
void missingRequiredTypeParameterIsEnvelopedAsRequestValidationFailed() throws Exception {
mvc.perform(get("/api/v1/studio/catalog"))
.andExpect(status().is(422))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.error.category").value("VALIDATION"));
}
@Test
void unknownTypeEnumValueIsEnvelopedAsRequestValidationFailed() throws Exception {
mvc.perform(get("/api/v1/studio/catalog").param("type", "BOGUS"))
.andExpect(status().is(422))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED"));
}
@Test
void nonNumericLimitIsEnvelopedAsRequestValidationFailed() throws Exception {
mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC").param("limit", "abc"))
.andExpect(status().is(422))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("REQUEST_VALIDATION_FAILED"));
}
static class TestBeans {
@Bean
ListCatalogUseCase listCatalogUseCase() {
CatalogQueryPort neverInvoked =
(type, query, cursor, limit) -> {
throw new AssertionError(
"ListCatalogUseCase must not run when request binding already failed");
};
return new ListCatalogUseCase(neverInvoked, new PassthroughTransactionPort());
}
}
/** Runs the action synchronously with no real transactional semantics — a slice test fake. */
private static final class PassthroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(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();
}
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
static class TestBootstrap {}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
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.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioSession;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.csrf.DefaultCsrfToken;
class StudioSessionControllerTest {
private final StudioSessionController controller =
new StudioSessionController(securitySettingsWithCsrfHeaderName("X-CSRF-TOKEN"));
@Test
void reportsAuthenticatedPrincipalAndCsrfToken() {
StudioSession session =
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token-value"));
assertThat(session.getAuthenticated()).isTrue();
assertThat(session.getDisplayName()).isEqualTo("donghyeon@example.com");
assertThat(session.getRoles()).containsExactly("STUDIO_EDITOR");
assertThat(session.getCsrfToken()).isEqualTo("token-value");
assertThat(session.getCsrfHeaderName()).isEqualTo("X-CSRF-TOKEN");
}
@Test
void fallsBackToIdpUserIdWhenEmailIsAbsent() {
StudioSession session =
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", null, Set.of()),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t"));
assertThat(session.getDisplayName()).isEqualTo("sub-1");
}
@Test
void rejectsAMisconfiguredCsrfHeaderNameAtConstructionRatherThanServingTheWrongHeader() {
assertThatThrownBy(() -> new StudioSessionController(securitySettingsWithCsrfHeaderName(null)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("X-CSRF-TOKEN")
.hasMessageContaining("X-XSRF-TOKEN");
}
@Test
void reportsStudioUnavailableRatherThanNpeWhenCsrfTokenIsNull() {
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
@Test
void reportsStudioUnavailableRatherThanTruncatingRolesBeyondTheContractMax() {
Set<String> tooManyRoles =
IntStream.range(0, 21).mapToObj(i -> "ROLE_" + i).collect(Collectors.toUnmodifiableSet());
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal("sub-1", "donghyeon@example.com", tooManyRoles),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
@Test
void reportsStudioUnavailableRatherThanAnEmptyDisplayNameWhenEmailAndIdpUserIdAreBothBlank() {
assertThatThrownBy(
() ->
controller.getStudioSession(
new AuthenticatedPrincipal(" ", " ", Set.of()),
new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "t")))
.isInstanceOf(StudioException.class)
.extracting(ex -> ((StudioException) ex).studioError())
.isEqualTo(StudioError.STUDIO_UNAVAILABLE);
}
private static SecuritySettings securitySettingsWithCsrfHeaderName(String csrfHeaderName) {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, csrfHeaderName);
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@@ -0,0 +1,116 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
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.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
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.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
/**
* 프로덕션 JWT auth-mode를 재현한다: {@code SecurityConfig.filterChain}의 JWT 분기는 {@code csrf(csrf ->
* csrf.disable())}로 {@code CsrfConfigurer} 자체를 제거한다 — {@code CsrfFilter}가 돌지 않고 {@code CsrfToken}
* request attribute를 아무도 채우지 않는다.
*
* <p>그런데 {@code CsrfTokenArgumentResolver}(Spring Security 7.0.0, {@code
* WebMvcSecurityConfiguration.addArgumentResolvers}가 {@code @EnableWebSecurity}만 있으면 무조건 등록한다)의
* {@code resolveArgument}는 request attribute를 캐스팅만 할 뿐 null 체크가 없다 — attribute가 없으면 그냥 {@code
* null}을 돌려준다. 그래서 JWT 모드에서는 컨트롤러의 {@code CsrfToken csrfToken} 파라미터가 **항상 null**이다.
*
* <p>이 테스트는 그 사실을 슬라이스 필터체인으로 직접 재현한다 — {@link StudioSessionEnvelopeTest}의 {@code
* SecurityTestConfig}(CSRF 켜짐, Spring 기본값)와 정확히 반대다. 일부러 {@code
* SecurityMockMvcRequestPostProcessors.csrf()}를 쓰지 않는다 — 그 포스트 프로세서는 실제 필터체인 여부와 무관하게 request
* attribute를 직접 채워버려서, 쓰면 이 재현이 무력화된다(CSRF가 꺼져 있어도 토큰이 채워진 것처럼 보이게 된다).
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
StudioSessionController.class,
EnvelopeBodyAdvice.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
StudioSessionCsrfDisabledTest.SecurityTestConfig.class
})
class StudioSessionCsrfDisabledTest {
@Autowired private MockMvc mvc;
@AfterEach
void clearMdc() {
MDC.remove(MdcKeys.TRACE_ID);
}
@Test
void reportsStudioUnavailableRatherThanCrashingWhenCsrfIsDisabled() throws Exception {
MDC.put(MdcKeys.TRACE_ID, "test-trace-id");
mvc.perform(
get("/api/v1/studio/session")
.with(
authentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null,
Set.of(new SimpleGrantedAuthority("ROLE_STUDIO_EDITOR"))))))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("STUDIO_UNAVAILABLE"))
.andExpect(jsonPath("$.error.retryable").value(true))
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
}
/**
* {@code SecurityConfig.filterChain}의 JWT 분기와 같은 모양 — {@code csrf().disable()} + {@code
* anyRequest().authenticated()}뿐. 실제 앱의 {@code SecurityConfig} 전체(CORS, JWT-vs-redis-session 분기,
* entry point 등)는 가져오지 않는다.
*/
@EnableWebSecurity
static class SecurityTestConfig {
@Bean
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
@Bean
SecuritySettings securitySettings() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestBootstrap {}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
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.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
/**
* 응답이 봉투로 정확히 한 번 감싸이는지 고정한다. 두 번 감싸이면 프론트가 조용히 깨진다. CSRF가 **켜진** 필터체인(Spring 기본값)에서의 해피 패스만 다룬다 —
* CSRF가 **꺼진**(프로덕션 JWT 모드와 같은 모양) 경로는 {@link StudioSessionCsrfDisabledTest}가 별도로 고정한다. 두 필터체인을 한
* 테스트 클래스에 같이 둘 수 없다({@code @WebMvcTest}는 클래스당 Spring 컨텍스트 하나뿐이라 {@code SecurityFilterChain} 빈도
* 하나뿐이다).
*
* <p>이 모듈(adapter:inbound:web)의 테스트 소스셋에는 {@code CaSkeletonApplication}이 없다 — 그건 app-bootstrap 모듈
* 소유다. 그래서 {@code @WebMvcTest}가 컨텍스트를 부트스트랩할 {@code @SpringBootConfiguration}을 못 찾는다. {@link
* dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdviceTest}와 {@link
* dev.caskeleton.adapter.inbound.web.error.NoResourceFoundErrorHandlingTest}가 쓰는 것과 같은 방식으로 — 테스트
* 전용 nested {@code TestBootstrap}을 두고 필요한 빈을 명시적으로 {@code @Import}한다.
*
* <p>보안: 이 슬라이스는 실제 {@code SecurityConfig}를 끌어오지 않는다(그건 {@code CorsSettings}, {@code
* JwtToAuthenticatedPrincipalConverter} 등 앱 전체 배선이 필요해서 슬라이스 테스트에 과하다). 이 저장소에 이 조합 (WebMvcTest +
* 실제 보안 필터 체인 + AuthenticatedPrincipal)의 선례가 없다 — {@code SecurityModeWebContractTest}는 {@code
* WebApplicationContextRunner}+standalone MockMvc를 쓰지 slice가 아니고, {@code
* EnvelopeBodyAdviceTest}/{@code NoResourceFoundErrorHandlingTest}는 반대로 Security를 통째로 배제한다 ({@code
* excludeAutoConfiguration = SecurityAutoConfiguration.class} + {@code addFilters = false}). 그래서
* 브리프가 제시한 fallback을 따른다 — {@code @WithMockUser} 대신 {@code
* SecurityMockMvcRequestPostProcessors.csrf()}와 커스텀 {@code authentication(...)}을 쓴다.
* {@code @WithMockUser}는 principal을 {@code org.springframework.security.core.userdetails.User}로
* 채우는데 컨트롤러가 기대하는 타입은 {@code AuthenticatedPrincipal}이라 타입 불일치로 {@code @AuthenticationPrincipal}이
* null을 주입하고 컨트롤러가 NPE를 던진다 — 실측으로 확인했다(task-8 report 참조).
*
* <p>{@code @WebMvcTest}는 표준 {@code @EnableAutoConfiguration}을
* {@code @OverrideAutoConfiguration(enabled = false)}로 끄고 test-slice 전용의 제한된 auto-configuration 목록만
* 적용한다 — 그 목록은 Boot의 {@code ServletWebSecurityAutoConfiguration}(기본 {@code SecurityFilterChain} +
* {@code @EnableWebSecurity})을 포함하지 않는다. 그래서 {@code TestBootstrap}이
* {@code @EnableAutoConfiguration}을 달고 있어도 {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자
* 리졸버가 등록되지 않는다 — 실제로 시도했더니 {@code CsrfToken}이 인자 리졸버 없이 {@code @ModelAttribute} 데이터바인딩 경로로 떨어져 "No
* primary or single unique constructor found for interface CsrfToken" {@code
* IllegalStateException}으로 500이 났다. 그래서 {@code @EnableWebSecurity}를 이 테스트가 직접 명시적으로 붙인다({@code
* SecurityTestConfig}) — 그래야 그 인자 리졸버들이 등록된다.
*
* <p>{@code meta.traceId}는 프로덕션에서 {@code RequestLoggingFilter}가 MDC에 채운다. 그 필터는 {@code
* UserPrincipalPseudonymizerPort} 빈이 필요하고 자체 테스트({@code RequestLoggingFilterTest})가 이미 있으므로 여기서는
* 재현하지 않는다 — MockMvc가 테스트 스레드에서 동기 실행되는 점을 이용해 MDC를 직접 채운다.
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
StudioSessionController.class,
EnvelopeBodyAdvice.class,
StudioSessionEnvelopeTest.SecurityTestConfig.class
})
class StudioSessionEnvelopeTest {
@Autowired private MockMvc mvc;
@AfterEach
void clearMdc() {
MDC.remove(MdcKeys.TRACE_ID);
}
@Test
void wrapsTheSessionPayloadExactlyOnce() throws Exception {
MDC.put(MdcKeys.TRACE_ID, "test-trace-id");
mvc.perform(
get("/api/v1/studio/session")
.with(csrf())
.with(
authentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"sub-1", "donghyeon@example.com", Set.of("STUDIO_EDITOR")),
null,
Set.of(new SimpleGrantedAuthority("ROLE_STUDIO_EDITOR"))))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.csrfHeaderName").value("X-CSRF-TOKEN"))
.andExpect(jsonPath("$.data.data").doesNotExist())
.andExpect(jsonPath("$.meta.traceId").isNotEmpty());
}
/**
* {@code CsrfToken}/{@code @AuthenticationPrincipal} 인자 리졸버를 등록하는 최소 보안 구성. 실제 앱의 {@code
* SecurityConfig}(CORS, JWT/redis-session 분기, entry point 등)는 가져오지 않고 이 슬라이스가 필요로 하는 것 — 인증된 요청만
* 통과, CSRF는 Spring 기본값(켜짐) — 만 남긴다. {@link StudioSessionCsrfDisabledTest}의 {@code
* SecurityTestConfig}가 정확히 반대(CSRF 꺼짐)를 재현한다.
*/
@EnableWebSecurity
static class SecurityTestConfig {
@Bean
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
/**
* 컨트롤러가 이제 {@code csrfHeaderName}을 이 설정에서 읽는다(하드코드하지 않음) — 계약값 {@code X-CSRF-TOKEN}과 일치해야 생성자가
* 통과한다.
*/
@Bean
SecuritySettings securitySettings() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT,
"https://issuer.example",
null,
List.of(),
session);
}
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestBootstrap {}
}
@@ -107,6 +107,15 @@ def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTes
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
def postgresqlTechLogSchemaMigrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogSchemaMigrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.TechLogSchemaMigrationTest')
// Task 9 (listStudioCatalog): JdbcCatalogQueryAdapterTest has no @SpringBootConfiguration to hang a
// @SpringBootTest off in this module (same reason as postgresqlTechLogSchemaMigrationTest above), so it
// needs its own opt-in Testcontainers task rather than reusing an existing one.
def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogCatalogQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
*
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
*/
@Repository
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
private final JdbcClient jdbcClient;
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
String pattern =
(query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase(Locale.ROOT) + "%";
List<CatalogEntryView> items =
switch (type) {
case TOPIC -> searchTopics(pattern, limit);
case PROJECT -> searchProjects(pattern, limit);
case RELATION, EVIDENCE -> List.of();
};
return new CatalogPageView(items, null);
}
private List<CatalogEntryView> searchTopics(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM topic "
+ "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern "
+ "ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.TOPIC,
rs.getString("name"),
null,
null,
"topic:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM project "
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.PROJECT,
rs.getString("name"),
"PROJECT",
null,
"project:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
}
@@ -0,0 +1,779 @@
-- Tech Log 코어 스키마.
-- 원본: tech-log-design-package/database/V1__init.sql
-- (branch feature/response-envelope-adr-006, HEAD b20d7a2 — 이 파일은 그 이후 바뀌지 않았다)
--
-- 원본 DDL과의 차이:
-- 1. `CREATE SCHEMA IF NOT EXISTS tech_log;` / `SET search_path TO tech_log, public;` 제거.
-- 이 저장소의 기존 마이그레이션(V1/V3/V4/V5/V6)과 JPA 설정(@EntityScan, @EnableJpaRepositories,
-- PostgreSqlPersistenceConfig의 FlywayConfigurationCustomizer)은 모두 기본 public 스키마를
-- 전제한다. tech_log 전용 스키마로 옮기면 Task 9 이후의 JPA 엔티티가 이 테이블들을 찾지
-- 못한다. 그래서 테이블은 이 저장소의 다른 모든 테이블과 마찬가지로 public 스키마에 만든다.
-- 2. `studio_idempotency` 테이블과 전용 인덱스(`idx_studio_idempotency_expiry`)를 제외한다.
-- 기존 `idempotency_record`를 재사용한다 (spec D5).
-- 3. `release` / `site_config` / `profile_page` / `home_focus_config` /
-- `topic_featured_document` / `project_topic` 테이블과 전용 인덱스(`uq_topic_start_here`)를
-- 제외한다. 이번 범위 밖이다 (spec §2.2). site_config/profile_page/home_focus_config를
-- 시딩하던 마지막 INSERT 구문도 대상 테이블이 없으므로 함께 제외했다.
-- 4. 그 밖의 테이블·컬럼·CHECK·UNIQUE·인덱스·주석·순서는 원본을 그대로 보존한다. 순환 FK
-- `publication.latest_event_id` -> `publication_event.publication_id`의
-- `DEFERRABLE INITIALLY DEFERRED`도 그대로 유지한다 — 즉시 검사로 바꾸면 첫 게시가
-- 구조적으로 불가능해진다.
-- Tech Log initial PostgreSQL schema
-- Target: PostgreSQL 16+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ---------------------------------------------------------------------------
-- Taxonomy and assets
-- ---------------------------------------------------------------------------
CREATE TABLE topic (
id uuid PRIMARY KEY,
name varchar(80) NOT NULL,
normalized_name varchar(80) NOT NULL,
slug varchar(100) NOT NULL,
description varchar(600),
scope text,
status varchar(20) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'ARCHIVED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_topic_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_topic_slug UNIQUE (slug)
);
CREATE TABLE tag (
id uuid PRIMARY KEY,
name varchar(40) NOT NULL,
normalized_name varchar(40) NOT NULL,
slug varchar(60) NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_tag_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_tag_slug UNIQUE (slug)
);
-- asset_key는 Public content가 참조하는 안정적인 key다.
-- object_key(스토리지 경로)와 분리되며 immutable이다.
--
-- asset_key -> Asset lookup -> current approved delivery path
--
-- 콘텐츠 원문에 object storage URL을 직접 영속하지 않는다.
-- 공개 이력이 있는 asset_key의 재사용 금지는 application rule로 강제한다.
-- (DB는 현재 행의 유일성만 보장한다.)
CREATE TABLE asset (
id uuid PRIMARY KEY,
asset_key varchar(200) NOT NULL,
asset_kind varchar(20) NOT NULL
CHECK (asset_kind IN ('IMAGE', 'DIAGRAM', 'ATTACHMENT')),
management_status varchar(20) NOT NULL
CHECK (management_status IN ('READY', 'ARCHIVED', 'REJECTED', 'QUARANTINED')),
object_key varchar(500) NOT NULL,
original_name varchar(255) NOT NULL,
display_name varchar(255),
content_type varchar(150) NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
width integer CHECK (width IS NULL OR width > 0),
height integer CHECK (height IS NULL OR height > 0),
checksum_sha256 char(64) NOT NULL,
alt_text varchar(300),
decorative boolean NOT NULL DEFAULT false,
first_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_asset_key UNIQUE (asset_key),
CONSTRAINT uq_asset_object_key UNIQUE (object_key)
);
-- alt/decorative는 DB CHECK로 강제하지 않는다.
--
-- 업로드 시점에는 alt를 아직 정하지 않을 수 있어야 하고(Asset Picker에서 이후 수정),
-- 최종 판단은 syntax parser가 아니라 Publication Validation이 한다.
--
-- Asset decorative=false + 사용 위치 alt 비어 있음 -> PublishValidationFailed
-- Asset decorative=true -> alt="" 허용
--
-- 즉 판단 대상은 asset.alt_text 자체가 아니라 "해당 사용 위치의 alt"다.
-- 같은 Asset이 문서마다 다른 alt로 쓰일 수 있으므로 행 단위 CHECK로 표현할 수 없다.
-- ---------------------------------------------------------------------------
-- Knowledge documents
-- ---------------------------------------------------------------------------
CREATE TABLE document (
id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL
CHECK (document_type IN ('CASE', 'REFERENCE')),
slug varchar(180),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
content_format varchar(20) NOT NULL DEFAULT 'MARKDOWN'
CHECK (content_format IN ('MARKDOWN')),
content_format_version smallint NOT NULL DEFAULT 1
CHECK (content_format_version > 0),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'IN_REVIEW', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
cover_asset_id uuid REFERENCES asset(id),
last_verified_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_document_id_type UNIQUE (id, document_type),
CONSTRAINT uq_document_type_slug UNIQUE (document_type, slug),
CONSTRAINT ck_document_slug_non_blank CHECK (slug IS NULL OR length(trim(slug)) > 0),
CONSTRAINT ck_document_publish_time_order CHECK (
first_published_at IS NULL
OR last_published_at IS NULL
OR first_published_at <= last_published_at
)
);
CREATE TABLE case_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'CASE'
CHECK (document_type = 'CASE'),
problem_summary varchar(600) NOT NULL DEFAULT '',
conclusion_summary varchar(600) NOT NULL DEFAULT '',
environment_items jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(environment_items) = 'array'),
CONSTRAINT fk_case_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE reference_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'REFERENCE'
CHECK (document_type = 'REFERENCE'),
scope_summary varchar(600) NOT NULL DEFAULT '',
applies_to jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(applies_to) = 'array'),
excluded_scope jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(excluded_scope) = 'array'),
freshness_status varchar(20) NOT NULL DEFAULT 'CURRENT'
CHECK (freshness_status IN ('CURRENT', 'REVIEW_DUE', 'HISTORICAL')),
CONSTRAINT fk_reference_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE document_tag (
document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (document_id, tag_id),
CONSTRAINT uq_document_tag_order UNIQUE (document_id, display_order)
);
CREATE TABLE document_relation (
source_document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
target_document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RELATED', 'DERIVED_FROM', 'SUPERSEDES')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source_document_id, target_document_id, relation_type),
CONSTRAINT ck_document_relation_not_self CHECK (source_document_id <> target_document_id)
);
-- ---------------------------------------------------------------------------
-- Open questions
-- ---------------------------------------------------------------------------
CREATE TABLE open_question (
id uuid PRIMARY KEY,
slug varchar(180),
question varchar(300) NOT NULL,
summary varchar(600),
context_markdown text NOT NULL DEFAULT '',
importance_markdown text NOT NULL DEFAULT '',
next_verification text,
question_status varchar(20) NOT NULL DEFAULT 'OPEN'
CHECK (question_status IN ('OPEN', 'INVESTIGATING', 'PAUSED', 'RESOLVED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
resolution_type varchar(30)
CHECK (resolution_type IS NULL OR resolution_type IN (
'DECISION_MADE',
'ASSUMPTION_REJECTED',
'QUESTION_REFRAMED',
'NO_LONGER_RELEVANT'
)),
resolution_summary text,
opened_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_open_question_slug UNIQUE (slug),
CONSTRAINT ck_question_resolution_consistency CHECK (
(question_status = 'RESOLVED'
AND resolution_type IS NOT NULL
AND resolution_summary IS NOT NULL
AND resolved_at IS NOT NULL)
OR
(question_status <> 'RESOLVED')
)
);
CREATE TABLE question_point (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
point_kind varchar(20) NOT NULL
CHECK (point_kind IN ('FACT', 'ASSUMPTION', 'UNKNOWN', 'CONSTRAINT')),
content text NOT NULL CHECK (length(trim(content)) > 0),
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_question_point_order UNIQUE (question_id, point_kind, display_order)
);
CREATE TABLE question_update (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
update_type varchar(30) NOT NULL
CHECK (update_type IN (
'OBSERVATION',
'EVIDENCE',
'SCOPE_CHANGE',
'BLOCKER',
'NEXT_STEP',
'RESOLUTION',
'RESOLUTION_REOPENED'
)),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
update_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (update_visibility IN ('PRIVATE', 'PUBLIC')),
sequence_no integer NOT NULL CHECK (sequence_no > 0),
occurred_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_question_update_sequence UNIQUE (question_id, sequence_no)
);
CREATE TABLE question_tag (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (question_id, tag_id),
CONSTRAINT uq_question_tag_order UNIQUE (question_id, display_order)
);
CREATE TABLE question_document_link (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RESULT_CASE', 'DERIVED_REFERENCE', 'RELATED')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (question_id, document_id, relation_type)
);
CREATE UNIQUE INDEX uq_question_result_case
ON question_document_link(question_id)
WHERE relation_type = 'RESULT_CASE';
-- ---------------------------------------------------------------------------
-- Projects, decisions, activities
-- ---------------------------------------------------------------------------
CREATE TABLE project (
id uuid PRIMARY KEY,
slug varchar(180),
name varchar(180) NOT NULL,
one_line_purpose varchar(600) NOT NULL DEFAULT '',
purpose_markdown text NOT NULL DEFAULT '',
boundary_markdown text NOT NULL DEFAULT '',
system_overview_markdown text NOT NULL DEFAULT '',
phase varchar(30) NOT NULL DEFAULT 'RESEARCH'
CHECK (phase IN (
'RESEARCH',
'DESIGN',
'IMPLEMENTATION',
'VERIFICATION',
'MAINTENANCE',
'PAUSED',
'COMPLETED'
)),
current_objective text,
next_step text,
technology_labels jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(technology_labels) = 'array'),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
featured_order integer,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_slug UNIQUE (slug),
CONSTRAINT ck_project_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE TABLE project_document_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, document_id),
CONSTRAINT ck_project_document_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_document_primary_project
ON project_document_link(document_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_question_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
question_id uuid NOT NULL REFERENCES open_question(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, question_id),
CONSTRAINT ck_project_question_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_question_primary_project
ON project_question_link(question_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_decision (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
statement varchar(1000) NOT NULL,
rationale_markdown text NOT NULL DEFAULT '',
consequences jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(consequences) = 'array'),
alternatives_markdown text NOT NULL DEFAULT '',
decision_status varchar(20) NOT NULL DEFAULT 'PROPOSED'
CHECK (decision_status IN ('PROPOSED', 'ACCEPTED', 'SUPERSEDED', 'REJECTED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
source_question_id uuid REFERENCES open_question(id),
source_case_id uuid REFERENCES document(id),
superseded_by_id uuid,
is_featured boolean NOT NULL DEFAULT false,
decided_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT fk_project_decision_superseded_by
FOREIGN KEY (superseded_by_id)
REFERENCES project_decision(id),
CONSTRAINT ck_project_decision_not_self_supersede
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id),
CONSTRAINT ck_project_decision_status_fields CHECK (
decision_status NOT IN ('ACCEPTED', 'SUPERSEDED')
OR decided_at IS NOT NULL
),
CONSTRAINT ck_project_decision_supersede_target CHECK (
decision_status <> 'SUPERSEDED'
OR superseded_by_id IS NOT NULL
)
);
CREATE UNIQUE INDEX uq_project_featured_decision
ON project_decision(project_id)
WHERE is_featured = true;
CREATE TABLE project_activity (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
activity_type varchar(40) NOT NULL
CHECK (activity_type IN (
'PHASE_CHANGED',
'QUESTION_OPENED',
'QUESTION_RESOLVED',
'DECISION_ACCEPTED',
'CASE_PUBLISHED',
'REFERENCE_PUBLISHED',
'MILESTONE_REACHED',
'PROJECT_PAUSED',
'PROJECT_RESUMED'
)),
title varchar(180) NOT NULL,
summary varchar(600),
visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (visibility IN ('PRIVATE', 'PUBLIC')),
origin varchar(20) NOT NULL
CHECK (origin IN ('AUTO', 'MANUAL')),
related_resource_type varchar(30),
related_resource_id uuid,
occurred_at timestamptz NOT NULL,
operation_key varchar(180),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_activity_operation_key UNIQUE (project_id, operation_key)
);
-- ---------------------------------------------------------------------------
-- Publication and public read model
-- ---------------------------------------------------------------------------
CREATE TABLE public_resource_projection (
resource_type varchar(30) NOT NULL
CHECK (resource_type IN (
'CASE',
'REFERENCE',
'QUESTION',
'PROJECT',
'PROJECT_DECISION',
'PROJECT_ACTIVITY',
'RELEASE',
'PROFILE'
)),
resource_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
publication_state varchar(20) NOT NULL
CHECK (publication_state IN ('ACTIVE', 'WITHDRAWN')),
visibility varchar(20) NOT NULL
CHECK (visibility IN ('PUBLIC', 'UNLISTED')),
title varchar(300) NOT NULL,
summary varchar(600),
state_code varchar(30),
primary_topic_id uuid REFERENCES topic(id),
payload_schema_version smallint NOT NULL CHECK (payload_schema_version > 0),
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
body_plain_text text NOT NULL DEFAULT '',
search_text text NOT NULL DEFAULT '',
content_hash char(64) NOT NULL,
published_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
last_verified_at timestamptz,
latest_index_at timestamptz,
navigation_path varchar(500) NOT NULL,
PRIMARY KEY (resource_type, resource_id)
);
CREATE TABLE public_route (
resource_type varchar(30) NOT NULL,
slug varchar(180) NOT NULL,
resource_id uuid NOT NULL,
route_role varchar(20) NOT NULL
CHECK (route_role IN ('CANONICAL', 'ALIAS')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (resource_type, slug),
CONSTRAINT fk_public_route_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX uq_public_route_canonical
ON public_route(resource_type, resource_id)
WHERE route_role = 'CANONICAL';
CREATE TABLE public_resource_tag (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (resource_type, resource_id, tag_id),
CONSTRAINT fk_public_resource_tag_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT uq_public_resource_tag_order
UNIQUE (resource_type, resource_id, display_order)
);
CREATE TABLE public_resource_project_link (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
project_id uuid NOT NULL REFERENCES project(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (resource_type, resource_id, project_id),
CONSTRAINT fk_public_resource_project_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT ck_public_project_featured_order CHECK (
featured_order IS NULL OR featured_order >= 0
)
);
CREATE UNIQUE INDEX uq_public_primary_project
ON public_resource_project_link(resource_type, resource_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE asset_reference (
asset_id uuid NOT NULL REFERENCES asset(id),
owner_type varchar(30) NOT NULL
CHECK (owner_type IN (
'DOCUMENT',
'QUESTION',
'QUESTION_UPDATE',
'PROJECT',
'DECISION',
'RELEASE',
'PROFILE',
'SITE'
)),
owner_id uuid NOT NULL,
reference_scope varchar(20) NOT NULL
CHECK (reference_scope IN ('WORKING', 'PUBLISHED')),
reference_role varchar(20) NOT NULL
CHECK (reference_role IN ('BODY', 'COVER', 'AVATAR', 'ATTACHMENT')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (asset_id, owner_type, owner_id, reference_scope, reference_role)
);
-- preview_token 테이블은 제거되었다.
-- Capability Token 기반 익명 Preview는 인증된 Preview Artifact(studio_preview)로
-- 대체되었다. contracts/openapi/preview-v1.deprecated.md 참고.
-- ---------------------------------------------------------------------------
-- Studio workflow artifacts
--
-- WorkingCopy는 API projection이므로 범용 working_copy 테이블을 만들지 않는다.
-- 아래 테이블은 편집 대상 자체가 아니라 "편집 흐름이 만들어내는 산출물"을 저장한다.
--
-- source_kind + source_id는 Studio API의 documentId를 가리킨다.
-- documentId는 source aggregate id를 그대로 사용하므로 별도 surrogate id가 없다.
-- ---------------------------------------------------------------------------
-- Validation은 일급 artifact다. 실행하고 버리는 결과가 아니라 특정 version을
-- 검증한 사실을 validation_id로 참조할 수 있어야 한다.
CREATE TABLE studio_validation (
validation_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
validated_version bigint NOT NULL CHECK (validated_version >= 0),
status varchar(20) NOT NULL
CHECK (status IN ('INVALID', 'WARNINGS', 'VALID')),
issues jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(issues) = 'array'),
-- 검증에 사용한 외부 의존 상태(Topic/Project publishability, relation target,
-- Asset READY/QUARANTINED, slug/route ownership, catalog revision,
-- renderer/content-format version)를 정규화한 hash.
-- Publish 시 다시 계산해 값이 다르면 VALIDATION_STALE로 거절한다.
dependency_revision varchar(200) NOT NULL,
validated_at timestamptz NOT NULL DEFAULT now(),
valid_until timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_validation_window CHECK (valid_until > validated_at)
);
-- Preview는 저장된 version + validation + dependency revision을 묶어 만든
-- PublicRenderModel snapshot이다. 인증된 Studio API로만 조회한다.
-- CURRENT/STALE/EXPIRED 상태는 저장하지 않고 조회 시점에 서버가 계산한다.
CREATE TABLE studio_preview (
preview_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
validation_id uuid NOT NULL REFERENCES studio_validation(validation_id),
dependency_revision varchar(200) NOT NULL,
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_preview_expiry CHECK (expires_at > created_at)
);
-- ---------------------------------------------------------------------------
-- Publication aggregate, immutable history, immutable snapshot
--
-- 세 개념을 분리한다.
--
-- publication 현재 게시 상태
-- publication_event 게시/재게시/게시 취소 불변 이력
-- publication_snapshot PUBLISHED/REPUBLISHED 시점의 불변 PublicRenderModel
--
-- public_resource_projection은 여전히 "현재 공개 상태"를 담당한다.
-- 과거 Snapshot을 현재 source나 현재 projection에서 재계산하지 않는다.
-- ---------------------------------------------------------------------------
CREATE TABLE publication (
publication_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
status varchar(20) NOT NULL
CHECK (status IN ('PUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- Publication 자체의 optimistic concurrency 토큰.
-- unpublish는 expectedPublicationRevision으로 이 값을 검증한다.
publication_revision bigint NOT NULL DEFAULT 1 CHECK (publication_revision >= 1),
latest_event_id uuid NOT NULL,
public_path varchar(500) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_publication_source UNIQUE (source_kind, source_id)
);
CREATE TABLE publication_event (
publication_event_id uuid PRIMARY KEY,
publication_id uuid NOT NULL REFERENCES publication(publication_id),
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
event_type varchar(20) NOT NULL
CHECK (event_type IN ('PUBLISHED', 'REPUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- UNPUBLISHED Event는 자체 snapshot을 만들지 않고 마지막 공개 Snapshot을 참조한다.
source_published_event_id uuid REFERENCES publication_event(publication_event_id),
occurred_at timestamptz NOT NULL DEFAULT now(),
-- Publish 재시도가 중복 Event를 만들지 않도록 최초 요청의 idempotency key를 남긴다.
idempotency_key varchar(200),
created_by varchar(255) NOT NULL,
CONSTRAINT ck_publication_event_source_ref CHECK (
(event_type = 'UNPUBLISHED' AND source_published_event_id IS NOT NULL)
OR (event_type <> 'UNPUBLISHED' AND source_published_event_id IS NULL)
)
);
-- Event row는 생성 후 수정하지 않는다. UPDATE/DELETE 차단은 권한과 application
-- rule로 강제하며, 필요하면 운영에서 REVOKE UPDATE, DELETE로 보강한다.
-- 첫 게시는 publication(latest_event_id) -> publication_event -> publication UPDATE
-- 순서로 한 transaction 안에서 처리된다. 순환 참조를 허용하기 위해 지연 검사한다.
ALTER TABLE publication
ADD CONSTRAINT fk_publication_latest_event
FOREIGN KEY (latest_event_id)
REFERENCES publication_event(publication_event_id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE publication_snapshot (
publication_event_id uuid PRIMARY KEY
REFERENCES publication_event(publication_event_id),
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
content_format_version varchar(50) NOT NULL,
renderer_contract_version varchar(50) NOT NULL,
-- 게시 시점에 사용된 Asset의 assetKey/delivery path/치수를 고정한다.
-- 이후 Asset이 교체되어도 과거 Snapshot의 표현은 변하지 않는다.
asset_manifest jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(asset_manifest) = 'array'),
created_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Indexes
-- ---------------------------------------------------------------------------
CREATE INDEX idx_document_management
ON document(document_type, workflow_status, updated_at DESC);
CREATE INDEX idx_document_topic
ON document(primary_topic_id, document_type, updated_at DESC);
CREATE INDEX idx_question_status
ON open_question(question_status, updated_at DESC);
CREATE INDEX idx_question_topic
ON open_question(primary_topic_id, question_status, updated_at DESC);
CREATE INDEX idx_question_update_timeline
ON question_update(question_id, occurred_at ASC, sequence_no ASC);
CREATE INDEX idx_project_phase
ON project(phase, updated_at DESC);
CREATE INDEX idx_project_decision
ON project_decision(project_id, decision_status, decided_at DESC);
CREATE INDEX idx_project_activity
ON project_activity(project_id, visibility, occurred_at DESC);
CREATE INDEX idx_asset_status
ON asset(management_status, created_at DESC);
CREATE INDEX idx_asset_checksum
ON asset(checksum_sha256);
CREATE INDEX idx_asset_reference_owner
ON asset_reference(owner_type, owner_id, reference_scope);
CREATE INDEX idx_asset_reference_asset
ON asset_reference(asset_id, reference_scope);
CREATE INDEX idx_public_latest
ON public_resource_projection(latest_index_at DESC, resource_type, resource_id)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC'
AND latest_index_at IS NOT NULL;
CREATE INDEX idx_public_topic
ON public_resource_projection(primary_topic_id, resource_type, published_at DESC)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_type
ON public_resource_projection(resource_type, visibility, published_at DESC)
WHERE publication_state = 'ACTIVE';
CREATE INDEX idx_public_projection_search_trgm
ON public_resource_projection
USING gin (search_text gin_trgm_ops)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_project_link_lookup
ON public_resource_project_link(project_id, relation_type, resource_type);
-- Studio workflow artifacts -------------------------------------------------
-- 특정 version에 대한 최신 Validation 조회 (nextAction 계산의 핵심 경로)
CREATE INDEX idx_studio_validation_source
ON studio_validation(source_kind, source_id, validated_version, validated_at DESC);
-- 특정 version에 대한 최신 Preview 조회
CREATE INDEX idx_studio_preview_source
ON studio_preview(source_kind, source_id, source_version, created_at DESC);
-- 만료 Preview 정리 배치
CREATE INDEX idx_studio_preview_expiry
ON studio_preview(expires_at);
-- Publication history -------------------------------------------------------
-- 한 문서의 게시 이력 (occurredAt DESC, publicationEventId DESC 정렬 계약과 일치)
CREATE INDEX idx_publication_event_publication
ON publication_event(publication_id, occurred_at DESC, publication_event_id DESC);
-- 전체 게시 기록 화면과 source 기준 조회
CREATE INDEX idx_publication_event_source
ON publication_event(source_kind, source_id, occurred_at DESC);
@@ -44,7 +44,7 @@ class PostgreSqlMigrationIntegrationTest {
.migrate();
assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6");
.containsExactly("1", "3", "4", "5", "6", "7");
Flyway coreStream =
Flyway.configure()
@@ -0,0 +1,157 @@
package dev.caskeleton.adapter.outbound.persistence.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기
* 때문이다.
*
* <p>이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에
* 있고, 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 {@code @SpringBootTest}로 컨텍스트를
* 띄울 수 없고, 이 패키지의 형제인 {@code readiness.PostgreSqlMigrationIntegrationTest}와 같은 방식 — Testcontainers
* 위에서 순수 Flyway API를 직접 구동 — 을 쓴다. 컨테이너는 매번 완전히 빈 상태로 시작하므로, 기존 V1/V3/V4/V5/V6 다음에 V7이 얹히는 전체 체인이
* 클린 DB에 처음부터 적용되는 경로를 그대로 검증한다.
*/
class TechLogSchemaMigrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Tech Log schema migration test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
// classpath:db/migration/postgresql only — the same location
// PostgreSqlPersistenceConfig's FlywayConfigurationCustomizer pins the application to. Using
// the full "classpath:db/migration" tree here would also pick up the unrelated jpa/* streams
// (each starting their own V1) and collide.
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void createsEveryTechLogTable() throws Exception {
List<String> expected =
List.of(
"topic",
"tag",
"document",
"case_detail",
"reference_detail",
"document_tag",
"document_relation",
"open_question",
"question_point",
"question_update",
"question_tag",
"question_document_link",
"project",
"project_decision",
"project_document_link",
"project_question_link",
"project_activity",
"asset",
"asset_reference",
"studio_validation",
"studio_preview",
"publication",
"publication_event",
"publication_snapshot",
"public_resource_projection",
"public_route",
"public_resource_tag",
"public_resource_project_link");
List<String> actual = new ArrayList<>();
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) {
while (rs.next()) {
actual.add(rs.getString(1));
}
}
assertThat(actual).containsAll(expected);
}
@Test
void doesNotCreateAStudioIdempotencyTable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT count(*) FROM information_schema.tables "
+ "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) {
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
@Test
void publicationLatestEventForeignKeyIsDeferrable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT condeferrable, condeferred FROM pg_constraint "
+ "WHERE conname = 'fk_publication_latest_event'")) {
assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue();
assertThat(rs.getBoolean(1)).as("deferrable").isTrue();
assertThat(rs.getBoolean(2)).as("initially deferred").isTrue();
}
}
private static DataSource dataSource() {
return dataSource;
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* 이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에 있고,
* 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 브리프의 {@code @SpringBootTest}로는
* 컨텍스트를 띄울 수 없다({@code TechLogSchemaMigrationTest}가 같은 문제를 겪었다). 이 테스트도 같은 형제 패턴 — Testcontainers
* 위에서 순수 Flyway로 V7까지 적용한 뒤, {@code JdbcClient}와 어댑터를 직접 조립 — 을 쓴다. Spring 컨테이너가 없어도 {@code
* JdbcCatalogQueryAdapter}는 생성자 인자로 {@code JdbcClient} 하나만 받으므로 문제가 없다.
*/
class JdbcCatalogQueryAdapterTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcCatalogQueryAdapter adapter;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the catalog query adapter test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
adapter = new JdbcCatalogQueryAdapter(jdbcClient);
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void findsTopicsByPrefix() {
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "kaf", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).label()).isEqualTo("Kafka");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
@Test
void returnsAnEmptyPageWhenNothingMatches() {
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "zzzz-none", null, 20);
assertThat(page.items()).isEmpty();
assertThat(page.nextCursor()).isNull();
}
/**
* Review Important 1: {@code searchProjects} (JdbcCatalogQueryAdapter) had never run against a
* real database — {@code project} has a different column shape than {@code topic} (slug/name/
* workflow_status vs. name/normalized_name/slug/status), so a column typo or bad bind would only
* have surfaced in production. {@code project}'s NOT-NULL-without-default columns are {@code id},
* {@code name}, {@code created_by}, {@code updated_by} (V7__techlog_core.sql CREATE TABLE
* project) — everything else has a DEFAULT or is nullable, so the minimal INSERT below is valid.
*/
@Test
void findsProjectsByPrefix() {
jdbcClient
.sql(
"INSERT INTO project (id, name, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Payments Platform', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.PROJECT, "pay", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).id()).isNotNull();
assertThat(page.items().get(0).label()).isEqualTo("Payments Platform");
assertThat(page.items().get(0).kind()).isEqualTo("PROJECT");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
}
+43 -1
View File
@@ -15,6 +15,12 @@ sourceSets {
functionalTest {
java.srcDir 'src/functionalTest/java'
resources.srcDir 'src/functionalTest/resources'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest reuses
// RepositoryContractResources (test-sourceSet-owned, see
// dev.caskeleton.bootstrap.contract.support) for fail-closed repo-root resolution instead
// of a hand-rolled relative Path.of(..), matching the sibling contract tests' convention.
compileClasspath += sourceSets.test.output
runtimeClasspath += sourceSets.test.output
}
conditionalTransportTest {
java.srcDir 'src/conditionalTransportTest/java'
@@ -121,6 +127,33 @@ dependencies {
functionalTestImplementation 'org.junit.jupiter:junit-jupiter'
functionalTestImplementation 'org.assertj:assertj-core'
functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a minimal Studio web
// slice (real StudioSessionController/StudioCatalogController, no persistence/messaging/cache)
// to diff springdoc's published /api/v1/studio/** surface against studio-v1.yaml. Only the two
// modules the slice actually needs — deliberately not the full app-bootstrap runtime graph, so
// no DataSource/Flyway/Redis auto-configuration is even on this classpath to exclude.
functionalTestImplementation project(':adapter:inbound:web')
functionalTestImplementation project(':application-core')
// test-only: @SpringBootTest/MockMvc/@AutoConfigureMockMvc — mirrors the root build.gradle
// subprojects{} pair every non-core module already gets on its ordinary `test` sourceSet.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-test'
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
// test-only: classic Jackson (2.x) ObjectMapper/JsonNode to read /v3/api-docs and convert the
// SnakeYaml-parsed contract into a comparable tree. adapter-inbound-web/application-core pull
// this in only as a project-dependency `implementation` (hidden from a consumer's
// compileClasspath by Gradle's api/implementation split), so it must be declared directly here
// — mirrors the existing `testImplementation 'org.springframework.boot:spring-boot-starter-json'`
// pattern below for app-bootstrap's own `test` sourceSet.
functionalTestImplementation 'com.fasterxml.jackson.core:jackson-databind'
// test-only: org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration (excluded
// below) is part of spring-boot-security, only pulled in transitively by spring-boot-starter-security
// — same api/implementation-hiding reason as jackson-databind above. app-bootstrap declares this
// on `implementation` for its own main/test sourceSets, which functionalTest does not extend.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-security'
// test-only: parses config/openapi/studio-v1.yaml with the same library StudioErrorRegistryTest
// already uses for docs/registries/error-codes.yaml — avoids adding jackson-dataformat-yaml
// (present only on runtimeClasspath repo-wide, transitively via springdoc, not compileClasspath).
functionalTestImplementation 'org.yaml:snakeyaml'
// Explicit qualification-only composition. These projects remain absent from main
// api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs.
conditionalTransportTestImplementation project(':adapter:inbound:graphql')
@@ -166,13 +199,22 @@ sampleOffQualification.configure {
tasks.register('functionalTest', Test) {
group = 'verification'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior.'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior, plus the ' +
'feature-techlog-studio-backend Studio contract drift gate.'
testClassesDirs = sourceSets.functionalTest.output.classesDirs
classpath = sourceSets.functionalTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
shouldRunAfter tasks.named('test')
jvmArgs '-Duser.timezone=UTC'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a real (minimal)
// Spring Boot context that needs Logback, on the same classpath as gradleTestKit() (whose own
// SLF4J provider — org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext —
// wins classpath scanning over the real one). Spring Boot's LogbackLoggingSystem then finds
// Logback's jar present but the bound ILoggerFactory is Gradle's fake context, and fails fast
// with IllegalStateException before the context even starts. LoggingSystem=none skips Boot's
// logging bootstrap entirely — this task doesn't assert on log output, so there is nothing lost.
systemProperty 'org.springframework.boot.logging.LoggingSystem', 'none'
}
def conditionalTransportCompositionQualification = registerStrictQualificationTest(
+108 -108
View File
@@ -2,26 +2,26 @@
# 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,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,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,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.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,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-annotations:4.8.6=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath
@@ -29,10 +29,10 @@ com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRu
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=conditionalTransportTestRuntimeClasspath,spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
@@ -54,11 +54,11 @@ com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath
com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -71,14 +71,14 @@ com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClassp
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.vaadin.external.google:android-json:0.0.20131108.vaadin1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
@@ -101,10 +101,10 @@ io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -147,39 +147,39 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas
io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,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.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,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,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -188,21 +188,21 @@ org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runti
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath
org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath
@@ -224,17 +224,17 @@ org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runti
org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath
org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -246,35 +246,35 @@ org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sa
org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,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,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,functionalTestCompileClasspath,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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,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,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-common:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
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
@@ -283,9 +283,9 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRun
org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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
@@ -293,73 +293,73 @@ org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtim
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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,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=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,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,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,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=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
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-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,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
@@ -367,10 +367,10 @@ org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClassp
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.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=developmentOnly,testAndDevelopmentOnly
@@ -0,0 +1,336 @@
package dev.caskeleton.bootstrap.contract;
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.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioCatalogController;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionController;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
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.function.Supplier;
import org.junit.jupiter.api.Nested;
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.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.yaml.snakeyaml.Yaml;
/**
* feature-techlog-studio-backend Task 10 — the drift gate spec §5.5 calls for: springdoc's
* published {@code /v3/api-docs} is diffed against the vendored {@code
* config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes.
* Only <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the
* reverse), so this stays green as slices 2-5 add the other 17 operations — <b>on one condition</b>:
* the new controllers must live somewhere under {@code dev.caskeleton.adapter.inbound.web.techlog},
* the package {@link ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp}
* {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to this
* file. A controller placed <em>outside</em> that package tree is invisible to both minimal contexts
* — springdoc never sees it, so this gate stays green even if its path/method/operationId contradicts
* the contract — and the {@code @ComponentScan} base package below must be widened (or the new
* controller moved) before this gate can be trusted again. (An earlier draft of this class named the
* two controllers directly via {@code @Import} instead of scanning; that hardcoded list had exactly
* this blind spot — confirmed by temporarily reintroducing it and observing a controller with an
* out-of-contract mapping pass silently, see task-10-report.md.) This test also fails the moment an
* in-scan controller's method name drifts from its {@code operationId} or ships an endpoint outside
* the contract.
*
* <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2>
*
* <p>This repository's own tests never boot the full app under test: {@code
* FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's
* {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자
* from {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in
* infrastructure a contract-shape test has nothing to say about. This test follows the same
* playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio web
* package (so real production controllers like {@link StudioSessionController} and {@link
* StudioCatalogController} are picked up the same way the real app's component scan finds them —
* see the class-level "why scan, not @Import" note above), with {@link SecurityAutoConfiguration}
* excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters combination
* {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} (adapter-inbound-web's
* own test sourceSet) already use for this class of test.
*
* <p>Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code
* :application-core} (not {@code :adapter:outbound:persistence-jpa}, cache, or messaging), none of
* the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for {@code
* @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code
* ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list.
*
* <p>{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of
* {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code
* AutoConfiguration.imports} entry, not part of Boot's curated {@code @WebMvcTest} slice allowlist,
* so {@code /v3/api-docs} would not be exposed under a sliced test. A full, unsliced context that
* only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + springdoc"
* without paying for DB/security infrastructure.
*
* <h2>Why two nested contexts instead of one</h2>
*
* <p>The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not
* work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler
* ({@code OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI
* model itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports}
* returns {@code true} unconditionally (by design — it wraps every controller response in the real
* app, not just Studio's), so if it is on the classpath of *that* request it rewrites the body from
* {@code byte[]} to {@code Envelope<byte[]>} — but Spring MVC picks the {@code HttpMessageConverter}
* from the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter}
* (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and
* {@code writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This
* reproduced with a full stack trace during this task (see task-10-report.md) — it is a real,
* pre-existing defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and
* not part of this task's brief), not an artifact of this test's plumbing: any app that boots both
* springdoc and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated
* would hit the same crash. Fixing that advice is out of scope for a contract-regression test, so
* {@link ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't
* invoke it for anything test 1 checks anyway — introspection is pure reflection over the mapping),
* and {@link EnvelopeWrapping} boots a separate context *with* it, hitting
* {@link StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO
* that the same JSON converter handles before and after wrapping.
*
* <h2>Why {@link ListCatalogUseCase} is real, not mocked</h2>
*
* <p>{@link StudioCatalogController}'s constructor takes the concrete (non-interface) {@code
* ListCatalogUseCase}, so there is no seam to substitute a fake at that boundary. Its own two
* constructor collaborators, {@link CatalogQueryPort} and {@link TransactionPort}, <em>are</em>
* interfaces (application ports), so this test wires trivial in-memory implementations of those
* instead of pulling in a real persistence adapter — springdoc never invokes a controller method to
* build {@code /v3/api-docs} (pure reflection over the mapping/return-type shape), and the envelope
* test only needs the query to return successfully, not to hold meaningful catalog data.
*
* <h2>Second test: envelope wrapping, without real DB/auth infrastructure</h2>
*
* <p>The brief's original template hits the endpoint through {@code TestRestTemplate} against a
* fully DB+auth-backed app; this functionalTest sourceSet has neither (confirmed: before this task
* it declared only {@code gradleTestKit()} + JUnit + AssertJ, no Spring dependency at all). Rather
* than skip the envelope assertion or force real persistence/security infrastructure into a
* contract-shape test, {@link EnvelopeWrapping} proves the same regression the brief wants — {@link
* EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response — against a minimal
* slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to persistence
* and authentication, so stubbing those out does not weaken what the assertion proves, and no
* production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this slice
* simply never wires a {@code SecurityFilterChain} at all (same as the two adapter-inbound-web
* precedents cited above), rather than widening what unauthenticated callers may reach in the real
* app.
*/
class StudioContractDriftTest {
@Nested
@SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class)
@AutoConfigureMockMvc(addFilters = false)
class ContractSurface {
@Autowired private MockMvc mvc;
/**
* "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를
* 순회한다. 슬라이스 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다
* 매번 실패했을 것이다.
*/
@Test
void publishedStudioOperationsMatchTheContract() throws Exception {
JsonNode contract = readContract();
JsonNode published = readPublishedApiDocs();
List<String> problems = new ArrayList<>();
JsonNode publishedPaths = published.path("paths");
for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) {
if (!path.getKey().startsWith("/api/v1/studio/")) {
continue;
}
JsonNode contractPath = contract.path("paths").path(path.getKey());
if (contractPath.isMissingNode()) {
problems.add("계약에 없는 path: " + path.getKey());
continue;
}
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
JsonNode contractOp = contractPath.path(method.getKey());
if (contractOp.isMissingNode()) {
problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey());
continue;
}
String publishedId = method.getValue().path("operationId").asText("");
String contractId = contractOp.path("operationId").asText("");
if (!publishedId.equals(contractId)) {
problems.add(
"operationId 불일치 "
+ method.getKey()
+ " "
+ path.getKey()
+ ": published="
+ publishedId
+ " contract="
+ contractId);
}
}
}
assertThat(problems).isEmpty();
}
/**
* SnakeYaml(이미 {@code StudioErrorRegistryTest}가 error-codes.yaml에 쓰는 라이브러리)로 읽은 뒤 {@code
* ObjectMapper#valueToTree}로 {@link JsonNode}로 옮긴다 — {@code jackson-dataformat-yaml}을 새 컴파일
* 의존으로 끌어오지 않고 기존 라이브러리 조합만으로 브리프 템플릿과 같은 {@code JsonNode} 기반 대조 로직을 쓸 수 있다({@code
* jackson-dataformat-yaml}은 이 모듈의 {@code compileClasspath}가 아니라 {@code runtimeClasspath}에만
* 전이적으로 있었다 — springdoc이 YAML 응답을 만들 때만 필요해서다).
*/
private static JsonNode readContract() throws Exception {
Path contractFile =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Map<String, Object> contractYaml;
try (InputStream in = Files.newInputStream(contractFile)) {
contractYaml = new Yaml().load(in);
}
return new ObjectMapper().valueToTree(contractYaml);
}
private JsonNode readPublishedApiDocs() throws Exception {
String body =
mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
return new ObjectMapper().readTree(body);
}
/**
* {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다.
* 나열 방식은 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는
* 함정이 있었다 — 드리프트가 "없는" 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시
* 컨트롤러를 하나 추가해(어떤 {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code
* @Import} 목록으로는 이 테스트가 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두
* task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code
* ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스 javadoc "Why two nested contexts" 참조).
* springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
static class ContractSurfaceApp {
@Bean
SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest();
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
return StudioContractDriftTest.listCatalogUseCaseForTest();
}
}
}
@Nested
@SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class)
@AutoConfigureMockMvc(addFilters = false)
class EnvelopeWrapping {
@Autowired private MockMvc mvc;
@Test
void everyStudioResponseIsWrappedInTheEnvelope() throws Exception {
String body =
mvc.perform(get("/api/v1/studio/catalog").param("type", "TOPIC"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\"");
assertThat(body).doesNotContain("\"data\":{\"success\"");
}
/**
* {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap
* 소유) {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code
* StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트)가 쓰는 것과 같은 이유의 같은 패턴. {@link
* ContractSurface.ContractSurfaceApp}과 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고,
* {@link EnvelopeBodyAdvice}만 별도로 {@code @Import}한다(스캔 범위 밖 패키지라서) — 이 컨텍스트는 {@code
* /v3/api-docs}를 두드리지 않으므로 클래스 javadoc이 설명하는 {@code byte[]} 크래시를 겪지 않는다.
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import(EnvelopeBodyAdvice.class)
static class EnvelopeApp {
@Bean
SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest();
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
return StudioContractDriftTest.listCatalogUseCaseForTest();
}
}
}
/** {@code csrfHeaderName}이 계약 const {@code X-CSRF-TOKEN}과 다르면 {@link StudioSessionController}의 생성자가 즉시 실패한다. */
private static SecuritySettings securitySettingsForTest() {
SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings(
SecuritySettings.AuthenticationMode.JWT, "https://issuer.example", null, List.of(), session);
}
/**
* 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고
* 리플렉션만 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다.
*/
private static ListCatalogUseCase listCatalogUseCaseForTest() {
return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort());
}
private static final class StubCatalogQueryPort implements CatalogQueryPort {
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
return new CatalogPageView(List.of(), null);
}
}
/** 실제 트랜잭션 관리자 없이 액션을 곧장 실행한다 — 이 슬라이스에는 커밋/롤백할 트랜잭션 리소스가 없다. */
private static final class PassThroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(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();
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Tech Log Studio 조립. application-core는 Spring을 보지 않으므로 여기서 배선한다. */
@Configuration
public class TechLogStudioConfig {
@Bean
ListCatalogUseCase listCatalogUseCase(
CatalogQueryPort catalogQueryPort, TransactionPort transactionPort) {
return new ListCatalogUseCase(catalogQueryPort, transactionPort);
}
}
@@ -6,9 +6,11 @@
# a value this file can know. What belongs here is the shape dev must have whatever the operator
# sets: the vendor and the schema owner.
#
# Both keys below restate the repository default rather than change it, so adding this file moves
# no behaviour. That is the point — the moment dev and prod diverge from local, the difference has
# a declared home instead of being implied by whatever the environment happened to inject.
# The flyway/persistence keys below restate the repository default rather than change it, so they
# move no behaviour on their own. That is the point — the moment dev and prod diverge from local,
# the difference has a declared home instead of being implied by whatever the environment happened
# to inject. The security.session key further down is the one exception: it genuinely overrides the
# template default for Tech Log Studio's contract. See the comment there for why.
# =============================================================================
spring:
@@ -20,3 +22,18 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
#
# auth-mode intentionally stays at the repository default (jwt) here. Task 8's brief proposed
# switching it to redis-session, but src/app-bootstrap's AuthenticationModeCompositionConfig
# requires a complete Redis session repository/filter pair (`redisVersionedSessionRepository`,
# `springSessionRepositoryFilter`) once that mode is active, and neither bean exists in this
# branch yet — the Redis session infrastructure is out of this task's scope (its design package
# was removed from the working tree ahead of this task; see AGENTS.md / task-8 report). Setting
# auth-mode: redis-session here would make a real `--spring.profiles.active=dev` boot fail the
# composition validator with "Redis Session repository/filter is incomplete". This csrf-header-
# name override is independent of auth-mode and safe on its own.
session:
csrf-header-name: X-CSRF-TOKEN
@@ -143,6 +143,15 @@ ca-skeleton:
issuer-uri: http://localhost:8081/realms/ca-skeleton
audience: ca-skeleton-api
public-paths: /api/healthcheck
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by
# src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor
# rejects any other value with IllegalStateException, and because that controller is an
# unconditional @RestController bean, the failure is a BeanCreationException that fails context
# refresh and kills the whole process, not just Studio. See application-dev.yml's identical
# override for the full rationale.
session:
csrf-header-name: X-CSRF-TOKEN
cors:
enabled: true
allowed-origins: http://localhost:3000
@@ -26,3 +26,13 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
# StudioSessionController's constructor rejects any other configured value with
# IllegalStateException, and because that controller is an unconditional @RestController bean,
# the failure is a BeanCreationException that fails context refresh and kills the whole
# process on this profile, not just Studio. See application-dev.yml's identical override for
# the full rationale.
session:
csrf-header-name: X-CSRF-TOKEN
@@ -0,0 +1,208 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.StudioClientSafeMessages;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* feature-techlog-studio-backend — pins {@link StudioError} against {@code
* docs/registries/error-codes.yaml} (the SSOT the contract and log tooling read) on three axes:
*
* <ol>
* <li>every {@link StudioError} constant has a matching registry row (code presence);
* <li>that row's {@code category}/{@code http_status}/{@code retryable} match the enum's
* declared values exactly — a standing drift gate. Nothing else in the suite checks this for
* {@link StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template)
* only walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔
* {@code OperationalError.VALIDATION_FAILED} code-name collision ship once already (see
* task-5-report.md Fix Round 1) — this closes that gap for good;
* <li>{@link StudioClientSafeMessages#forError(StudioError)}'s text matches the row's {@code
* client_safe_message} exactly — the single source of truth for {@code error.message} is the
* registry, and code drifting from it must fail here rather than silently changing what
* clients see.
* </ol>
*
* <p>Uses {@link RepositoryContractResources} (the same repository-root resolver the sibling
* registry contract tests in {@code dev.caskeleton.bootstrap.contract} use) rather than a
* hand-rolled relative {@code Path.of("..", "..", ...)}, since Gradle's test working directory is
* not guaranteed to be the module directory the brief's naive relative path assumed. Parses the
* registry with SnakeYaml — the same library/pattern {@code ErrorCodeRegistryMappingTest} and
* {@code RunbookCoverageContractTest} already use in this suite — rather than line-scanning, since
* this test needs structured field access (category/http_status/retryable/client_safe_message),
* not just the {@code code:} key.
*/
class StudioErrorRegistryTest {
private static Map<String, Map<String, Object>> registryRowsByCode;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadRegistry() throws Exception {
Path registry =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("docs/registries/error-codes.yaml");
registryRowsByCode = new LinkedHashMap<>();
try (InputStream in = Files.newInputStream(registry)) {
Map<String, Object> root = new Yaml().load(in);
List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors");
for (Map<String, Object> row : errors) {
registryRowsByCode.put((String) row.get("code"), row);
}
}
}
@Test
void everyStudioErrorHasARegistryRow() {
Set<String> declared =
Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());
assertThat(registryRowsByCode.keySet()).containsAll(declared);
}
/**
* The value-drift gate: every {@link StudioError}'s registry row must carry the exact same
* category/http_status/retryable the enum declares. {@code PAYLOAD_TOO_LARGE}/{@code
* UNSUPPORTED_MEDIA_TYPE} reuse a pre-existing {@code feature-api-contract-baseline} row instead
* of a Studio-owned one (task-5-report.md §5) — this still holds them to the same standard.
*/
@Test
void everyStudioErrorRowMatchesCategoryHttpStatusAndRetryable() {
for (StudioError error : StudioError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(row.get("category"))
.as("category for %s", error.code())
.isEqualTo(error.category().name());
assertThat(((Number) row.get("http_status")).intValue())
.as("http_status for %s", error.code())
.isEqualTo(error.httpStatus());
assertThat(row.get("retryable"))
.as("retryable for %s", error.code())
.isEqualTo(error.retryable());
}
}
/**
* {@code StudioClientSafeMessages} must never drift from the registry's {@code
* client_safe_message} — that column is the single source of truth for what {@code
* error.message} clients see (task-5-report.md Important 1).
*/
@Test
void everyStudioErrorClientSafeMessageMatchesRegistry() {
for (StudioError error : StudioError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(StudioClientSafeMessages.forError(error))
.as("client_safe_message for %s", error.code())
.isEqualTo(row.get("client_safe_message"));
}
}
/**
* final whole-branch review B3: {@code StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes}
* only counts ({@code hasSize(23)}) — it never reads the contract, so a rename or a 1:1 code
* substitution on either side (enum or {@code studio-v1.yaml}) leaves the count at 23 and passes.
* This is the gate that reads {@code src/config/openapi/studio-v1.yaml}'s {@code
* components.schemas.ApiError.properties.code.enum} and requires the two sets to be identical in
* both directions — a code present only in the contract, or only in the enum, fails here. This
* drift already happened once for real (Task 5's vendor copy carrying stale names) and a human
* caught it, not a gate; this closes that gap.
*/
@Test
void enumMatchesContractCodeSetExactly() throws Exception {
Path contract =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Set<String> contractCodes = contractApiErrorCodes(contract);
Set<String> enumCodes =
Arrays.stream(StudioError.values()).map(StudioError::code).collect(Collectors.toSet());
assertThat(enumCodes)
.as("StudioError enum vs studio-v1.yaml ApiError.code enum")
.containsExactlyInAnyOrderElementsOf(contractCodes);
}
@SuppressWarnings("unchecked")
private static Set<String> contractApiErrorCodes(Path contract) throws IOException {
try (InputStream in = Files.newInputStream(contract)) {
Map<String, Object> root = new Yaml().load(in);
Map<String, Object> components = (Map<String, Object>) root.get("components");
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
Map<String, Object> code = (Map<String, Object>) properties.get("code");
List<String> enumValues = (List<String>) code.get("enum");
return Set.copyOf(enumValues);
}
}
/**
* final whole-branch review B3: {@code src/config/openapi/studio-v1.yaml} is a vendored copy of
* the design package's contract (MANIFEST.sha256's {@code # source:} line records where from) and
* had zero consumers before this test — nothing detected a local edit to the vendor copy drifting
* from the hash the manifest recorded at vendoring time. This makes {@code MANIFEST.sha256} an
* actual tamper/drift gate rather than a file nobody reads.
*/
@Test
void vendoredContractMatchesTheRecordedManifestHash() throws Exception {
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
Path contract = resources.requireTrackedFile("src/config/openapi/studio-v1.yaml");
Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256");
String recordedHash = recordedSha256(manifest, "studio-v1.yaml");
String actualHash = sha256Hex(contract);
assertThat(actualHash)
.as(
"src/config/openapi/studio-v1.yaml sha256 must match the value MANIFEST.sha256 recorded"
+ " for it (local edit or vendoring drift)")
.isEqualTo(recordedHash);
}
/**
* Parses lines shaped {@code <hex sha256> <filename>}, skipping {@code #}-prefixed comment
* lines such as MANIFEST.sha256's {@code # source: ...} provenance line.
*/
private static String recordedSha256(Path manifest, String filename) throws IOException {
return Files.readAllLines(manifest).stream()
.map(String::strip)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.filter(line -> line.endsWith(filename))
.map(line -> line.substring(0, line.indexOf(' ')).strip())
.findFirst()
.orElseThrow(
() ->
new IllegalStateException(
"MANIFEST.sha256 has no hash row for " + filename + ": " + manifest));
}
private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(file));
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.bootstrap.architecture;
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;
/**
* 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고
* 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다.
*/
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
class TechLogBoundaryArchTest {
// 새 techlog context를 추가할 때 손봐야 할 지점 (fix round 1에서 asset/publication 규칙이
// 계획에서 통째로 빠졌던 것이 바로 이 체크리스트를 세워두지 않아서였다 — spec §4.3이 규칙
// 개수·내용의 원본이고, 이 클래스는 그것의 실행 가능한 사본일 뿐이다):
// (a) 그 context 전용 형제 비의존 규칙(XXX_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS)을 새로
// 추가한다.
// (b) 기존 형제 규칙들(CONTENT/INQUIRY/PROJECT/ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS)의
// dependOnClassesThat().resideInAnyPackage(...) 금지 목록에 새 context를 추가한다.
// (c) STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS의 금지 목록(service/port.out)에 새
// context의 service·port.out 패키지를 추가한다.
// (d) NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE의 대상 목록(that().resideInAnyPackage(...))에
// 새 context를 추가한다.
// 새 context가 도메인 엔터티를 갖고 다른 context가 그것을 직접 변조하면 안 되는 경우
// (publication과 같은 성격) NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN과 같은 모양의
// 전용 규칙도 검토한다.
@ArchTest
static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.content..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.inquiry..", "..techlog.project..", "..techlog.asset..")
.as("techlog.content는 형제 context에 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule INQUIRY_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.inquiry..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.content..", "..techlog.project..", "..techlog.asset..")
.as("techlog.inquiry는 형제 context에 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule PROJECT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.project..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.asset..")
.as("techlog.project는 형제 context에 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
.that()
.resideInAPackage("..techlog.asset..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..techlog.content..", "..techlog.inquiry..", "..techlog.project..")
.as(
"spec §4.3 규칙1 ASSET_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS: techlog.asset는 "
+ "형제 context(content/inquiry/project)에 의존하지 않는다 — fix round 1: "
+ "content/inquiry/project 세 방향은 이미 asset을 형제 금지 목록에 넣어 막고 "
+ "있었지만 asset 자신이 형제를 참조하는 반대 방향이 계획에서 빠져 있었다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule STUDIO_FACADE_ONLY_TOUCHES_INBOUND_PORTS =
noClasses()
.that()
.resideInAPackage("..application.techlog.studio..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"..application.techlog.content.service..",
"..application.techlog.content.port.out..",
"..application.techlog.inquiry.service..",
"..application.techlog.inquiry.port.out..",
"..application.techlog.project.service..",
"..application.techlog.project.port.out..",
"..application.techlog.asset.service..",
"..application.techlog.asset.port.out..",
"..application.techlog.publication.service..",
"..application.techlog.publication.port.out..",
"..domain.techlog..")
.as("studio facade는 타 context의 port.in만 호출한다 (domain·service·port.out 직접 접근 금지)")
.allowEmptyShould(true);
@ArchTest
static final ArchRule NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE =
noClasses()
.that()
.resideInAnyPackage(
"..techlog.content..",
"..techlog.inquiry..",
"..techlog.project..",
"..techlog.asset..",
"..techlog.publication..")
.should()
.dependOnClassesThat()
.resideInAPackage("..application.techlog.studio..")
.as("도메인 context는 studio facade에 역방향 의존하지 않는다")
.allowEmptyShould(true);
@ArchTest
static final ArchRule NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN =
noClasses()
.that()
.resideInAnyPackage(
"..domain.techlog.content..",
"..domain.techlog.inquiry..",
"..domain.techlog.project..",
"..domain.techlog.asset..",
"..domain.techlog.identity..")
.should()
.dependOnClassesThat()
.resideInAPackage("..domain.techlog.publication..")
.as(
"spec §4.3 규칙4 NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN: "
+ "domain.techlog.publication을 제외한 어떤 domain 패키지도 Publication을 "
+ "직접 변경하지 않는다. ArchUnit은 '직접 변경'이라는 동작을 정적으로 표현할 "
+ "수 없으므로, 타 domain context가 publication 도메인 패키지에 의존하는 것 "
+ "자체를 금지하는 보수적 근사로 대신한다 — 위 형제 규칙들(techlog.content/"
+ "inquiry/project/asset)도 전면 금지이므로 일관된 강도다. PublicationStatus "
+ "같은 타입을 타 context가 읽어야 하는 정당한 필요가 생기면 그 타입을 공유 "
+ "위치로 옮기거나 port로 노출하는 것이 옳은 해법이지 이 경계를 뚫는 것이 "
+ "아니다")
.allowEmptyShould(true);
}
@@ -0,0 +1,96 @@
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.Map;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* final whole-branch review B1: pins {@code ca-skeleton.security.session.csrf-header-name} to the
* contract {@code const} ({@code studio-v1.yaml StudioSession.csrfHeaderName}, config/openapi/
* studio-v1.yaml:832) for every profile that can actually boot the process — {@code local}, {@code
* dev}, {@code prod}.
*
* <p>{@code StudioSessionController}'s constructor throws {@code IllegalStateException} when the
* configured header name disagrees with the contract const. That controller is an unconditional
* {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException}
* during context refresh — the process never starts, taking healthcheck/actuator/fileserver down
* with it. Only {@code application-dev.yml} declared the override before this fix;
* {@code application-local.yml} (the profile {@code src/.env:8} actually activates) and
* {@code application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN}
* (application.yml:498's inline default, restated verbatim by {@code src/.env:125}), so both
* profiles could not boot.
*
* <p>File-assertion contract test rather than a booted context, for the same reason {@link
* ProfileSeparationContractTest} gives: booting the real composition root is not possible in this
* source set (see that class's javadoc). This test follows its pattern and location.
*/
class StudioSessionCsrfHeaderProfileContractTest {
private static final Path REPOSITORY_ROOT = repositoryRoot();
/** studio-v1.yaml {@code StudioSession.csrfHeaderName} — config/openapi/studio-v1.yaml:832. */
private static final String CONTRACT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
@ParameterizedTest
@ValueSource(strings = {"local", "dev", "prod"})
void everyBootableProfileResolvesTheContractCsrfHeaderName(String profile) throws IOException {
assertThat(csrfHeaderNameOf(profile(profile)))
.as(
"application-%s.yml must set ca-skeleton.security.session.csrf-header-name to \"%s\""
+ " (studio-v1.yaml StudioSession.csrfHeaderName const) — otherwise"
+ " StudioSessionController's constructor throws IllegalStateException and the"
+ " whole process fails to boot on this profile",
profile, CONTRACT_CSRF_HEADER_NAME)
.isEqualTo(CONTRACT_CSRF_HEADER_NAME);
}
private static String csrfHeaderNameOf(Map<?, ?> configuration) {
Map<?, ?> session =
child(child(child(configuration, "ca-skeleton"), "security"), "session");
Object value = session == null ? null : session.get("csrf-header-name");
return value == null ? null : value.toString();
}
private static Map<?, ?> child(Map<?, ?> owner, String key) {
if (owner == null) {
return null;
}
Object value = owner.get(key);
return value instanceof Map<?, ?> map ? map : null;
}
private static Map<?, ?> profile(String profile) throws IOException {
return yaml("application-" + profile + ".yml");
}
private static Map<?, ?> yaml(String resource) throws IOException {
String source =
Files.readString(
REPOSITORY_ROOT.resolve("src/app-bootstrap/src/main/resources").resolve(resource));
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
Object loaded = new Yaml(new SafeConstructor(options)).load(source);
assertThat(loaded).as("%s must parse as a YAML mapping", resource).isInstanceOf(Map.class);
return (Map<?, ?>) loaded;
}
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());
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/**
* Studio 계약(`studio-v1.yaml`)의 `ApiError.code` enum 23종. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
*/
public enum StudioError implements ApiErrorCode {
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false),
DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
VERSION_CONFLICT(Category.CONFLICT, 409, false),
REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false),
DOCUMENT_VALIDATION_FAILED(Category.VALIDATION, 422, false),
VALIDATION_STALE(Category.CONFLICT, 409, false),
PREVIEW_NOT_FOUND(Category.NOT_FOUND, 404, false),
PREVIEW_STALE(Category.CONFLICT, 409, false),
PREVIEW_EXPIRED(Category.CONFLICT, 409, false),
PUBLICATION_NOT_FOUND(Category.NOT_FOUND, 404, false),
PUBLICATION_CONFLICT(Category.CONFLICT, 409, false),
PUBLICATION_EVENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
PUBLICATION_SNAPSHOT_NOT_FOUND(Category.NOT_FOUND, 404, false),
WARNING_ACKNOWLEDGEMENT_REQUIRED(Category.VALIDATION, 422, false),
IDEMPOTENCY_KEY_REUSED(Category.CONFLICT, 409, false),
ASSET_NOT_FOUND(Category.NOT_FOUND, 404, false),
ASSET_NOT_READY(Category.CONFLICT, 409, false),
ASSET_IN_USE(Category.CONFLICT, 409, false),
ASSET_QUARANTINED(Category.DATA_INTEGRITY, 409, false),
PAYLOAD_TOO_LARGE(Category.VALIDATION, 413, false),
UNSUPPORTED_MEDIA_TYPE(Category.VALIDATION, 415, false),
STUDIO_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, 503, true);
private final Category category;
private final int httpStatus;
private final boolean retryable;
StudioError(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;
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* Studio use case와 facade가 던지는 유일한 실패 표현. 전송 계층은 {@link ApiErrorCarrier}만 보고 봉투로 옮기므로 application이
* HTTP를 알 필요가 없다.
*
* <p>{@code details}는 계약의 {@code ApiError.details}에 그대로 실린다 — {@code VERSION_CONFLICT}면 최신 문서,
* {@code PUBLICATION_CONFLICT}면 최신 Publication.
*/
public final class StudioException extends RuntimeException implements ApiErrorCarrier {
private final transient StudioError error;
private final transient Object details;
private StudioException(StudioError error, String message, Object details) {
super(message);
this.error = error;
this.details = details;
}
public static StudioException of(StudioError error, String message) {
return new StudioException(error, message, null);
}
public static StudioException withDetails(StudioError error, String message, Object details) {
return new StudioException(error, message, details);
}
@Override
public ApiErrorCode errorCode() {
return error;
}
public StudioError studioError() {
return error;
}
public Object details() {
return details;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
/** Studio catalog는 도메인 Aggregate를 재구성하지 않는다. 전용 read 포트로 union query를 돌린다 (설계 08장 §4). */
@FunctionalInterface
public interface CatalogQueryPort {
CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit);
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.query;
/** 계약 `CatalogEntryType`과 1:1. API enum이 그대로 application 용어다. */
public enum CatalogEntryType {
TOPIC,
PROJECT,
RELATION,
EVIDENCE
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.studio.query;
import java.util.UUID;
/**
* 계약 `CatalogEntry`의 application 표현. {@code kind}와 {@code publicPath}는 TOPIC/PROJECT에는 없으므로 null이다.
*/
public record CatalogEntryView(
UUID id,
CatalogEntryType type,
String label,
String kind,
String publicPath,
String dependencyRevision) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.query;
import java.util.List;
public record CatalogPageView(List<CatalogEntryView> items, String nextCursor) {
public CatalogPageView {
items = List.copyOf(items);
}
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
public record ListCatalogQuery(CatalogEntryType type, String query, String cursor, int limit)
implements Query {}
@@ -0,0 +1,86 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
/**
* Studio catalog 조회. 도메인 상태를 바꾸지 않으므로 read-only다.
*
* <p>브리프 원안은 {@code TransactionPort}를 배선하지 않았지만, {@code
* CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY}(feature
* -domain-feature-onboarding-contract D4)가 READ_REPOSITORY+READ_ONLY 조합에 {@code
* TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link
* dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다.
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListCatalogUseCase implements QueryUseCase<ListCatalogQuery, CatalogPageView> {
private static final int MAX_LIMIT = 100;
/**
* studio-v1.yaml {@code components.parameters.Query.schema.maxLength}
* (config/openapi/studio-v1.yaml:579).
*/
private static final int MAX_QUERY_LENGTH = 100;
/**
* studio-v1.yaml {@code components.parameters.Cursor.schema.minLength}
* (config/openapi/studio-v1.yaml:580).
*/
private static final int MIN_CURSOR_LENGTH = 1;
/**
* studio-v1.yaml {@code components.parameters.Cursor.schema.maxLength}
* (config/openapi/studio-v1.yaml:580).
*/
private static final int MAX_CURSOR_LENGTH = 2000;
private final CatalogQueryPort catalogQueryPort;
private final TransactionPort transactions;
public ListCatalogUseCase(CatalogQueryPort catalogQueryPort, TransactionPort transactions) {
this.catalogQueryPort = catalogQueryPort;
this.transactions = transactions;
}
@Override
public CatalogPageView handle(ListCatalogQuery input) {
if (input.type() == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "type is required");
}
if (input.limit() < 1 || input.limit() > MAX_LIMIT) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT);
}
if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"q must be at most " + MAX_QUERY_LENGTH + " characters");
}
if (input.cursor() != null
&& (input.cursor().length() < MIN_CURSOR_LENGTH
|| input.cursor().length() > MAX_CURSOR_LENGTH)) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"cursor must be between "
+ MIN_CURSOR_LENGTH
+ " and "
+ MAX_CURSOR_LENGTH
+ " characters");
}
return transactions.inRead(
() -> catalogQueryPort.search(input.type(), input.query(), input.cursor(), input.limit()));
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.application.techlog.error;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.Category;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class StudioErrorTest {
/**
* final whole-branch review B3: renamed from {@code declaresExactlyTheTwentyThreeContractCodes} —
* this test never reads {@code studio-v1.yaml}, only counts the enum, so "contract codes" was a
* false claim (a rename or 1:1 substitution on either side leaves the count at 23 and this still
* passes). The actual contract-vs-enum set-equality gate is {@code
* StudioErrorRegistryTest#enumMatchesContractCodeSetExactly}; this test stays as a cheap "did the
* count change" tripwire.
*/
@Test
void declaresExactlyTwentyThreeCodes() {
assertThat(StudioError.values()).hasSize(23);
}
@Test
void everyCodeCarriesACategoryAndAClientFacingStatus() {
Arrays.stream(StudioError.values())
.forEach(
error -> {
assertThat(error.code()).matches("[A-Z][A-Z0-9_]*");
assertThat(error.category()).isNotNull();
assertThat(error.httpStatus()).isBetween(400, 599);
});
}
@Test
void versionConflictIsAFourZeroNineConflict() {
assertThat(StudioError.VERSION_CONFLICT.httpStatus()).isEqualTo(409);
assertThat(StudioError.VERSION_CONFLICT.category()).isEqualTo(Category.CONFLICT);
assertThat(StudioError.VERSION_CONFLICT.retryable()).isFalse();
}
@Test
void studioUnavailableIsRetryable() {
assertThat(StudioError.STUDIO_UNAVAILABLE.httpStatus()).isEqualTo(503);
assertThat(StudioError.STUDIO_UNAVAILABLE.category()).isEqualTo(Category.TRANSIENT_DEPENDENCY);
assertThat(StudioError.STUDIO_UNAVAILABLE.retryable()).isTrue();
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.application.techlog.studio.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import dev.caskeleton.application.techlog.studio.query.ListCatalogQuery;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
class ListCatalogUseCaseTest {
private static CatalogQueryPort portReturning(CatalogPageView page) {
return (type, query, cursor, limit) -> page;
}
@Test
void returnsWhateverThePortFound() {
CatalogEntryView entry =
new CatalogEntryView(
UUID.randomUUID(), CatalogEntryType.TOPIC, "Kafka", null, null, "rev-1");
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(entry), null)), new DirectTransactions());
CatalogPageView page =
useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, "ka", null, 20));
assertThat(page.items()).containsExactly(entry);
assertThat(page.nextCursor()).isNull();
}
@Test
void rejectsALimitAboveTheContractCeiling() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, null, 101)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("limit");
}
@Test
void rejectsAMissingType() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
assertThatThrownBy(() -> useCase.handle(new ListCatalogQuery(null, null, null, 20)))
.isInstanceOf(StudioException.class);
}
/**
* studio-v1.yaml {@code components.parameters.Query} — {@code schema: { type: string, maxLength:
* 100 } } (src/config/openapi/studio-v1.yaml:579).
*/
@Test
void rejectsAQueryLongerThanTheContractCeiling() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
String tooLong = "q".repeat(101);
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, tooLong, null, 20)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("q");
}
/**
* studio-v1.yaml {@code components.parameters.Cursor} — {@code schema: { type: string, minLength:
* 1, maxLength: 2000 } } (src/config/openapi/studio-v1.yaml:580).
*/
@Test
void rejectsAnEmptyCursor() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, "", 20)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("cursor");
}
/** Same contract field as {@link #rejectsAnEmptyCursor()}; upper bound instead of lower. */
@Test
void rejectsACursorLongerThanTheContractCeiling() {
ListCatalogUseCase useCase =
new ListCatalogUseCase(
portReturning(new CatalogPageView(List.of(), null)), new DirectTransactions());
String tooLong = "c".repeat(2001);
assertThatThrownBy(
() -> useCase.handle(new ListCatalogQuery(CatalogEntryType.TOPIC, null, tooLong, 20)))
.isInstanceOf(StudioException.class)
.hasMessageContaining("cursor");
}
/**
* {@code CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY}가
* READ_REPOSITORY+READ_ONLY use case에 {@code TransactionPort.inRead(...)} 직접 호출을 요구하므로, {@code
* ListCatalogUseCase}는 생성자에 {@link TransactionPort}를 받는다. 같은 모양의 fake는 {@code
* NotificationOperationsSnapshotUseCaseTest.TrackingTransactions}를 참고했다 — 여기서는 검증 없이 그대로 통과시키기만
* 하면 된다.
*/
private static final class DirectTransactions implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(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();
}
}
}
+2
View File
@@ -16,6 +16,8 @@ plugins {
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
// Task 4 — Studio 계약(studio-v1.yaml)에서 DTO만 생성한다(ADR-004/ADR-006).
id 'org.openapi.generator' version '7.18.0' apply false
}
// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer
+2
View File
@@ -0,0 +1,2 @@
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006)
6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml
File diff suppressed because it is too large Load Diff
+24
View File
@@ -16,6 +16,14 @@
<Source name="~.*[\\/]generated[\\/].*"/>
</Match>
<!-- Task 4 — Studio 계약(studio-v1.yaml)에서 openapi-generator가 만드는 DTO는
손으로 고치지 않으므로 SpotBugs 대상이 아니다. 위 소스-경로 기반 제외
(build/generated/**)가 이미 넓게 걸리지만, 패키지 기준으로도 명시해 어떤
이유로 이 패키지를 뺐는지 분명히 남긴다. -->
<Match>
<Package name="~dev\.caskeleton\.adapter\.inbound\.web\.techlog\.studio\.api\.model.*"/>
</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
@@ -43,4 +51,20 @@
<Source name="DefaultTypingFixture.java"/>
</Match>
<!-- Task 9 — StudioSessionCsrfDisabledTest$SecurityTestConfig reproduces the JWT auth-mode's
csrf(csrf -> csrf.disable()) exactly as SecurityConfig.filterChain's JWT branch does, in
order to pin a real regression: CsrfTokenArgumentResolver (Spring Security 7.0.0) is
registered unconditionally by @EnableWebSecurity and casts the CsrfToken request
attribute without a null check, so when CSRF protection is off (no CsrfFilter, no
attribute) the controller's CsrfToken parameter is always null and
StudioSessionController.getStudioSession must report STUDIO_UNAVAILABLE rather than
crash. Removing csrf.disable() here would stop exercising that regression and defeat the
test's purpose. Scoped to the exact nested fixture class only (narrower than the outer
test class, since that is precisely where the finding is reported), same shape as the
GraphqlHttpBoundaryQualificationTest exception above. -->
<Match>
<Bug pattern="SPRING_CSRF_PROTECTION_DISABLED"/>
<Class name="dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionCsrfDisabledTest$SecurityTestConfig"/>
</Match>
</FindBugsFilter>