feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+24
@@ -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 {}
|
||||
+52
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingBridgeRootAutoConfiguration
|
||||
+16
-3
@@ -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);
|
||||
|
||||
+211
@@ -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());
|
||||
}
|
||||
}
|
||||
+60
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -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)) {
|
||||
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+149
@@ -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();
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+133
@@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+195
@@ -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.
|
||||
|
||||
|
||||
+68
@@ -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 {}
|
||||
+26
-10
@@ -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";
|
||||
}
|
||||
|
||||
+41
@@ -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 {}
|
||||
+5
@@ -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. */
|
||||
|
||||
+36
-22
@@ -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(
|
||||
|
||||
+4
-1
@@ -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),
|
||||
|
||||
+68
-3
@@ -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),
|
||||
|
||||
+35
-5
@@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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 {}
|
||||
+27
@@ -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
|
||||
$$;
|
||||
+48
@@ -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
|
||||
$$;
|
||||
+39
@@ -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
|
||||
$$;
|
||||
+101
@@ -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();
|
||||
}
|
||||
}
|
||||
+67
-29
@@ -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.')
|
||||
|
||||
+1
-1
@@ -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 {}
|
||||
|
||||
+11
-4
@@ -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
|
||||
+37
@@ -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 {}
|
||||
+2
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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. */
|
||||
+3
-4
@@ -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 =
|
||||
|
||||
+47
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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");
|
||||
|
||||
+19
-4
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+122
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -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());
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,2 +1 @@
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
|
||||
dev.caskeleton.adapter.outbound.mongo.MongoRootAutoConfiguration
|
||||
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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)
|
||||
|
||||
+2
-2
@@ -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());
|
||||
|
||||
+6
@@ -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(
|
||||
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+89
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -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,
|
||||
|
||||
+154
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user