This test intentionally does not use Testcontainers: it needs no directory, and keeping it + * standalone lets it run in any environment, including ones without a Docker daemon. + */ +class LdapDirectoryUnavailableTest { + + @Test + void login_whenDirectoryUnreachable_returnsServiceUnavailable() { + LdapProperties props = new LdapProperties(); + props.setEnabled(true); + props.setUrl("ldap://127.0.0.1:" + freePort()); + props.setBase("dc=example,dc=org"); + + LdapAuthService svc = new LdapAuthService( + props, + mock(UserAccountRepository.class), + mock(UserRoleBindingRepository.class), + mock(GlobalNamespaceMembershipService.class), + mock(IdentityBindingRepository.class)); + + assertThatThrownBy(() -> svc.login("alice", "secret")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> { + AuthFlowException ex = (AuthFlowException) e; + assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.directoryUnavailable"); + }); + } + + /** + * Reserves an ephemeral port and releases it, leaving a port that is (almost certainly) + * closed for the subsequent connection attempt. + */ + private static int freePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Failed to allocate an ephemeral port", e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java new file mode 100644 index 00000000..90d46f90 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapDisabledStartupTest.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration coverage for "startup with LDAP disabled" required by the PR #437 review: the full + * Spring context must boot without a directory configured, the conditional {@link LdapAuthService} + * bean must be absent, and the local login fallback must degrade to invalid credentials instead of + * failing on a missing LDAP bean. + */ +@SpringBootTest(properties = "skillhub.ldap.enabled=false") +@ActiveProfiles("test") +class LdapDisabledStartupTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private LdapProperties ldapProperties; + + @Autowired + private LocalAuthService localAuthService; + + @Test + void contextStartsWithoutLdapAuthServiceBean() { + assertThat(ldapProperties.isEnabled()).isFalse(); + // The bean is created only when skillhub.ldap.enabled=true; with LDAP disabled the + // context must start without it (LocalAuthService consumes it via ObjectProvider). + assertThat(context.getBeansOfType(LdapAuthService.class)).isEmpty(); + assertThat(localAuthService).isNotNull(); + } + + @Test + void localLogin_withoutLdapBean_fallsBackToInvalidCredentials() { + assertThatThrownBy(() -> localAuthService.login("no-such-user", "wrong-password")) + .isInstanceOf(AuthFlowException.class) + .satisfies(e -> assertThat(((AuthFlowException) e).getStatus()) + .isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java new file mode 100644 index 00000000..c2a4adf8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/ldap/LdapIntegrationTest.java @@ -0,0 +1,235 @@ +package com.iflytek.skillhub.auth.ldap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.config.LdapProperties; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +/** + * End-to-end LDAP coverage against a real OpenLDAP directory (Testcontainers) for the behavior + * required by PR #437 review: first-login provisioning, repeat-login identity stability, + * no-email placeholder handling, email-collision refusal, invalid credentials, attribute + * synchronization, and LDAPS TLS-failure classification. + * + *
The default {@code subject-attribute=entryUUID} is intentionally left untouched: OpenLDAP
+ * exposes entryUUID only as an operational attribute, which previously made every login fail
+ * with a 503 unless the operator changed the subject attribute.
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Testcontainers(disabledWithoutDocker = true)
+@Transactional
+class LdapIntegrationTest {
+
+ private static final String BASE_DN = "dc=example,dc=org";
+ private static final String BIND_DN = "cn=admin," + BASE_DN;
+ private static final String BIND_PASSWORD = "admin";
+
+ @Container
+ static final GenericContainer> LDAP = new GenericContainer<>("osixia/openldap:1.5.0")
+ .withEnv("LDAP_ORGANISATION", "Example Inc")
+ .withEnv("LDAP_DOMAIN", "example.org")
+ .withEnv("LDAP_ADMIN_PASSWORD", BIND_PASSWORD)
+ .withExposedPorts(389, 636);
+
+ @DynamicPropertySource
+ static void ldapProperties(DynamicPropertyRegistry registry) {
+ registry.add("skillhub.ldap.enabled", () -> "true");
+ registry.add("skillhub.ldap.url", () -> "ldap://" + LDAP.getHost() + ":" + LDAP.getMappedPort(389));
+ registry.add("skillhub.ldap.base", () -> BASE_DN);
+ registry.add("skillhub.ldap.username", () -> BIND_DN);
+ registry.add("skillhub.ldap.password", () -> BIND_PASSWORD);
+ registry.add("skillhub.ldap.user-search-attribute", () -> "uid");
+ // subject-attribute intentionally stays at its default (entryUUID).
+ }
+
+ @Autowired
+ private LocalAuthService localAuthService;
+
+ @Autowired
+ private UserAccountRepository userAccountRepository;
+
+ @Autowired
+ private IdentityBindingRepository identityBindingRepository;
+
+ // Local accounts are provisioned through ensureMember(), which requires a built-in global
+ // namespace row that the test profile does not seed. The membership side effect is unrelated
+ // to the LDAP behavior under test, so it is mocked away.
+ @MockBean
+ private GlobalNamespaceMembershipService globalNamespaceMembershipService;
+
+ @BeforeAll
+ static void seedDirectory() throws Exception {
+ LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/seed-users.ldif"), "/tmp/seed-users.ldif");
+ LDAP.copyFileToContainer(MountableFile.forClasspathResource("ldap/modify-dave.ldif"), "/tmp/modify-dave.ldif");
+ awaitLdapReady();
+ ExecResult add = LDAP.execInContainer("ldapadd", "-x", "-H", "ldap://localhost",
+ "-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/seed-users.ldif");
+ assertThat(add.getExitCode())
+ .as("ldapadd failed: %s", add.getStdout() + add.getStderr())
+ .isZero();
+ }
+
+ private static void awaitLdapReady() throws Exception {
+ long deadline = System.currentTimeMillis() + 30_000;
+ Exception last = null;
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost",
+ "-b", BASE_DN, "-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "dn");
+ if (r.getExitCode() == 0) {
+ return;
+ }
+ last = new IllegalStateException("ldapsearch exit " + r.getExitCode()
+ + ": " + r.getStdout() + r.getStderr());
+ } catch (Exception e) {
+ last = e;
+ }
+ Thread.sleep(500);
+ }
+ throw new IllegalStateException("OpenLDAP did not become ready", last);
+ }
+
+ @Test
+ void firstLogin_withDefaultEntryUuidSubject_provisionsAccountAndBinding() throws Exception {
+ PlatformPrincipal principal = localAuthService.login("alice", "alice123");
+
+ assertThat(principal.userId()).isNotBlank();
+ Optional