diff --git a/devslab-kit-cache-core/build.gradle.kts b/devslab-kit-cache-core/build.gradle.kts index 577d8be..bcc343d 100644 --- a/devslab-kit-cache-core/build.gradle.kts +++ b/devslab-kit-cache-core/build.gradle.kts @@ -7,15 +7,37 @@ dependencies { api("org.springframework:spring-context") api("org.springframework.boot:spring-boot-autoconfigure") - // Redis is OPTIONAL. The Redis-backed CacheManager (PR 2) only wires up when - // the consumer puts these on the classpath themselves (type=redis). Keeping - // them compileOnly here means the kit never forces Redis as a transitive + // Redis is OPTIONAL. The Redis-backed CacheManager only wires up when the + // consumer puts these on the classpath themselves (type=redis). Keeping them + // compileOnly here means the kit never forces Redis as a transitive // dependency — the in-memory default stays zero-dependency. See ADR 0002 §4. + // (spring-boot-starter-data-redis brings Spring Data Redis + Jackson 3 + // tools.jackson transitively, which GenericJacksonJsonRedisSerializer needs.) compileOnly("org.springframework.boot:spring-boot-starter-data-redis") - compileOnly("com.fasterxml.jackson.core:jackson-databind") + // Our RedisCacheConfiguration references Jackson 3 (JsonMapper) directly to + // build the value serializer. Spring Data Redis declares Jackson `optional`, + // so it isn't on the compile classpath transitively — declare it explicitly. + // compileOnly (like Redis itself): only needed when the consumer opts into + // type=redis, which already puts Jackson 3 on their runtime classpath via + // spring-boot-starter-data-redis. Version managed by the Spring Boot BOM. + compileOnly("tools.jackson.core:jackson-databind") compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + // The Redis path is exercised against a real Redis (Testcontainers): the + // serialization round-trip is the whole risk, so it gets an integration test + // rather than a mock. Redis deps are test-scoped here (compileOnly above for + // main), matching how a consumer who opts into type=redis would add them. + testImplementation("org.springframework.boot:spring-boot-starter-data-redis") + // Jackson 3 is `optional` in Spring Data Redis, so the Redis starter doesn't + // pull it transitively even at test scope. The Redis value serializer needs + // it at runtime (PolymorphicTypeValidator etc.) — declare it explicitly for + // the test runtime, mirroring the compileOnly on main. A real consumer who + // opts into type=redis gets Jackson 3 from spring-boot-starter-jackson, which + // SB4 apps always have. + testImplementation("tools.jackson.core:jackson-databind") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:testcontainers-junit-jupiter") } diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java index 6be4f98..923f0ba 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java @@ -16,7 +16,7 @@ * one property to get distributed caching and writes zero serializer config. * *

This class wires the {@code none} and {@code in-memory} backends. The - * {@code redis} backend lives in {@link RedisCacheConfiguration}, imported here + * {@code redis} backend lives in {@link DevslabKitRedisCacheConfiguration}, imported here * but inert unless Redis is on the classpath (ADR 0002 §2–§3). * *

Everything is {@link ConditionalOnMissingBean} on {@link CacheManager}: a @@ -26,7 +26,7 @@ @AutoConfiguration @ConditionalOnProperty(prefix = "devslab.kit.cache", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties(CacheProperties.class) -@org.springframework.context.annotation.Import(RedisCacheConfiguration.class) +@org.springframework.context.annotation.Import(DevslabKitRedisCacheConfiguration.class) public class CacheAutoConfiguration { /** diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java index 737ac5a..6e73248 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java @@ -34,6 +34,16 @@ public class CacheProperties { /** Whether to cache {@code null} return values. Off by default so a cached null doesn't mask a real miss. */ private boolean cacheNullValues = false; + /** + * Base package allowed for polymorphic deserialization when {@code type=redis}. + * The Redis value serializer enables JSON default typing (so records round-trip + * with their concrete type) but constrains it to an allow-list — {@code java.*} + * plus this package — to avoid the deserialization-gadget risk of unrestricted + * default typing. A consumer caching their own types sets this to their root + * package (e.g. {@code com.acme}); the default covers the kit's own types. + */ + private String allowedPackage = "kr.devslab"; + public boolean isEnabled() { return enabled; } @@ -73,4 +83,12 @@ public boolean isCacheNullValues() { public void setCacheNullValues(boolean cacheNullValues) { this.cacheNullValues = cacheNullValues; } + + public String getAllowedPackage() { + return allowedPackage; + } + + public void setAllowedPackage(String allowedPackage) { + this.allowedPackage = allowedPackage; + } } diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java new file mode 100644 index 0000000..bc114e1 --- /dev/null +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java @@ -0,0 +1,97 @@ +package kr.devslab.kit.cache.core; + +import java.time.Duration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; + +/** + * Redis-backed {@link CacheManager} with kit-owned JSON serialization + * (ADR 0002 §3). A consumer sets {@code devslab.kit.cache.type=redis}, adds + * {@code spring-boot-starter-data-redis}, and never touches serialization — + * values are stored as JSON, no {@code Serializable} requirement, no + * {@code SerializationException}. + * + *

Values are serialized with {@link GenericJacksonJsonRedisSerializer} — the + * Jackson-3 generic serializer (Spring Boot 4 is on Jackson 3 / {@code tools.jackson}; + * the older {@code GenericJackson2JsonRedisSerializer} is Jackson-2 based and + * deprecated in Spring Data Redis 4, so it's deliberately not used). The + * serializer is built with default typing enabled so type hints are embedded in + * the JSON: that's what lets a cached {@code record} read back as the same + * record rather than a {@code Map} (Spring's {@code RedisCache} deserializes + * without a target type, so the type has to live in the payload). + * + *

Default typing is the CVE-sensitive part of JSON deserialization, so it's + * constrained by a {@link BasicPolymorphicTypeValidator} allow-list — never the + * unsafe/laissez-faire variant. The allow-list covers {@code java.*} (the + * component types of collections, records, dates) plus the consumer's base + * package, taken from {@code devslab.kit.cache.allowed-package} (default + * {@code kr.devslab}). A consumer caching their own types sets that property to + * their root package; if they need finer control they can define their own + * {@code CacheManager} bean and the kit backs off entirely. + * + *

This class is named {@code DevslabKitRedisCacheConfiguration} — NOT + * {@code RedisCacheConfiguration} — on purpose: Spring Data Redis's own + * {@link RedisCacheConfiguration} (imported above) is the cache-defaults + * builder, and a same-name class would shadow that import. + * + *

Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}: + * with Redis off the classpath this whole configuration is skipped, so the + * {@code compileOnly} Redis dependency never leaks onto an in-memory consumer. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(RedisConnectionFactory.class) +@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis") +class DevslabKitRedisCacheConfiguration { + + @Bean + @ConditionalOnMissingBean(CacheManager.class) + CacheManager devslabKitRedisCacheManager( + RedisConnectionFactory connectionFactory, + CacheProperties properties + ) { + PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("java.") + .allowIfSubType(properties.getAllowedPackage()) + .build(); + + // Declared as RedisSerializer (a plain widening conversion — + // GenericJacksonJsonRedisSerializer implements RedisSerializer) + // so SerializationPair.fromSerializer infers the value type cleanly. + RedisSerializer valueSerializer = + GenericJacksonJsonRedisSerializer.builder() + .enableDefaultTyping(validator) + .build(); + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(valueSerializer)) + .prefixCacheNameWith(properties.getKeyPrefix()); + + Duration ttl = properties.getTtl(); + if (ttl != null && !ttl.isZero() && !ttl.isNegative()) { + config = config.entryTtl(ttl); + } + if (!properties.isCacheNullValues()) { + config = config.disableCachingNullValues(); + } + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } +} diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java deleted file mode 100644 index c29cf20..0000000 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.devslab.kit.cache.core; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; - -/** - * Redis-backed {@code CacheManager} with kit-owned JSON serialization - * (ADR 0002 §3) — a consumer sets {@code type: redis}, adds - * {@code spring-boot-starter-data-redis}, and never touches serialization. - * - *

PR 1 placeholder. This class is intentionally a no-op - * scaffold right now: it carries the {@link ConditionalOnClass} / - * {@link ConditionalOnProperty} guards so the wiring is in place, but the actual - * {@code RedisCacheManager} + the safe-default-typing {@code ObjectMapper} - * (the part that needs a real-Redis Testcontainers round-trip test) lands in - * PR 2. Until then, {@code type: redis} resolves no {@code CacheManager} here — - * documented so we don't ship a half-built serializer. - * - *

Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}: - * when Redis isn't on the classpath this whole configuration is skipped, so the - * {@code compileOnly} Redis dependency never leaks onto a consumer who stays - * in-memory. - */ -@Configuration(proxyBeanMethods = false) -@ConditionalOnClass(RedisConnectionFactory.class) -@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis") -class RedisCacheConfiguration { - - // PR 2: - // @Bean @ConditionalOnMissingBean(CacheManager.class) - // @ConditionalOnBean(RedisConnectionFactory.class) - // CacheManager devslabKitRedisCacheManager(RedisConnectionFactory cf, CacheProperties props) { ... } - // with GenericJackson2JsonRedisSerializer over a kit ObjectMapper using a - // SAFE default-typing validator (allow-list, not LaissezFaireSubTypeValidator). - // - // The unused imports below are referenced in the PR-2 signature; keeping the - // guard annotations active now means the conditional contract is testable. - @SuppressWarnings("unused") - private static final Class GUARD_MISSING = ConditionalOnMissingBean.class; -} diff --git a/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java new file mode 100644 index 0000000..b1ef133 --- /dev/null +++ b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java @@ -0,0 +1,100 @@ +package kr.devslab.kit.cache.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Proves the headline claim of ADR 0002: with {@code type=redis} a consumer's + * value round-trips through real Redis as JSON with its concrete type intact — + * no {@code Serializable}, no serializer config, no {@code SerializationException}. + * + *

Runs against a real Redis (Testcontainers) because serialization is the + * whole risk; a mock would prove nothing. The connection factory is created and + * started per test ({@link BeforeEach}), not once in a static + * {@code @BeforeAll}: a {@link LettuceConnectionFactory} built in a static + * context doesn't reliably finish its async client init before the cache's first + * write, which silently drops it (an isolated diagnostic confirmed the + * serializer + cache + Redis I/O are otherwise correct). Per-test setup gives + * each test a freshly-started, fully-initialized factory. + * + *

Drives the kit's {@link DevslabKitRedisCacheConfiguration} bean method + * directly with a real {@link LettuceConnectionFactory} — deliberately bypassing + * Spring Boot's autoconfiguration. The config class is package-private; this + * test shares its package. + */ +@Testcontainers +class RedisCacheRoundTripTest { + + @Container + static final GenericContainer REDIS = + new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379); + + private LettuceConnectionFactory connectionFactory; + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + connectionFactory.afterPropertiesSet(); + connectionFactory.start(); + + cacheManager = new DevslabKitRedisCacheConfiguration() + .devslabKitRedisCacheManager(connectionFactory, new CacheProperties()); + } + + @AfterEach + void tearDown() { + if (connectionFactory != null) { + connectionFactory.destroy(); + } + } + + /** A record — the classic type JDK serialization / a forgotten Serializable trips on. */ + record Customer(String id, String name, int visits) { + } + + @Test + void buildsRedisCacheManager() { + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + void recordRoundTripsThroughRealRedisWithItsConcreteType() { + Cache cache = cacheManager.getCache("customers"); + assertThat(cache).isNotNull(); + + Customer original = new Customer("c-1", "Aisha", 7); + cache.put("c-1", original); + + Customer fromCache = cache.get("c-1", Customer.class); + assertThat(fromCache).isEqualTo(original); + assertThat(fromCache.name()).isEqualTo("Aisha"); + assertThat(fromCache.visits()).isEqualTo(7); + } + + @Test + void stringRoundTrips() { + Cache cache = cacheManager.getCache("strings"); + assertThat(cache).isNotNull(); + cache.put("k", "hello-redis"); + assertThat(cache.get("k", String.class)).isEqualTo("hello-redis"); + } + + @Test + void missReturnsNull() { + Cache cache = cacheManager.getCache("customers"); + assertThat(cache).isNotNull(); + assertThat(cache.get("absent-key-" + System.nanoTime())).isNull(); + } +}