From 05f9033208108eaa80106aa73885e42886b8c605 Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Mon, 23 Mar 2026 00:12:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yoyuzh/admin/AdminServiceTest.java | 252 ++++++++++++++++++ .../com/yoyuzh/auth/PasswordPolicyTest.java | 66 +++++ .../common/GlobalExceptionHandlerTest.java | 119 +++++++++ .../config/JwtAuthenticationFilterTest.java | 179 +++++++++++++ .../yoyuzh/files/FileServiceEdgeCaseTest.java | 228 ++++++++++++++++ 5 files changed, 844 insertions(+) create mode 100644 backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java create mode 100644 backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java create mode 100644 backend/src/test/java/com/yoyuzh/common/GlobalExceptionHandlerTest.java create mode 100644 backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java create mode 100644 backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java new file mode 100644 index 0000000..f84f2ff --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/admin/AdminServiceTest.java @@ -0,0 +1,252 @@ +package com.yoyuzh.admin; + +import com.yoyuzh.auth.PasswordPolicy; +import com.yoyuzh.auth.RegistrationInviteService; +import com.yoyuzh.auth.RefreshTokenService; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import com.yoyuzh.auth.UserRole; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.PageResponse; +import com.yoyuzh.files.FileService; +import com.yoyuzh.files.StoredFile; +import com.yoyuzh.files.StoredFileRepository; +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 org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileService fileService; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private RefreshTokenService refreshTokenService; + @Mock + private RegistrationInviteService registrationInviteService; + + private AdminService adminService; + + @BeforeEach + void setUp() { + adminService = new AdminService( + userRepository, storedFileRepository, fileService, + passwordEncoder, refreshTokenService, registrationInviteService); + } + + // --- getSummary --- + + @Test + void shouldReturnSummaryWithCountsAndInviteCode() { + when(userRepository.count()).thenReturn(5L); + when(storedFileRepository.count()).thenReturn(42L); + when(registrationInviteService.getCurrentInviteCode()).thenReturn("INV-001"); + + AdminSummaryResponse summary = adminService.getSummary(); + + assertThat(summary.totalUsers()).isEqualTo(5L); + assertThat(summary.totalFiles()).isEqualTo(42L); + assertThat(summary.inviteCode()).isEqualTo("INV-001"); + } + + // --- listUsers --- + + @Test + void shouldListUsersWithPagination() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.searchByUsernameOrEmail(anyString(), any())) + .thenReturn(new PageImpl<>(List.of(user))); + + PageResponse response = adminService.listUsers(0, 10, "alice"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).username()).isEqualTo("alice"); + } + + @Test + void shouldNormalizeNullQueryToEmptyStringWhenListingUsers() { + when(userRepository.searchByUsernameOrEmail(anyString(), any())) + .thenReturn(new PageImpl<>(List.of())); + + adminService.listUsers(0, 10, null); + + verify(userRepository).searchByUsernameOrEmail(eq(""), any()); + } + + // --- listFiles --- + + @Test + void shouldListFilesWithPagination() { + User owner = createUser(1L, "alice", "alice@example.com"); + StoredFile file = createFile(10L, owner, "/docs", "report.pdf"); + when(storedFileRepository.searchAdminFiles(anyString(), anyString(), any())) + .thenReturn(new PageImpl<>(List.of(file))); + + PageResponse response = adminService.listFiles(0, 10, "report", "alice"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).filename()).isEqualTo("report.pdf"); + assertThat(response.items().get(0).ownerUsername()).isEqualTo("alice"); + } + + // --- deleteFile --- + + @Test + void shouldDeleteFileByDelegatingToFileService() { + User owner = createUser(1L, "alice", "alice@example.com"); + StoredFile file = createFile(10L, owner, "/docs", "report.pdf"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + + adminService.deleteFile(10L); + + verify(fileService).delete(owner, 10L); + } + + @Test + void shouldThrowWhenDeletingNonExistentFile() { + when(storedFileRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminService.deleteFile(99L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("文件不存在"); + } + + // --- updateUserRole --- + + @Test + void shouldUpdateUserRole() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + AdminUserResponse response = adminService.updateUserRole(1L, UserRole.ADMIN); + + assertThat(user.getRole()).isEqualTo(UserRole.ADMIN); + verify(userRepository).save(user); + } + + @Test + void shouldThrowWhenUpdatingRoleForNonExistentUser() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminService.updateUserRole(99L, UserRole.ADMIN)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("用户不存在"); + } + + // --- updateUserBanned --- + + @Test + void shouldBanUserAndRevokeTokens() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + adminService.updateUserBanned(1L, true); + + assertThat(user.isBanned()).isTrue(); + verify(refreshTokenService).revokeAllForUser(1L); + verify(userRepository).save(user); + } + + @Test + void shouldUnbanUserAndRevokeExistingTokens() { + User user = createUser(1L, "alice", "alice@example.com"); + user.setBanned(true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + + adminService.updateUserBanned(1L, false); + + assertThat(user.isBanned()).isFalse(); + verify(refreshTokenService).revokeAllForUser(1L); + } + + // --- updateUserPassword --- + + @Test + void shouldUpdateUserPasswordAndRevokeTokens() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(passwordEncoder.encode("NewStr0ng!Pass")).thenReturn("hashed"); + when(userRepository.save(user)).thenReturn(user); + + adminService.updateUserPassword(1L, "NewStr0ng!Pass"); + + assertThat(user.getPasswordHash()).isEqualTo("hashed"); + verify(refreshTokenService).revokeAllForUser(1L); + } + + @Test + void shouldRejectWeakPasswordWhenUpdating() { + assertThatThrownBy(() -> adminService.updateUserPassword(1L, "weakpass")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("密码至少10位"); + verify(userRepository, never()).findById(any()); + } + + // --- resetUserPassword --- + + @Test + void shouldResetUserPasswordAndReturnTemporaryPassword() { + User user = createUser(1L, "alice", "alice@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(passwordEncoder.encode(anyString())).thenReturn("hashed"); + when(userRepository.save(user)).thenReturn(user); + + AdminPasswordResetResponse response = adminService.resetUserPassword(1L); + + assertThat(response.temporaryPassword()).isNotBlank(); + assertThat(PasswordPolicy.isStrong(response.temporaryPassword())).isTrue(); + verify(refreshTokenService).revokeAllForUser(1L); + } + + // --- helpers --- + + private User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash("hashed"); + user.setRole(UserRole.USER); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private StoredFile createFile(Long id, User owner, String path, String filename) { + StoredFile file = new StoredFile(); + file.setId(id); + file.setUser(owner); + file.setPath(path); + file.setFilename(filename); + file.setSize(1024L); + file.setDirectory(false); + file.setCreatedAt(LocalDateTime.now()); + return file; + } +} diff --git a/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java b/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java new file mode 100644 index 0000000..afee6c9 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/auth/PasswordPolicyTest.java @@ -0,0 +1,66 @@ +package com.yoyuzh.auth; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class PasswordPolicyTest { + + @Test + void shouldRejectNullPassword() { + assertThat(PasswordPolicy.isStrong(null)).isFalse(); + } + + @Test + void shouldRejectPasswordShorterThanTenCharacters() { + assertThat(PasswordPolicy.isStrong("Abc1!defg")).isFalse(); // 9 chars + } + + @Test + void shouldAcceptPasswordWithExactlyTenCharacters() { + assertThat(PasswordPolicy.isStrong("Abcdefg1!x")).isTrue(); // 10 chars + } + + @Test + void shouldRejectPasswordMissingUppercase() { + assertThat(PasswordPolicy.isStrong("abcdefg1!x")).isFalse(); + } + + @Test + void shouldRejectPasswordMissingLowercase() { + assertThat(PasswordPolicy.isStrong("ABCDEFG1!X")).isFalse(); + } + + @Test + void shouldRejectPasswordMissingDigit() { + assertThat(PasswordPolicy.isStrong("Abcdefgh!x")).isFalse(); + } + + @Test + void shouldRejectPasswordMissingSpecialCharacter() { + assertThat(PasswordPolicy.isStrong("Abcdefg12x")).isFalse(); + } + + @Test + void shouldAcceptStrongPasswordWithAllRequirements() { + assertThat(PasswordPolicy.isStrong("MyP@ssw0rd!")).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"", "short", "nouppercase1!", "NOLOWERCASE1!", "NoSpecialChar1", "NoDigit!AbcXyz"}) + void shouldRejectWeakPasswords(String password) { + assertThat(PasswordPolicy.isStrong(password)).isFalse(); + } + + @Test + void shouldAcceptLongPasswordWithAllRequirements() { + assertThat(PasswordPolicy.isStrong("MyV3ryStr0ng&SecureP@ssword2024!")).isTrue(); + } + + @Test + void shouldRejectEmptyPassword() { + assertThat(PasswordPolicy.isStrong("")).isFalse(); + } +} diff --git a/backend/src/test/java/com/yoyuzh/common/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/yoyuzh/common/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..fcbcbfb --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/common/GlobalExceptionHandlerTest.java @@ -0,0 +1,119 @@ +package com.yoyuzh.common; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GlobalExceptionHandlerTest { + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + } + + @Test + void shouldReturn400ForUnknownBusinessException() { + BusinessException ex = new BusinessException(ErrorCode.UNKNOWN, "操作失败"); + ResponseEntity> response = handler.handleBusinessException(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().msg()).isEqualTo("操作失败"); + } + + @Test + void shouldReturn401ForNotLoggedInException() { + BusinessException ex = new BusinessException(ErrorCode.NOT_LOGGED_IN, "未登录"); + ResponseEntity> response = handler.handleBusinessException(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void shouldReturn403ForPermissionDeniedException() { + BusinessException ex = new BusinessException(ErrorCode.PERMISSION_DENIED, "无权限"); + ResponseEntity> response = handler.handleBusinessException(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void shouldReturn404ForFileNotFoundException() { + BusinessException ex = new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"); + ResponseEntity> response = handler.handleBusinessException(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void shouldReturn400WithFirstValidationMessageForMethodArgumentNotValidException() throws Exception { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "target"); + bindingResult.addError(new FieldError("target", "username", "用户名不能为空")); + bindingResult.addError(new FieldError("target", "email", "邮箱格式不正确")); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(null, bindingResult); + + ResponseEntity> response = handler.handleValidationException(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().msg()).isEqualTo("用户名不能为空"); + } + + @Test + void shouldReturn400WithDefaultMessageWhenNoValidationMessages() throws Exception { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "target"); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(null, bindingResult); + + ResponseEntity> response = handler.handleValidationException(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().msg()).isEqualTo("请求参数不合法"); + } + + @Test + void shouldReturn400ForConstraintViolationException() { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn("密码不能为空"); + ConstraintViolationException ex = new ConstraintViolationException(Set.of(violation)); + + ResponseEntity> response = handler.handleValidationException(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().msg()).isEqualTo("密码不能为空"); + } + + @Test + void shouldReturn403ForAccessDeniedException() { + AccessDeniedException ex = new AccessDeniedException("forbidden"); + ResponseEntity> response = handler.handleAccessDenied(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody().msg()).isEqualTo("没有权限访问该资源"); + } + + @Test + void shouldReturn401ForBadCredentialsException() { + BadCredentialsException ex = new BadCredentialsException("bad credentials"); + ResponseEntity> response = handler.handleBadCredentials(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody().msg()).isEqualTo("用户名或密码错误"); + } + + @Test + void shouldReturn500ForUnhandledException() { + RuntimeException ex = new RuntimeException("unexpected"); + ResponseEntity> response = handler.handleUnknown(ex); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody().msg()).isEqualTo("服务器内部错误"); + } +} diff --git a/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..d135a02 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/config/JwtAuthenticationFilterTest.java @@ -0,0 +1,179 @@ +package com.yoyuzh.config; + +import com.yoyuzh.auth.CustomUserDetailsService; +import com.yoyuzh.auth.JwtTokenProvider; +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.common.ErrorCode; +import jakarta.servlet.FilterChain; +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 org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + @Mock + private CustomUserDetailsService userDetailsService; + @Mock + private FilterChain filterChain; + + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService); + SecurityContextHolder.clearContext(); + } + + @Test + void shouldPassThroughRequestWithNoAuthorizationHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtTokenProvider, never()).validateToken(any()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void shouldPassThroughRequestWithNonBearerAuthorizationHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Basic dXNlcjpwYXNz"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtTokenProvider, never()).validateToken(any()); + } + + @Test + void shouldPassThroughRequestWithInvalidToken() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(jwtTokenProvider.validateToken("invalid-token")).thenReturn(false); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void shouldPassThroughWhenUserNotFound() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); + when(userDetailsService.loadDomainUser("alice")) + .thenThrow(new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void shouldPassThroughWhenSessionIdDoesNotMatch() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + User domainUser = createDomainUser("alice", "session-1"); + when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); + when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); + when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(false); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void shouldPassThroughWhenUserIsDisabled() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + User domainUser = createDomainUser("alice", "session-1"); + UserDetails disabledUserDetails = org.springframework.security.core.userdetails.User.builder() + .username("alice") + .password("hashed") + .disabled(true) + .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) + .build(); + when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); + when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); + when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(true); + when(userDetailsService.loadUserByUsername("alice")).thenReturn(disabledUserDetails); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void shouldSetAuthenticationWhenTokenIsValidAndUserIsActive() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + User domainUser = createDomainUser("alice", "session-1"); + UserDetails activeUserDetails = org.springframework.security.core.userdetails.User.builder() + .username("alice") + .password("hashed") + .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) + .build(); + when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true); + when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice"); + when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser); + when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(true); + when(userDetailsService.loadUserByUsername("alice")).thenReturn(activeUserDetails); + + filter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("alice"); + } + + private User createDomainUser(String username, String sessionId) { + User user = new User(); + user.setId(1L); + user.setUsername(username); + user.setEmail(username + "@example.com"); + user.setPasswordHash("hashed"); + user.setActiveSessionId(sessionId); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + // Helper to avoid unused import warning on Mockito.any() + private static T any() { + return org.mockito.ArgumentMatchers.any(); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java new file mode 100644 index 0000000..ad73fdc --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceEdgeCaseTest.java @@ -0,0 +1,228 @@ +package com.yoyuzh.files; + +import com.yoyuzh.auth.User; +import com.yoyuzh.common.BusinessException; +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.storage.FileContentStorage; +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 org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers edge cases not addressed in FileServiceTest. + */ +@ExtendWith(MockitoExtension.class) +class FileServiceEdgeCaseTest { + + @Mock + private StoredFileRepository storedFileRepository; + @Mock + private FileContentStorage fileContentStorage; + @Mock + private FileShareLinkRepository fileShareLinkRepository; + + private FileService fileService; + + @BeforeEach + void setUp() { + FileStorageProperties properties = new FileStorageProperties(); + properties.setMaxFileSize(500L * 1024 * 1024); + fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties); + } + + // --- normalizeDirectoryPath edge cases --- + + @Test + void shouldRejectPathContainingDotDot() { + User user = createUser(1L); + + assertThatThrownBy(() -> fileService.mkdir(user, "/docs/../secret")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("路径不合法"); + } + + @Test + void shouldNormalizeBackslashesInPath() { + User user = createUser(1L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(inv -> { + StoredFile f = inv.getArgument(0); + f.setId(10L); + return f; + }); + + // backslash should be treated as path separator and normalized + FileMetadataResponse response = fileService.mkdir(user, "\\docs"); + + assertThat(response.path()).isEqualTo("/docs"); + } + + @Test + void shouldNormalizeTrailingSlashInPath() { + User user = createUser(1L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(inv -> { + StoredFile f = inv.getArgument(0); + f.setId(10L); + return f; + }); + + FileMetadataResponse response = fileService.mkdir(user, "/docs/"); + + assertThat(response.path()).isEqualTo("/docs"); + } + + @Test + void shouldNormalizeDoubleSlashInPath() { + User user = createUser(1L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(inv -> { + StoredFile f = inv.getArgument(0); + f.setId(10L); + return f; + }); + + FileMetadataResponse response = fileService.mkdir(user, "//docs"); + + assertThat(response.path()).isEqualTo("/docs"); + } + + // --- mkdir edge cases --- + + @Test + void shouldRejectCreatingRootDirectory() { + User user = createUser(1L); + + assertThatThrownBy(() -> fileService.mkdir(user, "/")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("根目录无需创建"); + } + + @Test + void shouldRejectCreatingAlreadyExistingDirectory() { + User user = createUser(1L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(true); + + assertThatThrownBy(() -> fileService.mkdir(user, "/docs")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("目录已存在"); + } + + // --- download redirect for direct download --- + + @Test + void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() { + User user = createUser(1L); + StoredFile file = createFile(10L, user, "/docs", "notes.txt"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + when(fileContentStorage.supportsDirectDownload()).thenReturn(true); + when(fileContentStorage.createDownloadUrl(1L, "/docs", "notes.txt", "notes.txt")) + .thenReturn("https://cdn.example.com/notes.txt"); + + ResponseEntity response = fileService.download(user, 10L); + + assertThat(response.getStatusCodeValue()).isEqualTo(302); + assertThat(response.getHeaders().getFirst(HttpHeaders.LOCATION)) + .isEqualTo("https://cdn.example.com/notes.txt"); + } + + // --- createShareLink edge cases --- + + @Test + void shouldRejectCreatingShareLinkForDirectory() { + User user = createUser(1L); + StoredFile directory = createDirectory(10L, user, "/", "docs"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + + assertThatThrownBy(() -> fileService.createShareLink(user, 10L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("目录暂不支持分享链接"); + } + + // --- getDownloadUrl edge cases --- + + @Test + void shouldRejectDownloadUrlForDirectory() { + User user = createUser(1L); + StoredFile directory = createDirectory(10L, user, "/", "docs"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory)); + + assertThatThrownBy(() -> fileService.getDownloadUrl(user, 10L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("目录不支持下载"); + } + + // --- upload size limit --- + + @Test + void shouldRejectUploadExceedingMaxFileSize() { + User user = createUser(1L); + long oversizedFile = 500L * 1024 * 1024 + 1; + + assertThatThrownBy(() -> fileService.initiateUpload(user, + new InitiateUploadRequest("/docs", "big.zip", "application/zip", oversizedFile))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("文件大小超出限制"); + } + + // --- rename no-op when name unchanged --- + + @Test + void shouldReturnUnchangedFileWhenRenameToSameName() { + User user = createUser(1L); + StoredFile file = createFile(10L, user, "/docs", "notes.txt"); + when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file)); + + FileMetadataResponse response = fileService.rename(user, 10L, "notes.txt"); + + assertThat(response.filename()).isEqualTo("notes.txt"); + verify(storedFileRepository, org.mockito.Mockito.never()).save(any()); + } + + // --- helpers --- + + private User createUser(Long id) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + user.setEmail("user-" + id + "@example.com"); + user.setPasswordHash("encoded"); + user.setCreatedAt(LocalDateTime.now()); + return user; + } + + private StoredFile createFile(Long id, User user, String path, String filename) { + StoredFile file = new StoredFile(); + file.setId(id); + file.setUser(user); + file.setFilename(filename); + file.setPath(path); + file.setStorageName(filename); + file.setSize(5L); + file.setDirectory(false); + file.setCreatedAt(LocalDateTime.now()); + return file; + } + + private StoredFile createDirectory(Long id, User user, String path, String filename) { + StoredFile dir = createFile(id, user, path, filename); + dir.setDirectory(true); + dir.setContentType("directory"); + dir.setSize(0L); + return dir; + } +}