feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
The Redis wrapper and typed API described in
`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its
design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry
design models the SDK as twelve Gradle modules; this repository's fail-closed registry
outranks that layout, so each designed module is a package instead. Delivery status and the full
adaptation rationale are in
`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
+1 -1
View File
@@ -14,7 +14,7 @@ Package root: `dev.caskeleton.adapter.outbound.httpclient`.
The implementation follows
`httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`.
The design assumes 19 separate Gradle modules; this repository's fail-closed 19-leaf registry
The design assumes 19 separate Gradle modules; this repository's fail-closed registry
outranks that layout, so those modules are **packages** here. The mapping, and every other
deliberate substitution, is recorded in `docs/httpclient/repository-adaptation.md`. Read it before
moving a type between packages.
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.messaging.autoconfigure;
import dev.caskeleton.adapter.outbound.messaging.MessagingConfig;
import dev.caskeleton.adapter.outbound.messaging.MessagingSettings;
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Import;
/**
* The one place that decides whether this application publishes to a broker.
*
* <p>Before this, whether {@code app.messaging.broker} was blank was the de-facto switch. That is a
* selector doing a switch's job, and it reads badly in both directions: a blank broker with the
* relay enabled took down startup, while a deployment that wanted no messaging at all still
* assembled settings, a Kafka adapter configuration and two publishers. The broker id now selects
* <em>which</em> transport, and this switch decides <em>whether</em> there is one.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "app.messaging", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(MessagingSettings.class)
@Import({MessagingConfig.class, KafkaAdapterConfig.class})
public class MessagingBridgeRootAutoConfiguration {}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.messaging.autoconfigure;
import java.util.Set;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
/**
* Keeps Boot's broker auto-configurations out of the candidate set while messaging is off.
*
* <p>The Kafka and AMQP starters contribute theirs through Boot's import metadata, so a client
* library on the classpath is enough to build a connection factory, a template and a listener
* container — none of which any project condition was consulted about. It also means both brokers
* would assemble at once simply because both libraries are present, which is a different bug the
* same filter prevents.
*/
public final class MessagingOffAutoConfigurationImportFilter
implements AutoConfigurationImportFilter, EnvironmentAware {
private static final String ENABLE_PROPERTY = "app.messaging.enabled";
private static final Set<String> BROKER_AUTO_CONFIGURATIONS =
Set.of(
"org.springframework.boot.kafka.autoconfigure.KafkaAutoConfiguration",
"org.springframework.boot.kafka.autoconfigure.metrics.KafkaMetricsAutoConfiguration",
"org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration",
"org.springframework.boot.amqp.autoconfigure.RabbitAnnotationDrivenAutoConfiguration",
"org.springframework.boot.amqp.autoconfigure.health.RabbitHealthContributorAutoConfiguration");
private Environment environment;
@Override
public boolean[] match(String[] candidates, AutoConfigurationMetadata metadata) {
boolean enabled =
environment != null
&& "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false"));
boolean[] matches = new boolean[candidates.length];
for (int index = 0; index < candidates.length; index++) {
matches[index] =
enabled
|| candidates[index] == null
|| !BROKER_AUTO_CONFIGURATIONS.contains(candidates[index]);
}
return matches;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingOffAutoConfigurationImportFilter
@@ -0,0 +1 @@
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingBridgeRootAutoConfiguration
@@ -16,7 +16,6 @@ import dev.caskeleton.application.notification.platform.security.SecretMaterialP
import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -83,9 +82,23 @@ public class NotificationPlatformAutoConfiguration {
};
}
/** Contact point protection, only once key material is available. */
/**
* Contact point protection.
*
* <p>{@code @ConditionalOnBean(SecretMaterialProvider.class)} used to guard this, to express
* "only once key material is available". It does not express that. This is a plain
* {@code @Configuration} imported by a root, so the condition is evaluated while configurations
* are still being parsed and answers according to what happens to be registered at that moment —
* and the secret provider is declared by a sibling configuration the same root imports. The
* answer was "absent", so the bean vanished and the application failed on an unsatisfied
* dependency several layers away, in the deployment that first assembled the platform for real.
*
* <p>Nothing is lost by dropping it. Both configurations are reached only through the same root
* under the same master switch, so the provider exists whenever this does — and a deployment
* missing key material already fails at the provider itself, at startup, naming the purpose whose
* key is missing. That is a better failure than a bean quietly not being created.
*/
@Bean
@ConditionalOnBean(SecretMaterialProvider.class)
@ConditionalOnMissingBean
public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
return new AesGcmContactPointProtector(secrets);
@@ -0,0 +1,211 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import jakarta.mail.Session;
import java.time.Clock;
import java.util.concurrent.Executor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
/**
* The SMTP provider family, assembled (NTF-INT-001).
*
* <p>{@code NotificationPlatformProviderConfig} injects {@code List<ProviderRuntimeAssembler>} and
* nothing in the composition contributed one, so the list was always empty. Assembly then produced
* a registry with no runtimes and {@code SERVING} accepted requests that could never find a route —
* which from outside is a platform silently dropping notifications. {@code
* SmtpProviderRuntimeAssembler} existed in main source throughout; the only thing that ever
* constructed it was its own unit test.
*
* <p>It lives in this leaf rather than in the composition root because the root must not carry
* {@code jakarta.mail} or {@code JavaMailSender} on its own compile classpath: the mail starter is
* an implementation detail of the family this package assembles, and a composition root that
* imports it to wire one provider has taken ownership of that provider's transport.
*
* <p>Gated on {@code spring.mail.host}, not on the notification switch alone. A deployment that
* turns the platform on without configuring a relay contributes no SMTP assembler, and assembly
* then refuses any profile that declares {@code type: SMTP} by naming the missing family — a better
* failure than a mail sender pointed at a default nobody chose.
*
* <p>The relay's address is Spring's, not a second copy. Host, port, credentials and transport
* security come from {@code spring.mail.*} through the auto-configured {@link JavaMailSender}. One
* relay described in two places is a defect this repository has already paid for: {@code
* app.jpa-platform.datasource.*} validated a pool that {@code spring.datasource.hikari.*} had
* built. {@link NotificationSmtpSettings} carries only what {@code spring.mail} has no word for —
* the envelope sender, the per-attempt timeouts and the dispatch concurrency.
*/
@Configuration(proxyBeanMethods = false)
// The master switch is not repeated here. This configuration is reachable only through
// NotificationPlatformRuntimeConfig, which the root imports under that switch, so "off" stays a
// structural fact rather than a condition each new configuration has to remember. What this one
// owns
// is the narrower question: is a relay configured at all.
@ConditionalOnProperty(name = "spring.mail.host")
@EnableConfigurationProperties(NotificationSmtpSettings.class)
public class NotificationSmtpProviderConfig {
/** The send itself, over the mail sender Spring configured. */
@Bean
@ConditionalOnMissingBean
public SmtpDispatch notificationSmtpDispatch(JavaMailSender sender) {
return new JavaMailSenderSmtpDispatch(sender);
}
/**
* The MIME factory, on the sender's own session.
*
* <p>The session is taken from {@link JavaMailSenderImpl} rather than created here, so the
* message is built with the same properties it will be sent with. A second session would let the
* factory and the transport disagree about encoding or TLS, and the disagreement would only show
* as a malformed message at a recipient.
*/
@Bean
@ConditionalOnMissingBean
public SmtpMimeMessageFactory notificationSmtpMimeMessageFactory(JavaMailSender sender) {
if (sender instanceof JavaMailSenderImpl impl) {
return new SmtpMimeMessageFactory(impl.getSession());
}
return new SmtpMimeMessageFactory(Session.getInstance(new java.util.Properties()));
}
/** SMTP reply code to retry decision. */
@Bean
@ConditionalOnMissingBean
public SmtpFailureClassifier notificationSmtpFailureClassifier() {
return new SmtpFailureClassifier();
}
/**
* Attachment resolution, refused by default.
*
* <p>Attachments come from the file server or object storage capability, and a deployment that
* enabled neither has no way to read one. The default refuses rather than returning null: a null
* resolution reaches the integrity guard, which then compares a digest against nothing, and the
* message goes out without the attachment it claimed to carry.
*/
@Bean
@ConditionalOnMissingBean
public AttachmentResolver notificationAttachmentResolver() {
return (reference, context) -> {
throw new IllegalStateException(
"an attachment was requested but no attachment capability is bound; enable the file "
+ "server or object storage capability, or submit a notification without attachments");
};
}
/** Digest check between what a template referenced and what was read. */
@Bean
@ConditionalOnMissingBean
public AttachmentIntegrityGuard notificationAttachmentIntegrityGuard(
AttachmentResolver resolver) {
return new AttachmentIntegrityGuard(resolver);
}
/**
* Where an SMTP send runs.
*
* <p>Bounded, and it aborts rather than queues without limit: an SMTP relay that has stopped
* answering turns an unbounded queue into retained heap and a dispatch worker that never notices.
* {@code CallerRunsPolicy} would instead block the dispatch loop on the slow relay, which stalls
* every other channel too.
*/
@Bean(name = "notificationSmtpExecutor", destroyMethod = "shutdown")
@ConditionalOnMissingBean(name = "notificationSmtpExecutor")
public Executor notificationSmtpExecutor(NotificationSmtpSettings settings) {
ThreadPoolExecutor executor =
new ThreadPoolExecutor(
1,
settings.maxConcurrency(),
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(settings.maxConcurrency() * 4),
runnable -> {
Thread thread = new Thread(runnable, "notification-smtp");
thread.setDaemon(true);
return thread;
},
new ThreadPoolExecutor.AbortPolicy());
executor.allowCoreThreadTimeOut(true);
return executor;
}
/**
* The assembler itself.
*
* @param dispatch the send
* @param mimeFactory the MIME builder
* @param classifier the outcome classifier
* @param protector redacts contact points on the way out
* @param notificationSmtpExecutor where a send runs
* @param attachmentGuard the attachment integrity check
* @param settings the parts of the relay description Spring's own mail properties do not carry
* @param sender the auto-configured sender, read for host and port
* @param clock the clock the rate limiter measures against
* @return the SMTP assembler, contributed into the assembly's list
*/
@Bean
@ConditionalOnMissingBean(name = "smtpProviderRuntimeAssembler")
public ProviderRuntimeAssembler smtpProviderRuntimeAssembler(
SmtpDispatch dispatch,
SmtpMimeMessageFactory mimeFactory,
SmtpFailureClassifier classifier,
ContactPointProtector protector,
Executor notificationSmtpExecutor,
AttachmentIntegrityGuard attachmentGuard,
NotificationSmtpSettings settings,
JavaMailSender sender,
Clock clock) {
return new SmtpProviderRuntimeAssembler(
dispatch,
mimeFactory,
classifier,
protector,
notificationSmtpExecutor,
attachmentGuard,
transport(settings, sender),
clock);
}
/**
* The relay's address, read from Spring's mail configuration.
*
* <p>{@code SmtpProviderProperties.TlsMode} has two members and neither is plaintext, so the type
* refuses an unencrypted relay by construction rather than by a validator somebody has to
* remember to run. That is why the mode is a setting rather than something derived from {@code
* spring.mail.properties.mail.smtp.starttls.enable}: a derived mode would silently become
* "whatever that flag happened to say", including off.
*/
private static SmtpProviderProperties transport(
NotificationSmtpSettings settings, JavaMailSender sender) {
String host = settings.host();
int port = settings.port();
if (sender instanceof JavaMailSenderImpl impl) {
host = impl.getHost() == null ? host : impl.getHost();
port = impl.getPort() > 0 ? impl.getPort() : port;
}
return new SmtpProviderProperties(
host,
port,
settings.tlsMode(),
settings.senderIdentity(),
settings.connectTimeout(),
settings.readTimeout(),
settings.writeTimeout(),
settings.maxConcurrency());
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import java.time.Duration;
import java.util.Objects;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* The parts of an SMTP relay description that {@code spring.mail.*} has no word for.
*
* <p>Host, port, username, password and the mail properties stay Spring's. Repeating them here
* would be one relay described twice, which is the shape of a defect this repository has already
* paid for — {@code app.jpa-platform.datasource.*} validated a pool that {@code
* spring.datasource.hikari.*} had built, so the validation passed while describing something that
* was not running.
*
* <p>{@code host} and {@code port} are present all the same, as a fallback only: {@link
* NotificationSmtpProviderConfig} reads them from the configured sender and falls back here when
* the sender is not the standard implementation. They are never the primary source.
*
* @param host fallback relay host, used only when the mail sender cannot be read
* @param port fallback relay port, same
* @param tlsMode transport security; the type has no plaintext member, deliberately
* @param senderIdentity the envelope sender every message is sent as
* @param connectTimeout how long a connection attempt may take
* @param readTimeout how long a reply may take
* @param writeTimeout how long a write may take
* @param maxConcurrency how many sends may run at once
*/
@ConfigurationProperties("ca-skeleton.notification.platform.smtp")
public record NotificationSmtpSettings(
@DefaultValue("localhost") String host,
@DefaultValue("587") int port,
@DefaultValue("STARTTLS_REQUIRED") SmtpProviderProperties.TlsMode tlsMode,
@DefaultValue("no-reply@example.invalid") String senderIdentity,
@DefaultValue("5s") Duration connectTimeout,
@DefaultValue("10s") Duration readTimeout,
@DefaultValue("10s") Duration writeTimeout,
@DefaultValue("4") int maxConcurrency) {
public NotificationSmtpSettings {
Objects.requireNonNull(tlsMode, "tlsMode");
Objects.requireNonNull(senderIdentity, "senderIdentity");
Objects.requireNonNull(connectTimeout, "connectTimeout");
Objects.requireNonNull(readTimeout, "readTimeout");
Objects.requireNonNull(writeTimeout, "writeTimeout");
// The sender identity has a default that cannot deliver on purpose. example.invalid is reserved
// by RFC 2606 and resolves nowhere, so a deployment that forgot to set one gets a bounce it can
// trace rather than mail that appears to come from a real address it does not own.
if (senderIdentity.isBlank() || senderIdentity.indexOf('@') <= 0) {
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.smtp.sender-identity must be an email address");
}
if (maxConcurrency < 1) {
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.smtp.max-concurrency must be at least 1");
}
}
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderAttemptLimiter;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntime;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpNotificationProviderAdapter;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executor;
/**
* The first production assembler: one configured SMTP profile becomes one working provider
* (NTF-INT-001).
*
* <p>{@code NotificationPlatformProviderConfig} assembles {@code List<ProviderRuntimeAssembler>}
* and production main source contained no implementation of that interface. So {@code SERVING}
* could not work in production whatever an operator configured: profiles bound, validation passed,
* and the runtime registry was built empty — requests reached durable acceptance and then found no
* eligible route, which reads from outside as the platform dropping notifications.
*
* <p>SMTP is the reference family because every piece above the wire already existed and was
* tested: the adapter, the MIME factory, the failure classifier. What was missing was the send
* itself ({@code SmtpDispatch} had no implementation) and this — the step that turns a profile into
* a runtime.
*
* <p><b>Where the relay's address comes from.</b> Host, port, credentials and TLS come from
* Spring's own {@code spring.mail.*} through the injected {@code JavaMailSender}, not from a second
* description on the provider profile. One relay described twice is the defect this repository has
* already paid for elsewhere — {@code app.jpa-platform.datasource.*} validated a pool that {@code
* spring.datasource.hikari.*} had built. The profile owns what is per-profile: its timeout, its
* concurrency and its rate.
*
* <p>Contributed as {@link AssembledProvider#dispatchOnly}, honestly: SMTP has no callback adapter,
* no provider-event projector and no status-query capability. Claiming any of them would produce a
* profile that fails on the first provider event rather than at startup.
*/
public final class SmtpProviderRuntimeAssembler implements ProviderRuntimeAssembler {
private final SmtpDispatch dispatch;
private final SmtpMimeMessageFactory mimeFactory;
private final SmtpFailureClassifier classifier;
private final ContactPointProtector protector;
private final Executor executor;
private final AttachmentIntegrityGuard attachmentGuard;
private final SmtpProviderProperties transport;
private final Clock clock;
/**
* Creates the assembler.
*
* @param dispatch the send, over the configured mail sender
* @param mimeFactory builds the MIME message
* @param classifier turns an SMTP outcome into a retry decision
* @param protector redacts contact points on the way out
* @param executor where a send runs
* @param attachmentGuard the attachment integrity check
* @param transport the relay's address and transport security, from {@code spring.mail.*}
* @param clock the clock the rate limiter measures against
*/
public SmtpProviderRuntimeAssembler(
SmtpDispatch dispatch,
SmtpMimeMessageFactory mimeFactory,
SmtpFailureClassifier classifier,
ContactPointProtector protector,
Executor executor,
AttachmentIntegrityGuard attachmentGuard,
SmtpProviderProperties transport,
Clock clock) {
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.executor = Objects.requireNonNull(executor, "executor");
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
this.transport = Objects.requireNonNull(transport, "transport");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public ProviderType type() {
return ProviderType.SMTP;
}
@Override
public AssembledProvider assemble(
String profileId, NotificationPlatformSettings.Provider profile) {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(profile, "profile");
SmtpProviderProperties properties =
new SmtpProviderProperties(
transport.host(),
transport.port(),
transport.tlsMode(),
transport.senderIdentity(),
transport.connectTimeout(),
profile.timeout(),
profile.timeout(),
profile.maxConcurrency());
SmtpNotificationProviderAdapter adapter =
new SmtpNotificationProviderAdapter(
dispatch, mimeFactory, classifier, protector, properties, executor, attachmentGuard);
ProviderRuntime runtime =
new ProviderRuntime(
snapshot(profileId, profile),
adapter,
new ProviderAttemptLimiter(profile.maxConcurrency(), profile.ratePerSecond(), clock));
return AssembledProvider.dispatchOnly(runtime, Channel.EMAIL);
}
private static ProviderProfileSnapshot snapshot(
String profileId, NotificationPlatformSettings.Provider profile) {
return new ProviderProfileSnapshot(
new ProviderProfileId(profileId),
new ProviderId(ProviderType.SMTP.name().toLowerCase(java.util.Locale.ROOT)),
Channel.EMAIL,
profile.environment(),
// No rotation has happened yet for a freshly assembled profile. The generation is what a
// rotation increments; starting anywhere but the first would make the first rotation look
// like it had already occurred.
1L,
capabilities(),
Map.of());
}
/**
* What SMTP can actually do, stated rather than assumed.
*
* <p>No batch, no provider-side idempotency, no status callback, no status query, no delivery
* receipt, no native scheduling, cancel or collapse. SMTP is a protocol for handing a message to
* a relay; everything past that is the relay's business and invisible to the sender. A capability
* declared here that the protocol does not have is a promise the dispatch loop will act on.
*/
private static ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false,
false,
false,
false,
false,
false,
false,
false,
1,
25L * 1024 * 1024,
// A relay that has not accepted the message within this is not going to; the platform's own
// queue-age bound is what decides how long a request keeps being retried.
java.time.Duration.ofHours(24));
}
}
@@ -96,10 +96,14 @@ public final class NotificationSchedulerWorker implements AutoCloseable {
// The lease is left to expire rather than being released optimistically: a worker
// that
// failed mid-dispatch cannot prove what the provider did.
// The cause chain by type, never by message. "reason=NotificationValidationException"
// alone identified nothing — a dozen checks raise it — and a library's exception text
// can carry a recipient address, so the types are named and the messages are not.
log.warn(
"notification dispatch failed worker={} reason={}",
"notification dispatch failed worker={} reason={} causes={}",
workerId,
failure.getClass().getSimpleName());
failure.getClass().getSimpleName(),
causeChain(failure));
} finally {
globalConcurrency.release();
}
@@ -108,6 +112,25 @@ public final class NotificationSchedulerWorker implements AutoCloseable {
return claimed.size();
}
/**
* The exception's cause chain, as type names only.
*
* @param failure the dispatch failure
* @return the chain, outermost first, bounded so a self-referential cause cannot loop
*/
private static String causeChain(Throwable failure) {
StringBuilder chain = new StringBuilder();
Throwable current = failure.getCause();
for (int depth = 0; current != null && depth < 8; depth++) {
if (chain.length() > 0) {
chain.append('<');
}
chain.append(current.getClass().getSimpleName());
current = current.getCause();
}
return chain.length() == 0 ? "none" : chain.toString();
}
/** Start the polling loop on a dedicated thread. */
public void start() {
if (!running.compareAndSet(false, true)) {
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import jakarta.mail.internet.MimeMessage;
import java.util.Objects;
import java.util.Optional;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
/**
* The production SMTP send, over Spring's {@link JavaMailSender} (NTF-INT-001).
*
* <p>{@link SmtpDispatch} was an interface with no implementation. The adapter above it, its MIME
* factory and its failure classifier were all complete and unit-tested against fakes, so the SMTP
* family looked finished from every angle except the one that matters: nothing could send. That is
* the shape of the whole NTF-INT-001 finding — configuration bound, validation passed, and the
* runtime registry constructed empty.
*
* <p>Failures are translated rather than propagated. {@link SmtpDispatchException} is what {@code
* SmtpFailureClassifier} reads to decide retryable from permanent, and a raw {@link MailException}
* reaching the dispatch loop would be classified as an unknown failure — which this platform treats
* as ambiguous, and ambiguous means the attempt is not retried, because a message that may already
* have been delivered must not be sent twice.
*/
public final class JavaMailSenderSmtpDispatch implements SmtpDispatch {
private final JavaMailSender sender;
/**
* Creates the dispatch.
*
* @param sender the configured mail sender, whose host, port and credentials come from Spring's
* own {@code spring.mail.*} — the namespace an operator already knows, rather than a second
* description of one relay
*/
public JavaMailSenderSmtpDispatch(JavaMailSender sender) {
this.sender = Objects.requireNonNull(sender, "sender");
}
@Override
public void send(MimeMessage message) {
Objects.requireNonNull(message, "message");
try {
sender.send(message);
} catch (MailException failure) {
// dataCommitted = true, deliberately and conservatively. Spring collapses every JavaMail
// outcome into MailException subtypes that do not say whether the body reached the server
// before the connection broke, and the platform reads this flag to decide whether a retry
// could duplicate a delivered message. Claiming "not committed" without evidence is the
// assumption that sends a notification twice; claiming "committed" costs a delivery that has
// to be reconciled, which is the failure this platform is built to survive.
throw new SmtpDispatchException("SMTP_TRANSPORT_FAILURE", Optional.empty(), true, failure);
}
}
}
@@ -45,11 +45,16 @@ public final class SmtpMimeMessageFactory {
Objects.requireNonNull(attachments, "attachments");
if (!(submission.content().content() instanceof EmailContent email)) {
throw rejection();
// A static cause, not the value: which check fired is diagnostic, what it saw may be content.
throw rejection(
new IllegalStateException(
"rendered content for an SMTP submission is "
+ submission.content().content().getClass().getSimpleName()
+ ", not EmailContent"));
}
requireHeaderSafe(recipientAddress);
requireHeaderSafe(fromAddress);
requireHeaderSafe(email.subject());
requireHeaderSafe(recipientAddress, "recipient");
requireHeaderSafe(fromAddress, "sender");
requireHeaderSafe(email.subject(), "subject");
try {
MimeMessage message = new MimeMessage(session);
@@ -71,25 +76,38 @@ public final class SmtpMimeMessageFactory {
attachment.displayName(), () -> attachment.content(), attachment.contentType());
}
for (var header : email.options().approvedHeaders().entrySet()) {
requireHeaderSafe(header.getKey());
requireHeaderSafe(header.getValue());
requireHeaderSafe(header.getKey(), "approved header name");
requireHeaderSafe(header.getValue(), "approved header value");
message.setHeader(header.getKey(), header.getValue());
}
return message;
} catch (MessagingException failure) {
throw rejection();
throw rejection(failure);
}
}
private static void requireHeaderSafe(String value) {
private static void requireHeaderSafe(String value, String field) {
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
throw rejection();
// The field name, never the value: a header-injection attempt is exactly the payload that
// must
// not be echoed into a log.
throw rejection(new IllegalStateException(field + " contains a header separator"));
}
}
private static NotificationValidationException rejection() {
return new NotificationValidationException(
/**
* The rejection, carrying what caused it.
*
* <p>The descriptor is deliberately the same for every construction failure — it is what gets
* stored on the delivery row, and a per-check code there would be a cardinality problem. The
* cause is what tells an operator which check fired, and dropping it made an SMTP dispatch
* failure indistinguishable from any other: a lane saw "reason=NotificationValidationException"
* and nothing else.
*/
private static NotificationValidationException rejection(Throwable cause) {
NotificationFailureDescriptor descriptor =
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD);
return new NotificationValidationException(descriptor, cause);
}
}
@@ -0,0 +1,149 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* AES-GCM at rest, with the key id in the envelope (NTF-INT-007).
*
* <h2>The envelope</h2>
*
* <pre>
* byte version always 1
* byte keyIdLength 1..255, UTF-8 bytes
* byte[] keyId
* byte[12] nonce
* byte[] ciphertext + GCM tag
* </pre>
*
* <p><b>The key id is the whole point of having a format at all.</b> This repository's callback
* protection stores nonce and ciphertext and nothing else, so the day the active key changes, every
* row written under the previous one becomes unreadable and nothing in the row can say which key it
* needed. That is not a rotation story with a gap in it; it is the absence of one. Reading the id
* back out and asking the secret store for that specific key is what makes rotation a change of
* default rather than a data migration.
*
* <p>The version byte is here for the same reason and costs one byte: a format that cannot say
* which format it is can only ever be changed by rewriting every row first.
*
* <p>Authentication is not an add-on. GCM verifies the tag on decrypt, so a modified ciphertext
* fails rather than producing plausible-looking variables — which for a notification payload would
* mean rendering attacker-chosen content into a message a recipient trusts.
*/
public final class AesGcmNotificationPayloadProtection implements NotificationPayloadProtection {
/** The only version this class writes, and the only one it reads. */
static final byte VERSION = 1;
private static final int NONCE_BYTES = 12;
private static final int TAG_BITS = 128;
private static final int MAX_KEY_ID_BYTES = 255;
private final SecretMaterialProvider secrets;
private final SecureRandom random;
/**
* Creates the protection.
*
* @param secrets the key store, which owns the active key and every retired one
* @param random the nonce source
*/
public AesGcmNotificationPayloadProtection(SecretMaterialProvider secrets, SecureRandom random) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.random = Objects.requireNonNull(random, "random");
}
@Override
public byte[] protect(byte[] plaintext) {
Objects.requireNonNull(plaintext, "plaintext");
SecretKeyMaterial key = secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION);
byte[] keyId = key.keyId().getBytes(StandardCharsets.UTF_8);
if (keyId.length == 0 || keyId.length > MAX_KEY_ID_BYTES) {
throw new IllegalStateException(
"a payload encryption key id must be 1..255 UTF-8 bytes to fit the envelope; this one is "
+ keyId.length);
}
byte[] nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(key.material(), "AES"),
new GCMParameterSpec(TAG_BITS, nonce));
// The header is authenticated, not merely prefixed: without this, the key id and version are
// attacker-editable, and an envelope could be redirected at a key of the attacker's choosing.
byte[] header = header(keyId);
cipher.updateAAD(header);
byte[] ciphertext = cipher.doFinal(plaintext);
return ByteBuffer.allocate(header.length + nonce.length + ciphertext.length)
.put(header)
.put(nonce)
.put(ciphertext)
.array();
} catch (GeneralSecurityException failure) {
// The message is deliberately shapeless: a failure here is about keys and providers, and
// anything derived from the plaintext would put caller content into a log line.
throw new IllegalStateException("notification payload encryption failed", failure);
}
}
@Override
public byte[] reveal(byte[] envelope) {
Objects.requireNonNull(envelope, "envelope");
if (envelope.length < 2) {
throw new NotificationPayloadUnreadableException(
"the stored payload is not an envelope", "unknown", null);
}
if (envelope[0] != VERSION) {
throw new NotificationPayloadUnreadableException(
"unsupported payload envelope version " + envelope[0], "unknown", null);
}
int keyIdLength = Byte.toUnsignedInt(envelope[1]);
int nonceStart = 2 + keyIdLength;
if (keyIdLength == 0 || envelope.length < nonceStart + NONCE_BYTES) {
throw new NotificationPayloadUnreadableException(
"the stored payload envelope is truncated", "unknown", null);
}
String keyId = new String(envelope, 2, keyIdLength, StandardCharsets.UTF_8);
try {
SecretKeyMaterial key = secrets.keyById(keyId);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(key.material(), "AES"),
new GCMParameterSpec(TAG_BITS, envelope, nonceStart, NONCE_BYTES));
cipher.updateAAD(envelope, 0, nonceStart);
int cipherStart = nonceStart + NONCE_BYTES;
return cipher.doFinal(envelope, cipherStart, envelope.length - cipherStart);
} catch (GeneralSecurityException | RuntimeException failure) {
// Every reason collapses into one type on purpose. An unknown key, a wrong key and a modified
// ciphertext are the same event to a caller — the payload cannot be read — and telling them
// apart in the message tells an attacker which of the three they achieved.
throw new NotificationPayloadUnreadableException(
"the stored notification payload could not be decrypted", keyId, failure);
}
}
private static byte[] header(byte[] keyId) {
return ByteBuffer.allocate(2 + keyId.length)
.put(VERSION)
.put((byte) keyId.length)
.put(keyId)
.array();
}
}
@@ -28,7 +28,7 @@ public final class JacksonNotificationVariablesCodec implements NotificationVari
try {
return NotificationJsonMapper.mapper().writeValueAsString(new TreeMap<>(variables));
} catch (JacksonException failure) {
throw rejection();
throw rejection(failure);
}
}
@@ -39,13 +39,14 @@ public final class JacksonNotificationVariablesCodec implements NotificationVari
return NotificationJsonMapper.mapper()
.readValue(payload, new TypeReference<TreeMap<String, Object>>() {});
} catch (JacksonException failure) {
throw rejection();
throw rejection(failure);
}
}
private static NotificationValidationException rejection() {
private static NotificationValidationException rejection(Throwable cause) {
return new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
cause);
}
}
@@ -0,0 +1,133 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import jakarta.mail.Session;
import java.time.Clock;
import java.time.Duration;
import java.util.Properties;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* One configured profile becomes one working provider (NTF-INT-001).
*
* <p>Before this assembler existed, {@code NotificationPlatformProviderConfig} collected {@code
* List<ProviderRuntimeAssembler>} and production main source implemented the interface nowhere. A
* fully configured SMTP profile therefore produced no runtime, no route and no error: requests
* reached durable acceptance and then found nothing eligible to send them, which reads from outside
* as the platform silently dropping notifications.
*/
class SmtpProviderRuntimeAssemblerTest {
@Test
@DisplayName("the assembler serves the SMTP family on the email channel")
void theAssemblerServesSmtpOnEmail() {
assertThat(assembler().type()).isEqualTo(ProviderType.SMTP);
assertThat(ProviderType.SMTP.channel()).isEqualTo(Channel.EMAIL);
}
@Test
@DisplayName("a configured profile assembles into a runtime bound to its own profile id")
void aConfiguredProfileAssemblesIntoARuntime() {
AssembledProvider assembled = assembler().assemble("primary-email", profile());
assertThat(assembled.channel()).isEqualTo(Channel.EMAIL);
assertThat(assembled.runtime().profile().profileId().value()).isEqualTo("primary-email");
assertThat(assembled.runtime().profile().providerId().value()).isEqualTo("smtp");
assertThat(assembled.runtime().profile().environment()).isEqualTo("local");
}
@Test
@DisplayName("SMTP contributes dispatch and claims nothing it cannot do")
void smtpClaimsNothingItCannotDo() {
AssembledProvider assembled = assembler().assemble("primary-email", profile());
assertThat(assembled.callback())
.as("SMTP has no callback adapter; claiming one fails on the first provider event instead")
.isEmpty();
assertThat(assembled.projector()).isEmpty();
assertThat(assembled.reconciliation())
.as("handing a message to a relay is the end of what the sender can observe")
.isEmpty();
var capabilities = assembled.runtime().profile().capabilities();
assertThat(capabilities.statusCallback()).isFalse();
assertThat(capabilities.statusQuery()).isFalse();
assertThat(capabilities.providerIdempotency())
.as("a capability declared here is a promise the dispatch loop acts on")
.isFalse();
}
@Test
@DisplayName("the per-profile bounds come from the profile, not from the shared transport")
void thePerProfileBoundsComeFromTheProfile() {
AssembledProvider assembled = assembler().assemble("primary-email", profile());
assertThat(assembled.runtime().profile().credentialGeneration())
.as("a freshly assembled profile has had no rotation; starting elsewhere fakes one")
.isEqualTo(1L);
}
private static SmtpProviderRuntimeAssembler assembler() {
Session session = Session.getInstance(new Properties());
return new SmtpProviderRuntimeAssembler(
message -> {
throw new UnsupportedOperationException("assembly only; nothing is sent here");
},
new SmtpMimeMessageFactory(session),
new SmtpFailureClassifier(),
protector(),
Runnable::run,
new AttachmentIntegrityGuard((reference, context) -> null),
new SmtpProviderProperties(
"localhost",
1025,
// The type cannot express plaintext: STARTTLS_REQUIRED and IMPLICIT_TLS are the
// only members, which is the transport refusing an unencrypted relay by
// construction rather than by a validator somebody has to remember to run.
SmtpProviderProperties.TlsMode.STARTTLS_REQUIRED,
"no-reply@example.test",
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(5),
4),
Clock.systemUTC());
}
private static NotificationPlatformSettings.Provider profile() {
return new NotificationPlatformSettings.Provider(
"SMTP", true, true, "local", "smtp-local", "", "", "", Duration.ofSeconds(10), 4, 8);
}
/** Not a lambda: the protector has three operations, and only one of them is exercised here. */
private static dev.caskeleton.application.notification.platform.security.ContactPointProtector
protector() {
return new dev.caskeleton.application.notification.platform.security.ContactPointProtector() {
@Override
public dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
protect(
dev.caskeleton.application.notification.platform.contact.ContactPointValue value) {
throw new UnsupportedOperationException("assembly only");
}
@Override
public dev.caskeleton.application.notification.platform.contact.ContactPointValue reveal(
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint value,
dev.caskeleton.application.notification.platform.security.AccessContext context) {
throw new UnsupportedOperationException("assembly only");
}
@Override
public String fingerprint(
dev.caskeleton.application.notification.platform.contact.ContactPointValue value) {
throw new UnsupportedOperationException("assembly only");
}
};
}
}
@@ -0,0 +1,195 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The at-rest envelope, and the rotation it exists to make possible (NTF-INT-007).
*
* <p>The accept path stored template variables verbatim — caller content that can be a reset code,
* an order total or an address. What decides whether an encryption layer is real is not that it
* encrypts; it is whether the day the key changes is a change of default or a data migration.
*/
class AesGcmNotificationPayloadProtectionTest {
private static final byte[] PAYLOAD =
"{\"code\":\"481516\",\"total\":\"1250.00\"}".getBytes(StandardCharsets.UTF_8);
@Test
@DisplayName("a payload survives the round trip unchanged")
void aPayloadSurvivesTheRoundTrip() {
Keys keys = new Keys("payload-2026-08");
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] envelope = protection.protect(PAYLOAD);
assertThat(protection.reveal(envelope)).isEqualTo(PAYLOAD);
}
@Test
@DisplayName("the stored bytes contain no part of the plaintext")
void theStoredBytesRevealNothing() {
Keys keys = new Keys("payload-2026-08");
byte[] envelope =
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
assertThat(new String(envelope, StandardCharsets.UTF_8))
.as("a column an operator can read is a column an incident can read")
.doesNotContain("481516")
.doesNotContain("1250.00");
}
@Test
@DisplayName("two encryptions of one payload differ")
void twoEncryptionsDiffer() {
var protection = new AesGcmNotificationPayloadProtection(new Keys("k1"), new SecureRandom());
assertThat(protection.protect(PAYLOAD))
.as("a deterministic ciphertext tells an observer which two requests carried one payload")
.isNotEqualTo(protection.protect(PAYLOAD));
}
@Test
@DisplayName("a row written under a retired key is still readable after rotation")
void aRetiredKeyStillReads() {
Keys keys = new Keys("payload-2026-08");
var beforeRotation = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] oldRow = beforeRotation.protect(PAYLOAD);
keys.rotateTo("payload-2026-09");
assertThat(new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).reveal(oldRow))
.as(
"this is the whole reason the envelope carries a key id. Without one, rotation is a "
+ "one-way door: every row written under the previous key becomes unreadable and "
+ "nothing in the row can say which key it needed")
.isEqualTo(PAYLOAD);
}
@Test
@DisplayName("a new row uses the new key, so rotation actually rotates")
void aNewRowUsesTheNewKey() {
Keys keys = new Keys("payload-2026-08");
keys.rotateTo("payload-2026-09");
byte[] envelope =
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
assertThat(keyIdOf(envelope)).isEqualTo("payload-2026-09");
}
@Test
@DisplayName("a modified ciphertext is refused rather than decrypted into something plausible")
void aModifiedCiphertextIsRefused() {
Keys keys = new Keys("k1");
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] envelope = protection.protect(PAYLOAD);
envelope[envelope.length - 1] ^= 0x01;
assertThatThrownBy(() -> protection.reveal(envelope))
.as("rendering attacker-chosen variables into a message a recipient trusts is the risk")
.isInstanceOf(NotificationPayloadUnreadableException.class);
}
@Test
@DisplayName("an edited key id is refused, because the header is authenticated")
void anEditedKeyIdIsRefused() {
Keys keys = new Keys("k1");
keys.add("k2");
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] envelope = protection.protect(PAYLOAD);
envelope[2] = 'k';
envelope[3] = '2';
assertThatThrownBy(() -> protection.reveal(envelope))
.as(
"the header is passed as AAD, so redirecting an envelope at another key fails the tag "
+ "rather than being attempted")
.isInstanceOf(NotificationPayloadUnreadableException.class);
}
@Test
@DisplayName("an unknown key names itself in the failure")
void anUnknownKeyNamesItself() {
Keys keys = new Keys("payload-2026-08");
byte[] envelope =
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
Keys emptied = new Keys("payload-2026-09");
assertThatThrownBy(
() ->
new AesGcmNotificationPayloadProtection(emptied, new SecureRandom())
.reveal(envelope))
.isInstanceOf(NotificationPayloadUnreadableException.class)
.as("the operator's next question is always which key is missing")
.hasMessageContaining("payload-2026-08");
}
@Test
@DisplayName("a truncated or foreign envelope is refused, not misread")
void aTruncatedEnvelopeIsRefused() {
var protection = new AesGcmNotificationPayloadProtection(new Keys("k1"), new SecureRandom());
assertThatCode(() -> protection.reveal(new byte[] {1}))
.isInstanceOf(NotificationPayloadUnreadableException.class);
assertThatCode(
() -> protection.reveal("plaintext row from before".getBytes(StandardCharsets.UTF_8)))
.as("a pre-migration plaintext row must fail loudly rather than decode into nonsense")
.isInstanceOf(NotificationPayloadUnreadableException.class);
}
private static String keyIdOf(byte[] envelope) {
return new String(envelope, 2, Byte.toUnsignedInt(envelope[1]), StandardCharsets.UTF_8);
}
/** A key store with history, which is what rotation needs and what the envelope addresses. */
private static final class Keys implements SecretMaterialProvider {
private final Map<String, SecretKeyMaterial> byId = new LinkedHashMap<>();
private String activeId;
Keys(String activeId) {
add(activeId);
this.activeId = activeId;
}
void add(String keyId) {
byte[] material = new byte[32];
Arrays.fill(material, (byte) keyId.hashCode());
byId.put(keyId, new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, material));
}
void rotateTo(String keyId) {
add(keyId);
activeId = keyId;
}
@Override
public SecretKeyMaterial activeKey(SecretPurpose purpose) {
return byId.get(activeId);
}
@Override
public SecretKeyMaterial keyById(String keyId) {
SecretKeyMaterial key = byId.get(keyId);
if (key == null) {
throw new IllegalStateException("no key " + keyId);
}
return key;
}
}
}
@@ -22,7 +22,7 @@ adapters implement application/domain ports directly and must not depend on this
The platform in `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` is
implemented here. The design models it as 18 Stable library modules; this repository's fail-closed
19-leaf registry outranks that layout, so those modules are **packages** in this leaf and
registry outranks that layout, so those modules are **packages** in this leaf and
`docs/jpa/repository-adaptation.md` records the mapping. Read it before moving a type between
packages.
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.persistence.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/**
* Registers the JPA adapter's scanned components, which nothing registered (JPA-INT-006).
*
* <p>The composition root's {@code @ComponentScan} excludes {@code
* dev.caskeleton.adapter.outbound.persistence.**} by regex, and that exclusion is correct: it is
* what makes an optional capability optional, so a deployment with JPA off assembles no persistence
* beans rather than assembling them and hoping each one remembered to carry the switch.
*
* <p>What was missing is the other half. Eight classes in this leaf are written as scanned
* components — {@code SpringTransactionPort}, {@code PersistenceExceptionTranslator}, {@code
* StandardSqlStateErrorMapping}, {@code DomainContextAuditContextPort}, the idempotency store and
* its reaper, and the outbox store and its reaper — and once the broad scan stopped reaching them,
* nothing else did. They are annotated {@code @Component} and {@code @Repository} and were beans in
* no running application: {@code TransactionPort} in particular had no implementation at all, so
* every use case that opens a transaction had no port to open it with.
*
* <p>It surfaced as an unsatisfied dependency the first time a capability that needs a transaction
* was actually assembled — the notification orchestrator, in the local-notification-ingest lane —
* rather than as anything a unit test could see, because each of these classes is constructed
* directly by its own tests.
*
* <p>So the scan is restored, narrowed to the packages it should always have covered and reachable
* only through {@code PersistenceJpaRootAutoConfiguration}, which carries the JPA master switch.
* Off is still structural.
*
* <p>Two packages are deliberately absent:
*
* <ul>
* <li>{@code ..persistence.fileserver} — gated on its own capability switch, scanned by {@link
* dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig};
* <li>{@code ..persistence.notification} — assembled explicitly, bean by bean, by {@code
* NotificationJpaPersistenceFacade}.
* </ul>
*
* <p>Components under these packages keep their own {@code @ConditionalOnProperty} guards; being
* scanned makes them candidates, not unconditional beans.
*
* <p>{@code ..persistence.lock} is in the list for the same reason and with the same history.
* {@code DistributedLockPersistenceConfig} owns both lock providers — the in-process registry and
* the JDBC one — and was registered by nothing but a test calling {@code ctx.register(...)}. So a
* single-instance deployment had no {@code DistributedLockPort} at all, and a multi-instance one
* could not start: the composition root's own {@code DistributedLockConfig} asks for a bean
* qualified {@code jdbcDistributedLock} that only that configuration declares.
*
* <p>Each package's {@code @ConfigurationProperties} type is enabled by a configuration inside that
* same package — {@code JpaTransactionConfig} for {@code JpaTransactionSettings}, {@code
* DistributedLockPersistenceConfig} for {@code LockSettings} — rather than from here. Enabling them
* centrally would give {@code config} an edge to {@code lock} and {@code transaction} that the
* module map does not grant it, and the map is right: this class knows which packages to scan, not
* what is inside them. They need enabling at all because {@code @ConfigurationPropertiesScan}
* excludes this tree as deliberately as {@code @ComponentScan} does.
*/
@Configuration(proxyBeanMethods = false)
@ComponentScan(
basePackages = {
"dev.caskeleton.adapter.outbound.persistence.audit",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
"dev.caskeleton.adapter.outbound.persistence.outbox",
"dev.caskeleton.adapter.outbound.persistence.transaction"
})
public class JpaAdapterComponentsConfig {}
@@ -11,13 +11,25 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
* simple name avoids {@code JpaConfig} to dodge a bean-name collision with the sample module. See
* README "config".
*
* <p>The package list is enumerated rather than given as the persistence root, and the omission is
* deliberate: {@code ...persistence.notification} is an <em>opt-in</em> capability whose schema
* stream is not in the default Flyway location. Scanning the whole root put its entities into the
* persistence unit unconditionally, so a deployment that never enabled notification still had
* {@code ddl-auto=validate} looking for {@code notification_request} — and failed to boot over a
* capability it had switched off. {@code NotificationJpaPersistenceConfig} owns that scan and only
* when the capability is on.
* <p>The package list is enumerated rather than given as the persistence root, and two omissions
* are deliberate. {@code ...persistence.notification} and {@code ...persistence.fileserver} are
* <em>opt-in</em> capabilities whose schema streams are not in the default Flyway location, so
* their tables do not exist in a deployment that never asked for them. Scanning the whole root put
* those entities into the persistence unit unconditionally, and {@code ddl-auto=validate} then
* looked for {@code notification_request} and {@code fs_cleanup_item} in deployments that had
* switched both capabilities off — failing the boot over capabilities they had declined. {@code
* NotificationJpaPersistenceConfig} and {@code FileserverJpaPersistenceConfig} own those scans,
* each behind the same switch its adapter beans already carried.
*
* <p>The fileserver case was the more expensive of the two: it blocked every JPA-on Compose lane,
* and it was invisible under H2, whose {@code create-drop} builds whatever the entities describe.
* It took a real PostgreSQL with a real migration history to see.
*
* <p>Neither gated configuration is imported from here, and neither is found by a component scan:
* {@code dev.caskeleton.adapter.outbound.persistence..*} is excluded from the composition root's
* scan by design, and {@code config} is allowed to depend on {@code api} alone. The composition
* root registers them, which is where the decision belongs — it is the only place that knows both
* which capabilities are on and which JPA vendor is composed.
*
* <p>A new always-installed sub-package must be added here; leaving it out is a silent omission
* rather than a compile error, which is what {@code PersistenceEntityScanCoverageTest} checks.
@@ -32,7 +44,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"dev.caskeleton.adapter.outbound.persistence.envers",
"dev.caskeleton.adapter.outbound.persistence.experimental",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.fileserver",
"dev.caskeleton.adapter.outbound.persistence.hibernate",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
@@ -54,7 +65,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"dev.caskeleton.adapter.outbound.persistence.envers",
"dev.caskeleton.adapter.outbound.persistence.experimental",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.fileserver",
"dev.caskeleton.adapter.outbound.persistence.hibernate",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
@@ -69,7 +79,13 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
})
public class PersistenceJpaConfig {
/** The one sub-package deliberately excluded above, named so a test can assert the exclusion. */
/**
* An opt-in sub-package deliberately excluded above, named so a test can assert the exclusion.
*/
public static final String OPT_IN_NOTIFICATION_PACKAGE =
"dev.caskeleton.adapter.outbound.persistence.notification";
/** The other one. Its tables live only in {@code db/migration/jpa/fileserver}. */
public static final String OPT_IN_FILESERVER_PACKAGE =
"dev.caskeleton.adapter.outbound.persistence.fileserver";
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Scans the fileserver capability's entities and repositories, and only when it is enabled.
*
* <p>The second capability to need this, and it was found the same way as the first: by running.
* Six entities here map six tables — {@code fs_file}, {@code fs_upload_session}, {@code
* fs_verification_result}, {@code fs_quota_reservation}, {@code fs_recovery_item} and {@code
* fs_cleanup_item} — and all six live in {@code db/migration/jpa/fileserver}, a stream applied only
* when the capability is on. The primary Flyway location is {@code db/migration/postgresql}, which
* creates none of them.
*
* <p>So an unconditional scan put those entities into the persistence unit of every deployment, and
* {@code ddl-auto=validate} against real PostgreSQL failed on {@code fs_cleanup_item} — a table the
* deployment had correctly never created, for a capability it had switched off. Every JPA-on
* Compose lane was blocked on it. The adapter beans in this package already carried this exact
* condition; the entity metadata did not, so "disabled" meant two different things one annotation
* apart.
*
* <p>The condition is the same master switch those beans use, so disabled means one thing
* everywhere: no entity metadata, no repository beans, no schema expectation.
*
* <p>{@code @ComponentScan} is here for the reason {@link
* dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig} exists: the nine
* {@code @Repository} adapters in this package carry the condition quoted above but were reached by
* no scan at all once the composition root stopped scanning the persistence tree, so the condition
* had nothing to decide about. {@code @EnableJpaRepositories} does not cover them — it registers
* Spring Data interfaces, and these are classes that consume those interfaces.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
@EntityScan(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
@EnableJpaRepositories(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
@ComponentScan(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
public class FileserverJpaPersistenceConfig {}
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.outbound.persistence.lock;
import dev.caskeleton.application.lock.DistributedLockPort;
import javax.sql.DataSource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@@ -17,6 +18,10 @@ import org.springframework.integration.support.locks.DefaultLockRegistry;
* "lock" for the provider-selection and wiring rationale.
*/
@Configuration(proxyBeanMethods = false)
// LockSettings is bound here rather than by the composition root's @ConfigurationPropertiesScan,
// which excludes this tree so a JPA-off deployment binds no persistence settings. Both providers
// below are built from its lease TTL, so without this the configuration cannot assemble either.
@EnableConfigurationProperties(LockSettings.class)
public class DistributedLockPersistenceConfig {
/** In-process adapter; active when {@code multi-instance-enabled} is {@code false} or absent. */
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.outbound.persistence.notification.configuration;
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig;
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation;
import dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository;
import dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository;
@@ -56,6 +57,7 @@ import java.util.Locale;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcOperations;
/**
@@ -84,6 +86,14 @@ import org.springframework.jdbc.core.JdbcOperations;
prefix = "ca-skeleton.notification.platform",
name = "enabled",
havingValue = "true")
// The entity scan travels with the stores it serves. NotificationJpaPersistenceConfig carries the
// same condition and was imported by nothing at all — the composition root's component scan
// excludes
// this package by design and no configuration named it — so the capability had entity metadata
// nowhere, whether it was switched on or off. Importing it here means a composition that reaches
// the
// facade cannot get the stores without the mappings they need.
@Import(NotificationJpaPersistenceConfig.class)
public class NotificationJpaPersistenceFacade {
/**
@@ -132,11 +142,21 @@ public class NotificationJpaPersistenceFacade {
.JdbcNotificationServingState(jdbc, clock);
}
/** Entity/record mapping. */
/**
* Entity/record mapping, including the at-rest envelope (NTF-INT-007).
*
* <p>The protection is a required constructor argument rather than an optional one, so a
* composition cannot assemble this leaf's notification stores while leaving the payload in
* plaintext. That was the whole risk: the store existed, wiring it was one import away, and
* nothing about the store's shape said the row it wrote held caller content unprotected.
*/
@Bean
public NotificationRecordMapper notificationRecordMapper(
NotificationRoutingPlanCodecPort routingPlans, NotificationVariablesCodecPort variables) {
return new NotificationRecordMapper(routingPlans, variables);
NotificationRoutingPlanCodecPort routingPlans,
NotificationVariablesCodecPort variables,
dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection
payloadProtection) {
return new NotificationRecordMapper(routingPlans, variables, payloadProtection);
}
/** Request and recipient persistence. */
@@ -163,7 +183,19 @@ public class NotificationJpaPersistenceFacade {
return new JpaRecipientLeaseStore(recipients, clock);
}
/** Attempt persistence, which also serves the resolver and projection ports. */
/**
* Attempt persistence, which is also the resolver and the projection store.
*
* <p>One bean, not four. {@link JpaDeliveryAttemptStore} implements {@link
* DeliveryAttemptStorePort}, {@link DeliveryAttemptResolverPort} and {@link
* DeliveryProjectionStorePort}, so injection by any of those types already finds it — and three
* further {@code @Bean} methods returning this same instance under three more names is what made
* the container refuse to start: asking for the concrete type matched several definitions of one
* object, and {@code deliveryAttemptResolverPort} could not be built at all.
*
* <p>It failed only once this facade was actually assembled. Nothing imported it before, so the
* duplication sat in a class the runtime never read.
*/
@Bean
public JpaDeliveryAttemptStore deliveryAttemptStore(
DeliveryAttemptJpaRepository attempts,
@@ -173,24 +205,6 @@ public class NotificationJpaPersistenceFacade {
return new JpaDeliveryAttemptStore(attempts, recipients, hasher, clock);
}
/** Attempt store as its application port. */
@Bean
public DeliveryAttemptStorePort deliveryAttemptStorePort(JpaDeliveryAttemptStore store) {
return store;
}
/** Attempt resolution for incoming provider events. */
@Bean
public DeliveryAttemptResolverPort deliveryAttemptResolverPort(JpaDeliveryAttemptStore store) {
return store;
}
/** Projection persistence. */
@Bean
public DeliveryProjectionStorePort deliveryProjectionStorePort(JpaDeliveryAttemptStore store) {
return store;
}
/** Append-only provider event ledger. */
@Bean
public ProviderEventLedger providerEventLedger(
@@ -61,7 +61,10 @@ public final class JpaNotificationRequestStore implements NotificationRequestSto
? null
: request.template().locale().toLanguageTag(),
request.strategyType(),
request.variablesPayload(),
// Through the mapper, not raw. This native insert names every column itself and so
// bypasses toEntity, where payload protection lives; passing the plaintext here stored
// recipient-facing variables in clear text and produced a row nothing could read back.
mapper.protectPayload(request.variablesPayload()),
request.scheduleAt().orElse(null),
request.notBefore().orElse(null),
request.expiresAt().orElse(null),
@@ -14,7 +14,11 @@ import dev.caskeleton.application.notification.platform.dispatch.NotificationReq
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -31,11 +35,27 @@ public final class NotificationRecordMapper {
private final NotificationRoutingPlanCodecPort routingPlans;
private final NotificationVariablesCodecPort variables;
private final NotificationPayloadProtection payloadProtection;
/**
* Creates the mapper.
*
* @param routingPlans the frozen route-plan codec
* @param variables the variables codec
* @param payloadProtection the at-rest envelope (NTF-INT-007). Applied here, at the storage
* boundary, rather than in the accept path: "at rest" means in the row, and the application
* necessarily holds the plaintext in memory because it has to render it. What this removes is
* the plaintext sitting in {@code notification_request.variables_payload} for as long as the
* request is retained — caller content that can be a reset code, an order total or an
* address.
*/
public NotificationRecordMapper(
NotificationRoutingPlanCodecPort routingPlans, NotificationVariablesCodecPort variables) {
NotificationRoutingPlanCodecPort routingPlans,
NotificationVariablesCodecPort variables,
NotificationPayloadProtection payloadProtection) {
this.routingPlans = Objects.requireNonNull(routingPlans, "routingPlans");
this.variables = Objects.requireNonNull(variables, "variables");
this.payloadProtection = Objects.requireNonNull(payloadProtection, "payloadProtection");
}
/** Map a stored request. */
@@ -53,7 +73,7 @@ public final class NotificationRecordMapper {
? Locale.ROOT
: Locale.forLanguageTag(entity.templateLocale())),
entity.strategyType(),
entity.variablesPayload(),
revealPayload(entity.variablesPayload()),
Optional.ofNullable(entity.scheduleAt()),
Optional.ofNullable(entity.notBefore()),
Optional.ofNullable(entity.expiresAt()),
@@ -98,6 +118,51 @@ public final class NotificationRecordMapper {
entity.updatedAt());
}
/**
* Wraps the payload for storage: an AES-GCM envelope, base64 for the text column.
*
* <p>Base64 rather than a column type change, deliberately. Moving {@code variables_payload} to
* {@code bytea} would touch the entity, the mapper, the native upsert and every stored row, and
* would buy a third of the bytes; keeping the column and changing what is in it makes the
* migration a rewrite of values rather than of a schema.
*
* <p>Public because {@link #toEntity} is not the only write path. {@code
* JpaNotificationRequestStore.insert} claims the idempotency key with a native insert that names
* every column itself, so it never reaches the mapper — and it wrote the plaintext straight into
* the column. Every accepted request was stored unencrypted, and the first read-back refused it
* as "not an envelope", which is the reveal side working exactly as intended and the only reason
* this was visible at all. A store that writes rows this mapper is expected to read must protect
* them the way this mapper protects them.
*/
public String protectPayload(String plaintext) {
if (plaintext == null) {
return null;
}
return Base64.getEncoder()
.encodeToString(payloadProtection.protect(plaintext.getBytes(StandardCharsets.UTF_8)));
}
/**
* Unwraps a stored payload.
*
* <p>A row that cannot be decrypted throws rather than returning null or empty. A caller handed
* an empty payload renders every variable as nothing and sends "Hello , your code is " to a real
* person — the failure delivered instead of reported.
*/
private String revealPayload(String stored) {
if (stored == null) {
return null;
}
byte[] envelope;
try {
envelope = Base64.getDecoder().decode(stored);
} catch (IllegalArgumentException malformedEnvelope) {
throw new NotificationPayloadUnreadableException(
"the stored notification payload is not a base64 envelope", "unknown", malformedEnvelope);
}
return new String(payloadProtection.reveal(envelope), StandardCharsets.UTF_8);
}
/** Build a new request entity. */
public NotificationRequestEntity toEntity(NotificationRequestRecord record) {
return new NotificationRequestEntity(
@@ -110,7 +175,7 @@ public final class NotificationRecordMapper {
record.template().version(),
record.template().locale().toLanguageTag(),
record.strategyType(),
record.variablesPayload(),
protectPayload(record.variablesPayload()),
record.scheduleAt().orElse(null),
record.notBefore().orElse(null),
record.expiresAt().orElse(null),
@@ -15,20 +15,26 @@ import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcOperations;
/**
* PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor
* {@code @Bean}s. See the module README.
* PostgreSQL vendor persistence configuration: registers the vendor {@code @Bean}s. See the module
* README.
*
* <p>The vendor configuration is the entry point into the capability, and it imports the core JPA
* config rather than the reverse. Inverting that to give the composition root a single import
* produced a package cycle — {@code config} would import {@code postgresql}, which needs {@code
* PersistenceVendorSettings} back from {@code config} — so the composition root names both vendor
* configurations instead, and the persistence export surface says so explicitly.
*
* <p>{@code matchIfMissing = true} keeps PostgreSQL the default: this configuration was
* unconditional before {@link PersistenceVendorSettings} existed, and a deployment that never sets
* the selector must keep the vendor it already runs.
*/
@Configuration(proxyBeanMethods = false)
@Import(PersistenceJpaConfig.class)
@ConditionalOnProperty(
prefix = PersistenceVendorSettings.PREFIX,
name = "vendor",
havingValue = "postgresql",
matchIfMissing = true)
@Import(PersistenceJpaConfig.class)
public class PostgreSqlPersistenceConfig {
@Bean
@@ -52,8 +58,32 @@ public class PostgreSqlPersistenceConfig {
return new PostgreSqlIdempotencyClaimRepository(entityManager);
}
/**
* The vendor's migration location, as a default rather than as an override.
*
* <p>This unconditionally called {@code locations(...)}, which replaces whatever Spring bound
* from {@code spring.flyway.locations}. An operator could therefore set {@code
* SPRING_FLYWAY_LOCATIONS} to add the capability streams, watch Flyway report a successful
* migration, and get only the vendor stream — the property was read, bound, and then discarded by
* a customizer that runs after it. The {@code local-notification-ingest} lane set seven locations
* and applied one.
*
* <p>It is the same shape as {@code application-local.yml}'s literal pins, which outranked every
* environment a caller supplied, and the same fix: contribute the value when nobody has chosen
* one, and stay out of the way when somebody has. A deployment that names its own locations is
* responsible for including this one, which is exactly the responsibility it took by naming them.
*
* @param environment the resolved environment, consulted for an operator-supplied value
* @return the customizer
*/
@Bean
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
return configuration -> configuration.locations("classpath:db/migration/postgresql");
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer(
org.springframework.core.env.Environment environment) {
return configuration -> {
String chosen = environment.getProperty("spring.flyway.locations", "").trim();
if (chosen.isEmpty()) {
configuration.locations("classpath:db/migration/postgresql");
}
};
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Binds {@link JpaTransactionSettings}, which nothing bound.
*
* <p>{@link SpringTransactionPort} takes it as a constructor argument, and the composition root's
* {@code @ConfigurationPropertiesScan} deliberately excludes this tree so a deployment with JPA off
* binds no persistence settings. That exclusion left the type unbound in deployments with JPA
* <em>on</em> as well, so the transaction port could not be constructed at all — it surfaced as an
* unsatisfied dependency the first time a capability that opens a transaction was assembled.
*
* <p>It lives in this package, next to the settings and the component that needs them, because the
* module map grants {@code config} an edge to {@code api} only; a central enablement would need
* edges into every package whose settings it named.
*
* <p>Reached through {@code JpaAdapterComponentsConfig}'s scan, so it is registered exactly when
* the JPA master switch is on.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(JpaTransactionSettings.class)
public class JpaTransactionConfig {}
@@ -0,0 +1,27 @@
-- five-adapter-runtime-remediation Wave 2 — the capability stream's half of the same correction.
--
-- V1 here creates idempotency_record with `CREATE TABLE IF NOT EXISTS` and char(64), the same
-- declaration db/migration/postgresql/V1 uses. The two streams keep separate history tables and
-- their relative order is not fixed, so whichever creates the table has to be corrected by whichever
-- runs next. Both carry the same guarded conversion; the reasoning is recorded once, in
-- db/migration/postgresql/V10__idempotency_request_hash_varchar.sql.
--
-- Editing V1 in place would have been smaller and wrong: an applied migration's checksum is a
-- promise to every deployment that already ran it.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = 'idempotency_record'
AND a.attname = 'request_hash'
AND NOT a.attisdropped
AND format_type(a.atttypid, a.atttypmod) = 'character(64)'
) THEN
ALTER TABLE idempotency_record
ALTER COLUMN request_hash TYPE varchar(64);
END IF;
END
$$;
@@ -0,0 +1,48 @@
-- five-adapter-runtime-remediation Wave 2 / NTF-INT-007 — the at-rest envelope's row guard.
--
-- notification_request.variables_payload now holds a base64 AES-GCM envelope instead of the accepted
-- request's template variables in plaintext. The variables are the message's own content — a reset
-- code, an order total, a delivery address — so the column held caller-supplied sensitive data for as
-- long as the request was retained.
--
-- This is a guard rather than a backfill, and the reason is a fact worth stating: no deployment of
-- this repository can have written such a row. NotificationJpaPersistenceFacade, which assembles
-- JpaNotificationRequestStore, was imported by nothing, so the write path existed in code and was
-- reachable from no composition. A backfill here would be re-encrypting rows that cannot exist, and
-- it would need the key material, which a migration has no business holding.
--
-- A fork that wired the store itself is the case this exists for. It fails the migration rather than
-- letting the application meet the rows at runtime, where the mapper throws
-- NotificationPayloadUnreadableException per request and the failure looks like a decryption bug
-- instead of an un-migrated table.
--
-- Silently reinterpreting a plaintext row is the option this deliberately does not take. Accepting
-- both shapes would mean the protection can be bypassed by writing plaintext, which is a control that
-- announces itself and then declines to hold.
DO $$
DECLARE
unprotected bigint;
BEGIN
IF to_regclass('public.notification_request') IS NULL THEN
RETURN;
END IF;
-- A base64 envelope contains only the base64 alphabet; a stored JSON payload contains at least
-- one of { " : , which none of them is. That is enough to separate the two without decoding.
SELECT count(*)
INTO unprotected
FROM notification_request
WHERE variables_payload IS NOT NULL
AND variables_payload !~ '^[A-Za-z0-9+/]+=*$';
IF unprotected > 0 THEN
RAISE EXCEPTION
'notification_request holds % row(s) whose variables_payload is not an at-rest envelope. '
'These predate NTF-INT-007 and contain caller content in plaintext. Re-encrypt them with '
'the active PAYLOAD_ENCRYPTION key before applying this migration; this step refuses to '
'reinterpret them, because accepting both shapes would let the protection be bypassed by '
'writing plaintext.', unprotected;
END IF;
END
$$;
@@ -0,0 +1,39 @@
-- five-adapter-runtime-remediation Wave 2 — align request_hash with the mapping that validates it.
--
-- V1 declared request_hash as char(64) while IdempotencyRecordEntity maps it as length = 64, which
-- Hibernate reads as varchar(64). Startup with ddl-auto=validate against real PostgreSQL therefore
-- failed:
--
-- Schema-validation: wrong column type encountered in column [request_hash] in table
-- [idempotency_record]; found [bpchar (Types#CHAR)], but expecting [varchar(64) (Types#VARCHAR)]
--
-- It was invisible for as long as local development ran on H2, whose create-drop builds the schema
-- from the entities and so can never disagree with them. Every other string column in this table is
-- varchar; char(n) was the outlier, and PostgreSQL gives it no storage or speed advantage while
-- blank-padding every value.
--
-- varchar is the direction rather than changing the entity, because a 64-character hex digest never
-- uses the padding and the rest of the codebase — the entity, the H2 composition, the JPQL — already
-- assumes varchar. The bpchar-to-varchar cast strips trailing blanks, so a padded value converts
-- losslessly.
--
-- Guarded because this stream and db/migration/jpa/idempotency both create the table and their
-- relative order is not fixed: whichever ran first, this converts only a column that is still
-- character(64), and a table rewrite is not paid twice.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = 'idempotency_record'
AND a.attname = 'request_hash'
AND NOT a.attisdropped
AND format_type(a.atttypid, a.atttypmod) = 'character(64)'
) THEN
ALTER TABLE idempotency_record
ALTER COLUMN request_hash TYPE varchar(64);
END IF;
END
$$;
@@ -0,0 +1,101 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
import jakarta.persistence.EntityManagerFactory;
import java.util.Map;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
/**
* Hibernate's verdict on the always-installed mapping, against the always-applied migrations.
*
* <p>The notification and fileserver capabilities each have this check; the default persistence
* unit — the one every JPA deployment builds whether it enables a capability or not — did not. So
* the only thing that ever ran {@code validate} over it against real PostgreSQL was a Compose lane,
* four minutes at a time, and what it found was {@code request_hash} declared {@code char(64)} by
* the migration and mapped {@code varchar(64)} by the entity. Every JPA-on deployment failed to
* start on it.
*
* <p>Nothing caught it earlier because local development runs on H2, whose {@code create-drop}
* builds the schema from the entities and therefore cannot disagree with them. A vendor that
* generates the schema can never report a mismatch with it.
*
* <p>The scanned packages are read from {@link PersistenceJpaConfig} rather than listed here, so a
* package added to the shipped scan is covered by this test the moment it is added — which is the
* only arrangement that keeps the check honest as the unit grows.
*/
@Tag("jpa-migration")
class PostgreSqlDefaultPersistenceUnitIntegrationTest {
@Test
@DisplayName("Hibernate validates the always-installed mapping against the migrated schema")
void hibernateValidatesTheDefaultUnitAgainstTheMigratedSchema() throws Exception {
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
migrate(database);
assertThatCode(() -> entityManagerFactory(database.dataSource()).close())
.as(
"this is the check every JPA-on boot performs; a column type the mapping disagrees "
+ "with fails it, and until this test existed only a Compose lane would say so")
.doesNotThrowAnyException();
}
}
@Test
@DisplayName(
"request_hash is varchar, which is what the entity maps and what the rest of the "
+ "table uses")
void theIdempotencyHashColumnIsVarchar() throws Exception {
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
migrate(database);
String type =
new JdbcTemplate(database.dataSource())
.queryForObject(
"select format_type(a.atttypid, a.atttypmod)"
+ " from pg_attribute a"
+ " join pg_class c on c.oid = a.attrelid"
+ " where c.relname = 'idempotency_record'"
+ " and a.attname = 'request_hash'"
+ " and not a.attisdropped",
String.class);
assertThat(type)
.as("char(n) blank-pads every value and was the only such column in this table")
.isEqualTo("character varying(64)");
}
}
private static EntityManagerFactory entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factoryBean =
new LocalContainerEntityManagerFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setPackagesToScan(
PersistenceJpaConfig.class.getAnnotation(EntityScan.class).basePackages());
factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
factoryBean.setJpaPropertyMap(
Map.of(
"hibernate.hbm2ddl.auto", "validate",
"hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"));
factoryBean.afterPropertiesSet();
return factoryBean.getObject();
}
private static void migrate(PostgreSqlReadinessSupport database) {
Flyway.configure()
.dataSource(database.dataSource())
.locations("classpath:db/migration/postgresql")
.load()
.migrate();
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.config;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig;
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -20,24 +21,45 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
* What goes into the persistence unit, and what is allowed to stay out of it.
*
* <p>The scan named the persistence root, so every sub-package was in the unit whether the
* deployment wanted it or not. Notification is opt-in — its schema stream is not in the default
* Flyway location — so a deployment with the feature switched off still had {@code
* ddl-auto=validate} demanding {@code notification_request}, and failed to boot over a capability
* it had declined.
* deployment wanted it or not. Notification and fileserver are opt-in — their schema streams are
* not in the default Flyway location — so a deployment with the feature switched off still had
* {@code ddl-auto=validate} demanding {@code notification_request} or {@code fs_cleanup_item}, and
* failed to boot over a capability it had declined.
*
* <p>Both were found by running, not reading, and the second only after the first was fixed:
* notification failed a Testcontainers boot, fileserver blocked every JPA-on Compose lane. Neither
* is visible under H2, whose {@code create-drop} builds whatever the entities describe.
*
* <p>An enumerated list fixes that and introduces a different risk: a new always-installed
* sub-package is a silent omission rather than a compile error. This test is the other half.
* sub-package is a silent omission rather than a compile error. This test is the other half — and
* it is parameterized over the opt-in capabilities so a third one cannot be added with half the
* treatment.
*/
class PersistenceEntityScanCoverageTest {
/** Each opt-in capability: its package, its own configuration, and the switch that gates it. */
private record OptInCapability(String packageName, Class<?> configuration, String switchPrefix) {}
private static final List<OptInCapability> OPT_IN =
List.of(
new OptInCapability(
PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE,
NotificationJpaPersistenceConfig.class,
"ca-skeleton.notification.platform"),
new OptInCapability(
PersistenceJpaConfig.OPT_IN_FILESERVER_PACKAGE,
FileserverJpaPersistenceConfig.class,
"app.fileserver-platform"));
/** Sub-packages that hold no JPA entity or repository and therefore need no scan. */
private static final Set<String> NOT_SCANNED =
Set.of(
// Vendor-selection configuration only.
"config",
"h2",
// The opt-in capability, scanned by NotificationJpaPersistenceConfig instead.
"notification");
// The opt-in capabilities, each scanned by its own gated configuration instead.
"notification",
"fileserver");
@Test
@DisplayName("every persistence sub-package is either scanned or explicitly exempt")
@@ -61,40 +83,56 @@ class PersistenceEntityScanCoverageTest {
}
@Test
@DisplayName("the opt-in notification package is not in the always-installed scan")
void notificationIsNotScannedUnconditionally() {
@DisplayName("no opt-in package is in the always-installed scan")
void noOptInPackageIsScannedUnconditionally() {
List<String> entityPackages =
List.of(PersistenceJpaConfig.class.getAnnotation(EntityScan.class).basePackages());
List<String> repositoryPackages =
List.of(
PersistenceJpaConfig.class.getAnnotation(EnableJpaRepositories.class).basePackages());
for (OptInCapability capability : OPT_IN) {
assertThat(entityPackages)
.as(
"scanning %s here puts its tables into ddl-auto=validate for every deployment",
capability.packageName())
.doesNotContain(capability.packageName());
assertThat(repositoryPackages).doesNotContain(capability.packageName());
}
assertThat(entityPackages)
.as("scanning it here puts its tables into ddl-auto=validate for every deployment")
.doesNotContain(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
assertThat(repositoryPackages).doesNotContain(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
assertThat(entityPackages)
.as("and naming the root would scan it by inclusion")
.as("and naming the root would scan every one of them by inclusion")
.doesNotContain("dev.caskeleton.adapter.outbound.persistence");
}
@Test
@DisplayName("the notification scan is behind the same switch as its beans")
void notificationIsScannedOnlyWhenEnabled() {
var condition =
NotificationJpaPersistenceConfig.class.getAnnotation(
org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class);
@DisplayName("each opt-in scan is behind the same switch as its own beans")
void everyOptInScanIsGatedByItsCapabilitySwitch() {
for (OptInCapability capability : OPT_IN) {
var condition =
capability
.configuration()
.getAnnotation(
org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class);
assertThat(condition).isNotNull();
assertThat(condition.prefix()).isEqualTo("ca-skeleton.notification.platform");
assertThat(condition.name()).containsExactly("enabled");
assertThat(condition.havingValue()).isEqualTo("true");
assertThat(
List.of(
NotificationJpaPersistenceConfig.class
.getAnnotation(EntityScan.class)
.basePackages()))
.containsExactly(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
assertThat(condition)
.as(
"%s must carry the capability condition, or the scan is unconditional again",
capability.configuration().getSimpleName())
.isNotNull();
assertThat(condition.prefix()).isEqualTo(capability.switchPrefix());
assertThat(condition.name()).containsExactly("enabled");
assertThat(condition.havingValue()).isEqualTo("true");
assertThat(List.of(capability.configuration().getAnnotation(EntityScan.class).basePackages()))
.containsExactly(capability.packageName());
assertThat(
List.of(
capability
.configuration()
.getAnnotation(EnableJpaRepositories.class)
.basePackages()))
.as("entities without repositories is half a scan, and fails at the first query")
.containsExactly(capability.packageName());
}
}
private static Set<String> scannedLeafNames(Class<?> configuration) {
@@ -292,12 +292,17 @@ Closure<String> renderMongoApiSurface = {
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
// The approval flag is read at configuration time and carried in, not fetched from `project`
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
// incompatible with the configuration cache — which this build will need before it can adopt one.
boolean mongoApiSurfaceUpdateApproved = project.hasProperty('approveMongoApiSurfaceChange')
tasks.register('verifyMongoApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (project.hasProperty('approveMongoApiSurfaceChange')) {
if (mongoApiSurfaceUpdateApproved) {
throw new GradleException(
'verifyMongoApiSurface is read-only; use updateMongoApiSurface to record an ' +
'approved change.')
@@ -22,6 +22,6 @@ import org.springframework.context.annotation.Configuration;
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPersistenceProperties.class)
@EnableConfigurationProperties(MongoPersistenceSettings.class)
@ImportAutoConfiguration({MongoAutoConfiguration.class, DataMongoAutoConfiguration.class})
public class MongoPersistenceConfig {}
@@ -7,12 +7,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* ca-skeleton.persistence-mongo.*}.
*
* <p>The Mongo <b>connection URI</b> is intentionally NOT modelled here it is read from Spring's
* own standard {@code spring.data.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}),
* which keeps credentials, host, and database wiring in the one place operators already expect.
* This class owns only the module's opt-in switch.
* own standard {@code spring.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}), which
* keeps credentials, host, and database wiring in the one place operators already expect. This
* class owns only the module's opt-in switch.
*
* <p>{@code spring.mongodb.*} is the canonical namespace in Spring Boot 4; {@code
* spring.data.mongodb.*} is deprecated at error level in its metadata. This Javadoc named the
* deprecated one, which is the worst place for that drift to sit: an operator reads the class that
* owns the switch, sets the property it points at, and gets a deprecation they did not choose. The
* Compose lanes have always supplied {@code SPRING_MONGODB_URI}, so only the documentation was
* behind. {@code MongoNamespaceContractTest} keeps it from drifting back.
*/
@ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo")
public class MongoPersistenceProperties {
public class MongoPersistenceSettings {
/**
* Whether to activate the MongoDB scaffolding. Defaults to {@code false} so the driver never
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.mongo;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Import;
/**
* The one place that decides whether this application talks to MongoDB.
*
* <p>Three things behaved like the master before this: the auto-configuration import filter, a
* component-scanned persistence configuration, and the platform auto-configuration — each reading
* the same property, and each able to assemble something the others believed was off. The filter
* keeps its job of holding Boot's own Mongo auto-configurations out of the candidate set, because
* that has to happen before any project condition is evaluated; what it no longer is, is an
* authority.
*
* <p>It sits in the leaf root, not in {@code autoconfigure}, because it names the opt-in
* configuration and the enablement settings that live here — and the root package already names
* {@code autoconfigure}. Declaring the edge both ways would make the module's package graph cyclic,
* which its boundary test refuses; the leaf root is where this file belongs by that graph's own
* description, "the opt-in filter and the Spring configuration entry points".
*/
@AutoConfiguration
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPersistenceSettings.class)
@Import({
MongoPersistenceConfig.class,
MongoPlatformAutoConfiguration.class,
MongoDriverObservabilityAutoConfiguration.class
})
public class MongoRootAutoConfiguration {}
@@ -24,7 +24,7 @@ import org.springframework.context.annotation.Configuration;
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoAdvancedProperties.class)
@EnableConfigurationProperties(MongoAdvancedSettings.class)
public class MongoAdvancedConfiguration {
/**
@@ -37,7 +37,7 @@ public class MongoAdvancedConfiguration {
@Bean
@ConditionalOnMissingBean
public MongoAdvancedCapabilityGuard mongoAdvancedCapabilityGuard(
MongoAdvancedProperties properties) {
MongoAdvancedSettings properties) {
return new MongoAdvancedCapabilityGuard(properties.toFlags());
}
}
@@ -22,19 +22,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* startup instead of being ignored which matters more than usual here, because the failure mode
* of a misspelt Advanced flag is a capability that stays off while its operator believes it is on.
*/
@ConfigurationProperties(MongoAdvancedProperties.PREFIX)
public record MongoAdvancedProperties(Map<MongoCapability, CapabilitySwitch> advanced) {
@ConfigurationProperties(MongoAdvancedSettings.PREFIX)
public record MongoAdvancedSettings(Map<MongoCapability, CapabilitySwitch> advanced) {
/** The module prefix; the {@code advanced} component completes the documented property path. */
public static final String PREFIX = "ca-skeleton.persistence-mongo";
public MongoAdvancedProperties {
public MongoAdvancedSettings {
advanced = advanced == null || advanced.isEmpty() ? Map.of() : Map.copyOf(advanced);
}
/** Nothing enabled — the default for a deployment that configures no Advanced capability. */
public static MongoAdvancedProperties none() {
return new MongoAdvancedProperties(Map.of());
public static MongoAdvancedSettings none() {
return new MongoAdvancedSettings(Map.of());
}
/** The flag set this configuration describes. */
@@ -2,7 +2,6 @@ package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
import dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -25,7 +24,7 @@ import org.springframework.context.annotation.Bean;
* different things on purpose, and neither is derived from the other, so a command appears once in
* each rather than twice in one.
*/
@AutoConfiguration
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
@@ -38,9 +37,9 @@ public class MongoDriverObservabilityAutoConfiguration {
@ConditionalOnMissingBean(name = "mongoDriverObservabilityCustomizer")
public MongoClientSettingsBuilderCustomizer mongoDriverObservabilityCustomizer(
MeterRegistry registry,
org.springframework.beans.factory.ObjectProvider<MongoPlatformProperties> properties) {
org.springframework.beans.factory.ObjectProvider<MongoPlatformSettings> properties) {
String profile =
properties.getIfAvailable(MongoPlatformProperties::empty).profiles().keySet().stream()
properties.getIfAvailable(MongoPlatformSettings::empty).profiles().keySet().stream()
.findFirst()
.orElse("default");
MongoDriverObservabilityConfiguration observability =
@@ -17,7 +17,6 @@ import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -39,13 +38,13 @@ import org.springframework.data.mongodb.core.MongoTemplate;
* transitively. The admin gateway is absent for the same reason, and deliberately: it is
* constructed by a migration or deployment job with its own credential.
*/
@AutoConfiguration
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@ConditionalOnClass(MongoTemplate.class)
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPlatformProperties.class)
@EnableConfigurationProperties(MongoPlatformSettings.class)
@Import(MongoMappingConfiguration.class)
public class MongoPlatformAutoConfiguration {
@@ -148,7 +147,7 @@ public class MongoPlatformAutoConfiguration {
@Bean
@ConditionalOnBean(MongoTopologyProbe.class)
public InitializingBean mongoPlatformStartupCheck(
MongoPlatformProperties properties,
MongoPlatformSettings properties,
MongoTopologyProbe probe,
ObjectProvider<dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile>
runtimeSecurity,
@@ -174,15 +173,54 @@ public class MongoPlatformAutoConfiguration {
probe,
security,
admin,
// Capability flags come from the same beans a deployment supplies for them; the
// properties record carries only profiles.
true,
true,
// From settings, not literals. These were `true, true`, which told the validator that
// transactions and change streams were both wanted whatever the deployment had
// configured — and then validated the topology against that invented answer.
properties.transactions(),
properties.changeStreams(),
versions)
.validate();
};
}
/**
* Refuses a platform that is on with no way to look at the server it talks to (MNG-INT-003).
*
* <p>{@code mongoPlatformStartupCheck} above is conditioned on a {@link MongoTopologyProbe},
* which is right — the check is about the live server, and only a composition root knows how to
* reach it. But a condition is also an exit: a deployment that enables the platform and supplies
* no probe got no validation at all, silently, and not supplying a bean is exactly what an
* operator who has not finished wiring will do.
*
* <p>So the absence is a failure of its own. Deliberately <b>not</b> conditioned on the probe: a
* requirement that only applies when the thing it requires is present is not a requirement.
*
* <p>Scoped to a platform that is actually configured, which is what {@code profiles} being
* non-empty means — the settings record already treats "module opted in, no platform profile yet"
* as a state that must start. This repository ships no probe: it is built from the live
* data-plane client by the composition root that owns the connection, which is a fork's decision.
* Requiring one from every deployment that merely switches the module on would refuse the
* module's own opt-in contract, and the {@code local-mongo} lane with it.
*/
@Bean
public InitializingBean mongoTopologyProbeRequirement(
MongoPlatformSettings properties, ObjectProvider<MongoTopologyProbe> probe) {
return () -> {
if (properties.profiles().isEmpty()) {
return;
}
if (probe.getIfAvailable() == null) {
throw new IllegalStateException(
"the Mongo platform has configured profiles but no MongoTopologyProbe bean, so the "
+ "startup validator has nothing to ask about the server: supply a probe built from "
+ "the live data-plane client, or remove the platform profiles. Starting without one "
+ "means the topology, the Stable API level and the credential's real capabilities "
+ "are checked by nothing — silently, because the check was conditioned on the very "
+ "bean whose absence it should report.");
}
};
}
/**
* The credential generation registry.
*
@@ -211,7 +249,7 @@ public class MongoPlatformAutoConfiguration {
@ConditionalOnMissingBean
@ConditionalOnBean(MongoTopologyProbe.class)
public MongoPlatformHealthIndicator mongoPlatformHealthIndicator(
MongoTopologyProbe probe, MongoPlatformProperties properties) {
MongoTopologyProbe probe, MongoPlatformSettings properties) {
return new MongoPlatformHealthIndicator(probe, properties, 0);
}
}
@@ -20,7 +20,7 @@ public final class MongoPlatformHealthIndicator {
private final MongoTopologyProbe probe;
private final MongoPlatformProperties properties;
private final MongoPlatformSettings properties;
private final int availableSecondaries;
@@ -30,7 +30,7 @@ public final class MongoPlatformHealthIndicator {
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
public MongoPlatformHealthIndicator(
MongoTopologyProbe probe, MongoPlatformProperties properties, int availableSecondaries) {
MongoTopologyProbe probe, MongoPlatformSettings properties, int availableSecondaries) {
this(probe, properties, availableSecondaries, DEFAULT_REQUIRED_SECONDARIES);
}
@@ -44,7 +44,7 @@ public final class MongoPlatformHealthIndicator {
*/
public MongoPlatformHealthIndicator(
MongoTopologyProbe probe,
MongoPlatformProperties properties,
MongoPlatformSettings properties,
int availableSecondaries,
int requiredSecondaries) {
this.probe = Objects.requireNonNull(probe, "probe");
@@ -16,20 +16,35 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* <p>Every profile is validated at binding time. A misconfigured profile that is only reached at
* runtime fails during the first request that touches it, which is both later and harder to
* attribute than a refused startup.
*
* <p>{@code transactions} is a subordinate switch, off until a deployment asks for it; when on, the
* startup validator verifies the data-plane credential's real replica-set capability rather than
* assuming it. Both it and {@code changeStreams} used to be literal {@code true}s passed into
* {@link MongoStartupValidator}, which told the validator that two capabilities were wanted
* whatever the deployment had configured.
*/
@ConfigurationProperties("ca-skeleton.persistence-mongo.platform")
public record MongoPlatformProperties(Map<String, MongoProfileProperties> profiles) {
public record MongoPlatformSettings(
Map<String, MongoProfileProperties> profiles, boolean transactions, boolean changeStreams) {
public MongoPlatformProperties {
public MongoPlatformSettings {
// Absent rather than empty is the normal case: a deployment that has opted the module in but
// configured no platform profile yet must still start, so binding treats "no profiles" as an
// empty map instead of a binding failure.
profiles = profiles == null ? Map.of() : Map.copyOf(profiles);
// Experimental, and therefore not a switch (MNG-INT-003). The driver-side source watch,
// resumeAfter/startAfter, cursor lifetime, reconnection is not shipped; what exists is policy
// and value objects that do not add up to a running consumer. Accepting the flag and ignoring
// it
// would leave an operator believing it took effect, so the value is refused rather than stored:
// zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks
// honoured.
changeStreams = false;
}
/** An empty configuration, for a deployment that has not opted the platform in. */
public static MongoPlatformProperties empty() {
return new MongoPlatformProperties(Map.of());
public static MongoPlatformSettings empty() {
return new MongoPlatformSettings(Map.of(), false, false);
}
/**
@@ -21,7 +21,7 @@ import java.util.Optional;
*/
public final class MongoStartupValidator {
private final MongoPlatformProperties properties;
private final MongoPlatformSettings properties;
private final MongoTopologyProbe probe;
@@ -36,7 +36,7 @@ public final class MongoStartupValidator {
private final MongoSchemaVersionRange schemaVersionRange;
public MongoStartupValidator(
MongoPlatformProperties properties,
MongoPlatformSettings properties,
MongoTopologyProbe probe,
MongoSecurityProfile runtimeSecurity,
MongoCredentialReference adminCredential,
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.outbound.mongo.client;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.ServerApi;
import com.mongodb.ServerApiVersion;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver;
import dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import org.bson.UuidRepresentation;
/**
* The one place the typed profile becomes the settings the driver is built from (MNG-INT-002).
*
* <p>The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout
* policy all existed and were all unit-tested. None of them reached a {@link MongoClientSettings}:
* the values were checked as intermediate objects and whatever the driver ended up configured with
* was decided elsewhere, by defaults nobody had chosen. A policy that nothing applies reads exactly
* like a policy that is applied — the tests pass, the record is populated, and the client connects
* with a three-second timeout it inherited from the driver rather than the two the profile states.
*
* <p>Two cardinality rules this enforces by construction:
*
* <ul>
* <li><b>One credential resolution, for the active profile only.</b> A profile present in the map
* but not selected never has its secret read — resolving it reaches a secret store for a
* credential nobody asked for, and the audit trail then records an access that happened for
* no reason anybody can reconstruct.
* <li><b>The connection string does not escape.</b> The resolver hands it to a function; what
* comes back is a settings object carrying only what the driver needs. The URI is a local for
* the duration of one call and never a field, a bean property or a {@code toString}.
* </ul>
*/
public final class MongoClientSettingsFactory {
private final MongoPlatformSettings settings;
private final String activeProfile;
private final MongoCredentialResolver credentials;
/**
* Creates the factory.
*
* @param settings the platform settings holding every configured profile
* @param activeProfile the profile this deployment selected
* @param credentials the resolver that turns a {@code secret://} reference into a URI
*/
public MongoClientSettingsFactory(
MongoPlatformSettings settings, String activeProfile, MongoCredentialResolver credentials) {
this.settings = Objects.requireNonNull(settings, "settings");
this.activeProfile = Objects.requireNonNull(activeProfile, "activeProfile");
this.credentials = Objects.requireNonNull(credentials, "credentials");
}
/**
* Builds the settings for the active profile.
*
* @return the driver settings
* @throws dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException when
* the selected profile is not configured
*/
public MongoClientSettings create() {
MongoProfileProperties profile = settings.require(activeProfile);
MongoCredentialReference reference =
new MongoCredentialReference(profile.uriSecret(), MongoPrincipalRole.APP_WRITE);
return credentials.withConnectionString(reference, uri -> build(profile, uri));
}
private static MongoClientSettings build(MongoProfileProperties profile, String uri) {
MongoClientSettings.Builder builder =
MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(uri))
// Pinned from the manifest rather than left to the driver, whose own default has
// changed
// across major versions — a value that moves under a stored document is a migration
// nobody wrote.
.uuidRepresentation(uuidRepresentationOf(profile))
.applyToSocketSettings(
socket ->
socket
.connectTimeout(profile.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)
.readTimeout(profile.socketReadTimeout().toMillis(), TimeUnit.MILLISECONDS))
.applyToClusterSettings(
cluster ->
cluster.serverSelectionTimeout(
profile.serverSelectionTimeout().toMillis(), TimeUnit.MILLISECONDS))
.applyToConnectionPoolSettings(
pool ->
pool.minSize(profile.poolMinSize())
.maxSize(profile.poolMaxSize())
.maxWaitTime(profile.poolMaxWaitTime().toMillis(), TimeUnit.MILLISECONDS));
if (profile.tlsRequired()) {
// Stated by the profile, applied here. A profile that declares TLS and connects without it is
// the failure this leaf's production validation exists to prevent, and it cannot prevent it
// from a record the driver never reads.
builder.applyToSslSettings(ssl -> ssl.enabled(true));
}
if (profile.stableApiStrict()) {
builder.serverApi(
ServerApi.builder()
.version(ServerApiVersion.V1)
.strict(true)
.deprecationErrors(true)
.build());
}
return builder.build();
}
private static UuidRepresentation uuidRepresentationOf(MongoProfileProperties profile) {
return switch (profile.uuidRepresentation()) {
case STANDARD -> UuidRepresentation.STANDARD;
// Readable for migration and never written, which is the manifest's rule rather than the
// driver's: the driver would happily write subtype 3 if told to.
case JAVA_LEGACY_READ_ONLY -> UuidRepresentation.JAVA_LEGACY;
};
}
}
@@ -38,19 +38,31 @@ public record MongoCredentialReference(String secretReference, MongoPrincipalRol
}
}
/** A stable, non-reversible identity for this credential. */
/**
* A stable, non-reversible identity for this credential (MNG-INT-004).
*
* <p>The secret reference alone. The role used to be hashed in with it, which meant the same
* secret under two roles produced two identities — and the one check this exists for, {@link
* MongoSecurityProfileValidator#requireDistinctCredentials}, is always called with a runtime role
* and an admin role. <b>It could never fire.</b> A deployment pointing both planes at one secret
* passed a validator written to reject exactly that.
*
* <p>The role is how a credential is used, not which credential it is. {@code
* MongoCredentialRotationPolicy} always knew that: it compares roles on its own line, right after
* asking whether the credential is the same, because those are two questions. Folding one into
* the other left the security check answering neither.
*/
public String fingerprint() {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash =
digest.digest((role.name() + '|' + secretReference).getBytes(StandardCharsets.UTF_8));
byte[] hash = digest.digest(secretReference.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 16);
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is required to fingerprint credentials");
}
}
/** True when two references point at the same credential. */
/** True when two references point at the same credential, whatever role each is used under. */
public boolean sameCredentialAs(MongoCredentialReference other) {
return fingerprint().equals(Objects.requireNonNull(other, "other").fingerprint());
}
@@ -8,8 +8,8 @@ import java.util.function.Function;
* <p>{@code MongoProfileProperties.uriSecret} held a reference such as {@code secret://mongo/app}
* and nothing resolved it. The validation that a production URI must be a reference was therefore
* enforcing a convention no code depended on: the actual client was built from Spring's {@code
* spring.data.mongodb.uri}, which is an ordinary property — in configuration, in the image and in
* every environment dump, which is exactly what the reference was introduced to avoid.
* spring.mongodb.uri}, which is an ordinary property — in configuration, in the image and in every
* environment dump, which is exactly what the reference was introduced to avoid.
*
* <p>The resolved value is never returned. The caller passes in what it wants built from the URI,
* and the resolver hands the value only to that function, so the credential exists as a local for
@@ -1,2 +1 @@
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.MongoRootAutoConfiguration
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.mongo;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Production code and configuration name the canonical Mongo namespace (MNG-INT-002).
*
* <p>{@code spring.data.mongodb.*} is deprecated at error level in Spring Boot 4's metadata; the
* canonical namespace is {@code spring.mongodb.*}. The runtime was never on the wrong one — every
* Compose lane supplies {@code SPRING_MONGODB_URI} and {@code local-mongo} passes against a real
* replica set — but {@code MongoPersistenceSettings}' own Javadoc pointed operators at the
* deprecated key, which is the worst place for that drift to sit: somebody reads the class that
* owns the switch, sets the property it names, and inherits a deprecation they did not choose.
*
* <p>Comments are stripped before the check. A sentence recording that the old namespace is
* deprecated is the opposite of the defect — the defect was a document telling an operator to use
* it. Resources are checked whole, because a key in a YAML file is never commentary.
*/
class MongoNamespaceContractTest {
private static final String RETIRED_NAMESPACE = "spring.data.mongodb.";
@Test
@DisplayName("no production source names the deprecated namespace outside a comment")
void noProductionSourceNamesTheDeprecatedNamespace() {
List<Path> sources = productionSources(".java").toList();
assertThat(sources)
.as("a scan that reached no source would report every reference as absent")
.isNotEmpty();
List<String> offenders =
sources.stream()
.filter(path -> withoutJavaComments(read(path)).contains(RETIRED_NAMESPACE))
.map(path -> path.getFileName().toString())
.sorted()
.toList();
assertThat(offenders)
.as(
"the canonical namespace is spring.mongodb.*; the old one is an error-level deprecation")
.isEmpty();
}
@Test
@DisplayName("no shipped resource binds the deprecated namespace")
void noShippedResourceBindsTheDeprecatedNamespace() {
List<String> offenders =
Stream.concat(productionSources(".yml"), productionSources(".properties"))
.filter(path -> read(path).contains(RETIRED_NAMESPACE))
.map(Path::toString)
.sorted()
.toList();
assertThat(offenders).as("a key in a configuration file is never commentary").isEmpty();
}
/** Strips {@code //} and block comments, leaving the code a compiler would act on. */
private static String withoutJavaComments(String source) {
return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " ");
}
private static Stream<Path> productionSources(String suffix) {
Path root = repositoryRoot().resolve("src");
return Stream.of("adapter/outbound/persistence-mongo", "app-bootstrap")
.map(root::resolve)
.filter(Files::isDirectory)
.flatMap(
moduleRoot -> {
try (Stream<Path> walk = Files.walk(moduleRoot)) {
return walk
.filter(path -> path.toString().endsWith(suffix))
.filter(path -> path.toString().contains("/src/main/"))
.filter(path -> !path.toString().contains("/build/"))
.toList()
.stream();
} catch (IOException unreadable) {
throw new UncheckedIOException(unreadable);
}
});
}
private static String read(Path path) {
try {
return Files.readString(path);
} catch (IOException unreadable) {
throw new UncheckedIOException(unreadable);
}
}
private static Path repositoryRoot() {
Path candidate = Path.of("").toAbsolutePath();
while (candidate != null
&& !Files.isRegularFile(candidate.resolve("src/config/architecture/modules.json"))) {
candidate = candidate.getParent();
}
if (candidate == null) {
throw new IllegalStateException("could not locate the repository root from the test cwd");
}
return candidate;
}
}
@@ -49,8 +49,7 @@ class MongoPersistenceConfigTest {
.run(
context -> {
assertThat(context).hasNotFailed();
MongoPersistenceProperties properties =
context.getBean(MongoPersistenceProperties.class);
MongoPersistenceSettings properties = context.getBean(MongoPersistenceSettings.class);
assertThat(properties.isEnabled()).isTrue();
});
}
@@ -75,7 +74,7 @@ class MongoPersistenceConfigTest {
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(MongoPersistenceProperties.class)
@EnableConfigurationProperties(MongoPersistenceSettings.class)
static class PropertiesOnly {}
@Configuration(proxyBeanMethods = false)
@@ -36,7 +36,7 @@ class MongoAdvancedConfigurationTest {
void thePropertyPathIsOneFact() {
assertThat(MongoAdvancedCapabilityFlags.propertyFor(MongoCapability.DATABASE_PER_TENANT))
.as("a message naming a property nothing binds is worse than no message")
.isEqualTo(MongoAdvancedProperties.PREFIX + ".advanced.database-per-tenant.enabled");
.isEqualTo(MongoAdvancedSettings.PREFIX + ".advanced.database-per-tenant.enabled");
}
@Test
@@ -89,7 +89,7 @@ class MongoAdvancedConfigurationTest {
context ->
assertThat(
context
.getBean(MongoAdvancedProperties.class)
.getBean(MongoAdvancedSettings.class)
.toFlags()
.isEnabled(MongoCapability.SHARDING))
.isFalse());
@@ -60,6 +60,12 @@ class MongoModuleBoundaryTest {
"advanced",
java.util.Set.of(
"api", "changestream", "imperative", "migration", "schema", "security")),
// The one place the typed profile becomes the driver's MongoClientSettings (MNG-INT-002).
// It names the settings that describe the client, the security types that resolve its
// credential, and the mapping manifest that pins the UUID representation — and nothing
// that executes a query, because building a client is not running one.
java.util.Map.entry(
"client", java.util.Set.of("api", "autoconfigure", "mapping", "security")),
java.util.Map.entry(
"autoconfigure",
java.util.Set.of(
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test;
/** Design §28 — production configuration is validated at binding time, not at first use. */
@Tag("mongodb-contract")
class MongoPlatformPropertiesTest {
class MongoPlatformSettingsTest {
@Test
void productionUriMustBeSecretReference() {
@@ -105,9 +105,9 @@ class MongoPlatformPropertiesTest {
@Test
void anInvalidProfileNameIsRejectedWithTheProfileNamed() {
MongoPlatformProperties properties =
new MongoPlatformProperties(
Map.of("Tenant_A", MongoProfileProperties.local("secret://mongodb/uri")));
MongoPlatformSettings properties =
new MongoPlatformSettings(
Map.of("Tenant_A", MongoProfileProperties.local("secret://mongodb/uri")), false, false);
assertThatThrownBy(properties::validate)
.isInstanceOf(MongoOperationRejectedException.class)
@@ -116,13 +116,13 @@ class MongoPlatformPropertiesTest {
@Test
void anAbsentProfileMapBindsToAnEmptyConfiguration() {
assertThat(new MongoPlatformProperties(null).profiles()).isEmpty();
assertThat(MongoPlatformProperties.empty().profiles()).isEmpty();
assertThat(new MongoPlatformSettings(null, false, false).profiles()).isEmpty();
assertThat(MongoPlatformSettings.empty().profiles()).isEmpty();
}
@Test
void anUnconfiguredProfileIsRefusedRatherThanDefaulted() {
assertThatThrownBy(() -> MongoPlatformProperties.empty().require("default"))
assertThatThrownBy(() -> MongoPlatformSettings.empty().require("default"))
.isInstanceOf(MongoOperationRejectedException.class);
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Method;
import java.util.Arrays;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
/**
* The Mongo startup check cannot be skipped by not supplying a probe (MNG-INT-003).
*
* <p>{@code mongoPlatformStartupCheck} carries
* {@code @ConditionalOnBean(MongoTopologyProbe.class)}. Inside the probe-present case it already
* fails closed — a partial set of inputs is refused rather than half-validated. But the condition
* itself is the wider hole: a deployment that enables the platform and supplies no probe gets **no
* validation at all**, silently. Not supplying a bean is exactly what an operator who has not
* finished wiring will do, and the outcome is the platform starting against a topology nobody
* checked.
*
* <p>So the absence is now a startup error of its own, scoped to a platform that is actually
* configured — {@code profiles} non-empty. The settings record already treats "module opted in, no
* platform profile yet" as a state that must start, and this repository ships no probe: it is built
* from the live data-plane client by the composition root that owns the connection, which is a
* fork's decision. Demanding one from every deployment that merely switches the module on would
* refuse the module's own opt-in contract, and the {@code local-mongo} lane with it — which is what
* the first attempt at this did, and how the scope got settled.
*
* <p>The capability flags were literals — {@code true, true} passed straight into {@code
* MongoStartupValidator} — which told the validator that transactions and change streams were both
* wanted regardless of what the deployment asked for. They come from settings now: {@code
* transactions} is a subordinate switch defaulting {@code false}, and {@code change-streams} is
* experimental and always {@code false}, because a replica-set qualification observing that the
* server *can* do something is not evidence that this platform ships it.
*/
class MongoStartupValidationTest {
@Test
@DisplayName(
"a configured platform with no topology probe is a startup error, not a skipped check")
void aMissingProbeIsAStartupError() {
assertThat(beanMethod("mongoTopologyProbeRequirement"))
.as(
"without this, `@ConditionalOnBean(MongoTopologyProbe.class)` turns 'nobody wired the "
+ "probe' into 'nothing was validated', and the deployment starts anyway")
.isPresent();
assertThat(beanMethod("mongoTopologyProbeRequirement").orElseThrow())
.satisfies(
method ->
assertThat(method.getAnnotation(ConditionalOnBean.class))
.as("a requirement conditioned on the thing it requires is not a requirement")
.isNull());
}
@Test
@DisplayName("the capability flags come from settings, not from literals")
void theCapabilityFlagsComeFromSettings() {
assertThat(MongoPlatformSettings.empty().transactions())
.as("a subordinate switch, off until a deployment asks for it")
.isFalse();
assertThat(MongoPlatformSettings.empty().changeStreams())
.as(
"experimental: zero beans and zero threads. A server that supports change streams is not "
+ "a platform that ships them.")
.isFalse();
}
@Test
@DisplayName("change streams cannot be switched on by configuration")
void changeStreamsCannotBeSwitchedOn() {
MongoPlatformSettings asked = new MongoPlatformSettings(java.util.Map.of(), true, true);
assertThat(asked.transactions()).as("transactions are a real switch").isTrue();
assertThat(asked.changeStreams())
.as(
"change streams are not; accepting the flag and ignoring it would leave an operator "
+ "believing it took effect, so the record refuses to carry a true it cannot honour")
.isFalse();
}
private static java.util.Optional<Method> beanMethod(String name) {
return Arrays.stream(MongoPlatformAutoConfiguration.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(Bean.class))
.filter(method -> method.getName().equals(name))
.findFirst();
}
}
@@ -109,8 +109,10 @@ class MongoStartupValidatorTest {
MongoPlatformHealthIndicator health =
new MongoPlatformHealthIndicator(
new MongoTopologyProbe(MongoTopology.SHARDED, "8.0"),
new MongoPlatformProperties(
Map.of("default", MongoProfileProperties.production("secret://mongodb/uri"))),
new MongoPlatformSettings(
Map.of("default", MongoProfileProperties.production("secret://mongodb/uri")),
false,
false),
2);
assertThat(health.live()).isTrue();
@@ -123,8 +125,10 @@ class MongoStartupValidatorTest {
MongoPlatformHealthIndicator health =
new MongoPlatformHealthIndicator(
new MongoTopologyProbe(MongoTopology.REPLICA_SET, "8.0"),
new MongoPlatformProperties(
Map.of("default", MongoProfileProperties.production("secret://mongodb/uri"))),
new MongoPlatformSettings(
Map.of("default", MongoProfileProperties.production("secret://mongodb/uri")),
false,
false),
1);
assertThat(health.ready()).isTrue();
@@ -137,7 +141,7 @@ class MongoStartupValidatorTest {
boolean transactionsEnabled,
boolean changeStreamsEnabled) {
return new MongoStartupValidator(
new MongoPlatformProperties(Map.of("default", profile)),
new MongoPlatformSettings(Map.of("default", profile), false, false),
probe,
MongoSecurityProfile.production(RUNTIME_CREDENTIAL, Set.of()),
ADMIN_CREDENTIAL,
@@ -0,0 +1,154 @@
package dev.caskeleton.adapter.outbound.mongo.client;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.mongodb.MongoClientSettings;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The typed profile reaches the settings the driver is actually built from (MNG-INT-002).
*
* <p>The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout
* policy all existed and were all unit-tested. None of them was connected to a {@code
* MongoClientSettings}: the values were checked as intermediate objects, and whatever the driver
* ended up configured with was decided elsewhere. A policy that nothing applies is a policy that
* reads as applied.
*
* <p>So these cases assert on the **built** settings, not on the record that fed them, and on the
* cardinality the platform promises: one credential resolution, for the active profile only.
*/
class MongoClientSettingsFactoryTest {
private static final String ACTIVE = "primary";
private static final String RESOLVED_URI = "mongodb://mongo:27017/ca_skeleton?replicaSet=rs0";
@Test
@DisplayName("the profile's timeouts and pool bounds are what the driver is built with")
void theProfileReachesTheBuiltSettings() {
MongoProfileProperties profile = profile();
MongoClientSettings settings =
new MongoClientSettingsFactory(
settingsWith(profile), ACTIVE, resolver(new java.util.ArrayList<>()))
.create();
assertThat(settings.getServerSettings()).isNotNull();
assertThat(settings.getSocketSettings().getConnectTimeout(TimeUnit.MILLISECONDS))
.as("connect timeout, from the profile rather than from a driver default")
.isEqualTo(profile.connectTimeout().toMillis());
assertThat(settings.getSocketSettings().getReadTimeout(TimeUnit.MILLISECONDS))
.isEqualTo(profile.socketReadTimeout().toMillis());
assertThat(settings.getClusterSettings().getServerSelectionTimeout(TimeUnit.MILLISECONDS))
.isEqualTo(profile.serverSelectionTimeout().toMillis());
assertThat(settings.getConnectionPoolSettings().getMinSize()).isEqualTo(profile.poolMinSize());
assertThat(settings.getConnectionPoolSettings().getMaxSize()).isEqualTo(profile.poolMaxSize());
assertThat(settings.getConnectionPoolSettings().getMaxWaitTime(TimeUnit.MILLISECONDS))
.isEqualTo(profile.poolMaxWaitTime().toMillis());
}
@Test
@DisplayName("the Stable API declaration reaches the driver, strictly")
void theStableApiDeclarationReachesTheDriver() {
MongoClientSettings settings =
new MongoClientSettingsFactory(
settingsWith(profile()), ACTIVE, resolver(new java.util.ArrayList<>()))
.create();
assertThat(settings.getServerApi())
.as("declared in the profile and never applied is how a driver upgrade changes behaviour")
.isNotNull();
assertThat(settings.getServerApi().getStrict()).contains(true);
}
@Test
@DisplayName("the UUID representation is the manifest's, not the driver's default")
void theUuidRepresentationIsTheManifests() {
MongoClientSettings settings =
new MongoClientSettingsFactory(
settingsWith(profile()), ACTIVE, resolver(new java.util.ArrayList<>()))
.create();
assertThat(settings.getUuidRepresentation())
.as(
"the driver's own default has changed across versions; the manifest is why it is pinned")
.isEqualTo(org.bson.UuidRepresentation.STANDARD);
}
@Test
@DisplayName("only the active profile's secret is resolved")
void onlyTheActiveProfilesSecretIsResolved() {
java.util.List<String> resolved = new java.util.ArrayList<>();
MongoPlatformSettings settings =
new MongoPlatformSettings(
Map.of(ACTIVE, profile(), "unused", profileWith("secret://mongodb/never-read")),
false,
false);
new MongoClientSettingsFactory(settings, ACTIVE, resolver(resolved)).create();
assertThat(resolved)
.as(
"a profile present in the map but not selected must not have its secret read: resolving "
+ "it reaches a secret store for a credential nobody asked for, and an audit log "
+ "then records an access that never happened for a reason")
.containsExactly(profile().uriSecret());
}
@Test
@DisplayName("an unknown active profile is refused by name")
void anUnknownActiveProfileIsRefused() {
assertThatThrownBy(
() ->
new MongoClientSettingsFactory(
settingsWith(profile()), "absent", resolver(new java.util.ArrayList<>()))
.create())
.hasMessageContaining("absent");
}
@Test
@DisplayName("the resolved connection string does not escape the factory")
void theConnectionStringDoesNotEscape() {
MongoClientSettings settings =
new MongoClientSettingsFactory(
settingsWith(profile()), ACTIVE, resolver(new java.util.ArrayList<>()))
.create();
assertThat(settings.toString())
.as(
"the resolver hands the URI to a function and the settings keep only what the driver "
+ "needs; a credential that reaches toString reaches a log line")
.doesNotContain("mongodb://mongo:27017");
}
private static MongoPlatformSettings settingsWith(MongoProfileProperties profile) {
return new MongoPlatformSettings(Map.of(ACTIVE, profile), false, false);
}
private static MongoProfileProperties profile() {
return profileWith("secret://mongodb/primary-uri");
}
private static MongoProfileProperties profileWith(String secret) {
return MongoProfileProperties.production(secret);
}
/** Records which references were resolved, so the cardinality claim is checkable. */
private static MongoCredentialResolver resolver(List<String> resolved) {
return new MongoCredentialResolver() {
@Override
public <T> T withConnectionString(
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference reference,
java.util.function.Function<String, T> use) {
resolved.add(reference.secretReference());
return use.apply(RESOLVED_URI);
}
};
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.mongo.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* A credential's identity is the secret it points at, not the role it is used under (MNG-INT-004).
*
* <p>{@code fingerprint()} hashed {@code role.name() + '|' + secretReference}, so the same secret
* under two roles produced two identities and {@code sameCredentialAs} answered false. That is not
* a cosmetic mismatch: {@code MongoSecurityProfileValidator.requireDistinctCredentials(runtime,
* admin)} exists to refuse a deployment where one credential opens both planes, and it is always
* called with two <em>different</em> roles. **The check could never fire.** A deployment pointing
* the runtime and the admin client at the same secret passed a validator written to reject exactly
* that.
*
* <p>{@code MongoCredentialRotationPolicy} is the counter-evidence that the role was never meant to
* be part of the identity: it compares roles <em>separately</em>, on the line after {@code
* sameCredentialAs}, because "is this the same credential" and "is this the same principal" are two
* questions. Folding one into the other left the security check answering neither.
*/
class MongoCredentialIdentityTest {
private static final String SECRET = "secret://mongodb/shared-uri";
@Test
@DisplayName("one secret under two roles is one credential")
void oneSecretUnderTwoRolesIsOneCredential() {
MongoCredentialReference asRuntime =
new MongoCredentialReference(SECRET, MongoPrincipalRole.APP_WRITE);
MongoCredentialReference asMigration =
new MongoCredentialReference(SECRET, MongoPrincipalRole.MIGRATION);
assertThat(asRuntime.sameCredentialAs(asMigration))
.as("the role is how a credential is used, not which credential it is")
.isTrue();
assertThat(asRuntime.fingerprint()).isEqualTo(asMigration.fingerprint());
}
@Test
@DisplayName("different secrets stay different credentials")
void differentSecretsStayDifferent() {
MongoCredentialReference one =
new MongoCredentialReference("secret://mongodb/runtime-uri", MongoPrincipalRole.APP_WRITE);
MongoCredentialReference other =
new MongoCredentialReference("secret://mongodb/admin-uri", MongoPrincipalRole.APP_WRITE);
assertThat(one.sameCredentialAs(other)).isFalse();
}
@Test
@DisplayName("the plane-separation check can now actually fire")
void thePlaneSeparationCheckCanFire() {
MongoCredentialReference runtime =
new MongoCredentialReference(SECRET, MongoPrincipalRole.APP_WRITE);
MongoCredentialReference admin =
new MongoCredentialReference(SECRET, MongoPrincipalRole.MIGRATION);
assertThatThrownBy(
() -> new MongoSecurityProfileValidator().requireDistinctCredentials(runtime, admin))
.as(
"with the role in the hash this call was unreachable, because the two arguments always "
+ "carry different roles — a validator that cannot reject anything")
.hasMessageContaining("share credential");
}
@Test
@DisplayName("separate secrets still pass the plane-separation check")
void separateSecretsStillPass() {
assertThatCode(
() ->
new MongoSecurityProfileValidator()
.requireDistinctCredentials(
new MongoCredentialReference(
"secret://mongodb/runtime-uri", MongoPrincipalRole.APP_WRITE),
new MongoCredentialReference(
"secret://mongodb/admin-uri", MongoPrincipalRole.MIGRATION)))
.doesNotThrowAnyException();
}
@Test
@DisplayName("rotation still refuses a change of principal, which it checks itself")
void rotationStillRefusesAChangeOfPrincipal() {
assertThatThrownBy(
() ->
new MongoCredentialRotationPolicy(java.time.Duration.ofSeconds(30), true)
.validateRotation(
new MongoCredentialReference(
"secret://mongodb/current", MongoPrincipalRole.APP_WRITE),
new MongoCredentialReference(
"secret://mongodb/next", MongoPrincipalRole.MIGRATION)))
.as("the role comparison lives in the policy, where it always did")
.hasMessageContaining("must not change the principal");
}
@Test
@DisplayName("the fingerprint still reveals nothing about the secret")
void theFingerprintRevealsNothing() {
String fingerprint =
new MongoCredentialReference(SECRET, MongoPrincipalRole.APP_WRITE).fingerprint();
assertThat(fingerprint).doesNotContain(SECRET).hasSize(16);
}
}