From d8f82264dc161cfe375f71434c5734a0856f888b Mon Sep 17 00:00:00 2001 From: shong9124 Date: Tue, 30 Jun 2026 13:41:02 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20cursor=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/user/constant/UserSortBy.java | 13 ++++++++++ .../user/dto/request/UserCursorRequest.java | 25 +++++++++++++++++++ .../dto/response/UserCursorPageResponse.java | 18 +++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java diff --git a/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java b/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java new file mode 100644 index 00000000..b4f7d7b0 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.user.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserSortBy { + CREATED_AT("createdAt"), + ; + + private final String value; +} diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java new file mode 100644 index 00000000..e42a4cb0 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java @@ -0,0 +1,25 @@ +package com.codeit.team5.mopl.user.dto.request; + +import com.codeit.team5.mopl.user.constant.UserSortBy; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.UUID; +import org.springframework.data.domain.Sort; + +public record UserCursorRequest( + @NotNull + UserSortBy sortBy, + + @NotNull + Sort.Direction sortDirection, + + String cursor, + + UUID cursorId, + + @NotNull + @Positive + Integer limit +) { + +} diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java b/src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java new file mode 100644 index 00000000..5f6322fc --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java @@ -0,0 +1,18 @@ +package com.codeit.team5.mopl.user.dto.response; + +import com.codeit.team5.mopl.user.constant.UserSortBy; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Sort; + +public record UserCursorPageResponse( + List data, + String nextCursor, + UUID nextIdAfter, + Integer totalCount, + UserSortBy sortBy, + Sort.Direction sortDirection, + boolean hasNext +) { + +} From 167d684d4c975271d44f913d3ac8655591234da0 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 13:45:25 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/com/codeit/team5/mopl/user/service/UserService.java # Conflicts: # src/main/java/com/codeit/team5/mopl/user/service/UserService.java --- .../team5/mopl/config/SecurityConfig.java | 16 +- .../codeit/team5/mopl/config/WebConfig.java | 4 +- .../team5/mopl/user/constant/UserSortBy.java | 13 ++ .../mopl/user/controller/UserController.java | 13 ++ .../mopl/user/controller/api/UserApi.java | 6 + .../user/dto/request/UserCursorRequest.java | 18 ++- .../dto/response/UserCursorPageResponse.java | 18 --- .../exception/InvalidUserSortByException.java | 11 ++ .../team5/mopl/user/mapper/UserMapper.java | 29 ++++ .../mopl/user/repository/UserRepository.java | 3 +- .../querydsl/UserQueryRepository.java | 13 ++ .../querydsl/UserQueryRepositoryImpl.java | 142 ++++++++++++++++++ .../team5/mopl/user/service/UserService.java | 13 ++ 13 files changed, 264 insertions(+), 35 deletions(-) delete mode 100644 src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/exception/InvalidUserSortByException.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepository.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java diff --git a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java index def72a7a..88b36d66 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -55,18 +55,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/sign-out").permitAll() + + .requestMatchers(HttpMethod.POST, "/api/users").permitAll() + .requestMatchers(HttpMethod.GET, "/api/users").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/users/*/role").hasRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/users/*/locked").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/users/*").authenticated() + .requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated() + .requestMatchers("/api/follows/**").authenticated() .requestMatchers("/api/notifications/**").authenticated() - .requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated() - .requestMatchers(HttpMethod.GET, "/api/users/*").authenticated() - .requestMatchers("/api/users").permitAll() + .requestMatchers("/api/auth/sign-in").permitAll() .requestMatchers("/api/auth/csrf-token").permitAll() .requestMatchers("/api/auth/refresh").permitAll() - .requestMatchers(HttpMethod.PATCH, "/api/users/*/role").hasRole("ADMIN") - .requestMatchers(HttpMethod.PATCH, "/api/users/*/locked").hasRole("ADMIN") - - // Swagger, actuator 필요하면 추가 .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .anyRequest().permitAll() diff --git a/src/main/java/com/codeit/team5/mopl/config/WebConfig.java b/src/main/java/com/codeit/team5/mopl/config/WebConfig.java index f8764463..5f3d20b5 100644 --- a/src/main/java/com/codeit/team5/mopl/config/WebConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/WebConfig.java @@ -1,7 +1,7 @@ package com.codeit.team5.mopl.config; import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException; -import com.codeit.team5.mopl.watcher.constant.SortByType; +import com.codeit.team5.mopl.user.constant.UserSortBy; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.Sort; import org.springframework.format.FormatterRegistry; @@ -21,5 +21,7 @@ public void addFormatters(FormatterRegistry registry) { } throw new InvalidSortDirectionException(value); }); + + registry.addConverter(String.class, UserSortBy.class, UserSortBy::from); } } diff --git a/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java b/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java index b4f7d7b0..e87da521 100644 --- a/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java +++ b/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java @@ -1,13 +1,26 @@ package com.codeit.team5.mopl.user.constant; +import com.codeit.team5.mopl.user.exception.InvalidUserSortByException; +import java.util.Arrays; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor public enum UserSortBy { + NAME("name"), + EMAIL("email"), CREATED_AT("createdAt"), + LOCKED("locked"), + ROLE("role") ; private final String value; + + public static UserSortBy from(String value) { + return Arrays.stream(values()) + .filter(sortBy -> sortBy.value.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new InvalidUserSortByException(value)); + } } diff --git a/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java b/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java index af1d4faf..724c58fa 100644 --- a/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java +++ b/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java @@ -2,7 +2,9 @@ import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.user.controller.api.UserApi; +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest; @@ -93,4 +95,15 @@ public ResponseEntity updateLockStatus( return ResponseEntity.noContent().build(); } + + @Override + @GetMapping + public ResponseEntity> getUsers( + @Valid UserCursorRequest request) { + log.info("User list request: GET /api/users"); + + CursorResponse response = userService.findUsers(request); + + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java b/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java index f4ffa703..8d9652f1 100644 --- a/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java +++ b/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java @@ -1,7 +1,9 @@ package com.codeit.team5.mopl.user.controller.api; import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion; +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest; @@ -140,4 +142,8 @@ ResponseEntity updateLockStatus( @Parameter(description = "변경할 사용자 잠금 상태", required = true) @Valid @RequestBody UserLockedUpdateRequest request ); + + ResponseEntity> getUsers( + @Parameter(hidden = true)UserCursorRequest request + ); } diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java index e42a4cb0..9cd152dd 100644 --- a/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java +++ b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java @@ -1,25 +1,27 @@ package com.codeit.team5.mopl.user.dto.request; import com.codeit.team5.mopl.user.constant.UserSortBy; +import com.codeit.team5.mopl.user.entity.UserRole; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.util.UUID; import org.springframework.data.domain.Sort; public record UserCursorRequest( + String emailLike, + UserRole roleEqual, + Boolean isLocked, + String cursor, + UUID idAfter, + @NotNull - UserSortBy sortBy, + @Positive + Integer limit, @NotNull Sort.Direction sortDirection, - String cursor, - - UUID cursorId, - @NotNull - @Positive - Integer limit + UserSortBy sortBy ) { - } diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java b/src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java deleted file mode 100644 index 5f6322fc..00000000 --- a/src/main/java/com/codeit/team5/mopl/user/dto/response/UserCursorPageResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.codeit.team5.mopl.user.dto.response; - -import com.codeit.team5.mopl.user.constant.UserSortBy; -import java.util.List; -import java.util.UUID; -import org.springframework.data.domain.Sort; - -public record UserCursorPageResponse( - List data, - String nextCursor, - UUID nextIdAfter, - Integer totalCount, - UserSortBy sortBy, - Sort.Direction sortDirection, - boolean hasNext -) { - -} diff --git a/src/main/java/com/codeit/team5/mopl/user/exception/InvalidUserSortByException.java b/src/main/java/com/codeit/team5/mopl/user/exception/InvalidUserSortByException.java new file mode 100644 index 00000000..33236f34 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/exception/InvalidUserSortByException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.user.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidUserSortByException extends BusinessException { + + public InvalidUserSortByException(String value) { + super(HttpStatus.BAD_REQUEST, "정렬 입력값이 올바르지 않습니다. value=" + value ); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java b/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java index 9b853ac1..7fd864f8 100644 --- a/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java +++ b/src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java @@ -1,11 +1,15 @@ package com.codeit.team5.mopl.user.mapper; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.user.constant.UserSortBy; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; import com.codeit.team5.mopl.user.dto.response.UserResponse; import com.codeit.team5.mopl.user.entity.User; +import java.util.List; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; +import org.springframework.data.domain.Sort.Direction; @Mapper( componentModel = "spring", @@ -15,4 +19,29 @@ public interface UserMapper { @Mapping(target = "role", source = "user.role") @Mapping(target = "profileImageUrl", source = "user.profileImage.url") UserResponse toDto(User user); + + default CursorResponse toCursor( + List page, boolean hasNext, long totalCount, UserSortBy sortBy, Direction sortDirection + ) { + String nextCursor = null; + String nextIdAfter = null; + + if (hasNext && !page.isEmpty()) { + User last = page.get(page.size() - 1); + nextCursor = switch (sortBy) { + case EMAIL -> last.getEmail(); + case NAME -> last.getName(); + case CREATED_AT -> last.getCreatedAt().toString(); + case ROLE -> last.getRole().name(); + case LOCKED -> String.valueOf(last.isLocked()); + }; + + nextIdAfter = last.getId().toString(); + } + + List data = page.stream().map(this::toDto).toList(); + String direction = sortDirection == Direction.ASC ? "ASCENDING" : "DESCENDING"; + + return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, sortBy.getValue(), direction); + } } diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java index 7802aa02..f3db86c5 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.user.repository; import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.querydsl.UserQueryRepository; import jakarta.persistence.LockModeType; import java.util.Optional; import java.util.UUID; @@ -9,7 +10,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository, UserQueryRepository { boolean existsByEmail(String email); Optional findByEmail(String email); diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepository.java b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepository.java new file mode 100644 index 00000000..e5302906 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepository.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.user.repository.querydsl; + +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; +import com.codeit.team5.mopl.user.entity.User; +import java.util.List; + +public interface UserQueryRepository { + + List findUsers(UserCursorRequest request, int fetchLimit); + + long countUsers(UserCursorRequest request); + +} diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java new file mode 100644 index 00000000..2c8627f0 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java @@ -0,0 +1,142 @@ +package com.codeit.team5.mopl.user.repository.querydsl; + +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; +import com.codeit.team5.mopl.user.entity.QUser; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.entity.UserRole; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserQueryRepositoryImpl implements UserQueryRepository { + + private final JPAQueryFactory queryFactory; + + private static final QUser user = QUser.user; + + @Override + public List findUsers(UserCursorRequest request, int fetchLimit) { + return queryFactory + .selectFrom(user) + .leftJoin(user.profileImage).fetchJoin() + .where(buildWhere(request)) + .orderBy(buildOrder(request)) + .limit(fetchLimit) + .fetch(); + } + + @Override + public long countUsers(UserCursorRequest request) { + BooleanBuilder where = new BooleanBuilder(); + applyFilters(where, request); + + Long count = queryFactory + .select(user.count()) + .from(user) + .where(where) + .fetchOne(); + + return count != null ? count : 0L; + } + + private BooleanBuilder buildWhere(UserCursorRequest request) { + BooleanBuilder where = new BooleanBuilder(); + applyFilters(where, request); + applyCursor(where, request); + return where; + } + + private void applyFilters(BooleanBuilder where, UserCursorRequest request) { + where.and(emailFilter(request.emailLike())); + where.and(roleFilter(request.roleEqual())); + where.and(lockedFilter(request.isLocked())); + } + + private BooleanExpression emailFilter(String emailLike) { + if (emailLike == null || emailLike.isBlank()) { + return null; + } + return user.email.containsIgnoreCase(emailLike); + } + + private BooleanExpression roleFilter(UserRole roleEqual) { + return roleEqual != null ? user.role.eq(roleEqual) : null; + } + + private BooleanExpression lockedFilter(Boolean isLocked) { + return isLocked != null ? user.locked.eq(isLocked) : null; + } + + private void applyCursor(BooleanBuilder where, UserCursorRequest request) { + String cursor = request.cursor(); + if (cursor == null || cursor.isBlank() || request.idAfter() == null) { + return; + } + + boolean isAsc = request.sortDirection() == Direction.ASC; + + BooleanExpression cursorCondition = switch (request.sortBy()) { + case NAME -> isAsc + ? user.name.gt(cursor) + .or(user.name.eq(cursor).and(user.id.gt(request.idAfter()))) + : user.name.lt(cursor) + .or(user.name.eq(cursor).and(user.id.lt(request.idAfter()))); + + case EMAIL -> isAsc + ? user.email.gt(cursor) + .or(user.email.eq(cursor).and(user.id.gt(request.idAfter()))) + : user.email.lt(cursor) + .or(user.email.eq(cursor).and(user.id.lt(request.idAfter()))); + + case CREATED_AT -> { + Instant cursorInstant = Instant.parse(cursor); + yield isAsc + ? user.createdAt.gt(cursorInstant) + .or(user.createdAt.eq(cursorInstant).and(user.id.gt(request.idAfter()))) + : user.createdAt.lt(cursorInstant) + .or(user.createdAt.eq(cursorInstant).and(user.id.lt(request.idAfter()))); + } + + case LOCKED -> { + boolean cursorBoolean = Boolean.parseBoolean(cursor); + yield isAsc + ? user.locked.gt(cursorBoolean) + .or(user.locked.eq(cursorBoolean).and(user.id.gt(request.idAfter()))) + : user.locked.lt(cursorBoolean) + .or(user.locked.eq(cursorBoolean).and(user.id.lt(request.idAfter()))); + } + + case ROLE -> isAsc + ? user.role.stringValue().gt(cursor) + .or(user.role.stringValue().eq(cursor).and(user.id.gt(request.idAfter()))) + : user.role.stringValue().lt(cursor) + .or(user.role.stringValue().eq(cursor).and(user.id.lt(request.idAfter()))); + }; + + where.and(cursorCondition); + } + + private OrderSpecifier[] buildOrder(UserCursorRequest request) { + boolean isAsc = request.sortDirection() == Direction.ASC; + + OrderSpecifier primary = switch (request.sortBy()) { + case NAME -> isAsc ? user.name.asc() : user.name.desc(); + case EMAIL -> isAsc ? user.email.asc() : user.email.desc(); + case CREATED_AT -> isAsc ? user.createdAt.asc() : user.createdAt.desc(); + case LOCKED -> isAsc ? user.locked.asc() : user.locked.desc(); + case ROLE -> isAsc ? user.role.asc() : user.role.desc(); + }; + + OrderSpecifier secondary = isAsc ? user.id.asc() : user.id.desc(); + + return new OrderSpecifier[]{primary, secondary}; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java index 70e96e4e..5d4eec88 100644 --- a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java +++ b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java @@ -3,7 +3,9 @@ import com.codeit.team5.mopl.auth.service.RefreshTokenStore; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.dto.FileRequest; +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest; @@ -17,6 +19,7 @@ import com.codeit.team5.mopl.user.exception.UserNotFoundException; import com.codeit.team5.mopl.user.mapper.UserMapper; import com.codeit.team5.mopl.user.repository.UserRepository; +import java.util.List; import java.util.Locale; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -116,6 +119,16 @@ public void updateLock(UUID userId, UserLockedUpdateRequest request) { log.info("User lock status updated: userId={}, isLocked={}", userId, request.locked()); } + public CursorResponse findUsers(UserCursorRequest request) { + int fetchLimit = request.limit() + 1; + List fetched = userRepository.findUsers(request, fetchLimit); + boolean hasNext = fetched.size() > request.limit(); + List page = hasNext ? fetched.subList(0, request.limit()) : fetched; + long totalCount = userRepository.countUsers(request); + + return userMapper.toCursor(page, hasNext, totalCount, request.sortBy(), request.sortDirection()); + } + private void validateOwner(UUID currentUserId, UUID userId) { if (!currentUserId.equals(userId)) { throw new UserForbiddenException(currentUserId, userId); From 4debf51af7ef7bd38066fd1ef25e945c7c70247b Mon Sep 17 00:00:00 2001 From: shong9124 Date: Tue, 30 Jun 2026 19:00:47 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix:=20csrf=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/auth/controller/AuthController.java | 35 +++++++++++++++---- .../mopl/auth/controller/api/AuthApi.java | 6 ++-- .../mopl/auth/filter/CsrfCookieFilter.java | 31 ++++++++++++++++ .../team5/mopl/config/SecurityConfig.java | 7 ++++ 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java diff --git a/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java b/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java index eed7b7e1..de79d1b1 100644 --- a/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java +++ b/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java @@ -7,6 +7,7 @@ import com.codeit.team5.mopl.auth.service.AuthService; import com.codeit.team5.mopl.auth.service.model.AuthPayload; import jakarta.validation.Valid; +import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -34,15 +35,27 @@ public class AuthController implements AuthApi { consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE ) public ResponseEntity login( - @Valid @ModelAttribute SignInRequest request + @Valid @ModelAttribute SignInRequest request, + CsrfToken csrfToken ) { log.info("Login request: POST /api/auth/sign-in"); AuthPayload authPayload = authService.login(request); ResponseCookie refreshTokenCookie = cookieManager.createCookie(authPayload.refreshToken()); + ResponseCookie csrfCookie = ResponseCookie.from("XSRF-TOKEN", csrfToken.getToken()) + .path("/") + .maxAge(Duration.ofHours(1)) + .httpOnly(false) + .secure(false) + .sameSite("Lax") + .build(); + return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .headers(headers -> { + headers.add(HttpHeaders.SET_COOKIE, csrfCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + }) .body(authPayload.jwtResponse()); } @@ -62,17 +75,27 @@ public ResponseEntity logout( @PostMapping("/refresh") public ResponseEntity refresh( - @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken + @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken, + CsrfToken csrfToken ) { - log.info("RefreshToken regenerate request: POST /api/auth/refresh"); - AuthPayload authPayload = authService.refresh(refreshToken); ResponseCookie refreshTokenCookie = cookieManager.createCookie(authPayload.refreshToken()); + ResponseCookie csrfCookie = ResponseCookie.from("XSRF-TOKEN", csrfToken.getToken()) + .path("/") + .maxAge(Duration.ofHours(1)) + .httpOnly(false) + .secure(false) + .sameSite("Lax") + .build(); + return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .headers(headers -> { + headers.add(HttpHeaders.SET_COOKIE, csrfCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + }) .body(authPayload.jwtResponse()); } diff --git a/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java b/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java index 619a609a..961b59c0 100644 --- a/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java +++ b/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java @@ -56,7 +56,8 @@ public interface AuthApi { ) }) ResponseEntity login( - @Valid @ModelAttribute SignInRequest request + @Valid @ModelAttribute SignInRequest request, + CsrfToken csrfToken ); @Operation( @@ -106,7 +107,8 @@ ResponseEntity logout( }) ResponseEntity refresh( @Parameter(description = "Refresh Token Cookie", required = false) - @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken + @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken, + CsrfToken csrfToken ); @Operation( diff --git a/src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java b/src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java new file mode 100644 index 00000000..ca748562 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java @@ -0,0 +1,31 @@ +package com.codeit.team5.mopl.auth.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + CsrfToken csrfToken = + (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + + if (csrfToken != null) { + csrfToken.getToken(); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java index 88b36d66..c9b58ced 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.config; +import com.codeit.team5.mopl.auth.filter.CsrfCookieFilter; import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; import com.codeit.team5.mopl.auth.handler.UserAccessDeniedHandler; import com.codeit.team5.mopl.auth.handler.UserAuthenticationEntryPoint; @@ -16,6 +17,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @@ -25,6 +27,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CsrfCookieFilter csrfCookieFilter; private final UserAuthenticationEntryPoint userAuthenticationEntryPoint; private final UserAccessDeniedHandler userAccessDeniedHandler; private final MoplAuthenticationProvider moplAuthenticationProvider; @@ -77,6 +80,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class ) + .addFilterAfter( + csrfCookieFilter, + CsrfFilter.class + ) .build(); } From aceb65bcffd21c58915b20adf441af39f6dbc85b Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 13:46:30 +0900 Subject: [PATCH 04/16] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java --- .../AuthControllerIntegrationTest.java | 13 +-- .../auth/controller/AuthControllerTest.java | 14 +-- .../UserControllerIntegrationTest.java | 64 +++++++++++++ .../user/controller/UserControllerTest.java | 95 +++++++++++++++++++ .../user/repository/UserRepositoryTest.java | 42 ++++++++ .../mopl/user/service/UserServiceTest.java | 53 +++++++++++ 6 files changed, 269 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java index 646e7666..231f6bd1 100644 --- a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java @@ -66,14 +66,14 @@ void login_success() throws Exception { .param("username", request.username()) .param("password", request.password())) .andExpect(status().isOk()) - .andExpect(header().string(HttpHeaders.SET_COOKIE, - Matchers.allOf( + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + Matchers.hasItem(Matchers.allOf( Matchers.containsString("REFRESH_TOKEN="), Matchers.containsString("Path=/api/auth"), Matchers.containsString("Max-Age=25200"), Matchers.containsString("HttpOnly"), Matchers.containsString("SameSite=Lax") - ))) + )))) .andExpect(jsonPath("$.accessToken").isNotEmpty()) .andExpect(jsonPath("$.refreshToken").doesNotExist()) .andExpect(jsonPath("$.userDto.email").value(request.username())) @@ -210,13 +210,14 @@ void refresh_afterReLoginWithLatestRefreshToken_success() throws Exception { mockMvc.perform(post("/api/auth/refresh") .cookie(latestRefreshTokenCookie)) .andExpect(status().isOk()) - .andExpect(header().string(HttpHeaders.SET_COOKIE, - Matchers.allOf( + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + Matchers.hasItem(Matchers.allOf( Matchers.containsString("REFRESH_TOKEN="), Matchers.containsString("Path=/api/auth"), + Matchers.containsString("Max-Age=25200"), Matchers.containsString("HttpOnly"), Matchers.containsString("SameSite=Lax") - ))) + )))) .andExpect(jsonPath("$.accessToken").isNotEmpty()) .andExpect(jsonPath("$.refreshToken").doesNotExist()) .andExpect(jsonPath("$.userDto.email").value(request.username())); diff --git a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java index 48486bb5..fb2d95bb 100644 --- a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java @@ -119,14 +119,14 @@ void login_success() throws Exception { .param("username", request.username()) .param("password", request.password())) .andExpect(status().isOk()) - .andExpect(header().string(HttpHeaders.SET_COOKIE, - org.hamcrest.Matchers.allOf( + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + org.hamcrest.Matchers.hasItem(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers.containsString("REFRESH_TOKEN=refresh-token"), org.hamcrest.Matchers.containsString("Path=/api/auth"), org.hamcrest.Matchers.containsString("Max-Age=25200"), org.hamcrest.Matchers.containsString("HttpOnly"), org.hamcrest.Matchers.containsString("SameSite=Lax") - ))) + )))) .andExpect(jsonPath("$.accessToken").value("access-token")) .andExpect(jsonPath("$.refreshToken").doesNotExist()) .andExpect(jsonPath("$.userDto.id").value(userId.toString())) @@ -485,12 +485,14 @@ void refresh_success() throws Exception { mockMvc.perform(post("/api/auth/refresh") .cookie(new jakarta.servlet.http.Cookie("REFRESH_TOKEN", "refresh-token"))) .andExpect(status().isOk()) - .andExpect(header().string(HttpHeaders.SET_COOKIE, - org.hamcrest.Matchers.allOf( + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + org.hamcrest.Matchers.hasItem(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers.containsString("REFRESH_TOKEN=new-refresh-token"), + org.hamcrest.Matchers.containsString("Path=/api/auth"), org.hamcrest.Matchers.containsString("Max-Age=25200"), + org.hamcrest.Matchers.containsString("HttpOnly"), org.hamcrest.Matchers.containsString("SameSite=Lax") - ))) + )))) .andExpect(jsonPath("$.accessToken").value("new-access-token")) .andExpect(jsonPath("$.refreshToken").doesNotExist()); diff --git a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java index 4d5168f6..e4d0f970 100644 --- a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java @@ -308,6 +308,70 @@ private Authentication authOf(UUID userId) { return new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities()); } + @Test + @DisplayName("관리자가 사용자 목록을 조회하면 커서 응답을 반환한다") + void getUsers_byAdmin_success() throws Exception { + // Given + SignInRequest adminRequest = saveLoginUser("list-admin@example.com", "password1", UserRole.ADMIN, false); + User first = saveUser("list-target-a@example.com", "password1", UserRole.USER, false); + saveUser("list-target-b@example.com", "password1", UserRole.USER, true); + String adminAccessToken = login(adminRequest); + + // When & Then + mockMvc.perform(get("/api/users") + .header("Authorization", "Bearer " + adminAccessToken) + .param("emailLike", "list-target") + .param("roleEqual", "USER") + .param("limit", "1") + .param("sortDirection", "ASCENDING") + .param("sortBy", "email")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(first.getId().toString())) + .andExpect(jsonPath("$.data[0].email").value("list-target-a@example.com")) + .andExpect(jsonPath("$.data[0].name").value("사용자")) + .andExpect(jsonPath("$.data[0].role").value("USER")) + .andExpect(jsonPath("$.data[0].locked").value(false)) + .andExpect(jsonPath("$.nextCursor").value("list-target-a@example.com")) + .andExpect(jsonPath("$.nextIdAfter").value(first.getId().toString())) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.totalCount").value(2)) + .andExpect(jsonPath("$.sortBy").value("email")) + .andExpect(jsonPath("$.sortDirection").value("ASCENDING")); + } + + @Test + @DisplayName("일반 사용자가 사용자 목록을 조회하면 403 접근 거부 응답을 반환한다") + void getUsers_byUser_returnsForbidden() throws Exception { + // Given + SignInRequest userRequest = saveLoginUser("list-user@example.com", "password1", UserRole.USER, false); + String accessToken = login(userRequest); + + // When & Then + mockMvc.perform(get("/api/users") + .header("Authorization", "Bearer " + accessToken) + .param("limit", "10") + .param("sortDirection", "ASCENDING") + .param("sortBy", "name")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("FORBIDDEN")) + .andExpect(jsonPath("$.message").value("접근 권한이 없습니다.")) + .andExpect(jsonPath("$.details").doesNotExist()); + } + + @Test + @DisplayName("인증 없이 사용자 목록을 조회하면 401 인증 실패 응답을 반환한다") + void getUsers_unauthenticated_returnsUnauthorized() throws Exception { + // When & Then + mockMvc.perform(get("/api/users") + .param("limit", "10") + .param("sortDirection", "ASCENDING") + .param("sortBy", "name")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.exceptionType").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")) + .andExpect(jsonPath("$.details").doesNotExist()); + } + @Test @DisplayName("관리자가 사용자 권한을 변경하면 DB 권한이 변경되고 토큰이 무효화된다") void updateRole_byAdmin_success() throws Exception { diff --git a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java index 97d0480a..52f890ac 100644 --- a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java @@ -25,7 +25,9 @@ import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; import com.codeit.team5.mopl.auth.security.details.MoplUserDetailsService; import com.codeit.team5.mopl.auth.security.provider.MoplAuthenticationProvider; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest; @@ -40,6 +42,7 @@ import com.codeit.team5.mopl.user.service.UserService; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,6 +50,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; @@ -388,6 +392,97 @@ private Authentication authOf(UUID userId) { return authOf(userId, "USER", false); } + @Test + @DisplayName("관리자가 사용자 목록을 조회하면 커서 응답을 반환한다") + void getUsers_byAdmin_success() throws Exception { + // Given + UUID userId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UserResponse user = new UserResponse( + userId, + Instant.parse("2026-06-23T00:00:00Z"), + "admin@example.com", + "관리자", + null, + "ADMIN", + false + ); + CursorResponse response = new CursorResponse<>( + List.of(user), + "admin@example.com", + userId.toString(), + true, + 3, + "email", + "DESCENDING" + ); + given(userService.findUsers(any(UserCursorRequest.class))).willReturn(response); + + // When & Then + mockMvc.perform(get("/api/users") + .with(authentication(authOf(UUID.randomUUID(), "ADMIN", false))) + .param("emailLike", "admin") + .param("roleEqual", "ADMIN") + .param("isLocked", "false") + .param("limit", "1") + .param("sortDirection", "DESCENDING") + .param("sortBy", "email")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(userId.toString())) + .andExpect(jsonPath("$.data[0].email").value("admin@example.com")) + .andExpect(jsonPath("$.data[0].name").value("관리자")) + .andExpect(jsonPath("$.data[0].role").value("ADMIN")) + .andExpect(jsonPath("$.data[0].locked").value(false)) + .andExpect(jsonPath("$.nextCursor").value("admin@example.com")) + .andExpect(jsonPath("$.nextIdAfter").value(userId.toString())) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.totalCount").value(3)) + .andExpect(jsonPath("$.sortBy").value("email")) + .andExpect(jsonPath("$.sortDirection").value("DESCENDING")); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UserCursorRequest.class); + verify(userService).findUsers(requestCaptor.capture()); + UserCursorRequest captured = requestCaptor.getValue(); + assertThat(captured.emailLike()).isEqualTo("admin"); + assertThat(captured.roleEqual()).isEqualTo(UserRole.ADMIN); + assertThat(captured.isLocked()).isFalse(); + assertThat(captured.limit()).isEqualTo(1); + assertThat(captured.sortDirection()).isEqualTo(Sort.Direction.DESC); + assertThat(captured.sortBy().getValue()).isEqualTo("email"); + } + + @Test + @DisplayName("일반 사용자가 사용자 목록을 조회하면 403 접근 거부 응답을 반환한다") + void getUsers_byUser_returnsForbidden() throws Exception { + // When & Then + mockMvc.perform(get("/api/users") + .with(authentication(authOf(UUID.randomUUID(), "USER", false))) + .param("limit", "10") + .param("sortDirection", "ASCENDING") + .param("sortBy", "name")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("FORBIDDEN")) + .andExpect(jsonPath("$.message").value("접근 권한이 없습니다.")) + .andExpect(jsonPath("$.details").doesNotExist()); + + verify(userService, never()).findUsers(any()); + } + + @Test + @DisplayName("인증 없이 사용자 목록을 조회하면 401 인증 실패 응답을 반환한다") + void getUsers_unauthenticated_returnsUnauthorized() throws Exception { + // When & Then + mockMvc.perform(get("/api/users") + .param("limit", "10") + .param("sortDirection", "ASCENDING") + .param("sortBy", "name")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.exceptionType").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")) + .andExpect(jsonPath("$.details").doesNotExist()); + + verify(userService, never()).findUsers(any()); + } + @Test @DisplayName("관리자가 사용자 권한 변경 요청하면 204 응답을 반환한다") void updateRole_success() throws Exception { diff --git a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java index 113bb0a5..454718af 100644 --- a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java @@ -7,9 +7,13 @@ import com.codeit.team5.mopl.TestcontainersConfiguration; import com.codeit.team5.mopl.config.JpaAuditingConfig; import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; +import com.codeit.team5.mopl.user.constant.UserSortBy; +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.entity.UserRole; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -19,6 +23,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; @DataJpaTest @@ -136,6 +141,43 @@ void existsByEmail_returnFalse() { assertThat(result).isFalse(); } + @Test + @DisplayName("사용자 목록을 조건으로 필터링하고 이메일 오름차순으로 조회한다") + void findUsers_filterByRoleAndLocked_success() { + // Given + User first = User.create("list-a@example.com", "password", "목록 A"); + User second = User.create("list-b@example.com", "password", "목록 B"); + User locked = User.create("list-c@example.com", "password", "목록 C"); + locked.updateLocked(true); + User admin = User.create("list-admin@example.com", "password", "목록 관리자"); + admin.updateRole(UserRole.ADMIN); + + userRepository.saveAll(List.of(second, admin, locked, first)); + entityManager.flush(); + entityManager.clear(); + + UserCursorRequest request = new UserCursorRequest( + "list", + UserRole.USER, + false, + null, + null, + 10, + Sort.Direction.ASC, + UserSortBy.EMAIL + ); + + // When + List result = userRepository.findUsers(request, 10); + long count = userRepository.countUsers(request); + + // Then + assertThat(result) + .extracting(User::getEmail) + .containsExactly("list-a@example.com", "list-b@example.com"); + assertThat(count).isEqualTo(2); + } + @Test @DisplayName("식별자로 사용자를 비관적 쓰기 잠금으로 조회한다") void findByIdForUpdate_success() { diff --git a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java index b972b584..4d103cf5 100644 --- a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java @@ -15,7 +15,10 @@ import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.dto.FileRequest; +import com.codeit.team5.mopl.user.constant.UserSortBy; +import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest; @@ -32,6 +35,7 @@ import com.codeit.team5.mopl.user.mapper.UserMapper; import com.codeit.team5.mopl.user.repository.UserRepository; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -42,6 +46,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.util.ReflectionTestUtils; @@ -175,6 +180,54 @@ void getById_notFound_throwsException() { verifyNoInteractions(userMapper); } + @Test + @DisplayName("사용자 목록 조회 성공") + void findUsers_success() { + // Given + UserCursorRequest request = new UserCursorRequest( + "user", + UserRole.USER, + false, + null, + null, + 2, + Sort.Direction.ASC, + UserSortBy.EMAIL + ); + User first = User.create("a-user@example.com", "encoded-password", "사용자A"); + User second = User.create("b-user@example.com", "encoded-password", "사용자B"); + User extra = User.create("c-user@example.com", "encoded-password", "사용자C"); + List fetched = List.of(first, second, extra); + CursorResponse expectedResponse = new CursorResponse<>( + List.of( + new UserResponse(UUID.randomUUID(), Instant.parse("2026-06-22T00:00:00Z"), + "a-user@example.com", "사용자A", null, "USER", false), + new UserResponse(UUID.randomUUID(), Instant.parse("2026-06-22T00:00:01Z"), + "b-user@example.com", "사용자B", null, "USER", false) + ), + "b-user@example.com", + UUID.randomUUID().toString(), + true, + 3, + "email", + "ASCENDING" + ); + + when(userRepository.findUsers(request, 3)).thenReturn(fetched); + when(userRepository.countUsers(request)).thenReturn(3L); + when(userMapper.toCursor(List.of(first, second), true, 3L, UserSortBy.EMAIL, Sort.Direction.ASC)) + .thenReturn(expectedResponse); + + // When + CursorResponse result = userService.findUsers(request); + + // Then + assertThat(result).isSameAs(expectedResponse); + verify(userRepository).findUsers(request, 3); + verify(userRepository).countUsers(request); + verify(userMapper).toCursor(List.of(first, second), true, 3L, UserSortBy.EMAIL, Sort.Direction.ASC); + } + @Test @DisplayName("프로필 이름만 변경 성공") void update_nameOnly_success() { From 289a9dd85d214d391291536ba8bb1e7df9672d58 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 09:22:37 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20csrf=20=ED=86=A0=ED=81=B0=EC=9D=B4?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/auth/filter/CsrfCookieFilter.java | 31 --------------- .../handler/SpaCsrfTokenRequestHandler.java | 38 +++++++++++++++++++ .../team5/mopl/config/SecurityConfig.java | 14 +------ 3 files changed, 40 insertions(+), 43 deletions(-) delete mode 100644 src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java create mode 100644 src/main/java/com/codeit/team5/mopl/auth/handler/SpaCsrfTokenRequestHandler.java diff --git a/src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java b/src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java deleted file mode 100644 index ca748562..00000000 --- a/src/main/java/com/codeit/team5/mopl/auth/filter/CsrfCookieFilter.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.codeit.team5.mopl.auth.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -public class CsrfCookieFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { - - CsrfToken csrfToken = - (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - - if (csrfToken != null) { - csrfToken.getToken(); - } - - filterChain.doFilter(request, response); - } -} diff --git a/src/main/java/com/codeit/team5/mopl/auth/handler/SpaCsrfTokenRequestHandler.java b/src/main/java/com/codeit/team5/mopl/auth/handler/SpaCsrfTokenRequestHandler.java new file mode 100644 index 00000000..f43158b6 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/auth/handler/SpaCsrfTokenRequestHandler.java @@ -0,0 +1,38 @@ +package com.codeit.team5.mopl.auth.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.function.Supplier; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.util.StringUtils; + +public final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + + private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + Supplier csrfToken + ) { + // 응답 렌더링 시 BREACH 방어 + this.xor.handle(request, response, csrfToken); + // 지연 토큰 강제 로드 -> 쿠키에 기록 + csrfToken.get(); + } + + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + String headerValue = request.getHeader(csrfToken.getHeaderName()); + + // 헤더(X-XSRF-TOKEN)로 온 raw 토큰은 plain, 그 외는 xor로 해석 + return StringUtils.hasText(headerValue) + ? this.plain.resolveCsrfTokenValue(request, csrfToken) + : this.xor.resolveCsrfTokenValue(request, csrfToken); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java index c9b58ced..29b78ac4 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -1,7 +1,7 @@ package com.codeit.team5.mopl.config; -import com.codeit.team5.mopl.auth.filter.CsrfCookieFilter; import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; +import com.codeit.team5.mopl.auth.handler.SpaCsrfTokenRequestHandler; import com.codeit.team5.mopl.auth.handler.UserAccessDeniedHandler; import com.codeit.team5.mopl.auth.handler.UserAuthenticationEntryPoint; import com.codeit.team5.mopl.auth.security.provider.MoplAuthenticationProvider; @@ -17,8 +17,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfFilter; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @Configuration @@ -27,7 +25,6 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CsrfCookieFilter csrfCookieFilter; private final UserAuthenticationEntryPoint userAuthenticationEntryPoint; private final UserAccessDeniedHandler userAccessDeniedHandler; private final MoplAuthenticationProvider moplAuthenticationProvider; @@ -37,13 +34,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti PathPatternRequestMatcher.Builder paths = PathPatternRequestMatcher.withDefaults(); - CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = - new CsrfTokenRequestAttributeHandler(); - return http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(csrfTokenRequestHandler) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) .ignoringRequestMatchers( paths.matcher(HttpMethod.POST, "/api/auth/refresh") ) @@ -80,10 +74,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class ) - .addFilterAfter( - csrfCookieFilter, - CsrfFilter.class - ) .build(); } From 61a74d9df3475d225d0f840aa5966a9b8bd80baa Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 09:29:55 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20csrf=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/auth/controller/AuthController.java | 36 ++++--------------- .../mopl/auth/controller/api/AuthApi.java | 6 ++-- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java b/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java index de79d1b1..12719710 100644 --- a/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java +++ b/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java @@ -35,27 +35,15 @@ public class AuthController implements AuthApi { consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE ) public ResponseEntity login( - @Valid @ModelAttribute SignInRequest request, - CsrfToken csrfToken + @Valid @ModelAttribute SignInRequest request ) { log.info("Login request: POST /api/auth/sign-in"); AuthPayload authPayload = authService.login(request); ResponseCookie refreshTokenCookie = cookieManager.createCookie(authPayload.refreshToken()); - ResponseCookie csrfCookie = ResponseCookie.from("XSRF-TOKEN", csrfToken.getToken()) - .path("/") - .maxAge(Duration.ofHours(1)) - .httpOnly(false) - .secure(false) - .sameSite("Lax") - .build(); - return ResponseEntity.ok() - .headers(headers -> { - headers.add(HttpHeaders.SET_COOKIE, csrfCookie.toString()); - headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); - }) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) .body(authPayload.jwtResponse()); } @@ -73,29 +61,19 @@ public ResponseEntity logout( .build(); } - @PostMapping("/refresh") + @Override public ResponseEntity refresh( - @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken, - CsrfToken csrfToken + @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken ) { + log.info("RefreshToken regenerate request: POST /api/auth/refresh"); + AuthPayload authPayload = authService.refresh(refreshToken); ResponseCookie refreshTokenCookie = cookieManager.createCookie(authPayload.refreshToken()); - ResponseCookie csrfCookie = ResponseCookie.from("XSRF-TOKEN", csrfToken.getToken()) - .path("/") - .maxAge(Duration.ofHours(1)) - .httpOnly(false) - .secure(false) - .sameSite("Lax") - .build(); - return ResponseEntity.ok() - .headers(headers -> { - headers.add(HttpHeaders.SET_COOKIE, csrfCookie.toString()); - headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); - }) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) .body(authPayload.jwtResponse()); } diff --git a/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java b/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java index 961b59c0..619a609a 100644 --- a/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java +++ b/src/main/java/com/codeit/team5/mopl/auth/controller/api/AuthApi.java @@ -56,8 +56,7 @@ public interface AuthApi { ) }) ResponseEntity login( - @Valid @ModelAttribute SignInRequest request, - CsrfToken csrfToken + @Valid @ModelAttribute SignInRequest request ); @Operation( @@ -107,8 +106,7 @@ ResponseEntity logout( }) ResponseEntity refresh( @Parameter(description = "Refresh Token Cookie", required = false) - @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken, - CsrfToken csrfToken + @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken ); @Operation( From 8bd914c51e27fdfc64d33d342ad60ce7dea70263 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 09:30:30 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=83=81=ED=95=9C=EA=B0=92=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/user/dto/request/UserCursorRequest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java index 9cd152dd..3d4f9d11 100644 --- a/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java +++ b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserCursorRequest.java @@ -2,6 +2,7 @@ import com.codeit.team5.mopl.user.constant.UserSortBy; import com.codeit.team5.mopl.user.entity.UserRole; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.util.UUID; @@ -16,6 +17,7 @@ public record UserCursorRequest( @NotNull @Positive + @Max(100) Integer limit, @NotNull From cbc3799b2e73314548f96b6179593e2b55edba22 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 09:40:52 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix:=20=EC=A0=95=EB=A0=AC=EC=8B=9C=20dto?= =?UTF-8?q?=20=EB=B0=94=EC=9D=B8=EB=94=A9=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/user/constant/UserSortBy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java b/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java index e87da521..4f14d292 100644 --- a/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java +++ b/src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java @@ -11,7 +11,7 @@ public enum UserSortBy { NAME("name"), EMAIL("email"), CREATED_AT("createdAt"), - LOCKED("locked"), + LOCKED("isLocked"), ROLE("role") ; From 3cd54fb0a44edc9501216cc2321dc894677abae7 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 09:42:06 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20=EC=BB=A4=EC=84=9C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B0=92=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../querydsl/UserQueryRepositoryImpl.java | 117 ++++++++++++------ 1 file changed, 78 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java index 2c8627f0..bb726c3f 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.user.repository.querydsl; +import com.codeit.team5.mopl.notification.exception.InvalidCursorException; import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.entity.QUser; import com.codeit.team5.mopl.user.entity.User; @@ -9,7 +10,9 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Repository; @@ -76,52 +79,88 @@ private BooleanExpression lockedFilter(Boolean isLocked) { } private void applyCursor(BooleanBuilder where, UserCursorRequest request) { - String cursor = request.cursor(); - if (cursor == null || cursor.isBlank() || request.idAfter() == null) { + if (request.cursor() == null || request.cursor().isBlank() || request.idAfter() == null) { return; } + BooleanExpression cursorCondition = cursorCondition(request); + where.and(cursorCondition); + } + + private BooleanExpression cursorCondition(UserCursorRequest request) { boolean isAsc = request.sortDirection() == Direction.ASC; - BooleanExpression cursorCondition = switch (request.sortBy()) { - case NAME -> isAsc - ? user.name.gt(cursor) - .or(user.name.eq(cursor).and(user.id.gt(request.idAfter()))) - : user.name.lt(cursor) - .or(user.name.eq(cursor).and(user.id.lt(request.idAfter()))); - - case EMAIL -> isAsc - ? user.email.gt(cursor) - .or(user.email.eq(cursor).and(user.id.gt(request.idAfter()))) - : user.email.lt(cursor) - .or(user.email.eq(cursor).and(user.id.lt(request.idAfter()))); - - case CREATED_AT -> { - Instant cursorInstant = Instant.parse(cursor); - yield isAsc - ? user.createdAt.gt(cursorInstant) - .or(user.createdAt.eq(cursorInstant).and(user.id.gt(request.idAfter()))) - : user.createdAt.lt(cursorInstant) - .or(user.createdAt.eq(cursorInstant).and(user.id.lt(request.idAfter()))); - } - - case LOCKED -> { - boolean cursorBoolean = Boolean.parseBoolean(cursor); - yield isAsc - ? user.locked.gt(cursorBoolean) - .or(user.locked.eq(cursorBoolean).and(user.id.gt(request.idAfter()))) - : user.locked.lt(cursorBoolean) - .or(user.locked.eq(cursorBoolean).and(user.id.lt(request.idAfter()))); - } - - case ROLE -> isAsc - ? user.role.stringValue().gt(cursor) - .or(user.role.stringValue().eq(cursor).and(user.id.gt(request.idAfter()))) - : user.role.stringValue().lt(cursor) - .or(user.role.stringValue().eq(cursor).and(user.id.lt(request.idAfter()))); + return switch (request.sortBy()) { + case NAME -> nameCursorCondition(request.cursor(), request.idAfter(), isAsc); + case EMAIL -> emailCursorCondition(request.cursor(), request.idAfter(), isAsc); + case CREATED_AT -> createdAtCursorCondition(request.cursor(), request.idAfter(), isAsc); + case LOCKED -> lockedCursorCondition(request.cursor(), request.idAfter(), isAsc); + case ROLE -> roleCursorCondition(request.cursor(), request.idAfter(), isAsc); }; + } - where.and(cursorCondition); + private BooleanExpression nameCursorCondition(String cursor, UUID idAfter, boolean isAsc) { + return isAsc + ? user.name.gt(cursor) + .or(user.name.eq(cursor).and(user.id.gt(idAfter))) + : user.name.lt(cursor) + .or(user.name.eq(cursor).and(user.id.lt(idAfter))); + } + + private BooleanExpression emailCursorCondition(String cursor, UUID idAfter, boolean isAsc) { + return isAsc + ? user.email.gt(cursor) + .or(user.email.eq(cursor).and(user.id.gt(idAfter))) + : user.email.lt(cursor) + .or(user.email.eq(cursor).and(user.id.lt(idAfter))); + } + + private BooleanExpression createdAtCursorCondition(String cursor, UUID idAfter, boolean isAsc) { + Instant cursorInstant = parseInstantCursor(cursor); + + return isAsc + ? user.createdAt.gt(cursorInstant) + .or(user.createdAt.eq(cursorInstant).and(user.id.gt(idAfter))) + : user.createdAt.lt(cursorInstant) + .or(user.createdAt.eq(cursorInstant).and(user.id.lt(idAfter))); + } + + private BooleanExpression lockedCursorCondition(String cursor, UUID idAfter, boolean isAsc) { + boolean cursorBoolean = parseBooleanCursor(cursor); + + return isAsc + ? user.locked.gt(cursorBoolean) + .or(user.locked.eq(cursorBoolean).and(user.id.gt(idAfter))) + : user.locked.lt(cursorBoolean) + .or(user.locked.eq(cursorBoolean).and(user.id.lt(idAfter))); + } + + private BooleanExpression roleCursorCondition(String cursor, UUID idAfter, boolean isAsc) { + return isAsc + ? user.role.stringValue().gt(cursor) + .or(user.role.stringValue().eq(cursor).and(user.id.gt(idAfter))) + : user.role.stringValue().lt(cursor) + .or(user.role.stringValue().eq(cursor).and(user.id.lt(idAfter))); + } + + private Instant parseInstantCursor(String cursor) { + try { + return Instant.parse(cursor); + } catch (DateTimeParseException e) { + throw new InvalidCursorException(); + } + } + + private boolean parseBooleanCursor(String cursor) { + if ("true".equalsIgnoreCase(cursor)) { + return true; + } + + if ("false".equalsIgnoreCase(cursor)) { + return false; + } + + throw new InvalidCursorException(); } private OrderSpecifier[] buildOrder(UserCursorRequest request) { From 1aa04f5daf8769bf90544ced8b4f056ad68446b5 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 09:45:25 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-=20swagger?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/user/controller/api/UserApi.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java b/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java index 8d9652f1..51a02ee1 100644 --- a/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java +++ b/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java @@ -143,6 +143,22 @@ ResponseEntity updateLockStatus( @Valid @RequestBody UserLockedUpdateRequest request ); + @Operation( + summary = "[어드민] 사용자 목록 조회", + description = "[어드민 기능] 사용자의 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "목록 조회 성공", + content = @Content(schema = @Schema(implementation = CursorResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "403", description = "권한 오류(관리자만 조회 가능)", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) ResponseEntity> getUsers( @Parameter(hidden = true)UserCursorRequest request ); From cef12a7af43f6989aad6926e806935c4b43c7bdf Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 11:07:23 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=ED=95=98?= =?UTF-8?q?=EB=A9=B4=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/codeit/team5/mopl/auth/controller/AuthController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java b/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java index 12719710..79c07c13 100644 --- a/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java +++ b/src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java @@ -7,7 +7,6 @@ import com.codeit.team5.mopl.auth.service.AuthService; import com.codeit.team5.mopl.auth.service.model.AuthPayload; import jakarta.validation.Valid; -import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -62,6 +61,7 @@ public ResponseEntity logout( } @Override + @PostMapping("/refresh") public ResponseEntity refresh( @CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken ) { From b4bffb52a746964f77865c3087de097becdaa15f Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 11:07:34 +0900 Subject: [PATCH 12/16] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuthControllerIntegrationTest.java | 4 ++ .../auth/controller/AuthControllerTest.java | 6 +++ .../user/repository/UserRepositoryTest.java | 54 +++++++++++++++---- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java index 231f6bd1..83fa62cb 100644 --- a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerIntegrationTest.java @@ -74,6 +74,8 @@ void login_success() throws Exception { Matchers.containsString("HttpOnly"), Matchers.containsString("SameSite=Lax") )))) + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + Matchers.not(Matchers.hasItem(Matchers.containsString("XSRF-TOKEN="))))) .andExpect(jsonPath("$.accessToken").isNotEmpty()) .andExpect(jsonPath("$.refreshToken").doesNotExist()) .andExpect(jsonPath("$.userDto.email").value(request.username())) @@ -218,6 +220,8 @@ void refresh_afterReLoginWithLatestRefreshToken_success() throws Exception { Matchers.containsString("HttpOnly"), Matchers.containsString("SameSite=Lax") )))) + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + Matchers.not(Matchers.hasItem(Matchers.containsString("XSRF-TOKEN="))))) .andExpect(jsonPath("$.accessToken").isNotEmpty()) .andExpect(jsonPath("$.refreshToken").doesNotExist()) .andExpect(jsonPath("$.userDto.email").value(request.username())); diff --git a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java index fb2d95bb..9354ed32 100644 --- a/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/auth/controller/AuthControllerTest.java @@ -127,6 +127,9 @@ void login_success() throws Exception { org.hamcrest.Matchers.containsString("HttpOnly"), org.hamcrest.Matchers.containsString("SameSite=Lax") )))) + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem( + org.hamcrest.Matchers.containsString("XSRF-TOKEN="))))) .andExpect(jsonPath("$.accessToken").value("access-token")) .andExpect(jsonPath("$.refreshToken").doesNotExist()) .andExpect(jsonPath("$.userDto.id").value(userId.toString())) @@ -493,6 +496,9 @@ void refresh_success() throws Exception { org.hamcrest.Matchers.containsString("HttpOnly"), org.hamcrest.Matchers.containsString("SameSite=Lax") )))) + .andExpect(header().stringValues(HttpHeaders.SET_COOKIE, + org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem( + org.hamcrest.Matchers.containsString("XSRF-TOKEN="))))) .andExpect(jsonPath("$.accessToken").value("new-access-token")) .andExpect(jsonPath("$.refreshToken").doesNotExist()); diff --git a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java index 454718af..77cb1076 100644 --- a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java @@ -13,6 +13,7 @@ import com.codeit.team5.mopl.user.entity.UserRole; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -142,40 +143,71 @@ void existsByEmail_returnFalse() { } @Test - @DisplayName("사용자 목록을 조건으로 필터링하고 이메일 오름차순으로 조회한다") + @DisplayName("사용자 목록을 조건으로 필터링하고 이름 오름차순 커서로 이어서 조회한다") void findUsers_filterByRoleAndLocked_success() { // Given User first = User.create("list-a@example.com", "password", "목록 A"); + User sameName = User.create("list-aa@example.com", "password", "목록 A"); User second = User.create("list-b@example.com", "password", "목록 B"); User locked = User.create("list-c@example.com", "password", "목록 C"); locked.updateLocked(true); User admin = User.create("list-admin@example.com", "password", "목록 관리자"); admin.updateRole(UserRole.ADMIN); - userRepository.saveAll(List.of(second, admin, locked, first)); + userRepository.saveAll(List.of(second, admin, locked, sameName, first)); entityManager.flush(); entityManager.clear(); - UserCursorRequest request = new UserCursorRequest( + UserCursorRequest firstPageRequest = new UserCursorRequest( "list", UserRole.USER, false, null, null, - 10, + 1, Sort.Direction.ASC, - UserSortBy.EMAIL + UserSortBy.NAME ); // When - List result = userRepository.findUsers(request, 10); - long count = userRepository.countUsers(request); + List expectedOrder = userRepository.findUsers(firstPageRequest, 10); + List firstPage = userRepository.findUsers(firstPageRequest, 1); + User lastUserOfFirstPage = firstPage.get(0); + UserCursorRequest secondPageRequest = new UserCursorRequest( + "list", + UserRole.USER, + false, + lastUserOfFirstPage.getName(), + lastUserOfFirstPage.getId(), + 10, + Sort.Direction.ASC, + UserSortBy.NAME + ); + List secondPage = userRepository.findUsers(secondPageRequest, 10); + long count = userRepository.countUsers(firstPageRequest); // Then - assertThat(result) - .extracting(User::getEmail) - .containsExactly("list-a@example.com", "list-b@example.com"); - assertThat(count).isEqualTo(2); + List combinedPages = new ArrayList<>(firstPage); + combinedPages.addAll(secondPage); + + assertThat(expectedOrder) + .extracting(User::getName) + .containsExactly("목록 A", "목록 A", "목록 B"); + assertThat(combinedPages) + .extracting(User::getId) + .containsExactlyElementsOf(expectedOrder.stream() + .map(User::getId) + .toList()); + assertThat(firstPage) + .extracting(User::getId) + .doesNotContainAnyElementsOf(secondPage.stream() + .map(User::getId) + .toList()); + assertThat(firstPage).containsExactly(expectedOrder.get(0)); + assertThat(secondPage.get(0)).isEqualTo(expectedOrder.get(1)); + assertThat(secondPage.get(0).getName()).isEqualTo(lastUserOfFirstPage.getName()); + assertThat(secondPage.get(0).getId()).isEqualTo(expectedOrder.get(1).getId()); + assertThat(count).isEqualTo(3); } @Test From f06f2eb1446aff4b2e8c46a9475838585fee5fca Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 13:17:49 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/querydsl/UserQueryRepositoryImpl.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java index bb726c3f..7bf59ebd 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/querydsl/UserQueryRepositoryImpl.java @@ -79,10 +79,19 @@ private BooleanExpression lockedFilter(Boolean isLocked) { } private void applyCursor(BooleanBuilder where, UserCursorRequest request) { - if (request.cursor() == null || request.cursor().isBlank() || request.idAfter() == null) { + boolean hasCursor = request.cursor() != null && !request.cursor().isBlank(); + boolean hasIdAfter = request.idAfter() != null; + + // cursor와 idAfter가 모두 없으면 첫 페이지를 조회 + if (!hasCursor && !hasIdAfter) { return; } + // cursor와 idAfter 중 하나만 전달된 경우 잘못된 요청으로 처리 + if (hasCursor != hasIdAfter) { + throw new InvalidCursorException(); + } + BooleanExpression cursorCondition = cursorCondition(request); where.and(cursorCondition); } From c21e48be1bcc017839d84931af35bda1825eb13b Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 13:25:58 +0900 Subject: [PATCH 14/16] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/repository/UserRepositoryTest.java | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java index 77cb1076..fa605b18 100644 --- a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java @@ -7,6 +7,7 @@ import com.codeit.team5.mopl.TestcontainersConfiguration; import com.codeit.team5.mopl.config.JpaAuditingConfig; import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; +import com.codeit.team5.mopl.notification.exception.InvalidCursorException; import com.codeit.team5.mopl.user.constant.UserSortBy; import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.entity.User; @@ -210,6 +211,170 @@ void findUsers_filterByRoleAndLocked_success() { assertThat(count).isEqualTo(3); } + @Test + @DisplayName("생성일 커서로 사용자 목록을 이어서 조회한다") + void findUsers_createdAtCursor_success() { + // Given + User first = User.create("created-cursor-a@example.com", "password", "생성일 A"); + User second = User.create("created-cursor-b@example.com", "password", "생성일 B"); + User third = User.create("created-cursor-c@example.com", "password", "생성일 C"); + + userRepository.saveAll(List.of(third, first, second)); + entityManager.flush(); + entityManager.clear(); + + UserCursorRequest firstPageRequest = new UserCursorRequest( + "created-cursor", + UserRole.USER, + false, + null, + null, + 1, + Sort.Direction.ASC, + UserSortBy.CREATED_AT + ); + + // When + List expectedOrder = userRepository.findUsers(firstPageRequest, 10); + List firstPage = userRepository.findUsers(firstPageRequest, 1); + User lastUserOfFirstPage = firstPage.get(0); + UserCursorRequest secondPageRequest = new UserCursorRequest( + "created-cursor", + UserRole.USER, + false, + lastUserOfFirstPage.getCreatedAt().toString(), + lastUserOfFirstPage.getId(), + 10, + Sort.Direction.ASC, + UserSortBy.CREATED_AT + ); + List secondPage = userRepository.findUsers(secondPageRequest, 10); + + // Then + List combinedPages = new ArrayList<>(firstPage); + combinedPages.addAll(secondPage); + + assertThat(expectedOrder).hasSize(3); + assertThat(combinedPages) + .extracting(User::getId) + .containsExactlyElementsOf(expectedOrder.stream() + .map(User::getId) + .toList()); + assertThat(firstPage) + .extracting(User::getId) + .doesNotContainAnyElementsOf(secondPage.stream() + .map(User::getId) + .toList()); + assertThat(firstPage).containsExactly(expectedOrder.get(0)); + assertThat(secondPage.get(0)).isEqualTo(expectedOrder.get(1)); + assertThat(lastUserOfFirstPage.getCreatedAt()).isNotNull(); + assertThat(secondPage.get(0).getCreatedAt()) + .isAfterOrEqualTo(lastUserOfFirstPage.getCreatedAt()); + } + + @Test + @DisplayName("잠금 상태 커서로 사용자 목록을 이어서 조회한다") + void findUsers_lockedCursor_success() { + // Given + User firstUnlocked = User.create("locked-cursor-a@example.com", "password", "잠금 A"); + User secondUnlocked = User.create("locked-cursor-b@example.com", "password", "잠금 B"); + User firstLocked = User.create("locked-cursor-c@example.com", "password", "잠금 C"); + firstLocked.updateLocked(true); + User secondLocked = User.create("locked-cursor-d@example.com", "password", "잠금 D"); + secondLocked.updateLocked(true); + + userRepository.saveAll(List.of(firstLocked, secondUnlocked, secondLocked, firstUnlocked)); + entityManager.flush(); + entityManager.clear(); + + UserCursorRequest firstPageRequest = new UserCursorRequest( + "locked-cursor", + UserRole.USER, + null, + null, + null, + 1, + Sort.Direction.ASC, + UserSortBy.LOCKED + ); + + // When + List expectedOrder = userRepository.findUsers(firstPageRequest, 10); + List firstPage = userRepository.findUsers(firstPageRequest, 1); + User lastUserOfFirstPage = firstPage.get(0); + UserCursorRequest secondPageRequest = new UserCursorRequest( + "locked-cursor", + UserRole.USER, + null, + Boolean.toString(lastUserOfFirstPage.isLocked()), + lastUserOfFirstPage.getId(), + 10, + Sort.Direction.ASC, + UserSortBy.LOCKED + ); + List secondPage = userRepository.findUsers(secondPageRequest, 10); + + // Then + List combinedPages = new ArrayList<>(firstPage); + combinedPages.addAll(secondPage); + + assertThat(expectedOrder) + .extracting(User::isLocked) + .containsExactly(false, false, true, true); + assertThat(combinedPages) + .extracting(User::getId) + .containsExactlyElementsOf(expectedOrder.stream() + .map(User::getId) + .toList()); + assertThat(firstPage) + .extracting(User::getId) + .doesNotContainAnyElementsOf(secondPage.stream() + .map(User::getId) + .toList()); + assertThat(firstPage).containsExactly(expectedOrder.get(0)); + assertThat(secondPage.get(0)).isEqualTo(expectedOrder.get(1)); + assertThat(secondPage.get(0).isLocked()).isEqualTo(lastUserOfFirstPage.isLocked()); + assertThat(secondPage.get(0).getId()).isEqualTo(expectedOrder.get(1).getId()); + } + + @Test + @DisplayName("잘못된 커서 값이면 예외가 발생한다") + void findUsers_malformedCursor_throwsException() { + // Given + User user = User.create("malformed-cursor@example.com", "password", "잘못된 커서"); + User savedUser = userRepository.saveAndFlush(user); + entityManager.clear(); + + UserCursorRequest invalidCreatedAtCursorRequest = new UserCursorRequest( + "malformed-cursor", + UserRole.USER, + false, + "not-an-instant", + savedUser.getId(), + 10, + Sort.Direction.ASC, + UserSortBy.CREATED_AT + ); + UserCursorRequest invalidLockedCursorRequest = new UserCursorRequest( + "malformed-cursor", + UserRole.USER, + false, + "not-a-boolean", + savedUser.getId(), + 10, + Sort.Direction.ASC, + UserSortBy.LOCKED + ); + + // When & Then + assertThatThrownBy(() -> userRepository.findUsers(invalidCreatedAtCursorRequest, 10)) + .isInstanceOf(InvalidCursorException.class) + .hasMessage("커서 값이 유효하지 않습니다."); + assertThatThrownBy(() -> userRepository.findUsers(invalidLockedCursorRequest, 10)) + .isInstanceOf(InvalidCursorException.class) + .hasMessage("커서 값이 유효하지 않습니다."); + } + @Test @DisplayName("식별자로 사용자를 비관적 쓰기 잠금으로 조회한다") void findByIdForUpdate_success() { From 565d58b185c9115eb663dd6bbe22e331b43688d4 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 14:13:55 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=97=90=EC=84=9C=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java index 29b78ac4..a1681329 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -60,6 +60,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.GET, "/api/users/*").authenticated() .requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated() + .requestMatchers("/api/users/**").authenticated() .requestMatchers("/api/follows/**").authenticated() .requestMatchers("/api/notifications/**").authenticated() From 7e33cc1e0629b97b55aa7956917a80d25bff5ff5 Mon Sep 17 00:00:00 2001 From: shong9124 Date: Wed, 1 Jul 2026 14:23:08 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/repository/UserRepositoryTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java index fa605b18..a26d9f5c 100644 --- a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java @@ -337,6 +337,71 @@ void findUsers_lockedCursor_success() { assertThat(secondPage.get(0).getId()).isEqualTo(expectedOrder.get(1).getId()); } + @Test + @DisplayName("권한 커서로 사용자 목록을 이어서 조회한다") + void findUsers_roleCursor_success() { + // Given + User firstUser = User.create("role-cursor-a@example.com", "password", "권한 A"); + User secondUser = User.create("role-cursor-b@example.com", "password", "권한 B"); + User firstAdmin = User.create("role-cursor-c@example.com", "password", "권한 C"); + firstAdmin.updateRole(UserRole.ADMIN); + User secondAdmin = User.create("role-cursor-d@example.com", "password", "권한 D"); + secondAdmin.updateRole(UserRole.ADMIN); + + userRepository.saveAll(List.of(secondUser, firstAdmin, firstUser, secondAdmin)); + entityManager.flush(); + entityManager.clear(); + + UserCursorRequest firstPageRequest = new UserCursorRequest( + "role-cursor", + null, + false, + null, + null, + 1, + Sort.Direction.ASC, + UserSortBy.ROLE + ); + + // When + List expectedOrder = userRepository.findUsers(firstPageRequest, 10); + List firstPage = userRepository.findUsers(firstPageRequest, 1); + User lastUserOfFirstPage = firstPage.get(0); + UserCursorRequest secondPageRequest = new UserCursorRequest( + "role-cursor", + null, + false, + lastUserOfFirstPage.getRole().name(), + lastUserOfFirstPage.getId(), + 10, + Sort.Direction.ASC, + UserSortBy.ROLE + ); + List secondPage = userRepository.findUsers(secondPageRequest, 10); + + // Then + List combinedPages = new ArrayList<>(firstPage); + combinedPages.addAll(secondPage); + + assertThat(expectedOrder) + .extracting(User::getRole) + .containsExactly(UserRole.ADMIN, UserRole.ADMIN, UserRole.USER, UserRole.USER); + assertThat(combinedPages) + .extracting(User::getId) + .containsExactlyElementsOf(expectedOrder.stream() + .map(User::getId) + .toList()); + assertThat(firstPage) + .extracting(User::getId) + .doesNotContainAnyElementsOf(secondPage.stream() + .map(User::getId) + .toList()); + assertThat(firstPage).containsExactly(expectedOrder.get(0)); + assertThat(secondPage.get(0)).isEqualTo(expectedOrder.get(1)); + assertThat(secondPage.get(0).getRole()).isEqualTo(lastUserOfFirstPage.getRole()); + assertThat(secondPage.get(0).getId()).isEqualTo(expectedOrder.get(1).getId()); + } + @Test @DisplayName("잘못된 커서 값이면 예외가 발생한다") void findUsers_malformedCursor_throwsException() {