refactor(build,src): testkit 소스셋 이관과 빌드 게이트 정상화, 미추적 빌드 파일 추적
한 커밋인 이유: src/build.gradle 안에서 ca.testkit-publisher 플러그인 제거와
게이트 수정이 얽혀 있다. 플러그인 적용부만 빼면 web·websocket·persistence-jpa·
persistence-mongo·app-bootstrap 이 사라진 testkitPublisher() 와 *Testkit
컨피규레이션을 계속 참조해 설정 단계에서 빌드가 죽는다. 파일 단위로 나눌 수 없다.
1) testkit 소스셋 → Gradle 표준 java-test-fixtures 이관
web, websocket, persistence-jpa, persistence-mongo, httpclient, graphql 과
이들의 testkit 컨피규레이션을 소비하던 app-bootstrap.
자체 제작 ca.testkit-publisher.gradle 77줄이 사라진다.
2) 실행되지 않거나 실패할 수 없던 빌드 게이트 정상화 (E등급)
- strict-test-lane 의 실행 카운터가 skip 을 실행으로 세던 것 수정.
전부 skip 인 레인은 이제 실패한다 (회귀 테스트 2건 추가)
- public-path 스냅샷이 gitignore 된 src/.env 를 읽던 것을
config/security.yml 의 바인딩 기본값으로 교체
- verifyEnvKeys 가 build/ 산출물을 소스로 읽어 삭제된 키를 사용 중으로
오판하던 것 수정 (입력 4,637 → 4,630 파일)
- jpa-evidence 가 git 실패를 "워크트리 깨끗함"으로 읽던 것을 fail-closed 로
- notification-evidence 의 Grade 열 탐지를 헤더 기준으로 교체 +
표 부재 시 fail-closed
- spring70CompatibilityTest 가 레인을 복제하며 잃은 fail-closed 복구
(태스크명 유지 — 워크플로 3곳과 gate-matrix 린트 무손상)
- 메시징 R2 스켈레톤 주변의 도달 불가 검증 45줄을 MSG-015 명시적 실패로 교체
3) git 에 없던 빌드 필수 파일 추적
- src/gradle/libs.versions.toml — src/build.gradle 이 9곳에서 참조하는데
추적되지 않아 깨끗한 체크아웃에서 설정이 실패했다
- app-bootstrap config/*.yml 15개 — application.yml 이 전부 import 한다.
하드코딩된 시크릿은 없고 값은 secret://environment/APP_* 참조다
4) 진행 중이던 구현 작업 반영 (redis/idempotency 구성, startup 검증,
아키텍처 테스트 클래스, notification 콜백 레지스트리 등)
검증:
- 깨끗한 체크아웃에서 ./gradlew help 통과
- :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
→ 20개 클래스 174 tests, 실패 0, 스킵 0 (이전에는 0개 실행)
미해결: verifyOneTypePerFile 은 손대지 않았다(Checkstyle 로 교체 권고).
B/C/D 등급 100여 건과 CI 단계 분리는 별도 작업 —
docs/superpowers/plans/2026-09-16-ci-stage-separation.md 참고.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e34519113b
commit
1535481794
@@ -1,14 +1,55 @@
|
||||
Closure<String> renderPublicPathSnapshot = { File environmentFile ->
|
||||
if (!environmentFile.isFile()) {
|
||||
// The snapshot's input is the COMMITTED binding default in config/security.yml, not src/.env.
|
||||
//
|
||||
// It used to read rootProject.file('.env'). /.gitignore:7 excludes `src/.env*` (allowing only the
|
||||
// two *.example files), so `git ls-files src/.env` is empty and the file does not exist in a CI
|
||||
// checkout — a gate whose expected value comes from an untracked file is not reproducible, and the
|
||||
// first line of this closure turned that into a hard failure on any clean machine. Locally it was
|
||||
// worse than a failure: it passed against one developer's file. The committed snapshot recorded
|
||||
// `/api/healthcheck`, taken from that local .env, while the shipped default in
|
||||
// app-bootstrap/src/main/resources/config/security.yml binds
|
||||
// public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}
|
||||
// = /v1/healthcheck. The reviewed snapshot therefore described a surface no deployment had.
|
||||
//
|
||||
// What the snapshot now pins is the permitAll surface a deployment gets when no operator override
|
||||
// is set — the thing a reviewer must see change. An operator's own SECURITY_PUBLIC_PATHS at run
|
||||
// time is outside the repository and outside any build gate; the default is the part this
|
||||
// repository is accountable for.
|
||||
Closure<String> renderPublicPathSnapshot = { File securityConfigFile ->
|
||||
if (!securityConfigFile.isFile()) {
|
||||
throw new GradleException(
|
||||
"missing public-path environment file ${environmentFile}")
|
||||
"missing public-path security configuration ${securityConfigFile}")
|
||||
}
|
||||
|
||||
def valuePattern = ~/^SECURITY_PUBLIC_PATHS=(.*)$/
|
||||
String raw = environmentFile.readLines('UTF-8').findResult { String line ->
|
||||
def matcher = valuePattern.matcher(line)
|
||||
def bindingPattern = ~/^\s*public-paths:\s*(\S.*?)\s*$/
|
||||
List<String> bindings = securityConfigFile.readLines('UTF-8').findResults { String line ->
|
||||
def matcher = bindingPattern.matcher(line)
|
||||
matcher.matches() ? matcher.group(1) : null
|
||||
} ?: ''
|
||||
}
|
||||
if (bindings.size() != 1) {
|
||||
throw new GradleException(
|
||||
"expected exactly one 'public-paths:' binding in ${securityConfigFile}, " +
|
||||
"found ${bindings.size()} — the snapshot cannot say which surface it pins")
|
||||
}
|
||||
|
||||
// Resolve Spring placeholders to their defaults, innermost first:
|
||||
// ${A:${B:/v1}/healthcheck} -> ${A:/v1/healthcheck} -> /v1/healthcheck.
|
||||
// `[^{}]*` only ever matches the innermost placeholder, so one substitution per pass unwinds
|
||||
// the nesting from the inside out without any replacement-string escaping.
|
||||
def defaultedPlaceholder = ~/\$\{[A-Za-z0-9_.]+:([^{}]*)\}/
|
||||
String raw = bindings.first()
|
||||
for (int guard = 0; guard < 16; guard++) {
|
||||
def matcher = defaultedPlaceholder.matcher(raw)
|
||||
if (!matcher.find()) {
|
||||
break
|
||||
}
|
||||
raw = raw.substring(0, matcher.start()) + matcher.group(1) + raw.substring(matcher.end())
|
||||
}
|
||||
if (raw.contains('${')) {
|
||||
throw new GradleException(
|
||||
"'public-paths' in ${securityConfigFile} resolves to '${raw}', which still holds a " +
|
||||
'placeholder with no default — the deployed public path surface is not ' +
|
||||
'determined by the repository and cannot be snapshotted')
|
||||
}
|
||||
List<String> publicPaths = raw.split(',')
|
||||
.collect { String value -> value.trim() }
|
||||
.findAll { String value -> !value.isEmpty() }
|
||||
@@ -16,19 +57,23 @@ Closure<String> renderPublicPathSnapshot = { File environmentFile ->
|
||||
|
||||
String header =
|
||||
"# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" +
|
||||
"# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); " +
|
||||
"anyRequest authenticated.\n" +
|
||||
"# SSOT: ca-skeleton.security.public-paths default in " +
|
||||
"app-bootstrap/src/main/resources/config/security.yml\n" +
|
||||
"# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own " +
|
||||
"SECURITY_PUBLIC_PATHS\n" +
|
||||
"# overrides it at run time and is outside this snapshot.\n" +
|
||||
"# Update only after review with: ./gradlew updatePublicPathSnapshot " +
|
||||
"-PapprovePublicPathChange\n"
|
||||
header + (publicPaths.isEmpty() ? '' : publicPaths.join('\n') + '\n')
|
||||
}
|
||||
|
||||
File publicPathEnvironmentFile = rootProject.file('.env')
|
||||
File publicPathSourceFile =
|
||||
rootProject.file('app-bootstrap/src/main/resources/config/security.yml')
|
||||
File publicPathSnapshotFile =
|
||||
rootProject.file('../docs/security/public-paths-snapshot.txt')
|
||||
boolean publicPathUpdateApproved = project.hasProperty('approvePublicPathChange')
|
||||
def existingPublicPathEnvironment = providers.provider {
|
||||
publicPathEnvironmentFile.isFile() ? publicPathEnvironmentFile : null
|
||||
def existingPublicPathSource = providers.provider {
|
||||
publicPathSourceFile.isFile() ? publicPathSourceFile : null
|
||||
}
|
||||
def existingPublicPathSnapshot = providers.provider {
|
||||
publicPathSnapshotFile.isFile() ? publicPathSnapshotFile : null
|
||||
@@ -37,7 +82,7 @@ def existingPublicPathSnapshot = providers.provider {
|
||||
tasks.register('verifyPublicPathSnapshot') {
|
||||
group = 'verification'
|
||||
description = 'Fails without mutation when the committed deny-by-default public path baseline drifts.'
|
||||
inputs.file(existingPublicPathEnvironment).optional()
|
||||
inputs.file(existingPublicPathSource).optional()
|
||||
inputs.file(existingPublicPathSnapshot).optional()
|
||||
inputs.property('updateApprovalRequested', publicPathUpdateApproved)
|
||||
|
||||
@@ -49,7 +94,7 @@ tasks.register('verifyPublicPathSnapshot') {
|
||||
}
|
||||
String canonical
|
||||
try {
|
||||
canonical = renderPublicPathSnapshot(publicPathEnvironmentFile)
|
||||
canonical = renderPublicPathSnapshot(publicPathSourceFile)
|
||||
} catch (GradleException exception) {
|
||||
throw new GradleException(
|
||||
"verifyPublicPathSnapshot: ${exception.message}", exception)
|
||||
@@ -64,7 +109,7 @@ tasks.register('verifyPublicPathSnapshot') {
|
||||
throw new GradleException(
|
||||
"verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" +
|
||||
" expected (snapshot):\n${existing}\n" +
|
||||
" actual (SECURITY_PUBLIC_PATHS):\n${canonical}\n" +
|
||||
" actual (security.yml public-paths default):\n${canonical}\n" +
|
||||
'A protected endpoint may now be public. Review the change, then run:\n' +
|
||||
' ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange')
|
||||
}
|
||||
@@ -76,7 +121,7 @@ tasks.register('verifyPublicPathSnapshot') {
|
||||
tasks.register('updatePublicPathSnapshot') {
|
||||
group = 'build setup'
|
||||
description = 'Explicitly updates the committed public path baseline after security review.'
|
||||
inputs.file(existingPublicPathEnvironment).optional()
|
||||
inputs.file(existingPublicPathSource).optional()
|
||||
inputs.property('approved', publicPathUpdateApproved)
|
||||
outputs.file(publicPathSnapshotFile)
|
||||
outputs.upToDateWhen { false }
|
||||
@@ -88,7 +133,7 @@ tasks.register('updatePublicPathSnapshot') {
|
||||
}
|
||||
String canonical
|
||||
try {
|
||||
canonical = renderPublicPathSnapshot(publicPathEnvironmentFile)
|
||||
canonical = renderPublicPathSnapshot(publicPathSourceFile)
|
||||
} catch (GradleException exception) {
|
||||
throw new GradleException(
|
||||
"updatePublicPathSnapshot: ${exception.message}", exception)
|
||||
|
||||
Reference in New Issue
Block a user