mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
Merge pull request #712 from Vast-Stars/feat/personal-namespace-provisioning
feat(namespace): auto-provision a personal namespace on registration
This commit is contained in:
commit
f993ad6533
18 changed files with 856 additions and 9 deletions
85
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
85
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 注册时自动创建个人命名空间
|
||||
|
||||
## 背景
|
||||
|
||||
自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
|
||||
而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。
|
||||
|
||||
## 一、自动创建个人命名空间
|
||||
|
||||
### 「私有」在当前模型里的含义
|
||||
|
||||
命名空间没有可见性字段——只有 `GLOBAL` 和 `TEAM` 两种类型,
|
||||
技能的可见性是技能自己的属性。因此这里的「私有命名空间」= **一个只有本人为成员的 TEAM 命名空间**。
|
||||
本人拿到的是 `OWNER` 角色(比 `ADMIN` 更强:可以改设置、管成员、删除)。
|
||||
|
||||
如果要做到「别人搜不到这个命名空间」,那是独立的 namespace visibility 特性,不在本次范围内。
|
||||
|
||||
### 触发时机
|
||||
|
||||
在账号**第一次变得可用**时触发,共三处,均发布 `UserActivatedEvent`:
|
||||
|
||||
| 入口 | 位置 |
|
||||
|------|------|
|
||||
| 本地注册 | `LocalAuthService.register` |
|
||||
| 外部身份首次登录 | `IdentityBindingService.bindOrCreate`(仅 `initialStatus == ACTIVE`) |
|
||||
| 管理员审批 / 解封 | `AdminUserAppService.updateUserStatus`(仅从非 ACTIVE 转为 ACTIVE) |
|
||||
|
||||
第三处不可省略:开启了准入审批的部署里,用户在 OAuth 首次尝试时就以 `PENDING` 建号,
|
||||
真正可用是在管理员审批那一刻。
|
||||
|
||||
### 为什么走事件 + AFTER_COMMIT
|
||||
|
||||
`PersonalNamespaceProvisioningListener` 用 `@TransactionalEventListener`
|
||||
(默认 AFTER_COMMIT)并在自己的事务里建命名空间。原因是数据库约束:
|
||||
|
||||
```
|
||||
namespace.created_by REFERENCES user_account(id)
|
||||
namespace_member.user_id REFERENCES user_account(id)
|
||||
```
|
||||
|
||||
- 如果**加入注册事务**:命名空间创建失败(例如 slug 竞态撞唯一约束)会把注册一起回滚,
|
||||
用户会因为「命名空间没建成」而登不上来。
|
||||
- 如果在注册事务中**用 `REQUIRES_NEW` 挂起**:新事务看不到尚未提交的 `user_account` 行,
|
||||
外键检查会阻塞在外层事务的行锁上,形成互等。
|
||||
|
||||
放到提交之后就同时避开了这两点:账号已经落库,建命名空间失败只损失一个命名空间,
|
||||
监听器捕获异常并记 WARN。
|
||||
|
||||
监听器**不加 `@Async`**:命名空间要在用户下一个请求到达前就绪。
|
||||
|
||||
### 命名模板
|
||||
|
||||
两个模板,占位符语法 `${...}`:
|
||||
|
||||
| 占位符 | 取值 |
|
||||
|--------|------|
|
||||
| `${username}` | 认证路径提供的用户名;缺失时依次回落到邮箱前缀、用户 ID |
|
||||
| `${email_prefix}` | 邮箱 `@` 之前的部分 |
|
||||
| `${user_id}` | 平台内部用户 ID |
|
||||
|
||||
未知占位符原样保留,让拼错的名字暴露出来,而不是静默消失。
|
||||
|
||||
slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
|
||||
字母数字以外的字符变连字符、去掉首尾与重复连字符。
|
||||
**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。
|
||||
冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用,
|
||||
依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。
|
||||
`admin` 这类保留字因此自然落到 `admin-2`。
|
||||
|
||||
幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。
|
||||
解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。
|
||||
|
||||
## 二、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|------|-----|------|
|
||||
| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` |
|
||||
| 配置文件/环境变量 | 启用开关 | `true` |
|
||||
|
||||
默认只对新激活账号生效,不回填已有账号;如需关闭可设置环境变量。
|
||||
|
||||
模板刻意**不放在 `application.yml`**:它们含 `${...}`,
|
||||
Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。
|
||||
模板默认值固定为 `personal-${random}` 和 `${username}-个人空间`,
|
||||
如需关闭可设置 `SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED=false`。
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceOwner;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/**
|
||||
* Creates a newly activated account's own namespace once the account itself is committed.
|
||||
*
|
||||
* <p>Runs synchronously rather than on the event executor so the namespace exists by the time the
|
||||
* user's next request arrives, and swallows failures so a naming clash or a database hiccup costs
|
||||
* the user a namespace rather than their registration or login.
|
||||
*/
|
||||
@Component
|
||||
public class PersonalNamespaceProvisioningListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningListener.class);
|
||||
|
||||
private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService;
|
||||
|
||||
public PersonalNamespaceProvisioningListener(
|
||||
PersonalNamespaceProvisioningService personalNamespaceProvisioningService) {
|
||||
this.personalNamespaceProvisioningService = personalNamespaceProvisioningService;
|
||||
}
|
||||
|
||||
@TransactionalEventListener
|
||||
public void onUserActivated(UserActivatedEvent event) {
|
||||
try {
|
||||
personalNamespaceProvisioningService.provisionFor(
|
||||
new PersonalNamespaceOwner(event.userId(), event.username(), event.email()));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Personal namespace provisioning failed for user {}; the account is unaffected",
|
||||
event.userId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.Role;
|
|||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
|
|
@ -18,6 +19,7 @@ import org.springframework.data.domain.Page;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
|
@ -44,16 +46,19 @@ public class AdminUserAppService {
|
|||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public AdminUserAppService(
|
||||
AdminUserSearchRepository adminUserSearchRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
RoleRepository roleRepository) {
|
||||
RoleRepository roleRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.adminUserSearchRepository = adminUserSearchRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
|
@ -109,8 +114,13 @@ public class AdminUserAppService {
|
|||
UserAccount user = loadUser(userId);
|
||||
rejectSystemAccountMutation(user);
|
||||
UserStatus nextStatus = parseManageableStatus(status);
|
||||
UserStatus previousStatus = user.getStatus();
|
||||
user.setStatus(nextStatus);
|
||||
userAccountRepository.save(user);
|
||||
if (nextStatus == UserStatus.ACTIVE && previousStatus != UserStatus.ACTIVE) {
|
||||
eventPublisher.publishEvent(
|
||||
new UserActivatedEvent(user.getId(), user.getDisplayName(), user.getEmail()));
|
||||
}
|
||||
return new AdminUserMutationResponse(user.getId(), null, nextStatus.name());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,11 @@ skillhub:
|
|||
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
|
||||
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
|
||||
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
|
||||
namespace:
|
||||
# Whether a newly activated account gets a namespace of its own.
|
||||
# Slug and display-name templates use safe code defaults in PersonalNamespaceProvisioningProperties.
|
||||
personal-provisioning:
|
||||
enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:true}
|
||||
public:
|
||||
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
|
||||
access-policy:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserStatus;
|
|||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.repository.AdminUserSearchRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
|
@ -35,11 +36,13 @@ class AdminUserAppServiceTest {
|
|||
private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class);
|
||||
private final RoleRepository roleRepository = mock(RoleRepository.class);
|
||||
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
|
||||
private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
private final AdminUserAppService service = new AdminUserAppService(
|
||||
adminUserSearchRepository,
|
||||
userAccountRepository,
|
||||
userRoleBindingRepository,
|
||||
roleRepository
|
||||
roleRepository,
|
||||
eventPublisher
|
||||
);
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
|
||||
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.UUID;
|
||||
|
|
@ -31,15 +33,18 @@ public class IdentityBindingService {
|
|||
private final UserAccountRepository userRepo;
|
||||
private final UserRoleBindingRepository roleBindingRepo;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public IdentityBindingService(IdentityBindingRepository bindingRepo,
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.bindingRepo = bindingRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.roleBindingRepo = roleBindingRepo;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -70,6 +75,8 @@ public class IdentityBindingService {
|
|||
user = userRepo.save(user);
|
||||
if (initialStatus == UserStatus.ACTIVE) {
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(
|
||||
new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email()));
|
||||
}
|
||||
|
||||
binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin());
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -16,6 +17,7 @@ import java.util.Set;
|
|||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -44,6 +46,7 @@ public class LocalAuthService {
|
|||
private final PasswordPolicyValidator passwordPolicyValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final Clock clock;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public LocalAuthService(LocalCredentialRepository credentialRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
|
|
@ -51,7 +54,8 @@ public class LocalAuthService {
|
|||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
PasswordPolicyValidator passwordPolicyValidator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
Clock clock) {
|
||||
Clock clock,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.credentialRepository = credentialRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
|
|
@ -59,6 +63,7 @@ public class LocalAuthService {
|
|||
this.passwordPolicyValidator = passwordPolicyValidator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.clock = clock;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,6 +105,7 @@ public class LocalAuthService {
|
|||
passwordEncoder.encode(password)
|
||||
));
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail));
|
||||
|
||||
return buildPrincipal(user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.iflytek.skillhub.auth.oauth.SystemAccountLoginException;
|
|||
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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -31,6 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
|
|
@ -48,11 +50,15 @@ class IdentityBindingServiceTest {
|
|||
@Mock
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private IdentityBindingService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService);
|
||||
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo,
|
||||
globalNamespaceMembershipService, eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -79,6 +85,44 @@ class IdentityBindingServiceTest {
|
|||
assertThat(principal.oauthProvider()).isEqualTo("github");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindOrCreate_publishesActivationForActiveNewUsers() {
|
||||
OAuthClaims claims = new OAuthClaims(
|
||||
"github",
|
||||
"gh_1",
|
||||
"alice@example.com",
|
||||
true,
|
||||
"alice",
|
||||
Map.of()
|
||||
);
|
||||
when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty());
|
||||
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(roleBindingRepo.findByUserId(any())).thenReturn(List.of());
|
||||
|
||||
service.bindOrCreate(claims, UserStatus.ACTIVE);
|
||||
|
||||
ArgumentCaptor<UserActivatedEvent> eventCaptor = ArgumentCaptor.forClass(UserActivatedEvent.class);
|
||||
verify(eventPublisher).publishEvent(eventCaptor.capture());
|
||||
assertThat(eventCaptor.getValue().username()).isEqualTo("alice");
|
||||
assertThat(eventCaptor.getValue().email()).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindOrCreate_doesNotPublishActivationForReturningUsers() {
|
||||
OAuthClaims claims = new OAuthClaims("github", "gh_1", "alice@example.com", true, "alice", Map.of());
|
||||
UserAccount existing = new UserAccount("usr_1", "alice", "alice@example.com", null);
|
||||
existing.setStatus(UserStatus.ACTIVE);
|
||||
when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1"))
|
||||
.thenReturn(Optional.of(new IdentityBinding("usr_1", "github", "gh_1", "alice")));
|
||||
when(userRepo.findById("usr_1")).thenReturn(Optional.of(existing));
|
||||
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(roleBindingRepo.findByUserId(any())).thenReturn(List.of());
|
||||
|
||||
service.bindOrCreate(claims, UserStatus.ACTIVE);
|
||||
|
||||
verify(eventPublisher, never()).publishEvent(any(UserActivatedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() {
|
||||
OAuthClaims claims = new OAuthClaims(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
|||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -28,6 +29,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
|
|
@ -51,6 +53,9 @@ class LocalAuthServiceTest {
|
|||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private LocalAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -62,7 +67,8 @@ class LocalAuthServiceTest {
|
|||
globalNamespaceMembershipService,
|
||||
new PasswordPolicyValidator(),
|
||||
passwordEncoder,
|
||||
CLOCK
|
||||
CLOCK,
|
||||
eventPublisher
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +92,22 @@ class LocalAuthServiceTest {
|
|||
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_publishesActivationWithTheNormalizedUsername() {
|
||||
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
|
||||
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty());
|
||||
given(passwordEncoder.encode("Abcd123!")).willReturn("encoded");
|
||||
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0));
|
||||
given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of());
|
||||
|
||||
service.register("Alice", "Abcd123!", "alice@example.com");
|
||||
|
||||
ArgumentCaptor<UserActivatedEvent> eventCaptor = ArgumentCaptor.forClass(UserActivatedEvent.class);
|
||||
verify(eventPublisher).publishEvent(eventCaptor.capture());
|
||||
assertThat(eventCaptor.getValue().username()).isEqualTo("alice");
|
||||
assertThat(eventCaptor.getValue().email()).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_withValidPassword_resetsCounters() {
|
||||
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
/**
|
||||
* Published when an account becomes usable — local registration, the first login through an
|
||||
* external identity provider, or an administrator approving or re-enabling an account.
|
||||
*
|
||||
* <p>Listeners must be idempotent: re-enabling a previously disabled account publishes the event
|
||||
* again.
|
||||
*
|
||||
* @param username the name the authentication path knows the user by, or {@code null}
|
||||
*/
|
||||
public record UserActivatedEvent(String userId, String username, String email) {
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Renders the operator-configured name templates for a personal namespace.
|
||||
*
|
||||
* <p>Templates use {@code ${placeholder}} syntax. Unknown placeholders are left untouched so a typo
|
||||
* shows up in the resulting name instead of silently disappearing.
|
||||
*/
|
||||
final class PersonalNamespaceNaming {
|
||||
|
||||
/**
|
||||
* Longest slug {@link SlugValidator} accepts, minus room for a de-duplication suffix.
|
||||
*/
|
||||
private static final int SLUG_BASE_BUDGET = 59;
|
||||
|
||||
/**
|
||||
* Matches the {@code display_name} column width.
|
||||
*/
|
||||
private static final int DISPLAY_NAME_LIMIT = 128;
|
||||
|
||||
private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([a-z_]+)}");
|
||||
|
||||
private PersonalNamespaceNaming() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes placeholders in {@code template} using {@code owner}.
|
||||
*/
|
||||
static String render(String template, PersonalNamespaceOwner owner) {
|
||||
if (template == null || template.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
Map<String, String> values = Map.of(
|
||||
PersonalNamespaceSettings.PLACEHOLDER_USERNAME, username(owner),
|
||||
PersonalNamespaceSettings.PLACEHOLDER_EMAIL_PREFIX, emailPrefix(owner),
|
||||
PersonalNamespaceSettings.PLACEHOLDER_USER_ID, blankToEmpty(owner.userId()),
|
||||
PersonalNamespaceSettings.PLACEHOLDER_RANDOM, randomSuffix());
|
||||
|
||||
Matcher matcher = PLACEHOLDER.matcher(template);
|
||||
StringBuilder rendered = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String replacement = values.get(matcher.group(1));
|
||||
matcher.appendReplacement(rendered,
|
||||
Matcher.quoteReplacement(replacement != null ? replacement : matcher.group()));
|
||||
}
|
||||
matcher.appendTail(rendered);
|
||||
return rendered.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders {@code template} into a slug base, falling back to the user id when the template
|
||||
* cannot produce anything usable.
|
||||
*/
|
||||
static String slugBase(String template, PersonalNamespaceOwner owner) {
|
||||
String candidate = truncateSlug(SlugValidator.normalize(render(template, owner)));
|
||||
if (candidate.length() >= 2) {
|
||||
return candidate;
|
||||
}
|
||||
String fallback = truncateSlug(SlugValidator.normalize(owner.userId()));
|
||||
return fallback.length() >= 2 ? fallback : "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders {@code template} into a display name, falling back to the slug that was chosen.
|
||||
*/
|
||||
static String displayName(String template, PersonalNamespaceOwner owner, String slug) {
|
||||
String rendered = render(template, owner).trim();
|
||||
if (rendered.isEmpty()) {
|
||||
return slug;
|
||||
}
|
||||
return rendered.length() > DISPLAY_NAME_LIMIT ? rendered.substring(0, DISPLAY_NAME_LIMIT) : rendered;
|
||||
}
|
||||
|
||||
private static String username(PersonalNamespaceOwner owner) {
|
||||
if (owner.username() != null && !owner.username().isBlank()) {
|
||||
return owner.username().trim();
|
||||
}
|
||||
String emailPrefix = emailPrefix(owner);
|
||||
return !emailPrefix.isEmpty() ? emailPrefix : blankToEmpty(owner.userId());
|
||||
}
|
||||
|
||||
private static String emailPrefix(PersonalNamespaceOwner owner) {
|
||||
String email = owner.email();
|
||||
if (email == null || email.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int at = email.indexOf('@');
|
||||
return (at > 0 ? email.substring(0, at) : email).trim();
|
||||
}
|
||||
|
||||
private static String truncateSlug(String slug) {
|
||||
if (slug.length() <= SLUG_BASE_BUDGET) {
|
||||
return slug;
|
||||
}
|
||||
return SlugValidator.normalize(slug.substring(0, SLUG_BASE_BUDGET));
|
||||
}
|
||||
|
||||
private static String blankToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String randomSuffix() {
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
/**
|
||||
* The account a personal namespace is being created for.
|
||||
*
|
||||
* <p>{@code username} is whatever the authentication path calls a user name — the local login name,
|
||||
* or the provider login for an external identity. It is absent for accounts that have neither.
|
||||
*/
|
||||
public record PersonalNamespaceOwner(String userId, String username, String email) {
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Deployment defaults for personal namespace provisioning.
|
||||
*
|
||||
* <p>Deployments can disable provisioning with the environment-backed {@code enabled} property.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.namespace.personal-provisioning")
|
||||
public class PersonalNamespaceProvisioningProperties {
|
||||
|
||||
/**
|
||||
* Off by default: existing deployments must not start creating namespaces after an upgrade.
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Templates remain code defaults because Spring treats {@code ${...}} in YAML as property
|
||||
* references.
|
||||
*/
|
||||
private String slugTemplate = "personal-${random}";
|
||||
|
||||
private String displayNameTemplate = "${username}-个人空间";
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getSlugTemplate() {
|
||||
return slugTemplate;
|
||||
}
|
||||
|
||||
public void setSlugTemplate(String slugTemplate) {
|
||||
this.slugTemplate = slugTemplate;
|
||||
}
|
||||
|
||||
public String getDisplayNameTemplate() {
|
||||
return displayNameTemplate;
|
||||
}
|
||||
|
||||
public void setDisplayNameTemplate(String displayNameTemplate) {
|
||||
this.displayNameTemplate = displayNameTemplate;
|
||||
}
|
||||
|
||||
public PersonalNamespaceSettings toSettings() {
|
||||
return new PersonalNamespaceSettings(enabled, slugTemplate, displayNameTemplate);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Gives each newly activated account a namespace of its own, when the operator has asked for it.
|
||||
*
|
||||
* <p>The namespace is an ordinary team namespace whose only member is its owner, which is what
|
||||
* "private" means in this model: there is no namespace-level visibility flag, and skill visibility
|
||||
* stays a property of each skill.
|
||||
*
|
||||
* <p>Provisioning deliberately runs in its own transaction, after the account has been committed.
|
||||
* {@code namespace.created_by} and {@code namespace_member.user_id} both reference
|
||||
* {@code user_account(id)}, so creating the namespace inside the still-open registration
|
||||
* transaction would either join that transaction — letting a naming clash roll back the
|
||||
* registration — or, if suspended, block on the uncommitted account row. Running afterwards keeps a
|
||||
* failure here from costing the user their account; see
|
||||
* {@code PersonalNamespaceProvisioningListener}.
|
||||
*/
|
||||
@Service
|
||||
public class PersonalNamespaceProvisioningService {
|
||||
|
||||
/**
|
||||
* Upper bound on de-duplication suffixes before giving up on a slug base.
|
||||
*/
|
||||
private static final int MAX_SLUG_ATTEMPTS = 64;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningService.class);
|
||||
|
||||
private final PersonalNamespaceProvisioningProperties defaults;
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository;
|
||||
|
||||
public PersonalNamespaceProvisioningService(PersonalNamespaceProvisioningProperties defaults,
|
||||
NamespaceService namespaceService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository) {
|
||||
this.defaults = defaults;
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective policy: the administrator's stored choice, or the deployment defaults.
|
||||
*/
|
||||
public PersonalNamespaceSettings currentSettings() {
|
||||
return defaults.toSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the owner's namespace, or returns empty when provisioning is off, the owner already
|
||||
* has one, or no acceptable slug is available.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public Optional<Namespace> provisionFor(PersonalNamespaceOwner owner) {
|
||||
PersonalNamespaceSettings settings = currentSettings();
|
||||
if (!settings.enabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (userAccountRepository.findById(owner.userId())
|
||||
.map(com.iflytek.skillhub.domain.user.UserAccount::isSystemAccount)
|
||||
.orElse(false)) {
|
||||
log.info("Skipping personal namespace for system account {}", owner.userId());
|
||||
return Optional.empty();
|
||||
}
|
||||
if (alreadyOwnsNamespace(owner.userId())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String slug = allocateSlug(settings.slugTemplate(), owner);
|
||||
if (slug == null) {
|
||||
log.warn("No namespace slug available for user {} from template '{}'; skipping provisioning",
|
||||
owner.userId(), settings.slugTemplate());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String displayName = PersonalNamespaceNaming.displayName(settings.displayNameTemplate(), owner, slug);
|
||||
Namespace namespace = namespaceService.createNamespace(slug, displayName, null, owner.userId());
|
||||
log.info("Provisioned personal namespace '{}' for user {}", slug, owner.userId());
|
||||
return Optional.of(namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Treats owning any non-global namespace as "already has a personal namespace", which keeps a
|
||||
* repeated activation from handing the same user a second one.
|
||||
*/
|
||||
private boolean alreadyOwnsNamespace(String userId) {
|
||||
return namespaceMemberRepository.findByUserId(userId).stream()
|
||||
.filter(member -> member.getRole() == NamespaceRole.OWNER)
|
||||
.map(member -> namespaceRepository.findById(member.getNamespaceId()))
|
||||
.flatMap(Optional::stream)
|
||||
.anyMatch(namespace -> namespace.getType() != NamespaceType.GLOBAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first free slug for the owner, or {@code null} when every candidate is taken or
|
||||
* rejected — for example when the template renders to a reserved word for many users.
|
||||
*/
|
||||
private String allocateSlug(String slugTemplate, PersonalNamespaceOwner owner) {
|
||||
String base = PersonalNamespaceNaming.slugBase(slugTemplate, owner);
|
||||
for (int attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) {
|
||||
String candidate = attempt == 1 ? base : base + "-" + attempt;
|
||||
if (SlugValidator.isValid(candidate) && namespaceRepository.findBySlug(candidate).isEmpty()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* Operator-controlled policy for giving each new account its own namespace.
|
||||
*
|
||||
* @param slugTemplate template for the namespace slug, e.g. {@code ${username}-space}
|
||||
* @param displayNameTemplate template for the namespace display name
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PersonalNamespaceSettings(
|
||||
boolean enabled,
|
||||
String slugTemplate,
|
||||
String displayNameTemplate) {
|
||||
|
||||
/**
|
||||
* Supported placeholders, in the order they are documented to operators.
|
||||
*/
|
||||
public static final String PLACEHOLDER_USERNAME = "username";
|
||||
public static final String PLACEHOLDER_EMAIL_PREFIX = "email_prefix";
|
||||
public static final String PLACEHOLDER_USER_ID = "user_id";
|
||||
public static final String PLACEHOLDER_RANDOM = "random";
|
||||
}
|
||||
|
|
@ -44,12 +44,37 @@ public class SlugValidator {
|
|||
if (raw == null) {
|
||||
throw new DomainBadRequestException("error.slug.blank");
|
||||
}
|
||||
String slug = raw.trim().toLowerCase()
|
||||
String slug = normalize(raw);
|
||||
validate(slug);
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the slug character rules without asserting the result is usable.
|
||||
*
|
||||
* <p>Callers that generate candidate slugs — rather than accepting one from a user — need to
|
||||
* inspect and adjust the result (append a suffix, truncate) before validating it.
|
||||
*/
|
||||
public static String normalize(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
return raw.trim().toLowerCase()
|
||||
.replaceAll("[^\\p{L}\\p{N}\\p{So}]+", "-")
|
||||
.replaceAll("^-+", "")
|
||||
.replaceAll("-+$", "")
|
||||
.replaceAll("-{2,}", "-");
|
||||
validate(slug);
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether {@code slug} would pass {@link #validate(String)}.
|
||||
*/
|
||||
public static boolean isValid(String slug) {
|
||||
try {
|
||||
validate(slug);
|
||||
return true;
|
||||
} catch (DomainBadRequestException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PersonalNamespaceNamingTest {
|
||||
|
||||
private static final PersonalNamespaceOwner ALICE =
|
||||
new PersonalNamespaceOwner("usr_0f2a", "Alice.Wang", "alice.wang@example.com");
|
||||
|
||||
@Test
|
||||
void rendersEachSupportedPlaceholder() {
|
||||
assertEquals("Alice.Wang", PersonalNamespaceNaming.render("${username}", ALICE));
|
||||
assertEquals("alice.wang", PersonalNamespaceNaming.render("${email_prefix}", ALICE));
|
||||
assertEquals("usr_0f2a", PersonalNamespaceNaming.render("${user_id}", ALICE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesUnknownPlaceholdersInPlaceSoTyposAreVisible() {
|
||||
assertEquals("Alice.Wang-${nickname}", PersonalNamespaceNaming.render("${username}-${nickname}", ALICE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void slugBaseAppliesSlugCharacterRules() {
|
||||
assertEquals("alice-wang", PersonalNamespaceNaming.slugBase("${username}", ALICE));
|
||||
assertEquals("alice-wang-space", PersonalNamespaceNaming.slugBase("${username}-space", ALICE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void slugBaseRewritesUnderscoresBecauseSlugsDisallowThem() {
|
||||
String slug = PersonalNamespaceNaming.slugBase("${username}_space", ALICE);
|
||||
|
||||
assertEquals("alice-wang-space", slug);
|
||||
assertTrue(SlugValidator.isValid(slug));
|
||||
}
|
||||
|
||||
@Test
|
||||
void slugBaseFallsBackToEmailPrefixWhenUsernameIsMissing() {
|
||||
PersonalNamespaceOwner noUsername = new PersonalNamespaceOwner("usr_1", null, "bob@example.com");
|
||||
|
||||
assertEquals("bob", PersonalNamespaceNaming.slugBase("${username}", noUsername));
|
||||
}
|
||||
|
||||
@Test
|
||||
void slugBaseFallsBackToUserIdWhenTemplateRendersNothingUsable() {
|
||||
PersonalNamespaceOwner anonymous = new PersonalNamespaceOwner("usr_abc123", null, null);
|
||||
|
||||
assertEquals("usr-abc123", PersonalNamespaceNaming.slugBase("${username}", anonymous));
|
||||
}
|
||||
|
||||
@Test
|
||||
void slugBaseLeavesRoomForADeduplicationSuffix() {
|
||||
PersonalNamespaceOwner longName = new PersonalNamespaceOwner("usr_1", "a".repeat(200), null);
|
||||
|
||||
String base = PersonalNamespaceNaming.slugBase("${username}", longName);
|
||||
|
||||
assertTrue(base.length() <= 59, "base was " + base.length() + " chars");
|
||||
assertTrue(SlugValidator.isValid(base + "-64"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void displayNameFallsBackToTheSlugWhenTemplateRendersBlank() {
|
||||
PersonalNamespaceOwner noEmail = new PersonalNamespaceOwner("usr_1", "alice", null);
|
||||
|
||||
assertEquals("chosen-slug",
|
||||
PersonalNamespaceNaming.displayName("${email_prefix}", noEmail, "chosen-slug"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usernameFallsBackToTheUserIdWhenNothingElseIsKnown() {
|
||||
PersonalNamespaceOwner anonymous = new PersonalNamespaceOwner("usr_1", null, null);
|
||||
|
||||
assertEquals("usr_1", PersonalNamespaceNaming.render("${username}", anonymous));
|
||||
}
|
||||
|
||||
@Test
|
||||
void displayNameKeepsHumanReadableCharacters() {
|
||||
assertEquals("Alice.Wang's space",
|
||||
PersonalNamespaceNaming.displayName("${username}'s space", ALICE, "alice-wang"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PersonalNamespaceProvisioningServiceTest {
|
||||
|
||||
private static final PersonalNamespaceOwner ALICE =
|
||||
new PersonalNamespaceOwner("usr_alice", "alice", "alice@example.com");
|
||||
|
||||
@Mock
|
||||
private NamespaceService namespaceService;
|
||||
|
||||
@Mock
|
||||
private NamespaceRepository namespaceRepository;
|
||||
|
||||
@Mock
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Mock
|
||||
private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository;
|
||||
|
||||
private PersonalNamespaceProvisioningService service;
|
||||
private PersonalNamespaceProvisioningProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new PersonalNamespaceProvisioningProperties();
|
||||
service = new PersonalNamespaceProvisioningService(
|
||||
properties,
|
||||
namespaceService,
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository,
|
||||
userAccountRepository);
|
||||
}
|
||||
|
||||
private void withSettings(boolean enabled, String slugTemplate, String displayNameTemplate) {
|
||||
properties.setEnabled(enabled);
|
||||
properties.setSlugTemplate(slugTemplate);
|
||||
properties.setDisplayNameTemplate(displayNameTemplate);
|
||||
}
|
||||
|
||||
private void ownsNothing() {
|
||||
when(namespaceMemberRepository.findByUserId(ALICE.userId())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors {@link NamespaceService#createNamespace} returning the namespace it persisted.
|
||||
*/
|
||||
private void namespaceCreationSucceeds() {
|
||||
when(namespaceService.createNamespace(any(), any(), any(), any()))
|
||||
.thenAnswer(invocation -> new Namespace(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNothingWhenProvisioningIsDisabled() {
|
||||
withSettings(false, "${username}", "${username}");
|
||||
|
||||
assertTrue(service.provisionFor(ALICE).isEmpty());
|
||||
verify(namespaceService, never()).createNamespace(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSystemAccount() {
|
||||
withSettings(true, "personal-${random}", "${username}-个人空间");
|
||||
when(userAccountRepository.findById("usr_system"))
|
||||
.thenReturn(Optional.of(com.iflytek.skillhub.domain.user.UserAccount
|
||||
.systemAccount("usr_system", "system", null, null)));
|
||||
|
||||
assertTrue(service.provisionFor(new PersonalNamespaceOwner("usr_system", "system", null)).isEmpty());
|
||||
verify(namespaceService, never()).createNamespace(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsAreEnabledForNewAccountProvisioning() {
|
||||
assertEquals(true, new PersonalNamespaceProvisioningProperties().isEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsNamespaceFromTheConfiguredTemplate() {
|
||||
withSettings(true, "${username}-space", "${username}'s space");
|
||||
ownsNothing();
|
||||
namespaceCreationSucceeds();
|
||||
when(namespaceRepository.findBySlug("alice-space")).thenReturn(Optional.empty());
|
||||
|
||||
service.provisionFor(ALICE);
|
||||
|
||||
verify(namespaceService).createNamespace("alice-space", "alice's space", null, "usr_alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsSuffixWhenTheSlugIsAlreadyTaken() {
|
||||
withSettings(true, "${username}", "${username}");
|
||||
ownsNothing();
|
||||
namespaceCreationSucceeds();
|
||||
when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.of(new Namespace("alice", "Alice", "usr_x")));
|
||||
when(namespaceRepository.findBySlug("alice-2")).thenReturn(Optional.empty());
|
||||
|
||||
service.provisionFor(ALICE);
|
||||
|
||||
verify(namespaceService).createNamespace(eq("alice-2"), any(), isNull(), eq("usr_alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsReservedSlugsInsteadOfFailing() {
|
||||
PersonalNamespaceOwner admin = new PersonalNamespaceOwner("usr_admin", "admin", null);
|
||||
withSettings(true, "${username}", "${username}");
|
||||
when(namespaceMemberRepository.findByUserId(admin.userId())).thenReturn(List.of());
|
||||
namespaceCreationSucceeds();
|
||||
when(namespaceRepository.findBySlug("admin-2")).thenReturn(Optional.empty());
|
||||
|
||||
service.provisionFor(admin);
|
||||
|
||||
verify(namespaceService).createNamespace(eq("admin-2"), any(), isNull(), eq("usr_admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWhenTheUserAlreadyOwnsANamespace() {
|
||||
withSettings(true, "${username}", "${username}");
|
||||
when(namespaceMemberRepository.findByUserId(ALICE.userId()))
|
||||
.thenReturn(List.of(new NamespaceMember(7L, ALICE.userId(), NamespaceRole.OWNER)));
|
||||
when(namespaceRepository.findById(7L))
|
||||
.thenReturn(Optional.of(new Namespace("alice", "Alice", ALICE.userId())));
|
||||
|
||||
assertTrue(service.provisionFor(ALICE).isEmpty());
|
||||
verify(namespaceService, never()).createNamespace(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalMembershipDoesNotCountAsOwningANamespace() {
|
||||
withSettings(true, "${username}", "${username}");
|
||||
Namespace global = new Namespace("global", "Global", "usr_system");
|
||||
global.setType(NamespaceType.GLOBAL);
|
||||
when(namespaceMemberRepository.findByUserId(ALICE.userId()))
|
||||
.thenReturn(List.of(new NamespaceMember(1L, ALICE.userId(), NamespaceRole.OWNER)));
|
||||
when(namespaceRepository.findById(1L)).thenReturn(Optional.of(global));
|
||||
namespaceCreationSucceeds();
|
||||
when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty());
|
||||
|
||||
service.provisionFor(ALICE);
|
||||
|
||||
verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainMembershipDoesNotCountAsOwningANamespace() {
|
||||
withSettings(true, "${username}", "${username}");
|
||||
when(namespaceMemberRepository.findByUserId(ALICE.userId()))
|
||||
.thenReturn(List.of(new NamespaceMember(3L, ALICE.userId(), NamespaceRole.MEMBER)));
|
||||
namespaceCreationSucceeds();
|
||||
when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty());
|
||||
|
||||
service.provisionFor(ALICE);
|
||||
|
||||
verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void givesUpQuietlyWhenEveryCandidateSlugIsTaken() {
|
||||
withSettings(true, "${username}", "${username}");
|
||||
ownsNothing();
|
||||
when(namespaceRepository.findBySlug(any()))
|
||||
.thenReturn(Optional.of(new Namespace("taken", "Taken", "usr_x")));
|
||||
|
||||
assertTrue(service.provisionFor(ALICE).isEmpty());
|
||||
verify(namespaceService, never()).createNamespace(any(), any(), any(), any());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue