chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# adapter:outbound:identifier — non-IO infrastructure-capability adapter
## Registered identity
- Module ID: `adapter-outbound-identifier`
- Gradle path: `:adapter:outbound:identifier`
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:identifier:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.identifier`.
Design decisions previously kept as code comments (algorithm SSOT, salt origin,
build choices) live in [README.md](README.md).
## Responsibility
- Driven adapters for capabilities with **no external-system integration**:
identifier generation/encoding today, and clock / crypto/random sources by the
same rationale (feature-resource-identifier-contract §4 taxonomy).
- `UuidCodec` — UUID handling on top of the JDK `java.util.UUID` (RFC 9562 UUIDv7):
`normalize(String)` accepts a case-insensitive canonical UUID and returns the
canonical 36-character lowercase form (D3); `toUuid` / `fromUuid` convert between
the UUID string and the 128-bit `UUID` stored in the PostgreSQL `uuid` column (D10).
- Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is
infrastructure, not an outbound integration point, so `adapter-outbound` keeps its
documented meaning (external HTTP / messaging / cache / notifications).
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract` (Gradle matrix). Currently
only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in
[build.gradle](build.gradle).
## Forbidden
- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit
`identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`;
`.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`,
`sample-portfolio`.
- External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`.
## Test
Pure unit tests, no Spring context (`UuidCodecSpec`).
```bash
cd src
./gradlew :adapter:outbound:identifier:test --console=plain
```
+72
View File
@@ -0,0 +1,72 @@
# adapter-identifier — 설계 결정 참조
비-IO 인프라 능력(capability) 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.identifier`.
허용/금지 의존과 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다.
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
"왜 이렇게 했나"가 궁금할 때 본다.
## 왜 별도 모듈인가 (adapter-outbound 와의 구분)
`adapter-outbound` 처럼 도메인 포트를 구현하는 driven/secondary 어댑터지만, **외부 시스템 연동이
없는**(no external-system integration) 능력만 담는다: 식별자 생성/인코딩(UUIDv7), 같은 근거로 clock·
crypto/random 소스. UUID id/코덱 능력은 인프라이지 아웃바운드 연동 지점이 아니므로, 이것을
`adapter-outbound` 밖에 둬야 그 모듈의 문서화된 의미("외부 HTTP / messaging / cache / notifications")가
유지된다.
## UuidCodec
도메인 무관 UUID 변환 유틸. JDK `java.util.UUID`(RFC 9562 UUIDv7) 위에서 동작한다.
- `normalize(String)`**D3**: 대소문자 무관 canonical UUID 입력을 받아 canonical 36자 소문자
형태로 반환. 형식 오류 UUID 에는 `IllegalArgumentException`. `null` 입력은 `null` 반환.
- `toUuid(String)`**D10**: UUID 문자열 → 128-bit `UUID` (PostgreSQL `uuid` 컬럼용).
- `fromUuid(UUID)`**D10**: 저장된 `UUID` → canonical 36자 소문자 UUID 문자열.
## HmacUserPrincipalPseudonymizer
`UserPrincipalPseudonymizerPort`(application-core) 의 HMAC-SHA-256 구현.
### 알고리즘 SSOT
구체 알고리즘은 90일 회전 salt 로 키잉한 HMAC-SHA-256 이다. 이 클래스가 유일한 구현이며,
비-IO crypto 능력 어댑터로 이 모듈에 있고 `app-bootstrap`
이 싱글톤 빈으로 와이어링한다.
### 출력
비어있지 않은 `rawPrincipal` 에 대해 단방향·안정적인 256-bit HMAC 토큰을 64자 소문자 hex 로 반환.
`rawPrincipal``null` 이거나 blank 면 `null` 반환.
### Salt 출처
salt 는 `app-bootstrap``APP_PRIVACY_PSEUDONYMIZATION_SALT` 환경변수에서 공급한다(분류: secret,
회전 주기: 90일). 이 클래스는 salt 를 스스로 조달하지 않는다.
### Thread safety
`Mac` 인스턴스는 thread-safe 하지 않다. 매 `pseudonymize(String)` 호출마다 새 `Mac` 을 생성하므로
공유 싱글톤 빈으로 안전하다. HmacSHA256 은 JDK 필수 알고리즘(JCA spec)이라 `NoSuchAlgorithmException`·
`InvalidKeyException` 은 사실상 도달 불가능하며, 호출부에 checked exception 잡음을 남기지 않으려고
`IllegalStateException` 으로 감싼다.
### Spring-free
이 모듈(`adapter-identifier`)은 설계상 Spring-free 다. 어노테이션이 없고, 빈 생성은
`app-bootstrap` 의 책임이다.
## 빌드 결정 (build.gradle)
### Groovy / Spock (C2 테스트 형태)
순수 값-코덱 동작(`UuidCodec`)은 Groovy/Spock 스펙(`src/test/groovy`)으로 명세한다. core `groovy`
플러그인이 컴파일하고, 모든 서브프로젝트에 이미 켜진 JUnit Platform(`useJUnitPlatform()`)에서 실행된다.
가드/계약 테스트(`HmacUserPrincipalPseudonymizerTest` — 생성자 가드, 정확한 예외/포맷 계약)는 설계상
Java(`src/test/java`)로 둔다. Spock 2.4 / Groovy 4.0 variant 를 쓰며, spock-core 가 groovy.jar 를
transitive 로 끌어오므로 data-driven `where:` 스펙에 다른 Groovy 모듈이 필요 없다.
### implementation vs api
`:application-core``implementation` 으로 선언한다(`api` 아님). adapter-identifier 가 자신의 public
ABI 에 application-core 타입을 노출하지 않기 때문이다. 유일한 와이어링 소비자인 `app-bootstrap`
이미 자기 classpath 에 application-core 를 갖고 있다. 이 의존 edge 는 `src/build.gradle`
`allowedProjectDependencies['adapter-identifier']` 로 허용된다.
### UTF-8 인코딩 고정
한국어(비-ASCII) Spock 스펙 메서드명은 소스를 UTF-8 로 읽어야만 컴파일·리포팅이 정상이다. 이 모듈이
비-ASCII 소스를 처음 갖는 모듈이라 컴파일 인코딩을 명시적으로 고정한다 — UTF-8 호스트에선 no-op 지만,
플랫폼 기본이 다른 fork(예: 한국어 Windows / MS949)에서 mojibake 빌드를 막는다. C2 가 더 많은 모듈로
퍼지면 root subprojects 블록(`-parameters` 옆)으로 승격한다.
@@ -0,0 +1,20 @@
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
plugins {
id 'groovy'
}
dependencies {
implementation project(':application-core')
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
// Pin UTF-8 so non-ASCII (Korean) Spock spec names build on any host. See README.
tasks.withType(GroovyCompile).configureEach {
groovyOptions.encoding = 'UTF-8'
options.encoding = 'UTF-8'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
@@ -0,0 +1,156 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
empty=compileClasspath,runtimeClasspath
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.identifier;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* HMAC-SHA-256 implementation of {@link UserPrincipalPseudonymizerPort}: returns a stable
* 64-character lowercase hex token, or {@code null} for null/blank input. Thread-safe as a shared
* singleton. See README for the algorithm SSOT, salt origin, and design rationale.
*/
public final class HmacUserPrincipalPseudonymizer implements UserPrincipalPseudonymizerPort {
private static final String ALGORITHM = "HmacSHA256";
private final SecretKeySpec key;
public HmacUserPrincipalPseudonymizer(byte[] salt) {
if (salt == null || salt.length == 0) {
throw new IllegalArgumentException(
"HMAC salt must not be null or empty — supplied by APP_PRIVACY_PSEUDONYMIZATION_SALT");
}
byte[] saltCopy = salt.clone(); // defensive copy; caller's array is not retained
this.key = new SecretKeySpec(saltCopy, ALGORITHM);
}
@Override
public String pseudonymize(String rawPrincipal) {
if (rawPrincipal == null || rawPrincipal.isBlank()) {
return null;
}
try {
Mac mac = Mac.getInstance(ALGORITHM); // Mac is not thread-safe — fresh instance per call
mac.init(key);
byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
// Unreachable: HmacSHA256 is a mandatory JDK algorithm and the key spec is valid.
throw new IllegalStateException(
"HmacSHA256 unavailable or key invalid — this should never happen on a compliant JDK", e);
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.identifier;
import dev.caskeleton.application.fileserver.api.FileId;
import dev.caskeleton.application.fileserver.api.UploadId;
import dev.caskeleton.application.fileserver.upload.UploadIdentifierFactory;
import java.util.UUID;
/**
* Unpredictable file and upload identifiers.
*
* <p>These identifiers are the public handle for a file, so they are drawn from a cryptographically
* strong source rather than a sequence or a timestamp. A time-ordered identifier would be the
* better database key, and is deliberately not used: it would let anyone holding one id infer when
* neighbouring files were created and enumerate towards them, which is exactly what an opaque
* handle is for.
*
* <p>{@link UUID#randomUUID()} is backed by a seeded {@code SecureRandom} and is safe to share
* across threads.
*/
public final class RandomUploadIdentifierFactory implements UploadIdentifierFactory {
@Override
public FileId newFileId() {
return FileId.of(UUID.randomUUID());
}
@Override
public UploadId newUploadId() {
return UploadId.of(UUID.randomUUID());
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.identifier;
import java.util.UUID;
/** Domain-agnostic UUID conversion utility. See README for the design rationale. */
public final class UuidCodec {
private UuidCodec() {}
/**
* Accepts a case-insensitive canonical UUID string and returns the canonical 36-character
* lowercase form; {@code null} input returns {@code null}.
*
* @throws IllegalArgumentException on a malformed UUID
*/
public static String normalize(String input) {
if (input == null) {
return null;
}
return UUID.fromString(input).toString();
}
public static UUID toUuid(String uuidString) {
return UUID.fromString(uuidString);
}
public static String fromUuid(UUID uuid) {
return uuid.toString();
}
}
@@ -0,0 +1,5 @@
/**
* Non-IO infrastructure-capability adapters (identifier generation/codec, clock, crypto/random).
* See README for why these are separate from {@code adapter-outbound}.
*/
package dev.caskeleton.adapter.outbound.identifier;
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.identifier
import spock.lang.Specification
class UuidCodecSpec extends Specification {
static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab"
def "normalize 는 #label 을 36자 소문자 canonical 형태로 변환한다"() {
expect:
UuidCodec.normalize(input) == CANONICAL
where:
label | input
"이미 canonical 인 입력" | CANONICAL
"대문자 입력" | CANONICAL.toUpperCase()
}
def "normalize 는 null 입력에 대해 null 을 반환한다"() {
expect:
UuidCodec.normalize(null) == null
}
def "normalize 는 형식이 잘못된 UUID 를 거부한다"() {
when:
UuidCodec.normalize("not-a-uuid")
then:
thrown(IllegalArgumentException)
}
def "UUID -> UUID -> UUID 왕복 변환은 무손실이다"() {
given:
def uuid = UuidCodec.toUuid(CANONICAL)
expect:
UuidCodec.fromUuid(uuid) == CANONICAL
}
}
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.outbound.identifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HmacUserPrincipalPseudonymizerTest {
private static final byte[] SALT_A =
"test-salt-A-32-bytes-padding-xxx".getBytes(StandardCharsets.UTF_8);
private static final byte[] SALT_B =
"test-salt-B-32-bytes-padding-yyy".getBytes(StandardCharsets.UTF_8);
// -----------------------------------------------------------------------
// Constructor guard tests
// -----------------------------------------------------------------------
@Test
void nullSaltThrowsIllegalArgumentException() {
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void emptySaltThrowsIllegalArgumentException() {
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(new byte[0]))
.isInstanceOf(IllegalArgumentException.class);
}
// -----------------------------------------------------------------------
// Null / blank input → null output
// -----------------------------------------------------------------------
@Test
void nullInputReturnsNull() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
assertThat(pseudonymizer.pseudonymize(null)).isNull();
}
@Test
void blankInputReturnsNull() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
assertThat(pseudonymizer.pseudonymize(" ")).isNull();
}
@Test
void emptyStringInputReturnsNull() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
assertThat(pseudonymizer.pseudonymize("")).isNull();
}
// -----------------------------------------------------------------------
// Determinism
// -----------------------------------------------------------------------
@Test
void sameInputSameSaltProducesSameOutputOnSameInstance() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String first = pseudonymizer.pseudonymize("user-123");
String second = pseudonymizer.pseudonymize("user-123");
assertThat(first).isEqualTo(second);
}
@Test
void sameInputSameSaltProducesSameOutputAcrossTwoInstances() {
var p1 = new HmacUserPrincipalPseudonymizer(SALT_A);
var p2 = new HmacUserPrincipalPseudonymizer(SALT_A.clone());
assertThat(p1.pseudonymize("user-abc")).isEqualTo(p2.pseudonymize("user-abc"));
}
// -----------------------------------------------------------------------
// Salt-sensitivity
// -----------------------------------------------------------------------
@Test
void differentSaltProducesDifferentOutput() {
var pA = new HmacUserPrincipalPseudonymizer(SALT_A);
var pB = new HmacUserPrincipalPseudonymizer(SALT_B);
assertThat(pA.pseudonymize("user-xyz")).isNotEqualTo(pB.pseudonymize("user-xyz"));
}
// -----------------------------------------------------------------------
// One-way property (output != input, output does not contain input)
// -----------------------------------------------------------------------
@Test
void outputDoesNotEqualRawInput() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String raw = "alice@example.com";
assertThat(pseudonymizer.pseudonymize(raw)).isNotEqualTo(raw);
}
@Test
void outputDoesNotContainRawInputAsSubstring() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String raw = "alice";
assertThat(pseudonymizer.pseudonymize(raw)).doesNotContain(raw);
}
// -----------------------------------------------------------------------
// Output format: lowercase hex, exactly 64 characters (256-bit HMAC)
// -----------------------------------------------------------------------
@Test
void outputMatchesLowercaseHex64CharPattern() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String token = pseudonymizer.pseudonymize("some-user");
assertThat(token).matches("^[0-9a-f]{64}$");
}
}