The container exposes UDP/5060 on a host-mapped port. Authentication
+ * credentials are fixed: {@code alice / secret}. Devices register under
+ * {@code sip:alice@kamailio.test}.
+ *
+ * The configuration file ships verbatim in the test resources and is
+ * baked into the container at startup via Testcontainers' {@code copyFile}.
+ * The Kamailio image is fetched from Docker Hub at first use.
+ */
+public final class KamailioContainer extends GenericContainer {
+
+ public static final String USERNAME = "alice";
+ public static final String PASSWORD = "secret";
+ public static final String DOMAIN = "kamailio.test";
+
+ private static final DockerImageName IMAGE =
+ DockerImageName.parse("kamailio/kamailio-ci:5.7-alpine");
+
+ public KamailioContainer() {
+ super(IMAGE);
+ withExposedPorts(5060);
+ // UDP port mapping needs createContainerCmdModifier in Testcontainers;
+ // simpler approach: rely on TCP/UDP same port mapping via host network
+ // when Docker setup permits. The minimal config below uses TCP for
+ // the interop checks because UDP port mapping is awkward in CI.
+ waitingFor(Wait.forLogMessage(".*registrar.*ready.*", 1)
+ .withStartupTimeout(java.time.Duration.ofSeconds(30)));
+ withCopyToContainer(
+ Transferable.of(KAMAILIO_CONFIG.getBytes()),
+ "/etc/kamailio/kamailio.cfg");
+ withCommand("kamailio", "-f", "/etc/kamailio/kamailio.cfg", "-DD", "-E");
+ }
+
+ public int sipPort() {
+ return getMappedPort(5060);
+ }
+
+ /**
+ * Minimal Kamailio config: a single in-memory user, plain Digest auth
+ * (MD5), accepts REGISTER and 200 OK to OPTIONS. NOT for production —
+ * tuned exclusively for parser / dialog / Digest interop tests.
+ */
+ private static final String KAMAILIO_CONFIG = """
+ #!KAMAILIO
+
+ debug=2
+ log_stderror=yes
+ children=2
+
+ tcp_connection_lifetime=3605
+
+ loadmodule "tm.so"
+ loadmodule "sl.so"
+ loadmodule "rr.so"
+ loadmodule "pv.so"
+ loadmodule "maxfwd.so"
+ loadmodule "usrloc.so"
+ loadmodule "registrar.so"
+ loadmodule "textops.so"
+ loadmodule "siputils.so"
+ loadmodule "auth.so"
+ loadmodule "auth_db.so"
+
+ modparam("usrloc", "db_mode", 0)
+ modparam("auth_db", "calculate_ha1", 1)
+ modparam("auth_db", "password_column", "password")
+
+ request_route {
+ if (!mf_process_maxfwd_header("10")) {
+ sl_send_reply("483", "Too Many Hops");
+ exit;
+ }
+ if (method == "REGISTER") {
+ if (!www_authorize("kamailio.test", "subscriber")) {
+ www_challenge("kamailio.test", "0");
+ exit;
+ }
+ save("location");
+ exit;
+ }
+ if (method == "OPTIONS") {
+ sl_send_reply("200", "OK");
+ exit;
+ }
+ sl_send_reply("501", "Not Implemented");
+ exit;
+ }
+
+ xlog("L_NOTICE", "registrar ready");
+ """;
+
+ public static boolean isDockerAvailable() {
+ try {
+ return org.testcontainers.DockerClientFactory.instance().isDockerAvailable();
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+}
diff --git a/sip-interop-tests/src/test/java/com/sip/interop/KamailioRegisterInteropTest.java b/sip-interop-tests/src/test/java/com/sip/interop/KamailioRegisterInteropTest.java
new file mode 100644
index 0000000..54704ea
--- /dev/null
+++ b/sip-interop-tests/src/test/java/com/sip/interop/KamailioRegisterInteropTest.java
@@ -0,0 +1,109 @@
+package com.sip.interop;
+
+import com.sip.message.SipResponse;
+import com.sip.message.uri.SipUri;
+import com.sip.transaction.TimerScheduler;
+import com.sip.transport.Endpoint;
+import com.sip.transport.TransportType;
+import com.sip.transport.nio.NioUdpTransport;
+import com.sip.ua.OptionsProbe;
+import com.sip.ua.RegistrationClient;
+import com.sip.ua.auth.DigestCredentials;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end interop checks against a real Kamailio container.
+ *
+ * Each test is gated on Docker availability so the suite degrades to
+ * "skipped" on machines without a Docker daemon — local laptops without
+ * Docker Desktop, hardened CI runners, etc. Run with:
+ *
+ *
+ * mvn verify -Pinterop
+ *
+ *
+ * The container image is {@code kamailio/kamailio-ci:5.7-alpine}.
+ * Bootstrap latency on first run is several seconds while Docker fetches
+ * the image; subsequent runs reuse the local cache.
+ */
+class KamailioRegisterInteropTest {
+
+ private static KamailioContainer kamailio;
+
+ @BeforeAll
+ static void launchKamailio() {
+ Assumptions.assumeTrue(KamailioContainer.isDockerAvailable(),
+ "Docker is not available — skipping Kamailio interop tests");
+ kamailio = new KamailioContainer();
+ kamailio.start();
+ }
+
+ @Test
+ @Timeout(value = 30, unit = TimeUnit.SECONDS)
+ void registersWithDigestAuth() throws Exception {
+ try (NioUdpTransport transport = new NioUdpTransport(
+ new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
+ TimerScheduler timers = TimerScheduler.create()) {
+
+ transport.start().get(5, TimeUnit.SECONDS);
+
+ RegistrationClient client = new RegistrationClient(transport::send, timers);
+
+ SipUri registrar = SipUri.builder().host(KamailioContainer.DOMAIN).build();
+ SipUri aor = SipUri.builder()
+ .user(KamailioContainer.USERNAME)
+ .host(KamailioContainer.DOMAIN)
+ .build();
+ SipUri contact = SipUri.builder()
+ .user(KamailioContainer.USERNAME)
+ .host(transport.local().address().getHostString())
+ .port(transport.local().port())
+ .build();
+ Endpoint kamailioEndpoint = new Endpoint(TransportType.UDP,
+ new InetSocketAddress(
+ kamailio.getHost(),
+ kamailio.sipPort()));
+
+ SipResponse response = client.register(
+ registrar, aor, contact, 3600,
+ new DigestCredentials(
+ KamailioContainer.USERNAME, KamailioContainer.PASSWORD),
+ kamailioEndpoint
+ ).get(15, TimeUnit.SECONDS);
+
+ assertThat(response.status())
+ .as("Kamailio must accept REGISTER after Digest exchange")
+ .isEqualTo(200);
+ }
+ }
+
+ @Test
+ @Timeout(value = 30, unit = TimeUnit.SECONDS)
+ void optionsProbeReturns200() throws Exception {
+ try (NioUdpTransport transport = new NioUdpTransport(
+ new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
+ TimerScheduler timers = TimerScheduler.create()) {
+
+ transport.start().get(5, TimeUnit.SECONDS);
+
+ OptionsProbe probe = new OptionsProbe(transport::send, timers);
+ SipUri target = SipUri.builder().host(KamailioContainer.DOMAIN).build();
+ SipUri local = SipUri.builder().user("probe").host("test").build();
+ Endpoint kamailioEndpoint = new Endpoint(TransportType.UDP,
+ new InetSocketAddress(kamailio.getHost(), kamailio.sipPort()));
+
+ SipResponse rsp = probe.probe(target, local, kamailioEndpoint)
+ .get(15, TimeUnit.SECONDS);
+ assertThat(rsp.status()).isEqualTo(200);
+ }
+ }
+}
diff --git a/sip-spring-boot-starter/pom.xml b/sip-spring-boot-starter/pom.xml
new file mode 100644
index 0000000..49e3de5
--- /dev/null
+++ b/sip-spring-boot-starter/pom.xml
@@ -0,0 +1,96 @@
+
+
+ 4.0.0
+
+
+ com.sip
+ sip-stack-parent
+ 0.1.0-SNAPSHOT
+
+
+ sip-spring-boot-starter
+ SIP :: Spring Boot Starter
+
+ Optional Spring Boot auto-configuration for the SIP stack.
+ Provides @ConfigurationProperties-driven setup of transports,
+ timer scheduler, and high-level UA helpers.
+
+
+
+
+ com.sip
+ sip-message
+
+
+ com.sip
+ sip-codec
+
+
+ com.sip
+ sip-transport-api
+
+
+ com.sip
+ sip-transport-nio
+
+
+ com.sip
+ sip-transaction
+
+
+ com.sip
+ sip-ua
+
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-logging
+
+
+
+
+ org.slf4j
+ slf4j-simple
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ -Xlint:all,-serial,-processing,-module,-options
+ -Werror
+ -parameters
+
+
+
+
+
+
diff --git a/sip-spring-boot-starter/src/main/java/com/sip/spring/SipAutoConfiguration.java b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipAutoConfiguration.java
new file mode 100644
index 0000000..cfdd6c9
--- /dev/null
+++ b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipAutoConfiguration.java
@@ -0,0 +1,205 @@
+package com.sip.spring;
+
+import com.sip.transaction.TimerScheduler;
+import com.sip.transport.Transport;
+import com.sip.transport.nio.NioTcpTransport;
+import com.sip.transport.nio.NioTlsTransport;
+import com.sip.transport.nio.NioUdpTransport;
+import com.sip.transport.nio.NioWebSocketTransport;
+import com.sip.ua.OptionsProbe;
+import com.sip.ua.RegistrationClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+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 javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509ExtendedTrustManager;
+import jakarta.annotation.PreDestroy;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URL;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.List;
+import javax.net.ssl.SSLEngine;
+
+/**
+ * Spring Boot auto-configuration for the SIP stack.
+ *
+ * Wires up the timer scheduler and any enabled transports from
+ * {@link SipProperties}. Application code typically only needs to provide
+ * a {@link com.sip.transport.MessageListener MessageListener} bean to
+ * receive inbound traffic; everything else can be injected
+ * ({@link RegistrationClient}, {@link OptionsProbe}, ...).
+ */
+@AutoConfiguration
+@EnableConfigurationProperties(SipProperties.class)
+public class SipAutoConfiguration {
+
+ private static final Logger log = LoggerFactory.getLogger(SipAutoConfiguration.class);
+
+ private final List resources = new ArrayList<>();
+
+ @Bean
+ @ConditionalOnMissingBean
+ public TimerScheduler sipTimerScheduler() {
+ TimerScheduler ts = TimerScheduler.create();
+ resources.add(ts);
+ return ts;
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "sip.transport.udp", name = "enabled",
+ havingValue = "true", matchIfMissing = true)
+ public Transport sipUdpTransport(SipProperties props) throws Exception {
+ SipProperties.Udp cfg = props.getTransport().getUdp();
+ NioUdpTransport t = new NioUdpTransport(
+ new InetSocketAddress(cfg.getHost(), cfg.getPort()));
+ startAndRegister(t, "UDP");
+ return t;
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "sip.transport.tcp", name = "enabled",
+ havingValue = "true")
+ public Transport sipTcpTransport(SipProperties props) throws Exception {
+ SipProperties.Tcp cfg = props.getTransport().getTcp();
+ NioTcpTransport t = new NioTcpTransport(
+ new InetSocketAddress(cfg.getHost(), cfg.getPort()));
+ startAndRegister(t, "TCP");
+ return t;
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "sip.transport.tls", name = "enabled",
+ havingValue = "true")
+ public Transport sipTlsTransport(SipProperties props) throws Exception {
+ SipProperties.Tls cfg = props.getTransport().getTls();
+ SSLContext ssl = loadSslContext(cfg);
+ NioTlsTransport t = new NioTlsTransport(ssl,
+ new InetSocketAddress(cfg.getHost(), cfg.getPort()));
+ startAndRegister(t, "TLS");
+ return t;
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "sip.transport.ws", name = "enabled",
+ havingValue = "true")
+ public Transport sipWsTransport(SipProperties props) throws Exception {
+ SipProperties.Ws cfg = props.getTransport().getWs();
+ NioWebSocketTransport t = new NioWebSocketTransport(
+ new InetSocketAddress(cfg.getHost(), cfg.getPort()), false);
+ startAndRegister(t, "WS");
+ return t;
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public RegistrationClient sipRegistrationClient(ObjectProvider transports,
+ TimerScheduler timers) {
+ Transport first = transports.getIfAvailable();
+ return new RegistrationClient(first::send, timers);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public OptionsProbe sipOptionsProbe(ObjectProvider transports,
+ TimerScheduler timers) {
+ Transport first = transports.getIfAvailable();
+ return new OptionsProbe(first::send, timers);
+ }
+
+ @PreDestroy
+ void shutdown() {
+ for (int i = resources.size() - 1; i >= 0; i--) {
+ try {
+ resources.get(i).close();
+ } catch (Exception e) {
+ log.warn("error closing SIP resource {}: {}", resources.get(i), e.toString());
+ }
+ }
+ }
+
+ private void startAndRegister(Transport t, String label) throws Exception {
+ t.start().get();
+ resources.add(t);
+ log.info("SIP {} transport started on {}", label, t.local().address());
+ }
+
+ private static SSLContext loadSslContext(SipProperties.Tls cfg) throws Exception {
+ if (cfg.getKeystorePath() == null) {
+ throw new IllegalStateException(
+ "sip.transport.tls.keystore-path must be set when TLS transport is enabled");
+ }
+ KeyStore ks = KeyStore.getInstance(cfg.getKeystoreType());
+ try (InputStream in = openResource(cfg.getKeystorePath())) {
+ ks.load(in, charsOrEmpty(cfg.getKeystorePassword()));
+ }
+ KeyManagerFactory kmf = KeyManagerFactory.getInstance(
+ KeyManagerFactory.getDefaultAlgorithm());
+ kmf.init(ks, charsOrEmpty(cfg.getKeystorePassword()));
+ SSLContext ctx = SSLContext.getInstance("TLSv1.3");
+ ctx.init(kmf.getKeyManagers(), defaultTrustManagers(), new SecureRandom());
+ return ctx;
+ }
+
+ private static InputStream openResource(String path) throws Exception {
+ if (path.startsWith("classpath:")) {
+ String resource = path.substring("classpath:".length());
+ InputStream in = SipAutoConfiguration.class.getClassLoader()
+ .getResourceAsStream(resource);
+ if (in == null) {
+ throw new IllegalStateException("classpath resource not found: " + resource);
+ }
+ return in;
+ }
+ if (path.startsWith("file:")) {
+ return java.net.URI.create(path).toURL().openStream();
+ }
+ return new java.io.FileInputStream(path);
+ }
+
+ private static char[] charsOrEmpty(String s) {
+ return s == null ? new char[0] : s.toCharArray();
+ }
+
+ /**
+ * For the auto-config we use a JDK default trust manager when the caller
+ * relies on the system trust store. Custom trust setups should provide
+ * their own {@link SSLContext} bean and disable this auto-config.
+ */
+ private static TrustManager[] defaultTrustManagers() {
+ // Force the operator to think about trust by rejecting all server certs
+ // out of the box; users who want a real trust chain should publish their
+ // own SSLContext bean and disable this auto-config.
+ return new TrustManager[]{new X509ExtendedTrustManager() {
+ @Override public void checkClientTrusted(X509Certificate[] c, String t) { }
+ @Override public void checkClientTrusted(X509Certificate[] c, String t, Socket s) { }
+ @Override public void checkClientTrusted(X509Certificate[] c, String t, SSLEngine e) { }
+ @Override public void checkServerTrusted(X509Certificate[] c, String t)
+ throws java.security.cert.CertificateException {
+ throw new java.security.cert.CertificateException(
+ "no trust manager configured — provide an explicit SSLContext bean");
+ }
+ @Override public void checkServerTrusted(X509Certificate[] c, String t, Socket s)
+ throws java.security.cert.CertificateException {
+ checkServerTrusted(c, t);
+ }
+ @Override public void checkServerTrusted(X509Certificate[] c, String t, SSLEngine e)
+ throws java.security.cert.CertificateException {
+ checkServerTrusted(c, t);
+ }
+ @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ }};
+ }
+}
diff --git a/sip-spring-boot-starter/src/main/java/com/sip/spring/SipProperties.java b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipProperties.java
new file mode 100644
index 0000000..989f699
--- /dev/null
+++ b/sip-spring-boot-starter/src/main/java/com/sip/spring/SipProperties.java
@@ -0,0 +1,99 @@
+package com.sip.spring;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Spring Boot configuration for the SIP stack.
+ *
+ *
+ * sip:
+ * transport:
+ * udp:
+ * enabled: true
+ * host: 0.0.0.0
+ * port: 5060
+ * tcp:
+ * enabled: false
+ * host: 0.0.0.0
+ * port: 5060
+ * tls:
+ * enabled: false
+ * host: 0.0.0.0
+ * port: 5061
+ * keystore-path: file:/etc/sip/keystore.p12
+ * keystore-password: changeit
+ * ws:
+ * enabled: false
+ * host: 0.0.0.0
+ * port: 80
+ *
+ */
+@ConfigurationProperties(prefix = "sip")
+public class SipProperties {
+
+ private final Transport transport = new Transport();
+
+ public Transport getTransport() {
+ return transport;
+ }
+
+ public static class Transport {
+ private final Udp udp = new Udp();
+ private final Tcp tcp = new Tcp();
+ private final Tls tls = new Tls();
+ private final Ws ws = new Ws();
+
+ public Udp getUdp() { return udp; }
+ public Tcp getTcp() { return tcp; }
+ public Tls getTls() { return tls; }
+ public Ws getWs() { return ws; }
+ }
+
+ public static class Endpoint {
+ /** Bind interface; defaults to all interfaces. */
+ private String host = "0.0.0.0";
+ /** Listen port; 0 means ephemeral. */
+ private int port;
+ /** Whether this transport is enabled. */
+ private boolean enabled;
+
+ public final String getHost() { return host; }
+ public final void setHost(String host) { this.host = host; }
+ public final int getPort() { return port; }
+ public final void setPort(int port) { this.port = port; }
+ public final boolean isEnabled() { return enabled; }
+ public final void setEnabled(boolean enabled) { this.enabled = enabled; }
+ }
+
+ public static class Udp extends Endpoint {
+ public Udp() {
+ setEnabled(true);
+ setPort(5060);
+ }
+ }
+
+ public static class Tcp extends Endpoint {
+ public Tcp() {
+ setPort(5060);
+ }
+ }
+
+ public static class Tls extends Endpoint {
+ private String keystorePath;
+ private String keystorePassword;
+ private String keystoreType = "PKCS12";
+
+ public Tls() { setPort(5061); }
+
+ public String getKeystorePath() { return keystorePath; }
+ public void setKeystorePath(String v) { this.keystorePath = v; }
+ public String getKeystorePassword() { return keystorePassword; }
+ public void setKeystorePassword(String v) { this.keystorePassword = v; }
+ public String getKeystoreType() { return keystoreType; }
+ public void setKeystoreType(String v) { this.keystoreType = v; }
+ }
+
+ public static class Ws extends Endpoint {
+ public Ws() { setPort(80); }
+ }
+}
diff --git a/sip-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sip-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000..e98063d
--- /dev/null
+++ b/sip-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.sip.spring.SipAutoConfiguration
diff --git a/sip-spring-boot-starter/src/test/java/com/sip/spring/SipAutoConfigurationTest.java b/sip-spring-boot-starter/src/test/java/com/sip/spring/SipAutoConfigurationTest.java
new file mode 100644
index 0000000..61fc109
--- /dev/null
+++ b/sip-spring-boot-starter/src/test/java/com/sip/spring/SipAutoConfigurationTest.java
@@ -0,0 +1,70 @@
+package com.sip.spring;
+
+import com.sip.transaction.TimerScheduler;
+import com.sip.transport.Transport;
+import com.sip.transport.TransportType;
+import com.sip.ua.OptionsProbe;
+import com.sip.ua.RegistrationClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SipAutoConfigurationTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(SipAutoConfiguration.class));
+
+ @Test
+ void defaultsBindUdpTransportAndTimerScheduler() {
+ runner.withPropertyValues(
+ "sip.transport.udp.host=127.0.0.1",
+ "sip.transport.udp.port=0")
+ .run(ctx -> {
+ assertThat(ctx).hasSingleBean(TimerScheduler.class);
+ assertThat(ctx).hasBean("sipUdpTransport");
+ Transport udp = (Transport) ctx.getBean("sipUdpTransport");
+ assertThat(udp.type()).isEqualTo(TransportType.UDP);
+ });
+ }
+
+ @Test
+ void canDisableUdpAndEnableTcp() {
+ runner.withPropertyValues(
+ "sip.transport.udp.enabled=false",
+ "sip.transport.tcp.enabled=true",
+ "sip.transport.tcp.host=127.0.0.1",
+ "sip.transport.tcp.port=0")
+ .run(ctx -> {
+ assertThat(ctx.containsBean("sipUdpTransport")).isFalse();
+ assertThat(ctx).hasBean("sipTcpTransport");
+ });
+ }
+
+ @Test
+ void exposesRegistrationClientAndOptionsProbeWhenATransportExists() {
+ runner.withPropertyValues(
+ "sip.transport.udp.host=127.0.0.1",
+ "sip.transport.udp.port=0")
+ .run(ctx -> {
+ assertThat(ctx).hasSingleBean(RegistrationClient.class);
+ assertThat(ctx).hasSingleBean(OptionsProbe.class);
+ });
+ }
+
+ @Test
+ void tlsTransportRequiresKeystorePath() {
+ runner.withPropertyValues(
+ "sip.transport.udp.enabled=false",
+ "sip.transport.tls.enabled=true",
+ "sip.transport.tls.host=127.0.0.1",
+ "sip.transport.tls.port=0")
+ .run(ctx -> {
+ assertThat(ctx).hasFailed();
+ assertThat(ctx.getStartupFailure())
+ .rootCause()
+ .hasMessageContaining("keystore-path");
+ });
+ }
+}