LDAP: Allow specification of multiple servers as fallbacks

Resolves #933

Change-Id: I30f16778d0031620a07f1e35601d22251e5c6883
diff --git a/src/main/java/de/ids_mannheim/korap/authentication/LdapAuth3.java b/src/main/java/de/ids_mannheim/korap/authentication/LdapAuth3.java
index 95e3071..51a0cb1 100644
--- a/src/main/java/de/ids_mannheim/korap/authentication/LdapAuth3.java
+++ b/src/main/java/de/ids_mannheim/korap/authentication/LdapAuth3.java
@@ -207,19 +207,45 @@
             lc = new LDAPConnection();
         }
         
-        try {
-        	// timeout - 18.06.25/FB
-            lc.connect(ldapConfig.host, ldapConfig.port, ldapConfig.ldapTimeout);
-            
-            jlog.debug("{}: connect: successfull.", ldapConfig.useSSL ? "LDAPS" : "LDAP");
+        boolean connected = false;
+        LDAPException lastException = null;
+
+        String[] hosts = ldapConfig.host.split("[,\\s]+");
+        for (String hostString : hosts) {
+            if (hostString.isEmpty()) continue;
+
+            String host = hostString;
+            int port = ldapConfig.port;
+
+            if (hostString.contains(":")) {
+                String[] parts = hostString.split(":");
+                host = parts[0].trim();
+                try {
+                    port = Integer.parseInt(parts[1].trim());
+                } catch (NumberFormatException e) {
+                    jlog.warn("Invalid parsing port for LDAP server {}: {}", hostString, e.getMessage());
+                }
             }
-        catch (LDAPException e) {
-            String fullStackTrace = org.apache.commons.lang3.exception.ExceptionUtils
-                    .getStackTrace(e);
+
+            try {
+                // timeout - 18.06.25/FB
+                lc.connect(host, port, ldapConfig.ldapTimeout);
+                jlog.debug("{}: connect to {}:{} successful.", ldapConfig.useSSL ? "LDAPS" : "LDAP", host, port);
+                connected = true;
+                break; // Successfully connected
+            } catch (LDAPException e) {
+                lastException = e;
+                jlog.warn("Connecting to LDAP Server {}:{} failed: {}", host, port, e.getMessage());
+            }
+        }
+
+        if (!connected) {
+            String fullStackTrace = lastException != null ? org.apache.commons.lang3.exception.ExceptionUtils
+                    .getStackTrace(lastException) : "No valid hosts specified";
             jlog.error("Connecting to LDAP Server: failed: '{}'!\n", fullStackTrace);
-            
+
             ldapTerminate(lc);
-            return new LdapAuth3Result(null, isTimeout(e) ? LDAP_AUTH_RTIMEOUT : LDAP_AUTH_RCONNECT);
+            return new LdapAuth3Result(null, lastException != null && isTimeout(lastException) ? LDAP_AUTH_RTIMEOUT : LDAP_AUTH_RCONNECT);
         }
         
         jlog.debug("isConnected={}.\n", lc.isConnected() ? "yes" : "no");
diff --git a/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java b/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java
index d6b81f5..f45bce9 100644
--- a/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java
+++ b/src/test/java/de/ids_mannheim/korap/authentication/LdapAuth3Test.java
@@ -30,6 +30,10 @@
 
     public static final String TEST_LDAPS_TS_CONF = "src/test/resources/test-ldaps-with-truststore.conf";
 
+    public static final String TEST_LDAP_FALLBACK_CONF = "src/test/resources/test-ldap-fallback.conf";
+    public static final String TEST_LDAP_FALLBACK_FAIL_CONF = "src/test/resources/test-ldap-fallback-fail.conf";
+    public static final String TEST_LDAP_FALLBACK_PORT_CONF = "src/test/resources/test-ldap-fallback-port.conf";
+
     public static final String TEST_LDAP_USERS_LDIF = "src/test/resources/test-ldap-users.ldif";
 
     private static final String keyStorePath = "src/test/resources/keystore.p12";
@@ -245,4 +249,30 @@
         assertEquals(3269, ldapConfig.port);
         assertEquals("localhost", ldapConfig.host);
     }
+
+    @Test
+    public void loginWithFallbackWorks () throws LDAPException {
+        // `test-ldap-fallback.conf` defines `host = invalid.local, localhost`
+        // Should connect to localhost successfully after failing on invalid.local
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAP_FALLBACK_CONF));
+    }
+
+    @Test
+    public void loginWithFallbackFails () throws LDAPException {
+        // `test-ldap-fallback-fail.conf` defines `host = invalid1.local, invalid2.local`
+        // Should return LDAP_AUTH_RCONNECT or LDAP_AUTH_RTIMEOUT depending on network
+        int rc = LdapAuth3.login("testuser", "password", TEST_LDAP_FALLBACK_FAIL_CONF);
+        assertTrue(rc == LDAP_AUTH_RCONNECT || rc == LDAP_AUTH_RTIMEOUT,
+                "Expected connection failure, but got code=" + rc);
+    }
+
+    @Test
+    public void loginWithFallbackAndPortWorks () throws LDAPException {
+        // `test-ldap-fallback-port.conf` defines `host = invalid.local, localhost:3268`
+        // and a default port of 1111 which is wrong
+        // Should connect to localhost on port 3268 specifically
+        assertEquals(LDAP_AUTH_ROK,
+                LdapAuth3.login("testuser", "password", TEST_LDAP_FALLBACK_PORT_CONF));
+    }
 }
diff --git a/src/test/resources/test-ldap-fallback-fail.conf b/src/test/resources/test-ldap-fallback-fail.conf
new file mode 100644
index 0000000..87eb005
--- /dev/null
+++ b/src/test/resources/test-ldap-fallback-fail.conf
@@ -0,0 +1,6 @@
+host = invalid1.local, invalid2.local
+port = 3268
+searchBase = dc=example,dc=com
+sLoginDN = cn=admin,dc=example,dc=com
+pwd = adminpassword
+searchFilter=(&(|(uid=${login})(mail=${login})(extraProfile=${login}))(|(userPassword=${password})(extraPassword=${password})))
diff --git a/src/test/resources/test-ldap-fallback-port.conf b/src/test/resources/test-ldap-fallback-port.conf
new file mode 100644
index 0000000..5f88fc7
--- /dev/null
+++ b/src/test/resources/test-ldap-fallback-port.conf
@@ -0,0 +1,6 @@
+host = invalid.local, localhost:3268
+port = 1111
+searchBase = dc=example,dc=com
+sLoginDN = cn=admin,dc=example,dc=com
+pwd = adminpassword
+searchFilter=(&(|(uid=${login})(mail=${login})(extraProfile=${login}))(|(userPassword=${password})(extraPassword=${password})))
diff --git a/src/test/resources/test-ldap-fallback.conf b/src/test/resources/test-ldap-fallback.conf
new file mode 100644
index 0000000..51ab913
--- /dev/null
+++ b/src/test/resources/test-ldap-fallback.conf
@@ -0,0 +1,6 @@
+host = invalid.local, localhost
+port = 3268
+searchBase = dc=example,dc=com
+sLoginDN = cn=admin,dc=example,dc=com
+pwd = adminpassword
+searchFilter=(&(|(uid=${login})(mail=${login})(extraProfile=${login}))(|(userPassword=${password})(extraPassword=${password})))