mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
test(auth): 新增 LDAP 集成测试覆盖登录全链路与边界场景
- OpenLDAP(Testcontainers)全链路:默认 entryUUID 首登建号绑定、重复登录不重复建号、无邮箱占位与 cn 回退、邮箱碰撞 409、错误密码 401、属性变更同步刷新、LDAPS TLS 错误分类 - 目录不可用:真实 JNDI 连接关闭端口,断言 503 directoryUnavailable;不依赖 Docker,任意环境可运行 - LDAP disabled 启动:完整上下文启动,断言 LdapAuthService Bean 缺席且本地登录降级 401 - 统一 Testcontainers 版本:junit-jupiter 移除显式 1.20.3,由 Spring Boot BOM 统一管理为 1.19.5 Signed-off-by: jangrui <admin@jangrui.com>
This commit is contained in:
parent
fe4784861f
commit
f8ddd2fdc4
6 changed files with 402 additions and 0 deletions
|
|
@ -103,6 +103,11 @@
|
|||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
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.exception.AuthFlowException;
|
||||
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.UserAccountRepository;
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Integration coverage for the "directory unavailable" error classification required by the
|
||||
* PR #437 review. A real JNDI connection is attempted against a guaranteed-closed localhost
|
||||
* port, so the CommunicationException path (not a mocked exception) is exercised end to end.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<UserAccount> account = userAccountRepository.findById(principal.userId());
|
||||
assertThat(account).isPresent();
|
||||
assertThat(account.get().getEmail()).isEqualTo("alice@example.com");
|
||||
assertThat(account.get().getDisplayName()).isEqualTo("Alice Smith");
|
||||
|
||||
String entryUuid = directoryEntryUuid("alice");
|
||||
Optional<IdentityBinding> binding =
|
||||
identityBindingRepository.findByProviderCodeAndSubject("ldap", entryUuid);
|
||||
assertThat(binding).as("identity binding keyed by OpenLDAP entryUUID").isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatLogin_returnsSameAccount_andDoesNotDuplicate() {
|
||||
PlatformPrincipal first = localAuthService.login("alice", "alice123");
|
||||
PlatformPrincipal second = localAuthService.login("alice", "alice123");
|
||||
|
||||
assertThat(second.userId()).isEqualTo(first.userId());
|
||||
Optional<UserAccount> account = userAccountRepository.findByEmailIgnoreCase("alice@example.com");
|
||||
assertThat(account).isPresent();
|
||||
assertThat(account.get().getId()).isEqualTo(first.userId());
|
||||
assertThat(identityBindingRepository.findAll()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noEmailUser_usesPlaceholderEmail_andCnFallback() {
|
||||
PlatformPrincipal principal = localAuthService.login("bob", "bob123");
|
||||
|
||||
assertThat(principal.email()).isEqualTo("ldap:bob@internal");
|
||||
// bob has no displayName in the directory; the configured cn fallback must apply.
|
||||
assertThat(principal.displayName()).isEqualTo("Bob Jones");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emailCollision_returnsConflict_andDoesNotTakeOverExistingAccount() {
|
||||
UserAccount local = new UserAccount("usr_local_carol", "Carol Local", "carol@example.com", null);
|
||||
userAccountRepository.save(local);
|
||||
|
||||
assertThatThrownBy(() -> localAuthService.login("carol", "carol123"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.satisfies(e -> {
|
||||
AuthFlowException ex = (AuthFlowException) e;
|
||||
assertThat(ex.getStatus()).isEqualTo(HttpStatus.CONFLICT);
|
||||
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.emailConflict");
|
||||
});
|
||||
|
||||
// The existing account is untouched and no LDAP binding was written.
|
||||
Optional<UserAccount> untouched = userAccountRepository.findById(local.getId());
|
||||
assertThat(untouched).isPresent();
|
||||
assertThat(untouched.get().getDisplayName()).isEqualTo("Carol Local");
|
||||
assertThat(identityBindingRepository.findAll()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongPassword_returnsUnauthorized() {
|
||||
assertThatThrownBy(() -> localAuthService.login("alice", "wrong-password"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.satisfies(e -> assertThat(((AuthFlowException) e).getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributeChanges_areRefreshedOnNextLogin() throws Exception {
|
||||
PlatformPrincipal before = localAuthService.login("dave", "dave123");
|
||||
assertThat(before.displayName()).isEqualTo("Dave Miller");
|
||||
|
||||
ExecResult mod = LDAP.execInContainer("ldapmodify", "-x", "-H", "ldap://localhost",
|
||||
"-D", BIND_DN, "-w", BIND_PASSWORD, "-f", "/tmp/modify-dave.ldif");
|
||||
assertThat(mod.getExitCode())
|
||||
.as("ldapmodify failed: %s", mod.getStdout() + mod.getStderr())
|
||||
.isZero();
|
||||
|
||||
PlatformPrincipal after = localAuthService.login("dave", "dave123");
|
||||
assertThat(after.userId()).isEqualTo(before.userId());
|
||||
assertThat(after.displayName()).isEqualTo("Dave D. Miller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ldaps_withUntrustedCertificate_returnsTlsError() {
|
||||
LdapProperties props = new LdapProperties();
|
||||
props.setEnabled(true);
|
||||
props.setUrl("ldaps://" + LDAP.getHost() + ":" + LDAP.getMappedPort(636));
|
||||
props.setBase(BASE_DN);
|
||||
props.setUsername(BIND_DN);
|
||||
props.setPassword(BIND_PASSWORD);
|
||||
LdapAuthService svc = new LdapAuthService(props,
|
||||
mock(UserAccountRepository.class),
|
||||
mock(UserRoleBindingRepository.class),
|
||||
mock(GlobalNamespaceMembershipService.class),
|
||||
mock(IdentityBindingRepository.class));
|
||||
|
||||
assertThatThrownBy(() -> svc.login("alice", "alice123"))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.satisfies(e -> {
|
||||
AuthFlowException ex = (AuthFlowException) e;
|
||||
assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(ex.getMessageCode()).isEqualTo("error.auth.ldap.tlsError");
|
||||
});
|
||||
}
|
||||
|
||||
private static String directoryEntryUuid(String uid) throws Exception {
|
||||
ExecResult r = LDAP.execInContainer("ldapsearch", "-x", "-H", "ldap://localhost",
|
||||
"-b", "uid=" + uid + "," + BASE_DN, "-s", "base",
|
||||
"-D", BIND_DN, "-w", BIND_PASSWORD, "(objectClass=*)", "entryUUID");
|
||||
assertThat(r.getExitCode())
|
||||
.as("ldapsearch failed: %s", r.getStdout() + r.getStderr())
|
||||
.isZero();
|
||||
return r.getStdout().lines()
|
||||
.filter(line -> line.startsWith("entryUUID:"))
|
||||
.map(line -> line.substring("entryUUID:".length()).trim())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("entryUUID not returned by ldapsearch"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
dn: uid=dave,dc=example,dc=org
|
||||
changetype: modify
|
||||
replace: displayName
|
||||
displayName: Dave D. Miller
|
||||
45
server/skillhub-app/src/test/resources/ldap/seed-users.ldif
Normal file
45
server/skillhub-app/src/test/resources/ldap/seed-users.ldif
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
dn: uid=alice,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: alice
|
||||
cn: Alice Smith
|
||||
sn: Smith
|
||||
displayName: Alice Smith
|
||||
mail: alice@example.com
|
||||
userPassword: alice123
|
||||
|
||||
dn: uid=bob,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: bob
|
||||
cn: Bob Jones
|
||||
sn: Jones
|
||||
userPassword: bob123
|
||||
|
||||
dn: uid=carol,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: carol
|
||||
cn: Carol King
|
||||
sn: King
|
||||
displayName: Carol King
|
||||
mail: carol@example.com
|
||||
userPassword: carol123
|
||||
|
||||
dn: uid=dave,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
uid: dave
|
||||
cn: Dave Miller
|
||||
sn: Miller
|
||||
displayName: Dave Miller
|
||||
mail: dave@example.com
|
||||
userPassword: dave123
|
||||
Loading…
Add table
Reference in a new issue