feat: 多用户管理与公开注册功能
- 后端:User 实体新增 role/enabled 字段、UserController CRUD、UserService - 安全:SecurityConfig /api/users/** 要求 ROLE_ADMIN、JWT 过滤器检查账号状态 - 注册:POST /api/auth/register 公开注册,固定 ROLE_USER - 保护:删除/禁用/降级最后 admin 均拒绝,DataInitializer 含 backfill - 前端:用户管理页面、登录/注册切换、admin 专属导航入口 - 测试:UserServiceTest 19 个 + UserControllerTest 6 个 + AuthControllerTest 适配
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.CreateUserRequest;
|
||||
import com.sshmanager.dto.UpdateUserRequest;
|
||||
import com.sshmanager.dto.UserDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.exception.InvalidOperationException;
|
||||
import com.sshmanager.exception.NotFoundException;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private UserService userService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
passwordEncoder = new BCryptPasswordEncoder();
|
||||
userService = new UserService(userRepository, passwordEncoder);
|
||||
}
|
||||
|
||||
// --- listUsers ---
|
||||
|
||||
@Test
|
||||
void listUsersReturnsAllUsers() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Administrator", true);
|
||||
User regular = createUser(2L, "user1", "ROLE_USER", "User One", true);
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin, regular));
|
||||
|
||||
List<UserDto> result = userService.listUsers();
|
||||
|
||||
assertEquals(2, result.size());
|
||||
assertEquals("admin", result.get(0).getUsername());
|
||||
assertEquals("ROLE_ADMIN", result.get(0).getRole());
|
||||
assertTrue(result.get(0).getEnabled());
|
||||
}
|
||||
|
||||
// --- getUser ---
|
||||
|
||||
@Test
|
||||
void getUserReturnsUserWhenFound() {
|
||||
User user = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
UserDto result = userService.getUser(1L);
|
||||
|
||||
assertEquals("admin", result.getUsername());
|
||||
assertEquals("ROLE_ADMIN", result.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserThrowsWhenNotFound() {
|
||||
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
assertThrows(NotFoundException.class, () -> userService.getUser(99L));
|
||||
}
|
||||
|
||||
// --- createUser ---
|
||||
|
||||
@Test
|
||||
void createUserSucceedsWithValidRequest() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("newuser");
|
||||
request.setPassword("password123");
|
||||
request.setDisplayName("New User");
|
||||
|
||||
when(userRepository.existsByUsername("newuser")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> {
|
||||
User saved = invocation.getArgument(0);
|
||||
saved.setId(10L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserDto result = userService.createUser(request);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("newuser", result.getUsername());
|
||||
assertEquals("ROLE_USER", result.getRole());
|
||||
assertTrue(result.getEnabled());
|
||||
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(captor.capture());
|
||||
User saved = captor.getValue();
|
||||
assertTrue(passwordEncoder.matches("password123", saved.getPasswordHash()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserDefaultsDisplayNameToUsername() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("minimal");
|
||||
request.setPassword("password123");
|
||||
|
||||
when(userRepository.existsByUsername("minimal")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> {
|
||||
User saved = invocation.getArgument(0);
|
||||
saved.setId(11L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserDto result = userService.createUser(request);
|
||||
assertEquals("minimal", result.getDisplayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserRejectsDuplicateUsername() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("existing");
|
||||
request.setPassword("password123");
|
||||
|
||||
when(userRepository.existsByUsername("existing")).thenReturn(true);
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.createUser(request));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserRejectsShortPassword() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername("user");
|
||||
request.setPassword("short");
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.createUser(request));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserRejectsEmptyUsername() {
|
||||
CreateUserRequest request = new CreateUserRequest();
|
||||
request.setUsername(" ");
|
||||
request.setPassword("password123");
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.createUser(request));
|
||||
}
|
||||
|
||||
// --- updateUser ---
|
||||
|
||||
@Test
|
||||
void updateUserChangesDisplayNameAndRole() {
|
||||
User user = createUser(1L, "admin", "ROLE_ADMIN", "Old Name", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setDisplayName("New Name");
|
||||
request.setRole("ROLE_USER");
|
||||
// We need another admin to avoid the "last admin" guard
|
||||
User otherAdmin = createUser(2L, "admin2", "ROLE_ADMIN", "Admin 2", true);
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(user, otherAdmin));
|
||||
|
||||
UserDto result = userService.updateUser(1L, request, 2L);
|
||||
|
||||
assertEquals("New Name", result.getDisplayName());
|
||||
assertEquals("ROLE_USER", result.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserCannotDisableLastAdmin() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setEnabled(false);
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserCannotChangeLastAdminRole() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setRole("ROLE_USER");
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserCannotDisableSelf() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
|
||||
UpdateUserRequest request = new UpdateUserRequest();
|
||||
request.setEnabled(false);
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 1L));
|
||||
}
|
||||
|
||||
// --- deleteUser ---
|
||||
|
||||
@Test
|
||||
void deleteUserSucceedsWhenNotLastAdmin() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
User other = createUser(2L, "admin2", "ROLE_ADMIN", "Admin 2", true);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(other));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin, other));
|
||||
|
||||
userService.deleteUser(2L, 1L);
|
||||
|
||||
verify(userRepository).delete(other);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserRefusesSelfDeletion() {
|
||||
assertThrows(InvalidOperationException.class, () -> userService.deleteUser(1L, 1L));
|
||||
verify(userRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserRefusesLastAdmin() {
|
||||
User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(admin));
|
||||
when(userRepository.findAll()).thenReturn(Arrays.asList(admin));
|
||||
|
||||
assertThrows(InvalidOperationException.class, () -> userService.deleteUser(1L, 2L));
|
||||
verify(userRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
// --- resetPassword ---
|
||||
|
||||
@Test
|
||||
void resetPasswordUpdatesHashAndSetsEpoch() {
|
||||
User user = createUser(1L, "user1", "ROLE_USER", "User", true);
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
userService.resetPassword(1L, "newPassword123");
|
||||
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(captor.capture());
|
||||
User saved = captor.getValue();
|
||||
assertTrue(passwordEncoder.matches("newPassword123", saved.getPasswordHash()));
|
||||
assertEquals(Instant.EPOCH, saved.getPasswordChangedAt());
|
||||
}
|
||||
|
||||
// --- createRegularUser ---
|
||||
|
||||
@Test
|
||||
void createRegularUserSetsRoleUser() {
|
||||
when(userRepository.existsByUsername("newguy")).thenReturn(false);
|
||||
when(userRepository.save(any())).thenAnswer(invocation -> {
|
||||
User saved = invocation.getArgument(0);
|
||||
saved.setId(20L);
|
||||
return saved;
|
||||
});
|
||||
|
||||
UserDto result = userService.createRegularUser("newguy", "password123", "New Guy");
|
||||
|
||||
assertEquals("ROLE_USER", result.getRole());
|
||||
assertTrue(result.getEnabled());
|
||||
assertFalse(result.getPasswordChangedAt().equals(Instant.EPOCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRegularUserRejectsShortPassword() {
|
||||
assertThrows(InvalidOperationException.class,
|
||||
() -> userService.createRegularUser("user", "short", null));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRegularUserRejectsDuplicateUsername() {
|
||||
when(userRepository.existsByUsername("taken")).thenReturn(true);
|
||||
|
||||
assertThrows(InvalidOperationException.class,
|
||||
() -> userService.createRegularUser("taken", "password123", null));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private User createUser(Long id, String username, String role, String displayName, boolean enabled) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash(passwordEncoder.encode("password123"));
|
||||
user.setDisplayName(displayName);
|
||||
user.setRole(role);
|
||||
user.setEnabled(enabled);
|
||||
user.setCreatedAt(Instant.now());
|
||||
user.setUpdatedAt(Instant.now());
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user