From f3dc95da956f767c7ce4192bfbea3b8d4ba900be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:30:41 +0900 Subject: [PATCH 01/17] =?UTF-8?q?chore:=20CORS=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=84=EC=97=90=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EB=B0=9C=20=EC=A3=BC=EC=86=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vite 기본 포트(5173)와 Vercel dev 프리뷰 주소 추가 - 이 목록은 CookieCsrfProtectionFilter 의 신뢰 출처로도 쓰인다 --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 383e1d1..994c6eb 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,8 @@ COOKIE_SAME_SITE=Lax # CORS 허용 오리진. 콤마로 구분 # POST /api/v1/auth/refresh의 CSRF 방어에도 쓰인다. 이 목록에 없는 오리진의 재발급 요청은 403으로 차단된다 -CORS_ALLOWED_ORIGINS=http://localhost:3000 +# 프론트 로컬은 Next.js 3000, Vite 5173. Vercel 프리뷰는 브랜치마다 주소가 달라 개별 등록이 필요하다 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://slate-to-fe-git-dev-kcleverp-7022s-projects.vercel.app # 인증번호 발송용 Gmail SMTP 계정 # MAIL_PASSWORD는 계정 비밀번호가 아니라 2단계 인증을 켠 뒤 발급한 16자리 앱 비밀번호다 From f943630324512507bfedee494353b3d869d4000d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:32:17 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/v1/users/me 추가, 동의하지 않으면 400 - 물리 삭제 대신 deleted_at 기록과 개인정보 익명화로 처리한다. 연관 데이터가 유저를 FK 로 참조하고 있어 행을 지우면 이력이 끊긴다 - email 과 nickname 이 NOT NULL 이라 NULL 대신 id 를 섞은 고유 값으로 덮는다. email 유니크 제약을 피하면서 원래 주소가 사라져 같은 이메일로 재가입할 수 있다 - 포트폴리오 일괄 soft delete, 리프레시 토큰 삭제, 쿠키 만료 --- .../user/controller/UserController.java | 31 +++++++++++++++++++ .../domain/user/dto/UserWithdrawRequest.java | 16 ++++++++++ .../com/slatto/domain/user/entity/Users.java | 13 ++++++++ .../repository/UserPortfolioRepository.java | 13 ++++++++ .../domain/user/service/UserService.java | 18 +++++++++++ 5 files changed, 91 insertions(+) create mode 100644 src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index c53d663..005137f 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -7,6 +7,8 @@ import com.slatto.domain.user.dto.UserProfileUpdateResponse; import com.slatto.domain.user.dto.UserProfileImageResponse; import com.slatto.domain.user.dto.UserPublicProfileResponse; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.auth.support.AuthCookieFactory; import com.slatto.domain.user.service.UserService; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; @@ -14,7 +16,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -34,6 +39,7 @@ public class UserController { private final UserService userService; + private final AuthCookieFactory authCookieFactory; @Operation(summary = "유저 정보 조회", description = "로그인한 유저의 기본 정보와 온보딩 완료 여부를 조회한다.") @GetMapping("/me") @@ -76,6 +82,31 @@ public ApiResponse uploadProfileImage( return ApiResponse.success(CommonSuccessCode.OK, response); } + @Operation( + summary = "회원 탈퇴", + description = """ + 계정을 탈퇴 처리한다. 물리 삭제가 아니라 `deleted_at` 기록과 개인정보 익명화로 처리한다. + + 프로젝트·공고 등 연관 데이터가 유저를 참조하고 있어 행 자체는 남기며, + 이메일·닉네임·소셜 ID·프로필 이미지·소개는 지운다. + + 포트폴리오도 함께 내려가고 리프레시 토큰은 삭제된다. + 이메일이 비워지므로 같은 이메일로 다시 가입하면 새 계정으로 시작한다. + """ + ) + @DeleteMapping("/me") + public ResponseEntity> withdraw( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody UserWithdrawRequest request + ) { + userService.withdraw(userId, request); + + return ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, authCookieFactory.expiredRefreshToken().toString()) + .body(ApiResponse.success(CommonSuccessCode.OK, null)); + } + @Operation(summary = "공개 프로필 조회", description = "다른 유저의 공개 프로필을 조회한다. 이메일 등 비공개 필드는 제외된다.") @GetMapping("/{userId}") public ApiResponse getPublicProfile(@PathVariable Long userId) { diff --git a/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java b/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java new file mode 100644 index 0000000..1b962f4 --- /dev/null +++ b/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java @@ -0,0 +1,16 @@ +package com.slatto.domain.user.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserWithdrawRequest { + + @NotNull(message = "탈퇴 동의 여부는 필수입니다.") + @AssertTrue(message = "탈퇴에 동의해야 합니다.") + private Boolean agreed; +} diff --git a/src/main/java/com/slatto/domain/user/entity/Users.java b/src/main/java/com/slatto/domain/user/entity/Users.java index 3db8144..598842b 100644 --- a/src/main/java/com/slatto/domain/user/entity/Users.java +++ b/src/main/java/com/slatto/domain/user/entity/Users.java @@ -104,6 +104,19 @@ public void updateProfile(String nickname, String bio, String profileImageUrl) { } } + // email 과 nickname 이 NOT NULL 이라 비울 수 없다. id 를 섞은 고유 값으로 덮어 + // 개인정보를 지우면서 email 유니크 제약도 피한다. 원래 주소가 사라지므로 재가입이 열린다. + // .invalid 는 RFC 2606 예약 TLD 라 실수로 발송돼도 외부로 나가지 않는다. + public void withdraw(LocalDateTime withdrawnAt) { + this.deletedAt = withdrawnAt; + this.email = "withdrawn_" + id + "@slatto.invalid"; + this.nickname = "탈퇴한 사용자_" + id; + this.password = null; + this.socialId = null; + this.profileImageUrl = null; + this.bio = null; + } + public void completeOnboarding(String nickname, String bio, String profileImageUrl) { this.nickname = nickname; this.bio = bio; diff --git a/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java b/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java index c558827..ceb7362 100644 --- a/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java +++ b/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java @@ -3,9 +3,11 @@ import com.slatto.domain.user.entity.UserPortfolio; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -26,4 +28,15 @@ List findActivePortfoliosByCursor( @Param("cursor") Long cursor, Pageable pageable ); + + // 탈퇴 시 포트폴리오를 한 번에 내린다. 건별 로딩 없이 처리하려고 벌크 업데이트를 쓴다. + // clearAutomatically 는 쓰지 않는다. 같은 트랜잭션에 로딩된 Users 가 detach 된다. + @Modifying + @Query(""" + update UserPortfolio p + set p.deletedAt = :deletedAt + where p.user.id = :userId + and p.deletedAt is null + """) + int softDeleteAllByUserId(@Param("userId") Long userId, @Param("deletedAt") LocalDateTime deletedAt); } diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 27b285c..30904a2 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -7,6 +7,9 @@ import com.slatto.domain.user.dto.UserProfileUpdateResponse; import com.slatto.domain.user.dto.UserProfileImageResponse; import com.slatto.domain.user.dto.UserPublicProfileResponse; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.entity.Location; import com.slatto.domain.user.entity.UserCategory; import com.slatto.domain.user.entity.UserRole; @@ -32,6 +35,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; import java.util.List; import java.util.Locale; import java.util.Map; @@ -56,6 +60,8 @@ public class UserService { private final UserRoleRepository userRoleRepository; private final UserCategoryRepository userCategoryRepository; private final LocationRepository locationRepository; + private final UserPortfolioRepository userPortfolioRepository; + private final RefreshTokenRepository refreshTokenRepository; private final StorageService storageService; @Value("${cloud.aws.s3.public-base-url:}") @@ -265,6 +271,18 @@ public UserPublicProfileResponse getPublicProfile(Long userId) { .build(); } + // 유저 행은 남긴다. 프로젝트·공고 등 연관 데이터가 FK 로 참조하고 있어 지우면 이력이 끊긴다. + @Transactional + public void withdraw(Long userId, UserWithdrawRequest request) { + Users user = getUserOrThrow(userId); + LocalDateTime withdrawnAt = LocalDateTime.now(); + + userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt); + refreshTokenRepository.deleteByUser(user); + + user.withdraw(withdrawnAt); + } + private List getUserRegions(Long userId) { return locationRepository.findAllByUserIdAndRecruitmentIsNullOrderByIdAsc(userId) .stream() From e5fe86d89f9ced32820e38a4d964c9c0d823e0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:33:43 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EC=9D=98=20=EC=9E=94=EC=A1=B4=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과해 그 시간 동안 API 가 열려 있었다. 인증 필터에서 삭제 여부를 확인해 인증을 세우지 않도록 한다. 인증된 요청마다 PK 조회가 한 번 늘어난다. --- .../slatto/global/security/JwtAuthenticationFilter.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java b/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java index ad55b5b..95939bb 100644 --- a/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.slatto.global.security; +import com.slatto.domain.user.repository.UserRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -22,6 +23,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; @Override protected void doFilterInternal( @@ -34,7 +36,10 @@ protected void doFilterInternal( if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { Long userId = jwtTokenProvider.parseUserId(token, false); - if (userId != null) { + // 탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과한다. + // 여기서 걸러내지 않으면 탈퇴 후에도 그 시간 동안 API 가 열린다. + // 인증된 요청마다 PK 조회가 한 번 늘어난다. + if (userId != null && userRepository.existsByIdAndDeletedAtIsNull(userId)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userId, null, List.of() ); From 41648b8633f8db158211541afbf3d55d6da4c16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:36:09 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/v1/auth/password 추가, 로그인 상태에서만 호출한다 - 현재 비밀번호를 확인한다. 세션이 탈취된 상태에서 비밀번호까지 바뀌면 계정을 통째로 빼앗기므로 생략할 수 없다 - 비밀번호가 없는 소셜 전용 계정은 거부하고 비밀번호 찾기로 유도한다 - 기존과 같은 비밀번호는 거부한다 - 변경 성공 시 리프레시 토큰을 새로 발급한다. 유출을 가정해 세션을 끊는 비밀번호 재설정과 달리 본인이 로그인한 상태라 끊을 이유가 없다 --- .../auth/controller/AuthController.java | 29 +++++++++++++++++++ .../auth/dto/PasswordChangeRequest.java | 18 ++++++++++++ .../domain/auth/exception/AuthErrorCode.java | 7 ++++- .../domain/auth/service/AuthService.java | 26 +++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index aa67889..79e70c5 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -8,6 +8,7 @@ import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse; import com.slatto.domain.auth.dto.EmailVerificationSendRequest; import com.slatto.domain.auth.dto.EmailVerificationSendResponse; +import com.slatto.domain.auth.dto.PasswordChangeRequest; import com.slatto.domain.auth.dto.PasswordResetRequest; import com.slatto.domain.auth.service.AuthService; import com.slatto.domain.auth.service.EmailVerificationService; @@ -23,8 +24,10 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -197,6 +200,32 @@ public ApiResponse confirmEmailVerificationCod return ApiResponse.success(CommonSuccessCode.OK, response); } + @Operation( + summary = "비밀번호 변경", + description = """ + 로그인한 상태에서 현재 비밀번호를 확인하고 새 비밀번호로 바꾼다. + + 비밀번호를 잊어버려 인증번호로 재설정하는 `POST /auth/password/reset` 과는 다른 경로다. + 변경 성공 시 리프레시 토큰을 새로 발급한다. + + 구글로만 가입해 비밀번호가 없는 계정은 이 API 로 설정할 수 없다. 비밀번호 찾기를 이용한다. + """ + ) + @PatchMapping("/password") + public ResponseEntity> changePassword( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PasswordChangeRequest request + ) { + AuthService.EmailAuthResult result = authService.changePassword( + userId, request.currentPassword(), request.newPassword() + ); + + return ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result)) + .body(ApiResponse.success(CommonSuccessCode.OK, toEmailAuthResponse(result))); + } + @Operation( summary = "비밀번호 재설정", description = """ diff --git a/src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java b/src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java new file mode 100644 index 0000000..b8e5bdf --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java @@ -0,0 +1,18 @@ +package com.slatto.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordChangeRequest( + + @NotBlank(message = "현재 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$", + message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다." + ) + String newPassword +) { +} diff --git a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java index 39f09b6..9e65194 100644 --- a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java @@ -20,7 +20,12 @@ public enum AuthErrorCode implements BaseCode { SIGNUP_SOCIAL_ACCOUNT_EXISTS(HttpStatus.CONFLICT, "AUTH_SIGNUP_SOCIAL409", "구글 계정으로 가입된 이메일입니다. 구글 로그인을 이용해 주세요."), // 이메일 미존재·비밀번호 불일치·소셜 전용 계정을 모두 같은 응답으로 처리한다. 이메일 열거 방지다. - LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다."); + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다."), + + // 로그인한 본인이 호출하는 경로라 사유를 구분해도 새어 나갈 정보가 없다. + CURRENT_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "AUTH_PASSWORD401", "현재 비밀번호가 올바르지 않습니다."), + PASSWORD_NOT_SET(HttpStatus.BAD_REQUEST, "AUTH_PASSWORD_NOT_SET400", "비밀번호가 설정되지 않은 계정입니다. 비밀번호 찾기를 이용해 주세요."), + PASSWORD_UNCHANGED(HttpStatus.BAD_REQUEST, "AUTH_PASSWORD_UNCHANGED400", "새 비밀번호가 기존 비밀번호와 같습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/auth/service/AuthService.java b/src/main/java/com/slatto/domain/auth/service/AuthService.java index 71d49c9..968d15a 100644 --- a/src/main/java/com/slatto/domain/auth/service/AuthService.java +++ b/src/main/java/com/slatto/domain/auth/service/AuthService.java @@ -17,6 +17,7 @@ import com.slatto.domain.user.repository.UserRepository; import com.slatto.global.config.properties.FrontendProperties; import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; import com.slatto.global.security.JwtTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -134,6 +135,31 @@ public EmailAuthResult login(String email, String rawPassword) { return toEmailAuthResult(user); } + // 재설정과 달리 리프레시 토큰을 새로 발급한다. 본인이 로그인한 상태에서 바꾸는 것이라 + // 세션을 끊을 이유가 없다. 유출을 가정하는 resetPassword 와 여기가 갈리는 지점이다. + @Transactional + public EmailAuthResult changePassword(Long userId, String currentPassword, String newRawPassword) { + Users user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + + if (!user.hasPassword()) { + throw new BaseException(AuthErrorCode.PASSWORD_NOT_SET); + } + + // 세션이 탈취된 상황에서 비밀번호까지 바꿀 수 있으면 계정을 통째로 빼앗긴다. + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new BaseException(AuthErrorCode.CURRENT_PASSWORD_MISMATCH); + } + + if (passwordEncoder.matches(newRawPassword, user.getPassword())) { + throw new BaseException(AuthErrorCode.PASSWORD_UNCHANGED); + } + + user.changePassword(passwordEncoder.encode(newRawPassword)); + + return toEmailAuthResult(user); + } + @Transactional public void resetPassword(String email, String newRawPassword) { emailVerificationService.consumeVerified(email, VerificationPurpose.PASSWORD_RESET); From 4648067ff35ec150cdcb3239df1b9fa07dc45cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:39:43 +0900 Subject: [PATCH 05/17] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EC=99=80=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탈퇴 시 익명화, 유저 행 보존, 포트폴리오 동반 삭제, 이메일 해제, 토큰 삭제 검증 - 비밀번호 변경 시 현재 비밀번호 검증, 소셜 전용 계정 거부, 동일 비밀번호 거부 검증 - 비밀번호 검증은 목이 아닌 실제 BCrypt 로 확인한다 --- .../auth/service/PasswordChangeTest.java | 144 ++++++++++++++++ .../domain/user/service/UserWithdrawTest.java | 162 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java create mode 100644 src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java diff --git a/src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java b/src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java new file mode 100644 index 0000000..f275f14 --- /dev/null +++ b/src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java @@ -0,0 +1,144 @@ +package com.slatto.domain.auth.service; + +import com.slatto.domain.auth.client.GoogleOAuthClient; +import com.slatto.domain.auth.exception.AuthErrorCode; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.global.config.properties.FrontendProperties; +import com.slatto.global.exception.BaseException; +import com.slatto.global.security.JwtTokenProvider; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@DataJpaTest +@Import({AuthService.class, PasswordChangeTest.PasswordEncoderTestConfig.class}) +@TestPropertySource(properties = { + "spring.jpa.database=h2", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class PasswordChangeTest { + + private static final String CURRENT_PASSWORD = "slatto!2026"; + private static final String NEW_PASSWORD = "slatto@2027"; + + @TestConfiguration + static class PasswordEncoderTestConfig { + + // 실제 BCrypt 를 쓴다. 목으로 대체하면 현재 비밀번호 검증이 통과하는지 확인할 수 없다. + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + } + + @MockitoBean + private GoogleOAuthClient googleOAuthClient; + + @MockitoBean + private EmailVerificationService emailVerificationService; + + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @MockitoBean + private FrontendProperties frontendProperties; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EntityManager entityManager; + + private Long emailUserId; + private Long socialOnlyUserId; + + @BeforeEach + void setUp() { + given(jwtTokenProvider.createAccessToken(org.mockito.ArgumentMatchers.anyLong())) + .willReturn("access-token"); + given(jwtTokenProvider.createRefreshToken(org.mockito.ArgumentMatchers.anyLong())) + .willReturn("refresh-token"); + given(jwtTokenProvider.refreshTokenExpiresAt()) + .willReturn(LocalDateTime.now().plusDays(14)); + + Users emailUser = userRepository.save( + Users.createEmailUser("email@slatto.com", "이메일유저", passwordEncoder.encode(CURRENT_PASSWORD)) + ); + emailUserId = emailUser.getId(); + + Users socialOnlyUser = userRepository.save( + Users.createSocialUser("social@slatto.com", "소셜유저", null, SocialType.GOOGLE, "social-1") + ); + socialOnlyUserId = socialOnlyUser.getId(); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("현재 비밀번호가 맞으면 새 비밀번호로 바뀐다") + void changesPassword() { + authService.changePassword(emailUserId, CURRENT_PASSWORD, NEW_PASSWORD); + entityManager.flush(); + entityManager.clear(); + + String stored = userRepository.findById(emailUserId).orElseThrow().getPassword(); + + assertThat(passwordEncoder.matches(NEW_PASSWORD, stored)).isTrue(); + assertThat(passwordEncoder.matches(CURRENT_PASSWORD, stored)).isFalse(); + } + + // 세션이 탈취된 상태에서 비밀번호까지 바꿀 수 있으면 계정을 통째로 빼앗긴다. + @Test + @DisplayName("현재 비밀번호가 틀리면 거부한다") + void rejectsWrongCurrentPassword() { + assertThatThrownBy(() -> authService.changePassword(emailUserId, "wrong!2026", NEW_PASSWORD)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.CURRENT_PASSWORD_MISMATCH); + } + + @Test + @DisplayName("비밀번호가 없는 소셜 전용 계정은 거부한다") + void rejectsSocialOnlyAccount() { + assertThatThrownBy(() -> authService.changePassword(socialOnlyUserId, CURRENT_PASSWORD, NEW_PASSWORD)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.PASSWORD_NOT_SET); + } + + @Test + @DisplayName("기존과 같은 비밀번호는 거부한다") + void rejectsUnchangedPassword() { + assertThatThrownBy(() -> authService.changePassword(emailUserId, CURRENT_PASSWORD, CURRENT_PASSWORD)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.PASSWORD_UNCHANGED); + } + +} diff --git a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java new file mode 100644 index 0000000..fe6473a --- /dev/null +++ b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java @@ -0,0 +1,162 @@ +package com.slatto.domain.user.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.domain.auth.entity.RefreshToken; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.user.entity.UserPortfolio; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserPortfolioRepository; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.domain.video.util.YoutubeUrlParser; +import com.slatto.global.storage.StorageService; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({UserService.class, YoutubeUrlParser.class}) +@TestPropertySource(properties = { + "spring.jpa.database=h2", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class UserWithdrawTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String EMAIL = "tester@slatto.com"; + + // UserService 가 프로필 이미지 업로드용으로 의존한다. 이 슬라이스에서는 빈만 제공한다. + @MockitoBean + private StorageService storageService; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserPortfolioRepository userPortfolioRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private EntityManager entityManager; + + private Long userId; + private Long portfolioId; + + @BeforeEach + void setUp() { + Users user = userRepository.save( + Users.createSocialUser(EMAIL, "테스터", "https://cdn.test/a.png", SocialType.GOOGLE, "social-1") + ); + userId = user.getId(); + + UserPortfolio portfolio = userPortfolioRepository.save(UserPortfolio.create( + user, + "연애혁명", + CategoryName.FILM_DRAMA, + null, + Kind.EXTERNAL, + "스튜디오 X", + "웹드라마 연출", + "감정선 중심으로 작업", + "https://www.youtube.com/watch?v=abcdefghijk", + "https://img.youtube.com/vi/abcdefghijk/hqdefault.jpg" + )); + portfolioId = portfolio.getId(); + + refreshTokenRepository.save( + RefreshToken.issue(user, "refresh-token-value", LocalDateTime.now().plusDays(14)) + ); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("탈퇴하면 개인정보를 익명화하고 삭제 시각을 남긴다") + void anonymizesPersonalDataOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + Users withdrawn = userRepository.findById(userId).orElseThrow(); + + assertThat(withdrawn.getDeletedAt()).isNotNull(); + assertThat(withdrawn.getEmail()).isEqualTo("withdrawn_" + userId + "@slatto.invalid"); + assertThat(withdrawn.getNickname()).isEqualTo("탈퇴한 사용자_" + userId); + assertThat(withdrawn.getSocialId()).isNull(); + assertThat(withdrawn.getProfileImageUrl()).isNull(); + assertThat(withdrawn.getBio()).isNull(); + } + + // 유저 행 자체는 남겨야 한다. 프로젝트·공고가 FK 로 참조하고 있어 지우면 이력이 끊긴다. + @Test + @DisplayName("탈퇴해도 유저 행은 남는다") + void keepsUserRowOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userRepository.findById(userId)).isPresent(); + assertThat(userRepository.findByIdAndDeletedAtIsNull(userId)).isEmpty(); + } + + @Test + @DisplayName("탈퇴하면 포트폴리오도 함께 내려간다") + void softDeletesPortfoliosOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userPortfolioRepository.findByIdAndUserIdAndDeletedAtIsNull(portfolioId, userId)).isEmpty(); + assertThat(userPortfolioRepository.findById(portfolioId).orElseThrow().getDeletedAt()).isNotNull(); + } + + // 원래 이메일이 지워져야 같은 주소로 다시 가입할 수 있다. 유니크 제약이 남으면 영구히 막힌다. + @Test + @DisplayName("탈퇴 후에는 같은 이메일이 조회되지 않아 재가입할 수 있다") + void releasesEmailOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userRepository.findByEmail(EMAIL)).isEmpty(); + } + + @Test + @DisplayName("탈퇴하면 리프레시 토큰이 삭제된다") + void deletesRefreshTokenOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(refreshTokenRepository.findByToken("refresh-token-value")).isEmpty(); + } + + private UserWithdrawRequest withdrawRequest() { + try { + return OBJECT_MAPPER.readValue("{\"agreed\":true}", UserWithdrawRequest.class); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + +} From b89adeedf43cdeb928886d8afb914b51bc8d5355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:44:51 +0900 Subject: [PATCH 06/17] =?UTF-8?q?test:=20=ED=83=88=ED=87=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=91=EA=B7=BC=20=EC=B0=A8=EB=8B=A8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 활성 유저는 인증을 세우고 탈퇴 유저는 세우지 않는지 검증 - 토큰이 없거나 유효하지 않으면 유저 조회를 건너뛰는지 확인해 불필요한 쿼리가 늘지 않도록 고정한다 --- .../security/JwtAuthenticationFilterTest.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java diff --git a/src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java b/src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..55e04f5 --- /dev/null +++ b/src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,98 @@ +package com.slatto.global.security; + +import com.slatto.domain.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class JwtAuthenticationFilterTest { + + private static final Long USER_ID = 42L; + private static final String TOKEN = "valid-access-token"; + + private JwtTokenProvider jwtTokenProvider; + private UserRepository userRepository; + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + jwtTokenProvider = mock(JwtTokenProvider.class); + userRepository = mock(UserRepository.class); + filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("활성 유저의 토큰이면 인증을 세운다") + void authenticatesActiveUser() throws Exception { + given(jwtTokenProvider.parseUserId(eq(TOKEN), eq(false))).willReturn(USER_ID); + given(userRepository.existsByIdAndDeletedAtIsNull(USER_ID)).willReturn(true); + + doFilterWithToken(); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isEqualTo(USER_ID); + } + + // 탈퇴해도 이미 발급된 토큰은 만료까지 서명 검증을 통과한다. + // 여기서 걸러내지 않으면 탈퇴 후에도 그 시간 동안 API 가 열린다. + @Test + @DisplayName("탈퇴한 유저의 토큰이면 인증을 세우지 않는다") + void rejectsWithdrawnUser() throws Exception { + given(jwtTokenProvider.parseUserId(eq(TOKEN), eq(false))).willReturn(USER_ID); + given(userRepository.existsByIdAndDeletedAtIsNull(USER_ID)).willReturn(false); + + doFilterWithToken(); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("토큰이 유효하지 않으면 유저를 조회하지 않는다") + void skipsUserLookupForInvalidToken() throws Exception { + given(jwtTokenProvider.parseUserId(anyString(), eq(false))).willReturn(null); + + doFilterWithToken(); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(userRepository, never()).existsByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 유저를 조회하지 않는다") + void skipsUserLookupWithoutHeader() throws Exception { + filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(userRepository, never()).existsByIdAndDeletedAtIsNull(anyLong()); + } + + private void doFilterWithToken() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + TOKEN); + + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + } + +} From 42ce3282bd91ef6613a24aee4ff3230edd5aa7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 18:48:49 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EA=B3=B5=EA=B3=A0=EB=8F=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EB=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성자가 없는 공고가 목록에 남으면 지원자가 응답받을 수 없는 곳에 지원하게 된다. 포트폴리오와 같은 기준으로 공고도 soft delete 한다. 내 지원 목록 조회가 공고의 deleted_at 을 함께 확인하므로, 해당 공고에 지원했던 이력은 목록에서 빠진다. 지원 데이터 자체는 남는다. --- .../repository/RecruitmentRepository.java | 13 +++++++ .../domain/user/service/UserService.java | 3 ++ .../domain/user/service/UserWithdrawTest.java | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java b/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java index 163517f..d0b05f9 100644 --- a/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java +++ b/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java @@ -12,11 +12,24 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; public interface RecruitmentRepository extends JpaRepository { + // 작성자가 탈퇴하면 공고도 함께 내린다. 연락받을 사람이 없는 공고가 목록에 남으면 + // 지원자가 응답 없는 공고에 지원하게 된다. + // clearAutomatically 는 쓰지 않는다. 같은 트랜잭션에 로딩된 엔티티가 detach 된다. + @Modifying + @Query(""" + update Recruitment r + set r.deletedAt = :deletedAt + where r.writer.id = :writerId + and r.deletedAt is null + """) + int softDeleteAllByWriterId(@Param("writerId") Long writerId, @Param("deletedAt") LocalDateTime deletedAt); + Optional findByIdAndDeletedAtIsNull(Long id); boolean existsByIdAndDeletedAtIsNull(Long id); diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 30904a2..27f5453 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -9,6 +9,7 @@ import com.slatto.domain.user.dto.UserPublicProfileResponse; import com.slatto.domain.user.dto.UserWithdrawRequest; import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.entity.Location; import com.slatto.domain.user.entity.UserCategory; @@ -61,6 +62,7 @@ public class UserService { private final UserCategoryRepository userCategoryRepository; private final LocationRepository locationRepository; private final UserPortfolioRepository userPortfolioRepository; + private final RecruitmentRepository recruitmentRepository; private final RefreshTokenRepository refreshTokenRepository; private final StorageService storageService; @@ -278,6 +280,7 @@ public void withdraw(Long userId, UserWithdrawRequest request) { LocalDateTime withdrawnAt = LocalDateTime.now(); userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt); + recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt); refreshTokenRepository.deleteByUser(user); user.withdraw(withdrawnAt); diff --git a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java index fe6473a..f66b25c 100644 --- a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java @@ -3,11 +3,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.slatto.domain.auth.entity.RefreshToken; import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.recruitment.entity.Recruitment; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; import com.slatto.domain.user.dto.UserWithdrawRequest; import com.slatto.domain.user.entity.UserPortfolio; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.enums.CategoryName; import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.RegionName; +import com.slatto.domain.user.enums.RoleName; import com.slatto.domain.user.enums.SocialType; import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.repository.UserRepository; @@ -23,6 +28,7 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import java.time.LocalDate; import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; @@ -52,6 +58,9 @@ class UserWithdrawTest { @Autowired private UserPortfolioRepository userPortfolioRepository; + @Autowired + private RecruitmentRepository recruitmentRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -60,6 +69,7 @@ class UserWithdrawTest { private Long userId; private Long portfolioId; + private Long recruitmentId; @BeforeEach void setUp() { @@ -82,6 +92,21 @@ void setUp() { )); portfolioId = portfolio.getId(); + Recruitment recruitment = recruitmentRepository.save(Recruitment.create( + user, + "단편영화 촬영감독 구합니다", + CategoryName.FILM_DRAMA, + LengthType.SHORT_FORM, + RoleName.CINEMATOGRAPHER, + RegionName.SEOUL, + "2026년 9월", + "협의", + "010-0000-0000", + "단편영화 촬영 인력을 모집합니다.", + LocalDate.now().plusDays(30) + )); + recruitmentId = recruitment.getId(); + refreshTokenRepository.save( RefreshToken.issue(user, "refresh-token-value", LocalDateTime.now().plusDays(14)) ); @@ -141,6 +166,17 @@ void releasesEmailOnWithdraw() { assertThat(userRepository.findByEmail(EMAIL)).isEmpty(); } + // 작성자가 없는 공고가 목록에 남으면 지원자가 응답받을 수 없는 곳에 지원하게 된다. + @Test + @DisplayName("탈퇴하면 작성한 공고도 함께 내려간다") + void softDeletesRecruitmentsOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(recruitmentRepository.findById(recruitmentId).orElseThrow().getDeletedAt()).isNotNull(); + } + @Test @DisplayName("탈퇴하면 리프레시 토큰이 삭제된다") void deletesRefreshTokenOnWithdraw() { From 1717967b6037f7199198030c6423c07583600d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:06:36 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B3=A0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=95=84=EC=88=98=20=ED=95=AD=EB=AA=A9=20=EC=99=84?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EB=A7=88=EA=B0=90=20=EA=B3=B5=EA=B3=A0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정책 문서 기준으로 맞춘다. - 필수는 제목, 모집 파트, 상세 내용, 연락처만 남긴다. 카테고리·영상 길이·지역· 촬영 기간·급여·마감일은 선택으로 연다. 컬럼이 모두 nullable 이라 스키마 변경은 없다 - 공고 제목을 5~50자로 조정한다 - 마감일을 비우면 수동으로 마감할 때까지 모집이 유지된다 - 마감된 공고는 내용을 수정할 수 없다. 다만 상태 변경이 같은 API 를 쓰므로 다시 모집중으로 되돌리는 요청은 통과시킨다 --- .../recruitment/dto/RecruitmentCreateRequest.java | 13 ++++++------- .../recruitment/dto/RecruitmentUpdateRequest.java | 2 +- .../exception/RecruitmentErrorCode.java | 3 ++- .../recruitment/service/RecruitmentService.java | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java index 1d104e1..5e56e94 100644 --- a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java +++ b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -19,29 +20,27 @@ public class RecruitmentCreateRequest { @NotBlank(message = "공고 제목은 필수입니다.") - @Size(max = 100, message = "공고 제목은 최대 100자까지 입력할 수 있습니다.") + @Size(min = 5, max = 50, message = "공고 제목은 5자 이상 50자 이하로 입력해야 합니다.") private String title; - @NotNull(message = "카테고리는 필수입니다.") private CategoryName category; - @NotNull(message = "영상 길이 유형은 필수입니다.") private LengthType lengthType; @NotNull(message = "모집 파트는 필수입니다.") private RoleName recruitPart; - @NotNull(message = "지역은 필수입니다.") private RegionName location; - @NotBlank(message = "촬영 기간은 필수입니다.") + @Pattern(regexp = "(?s).*\\S.*", message = "촬영 기간은 공백일 수 없습니다.") @Size(max = 50, message = "촬영 기간은 최대 50자까지 입력할 수 있습니다.") private String shootingPeriod; - @NotBlank(message = "급여는 필수입니다.") + @Pattern(regexp = "(?s).*\\S.*", message = "급여는 공백일 수 없습니다.") @Size(max = 50, message = "급여는 최대 50자까지 입력할 수 있습니다.") private String pay; + // 지원자가 연락할 방법이 없으면 공고가 성립하지 않아 선택으로 열지 않는다. @NotBlank(message = "연락처는 필수입니다.") @Size(max = 100, message = "연락처는 최대 100자까지 입력할 수 있습니다.") private String contact; @@ -50,7 +49,7 @@ public class RecruitmentCreateRequest { @Size(max = 2000, message = "상세 내용은 최대 2000자까지 입력할 수 있습니다.") private String description; - @NotNull(message = "마감일은 필수입니다.") + // 마감일을 비우면 수동으로 마감할 때까지 모집이 유지된다. @FutureOrPresent(message = "마감일은 오늘 또는 그 이후여야 합니다.") private LocalDate deadline; } diff --git a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java index ca0ec0f..2d9adad 100644 --- a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java +++ b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java @@ -20,7 +20,7 @@ public class RecruitmentUpdateRequest { @Pattern(regexp = "(?s).*\\S.*", message = "공고 제목은 공백일 수 없습니다.") - @Size(max = 100, message = "공고 제목은 100자 이하여야 합니다.") + @Size(min = 5, max = 50, message = "공고 제목은 5자 이상 50자 이하여야 합니다.") private String title; private CategoryName category; diff --git a/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java b/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java index 692bc1b..04395a0 100644 --- a/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java +++ b/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java @@ -12,7 +12,8 @@ public enum RecruitmentErrorCode implements BaseCode { RECRUITMENT_CLOSED(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED400", "마감된 공고에는 지원할 수 없습니다."), RECRUITMENT_SELF_APPLICATION(HttpStatus.BAD_REQUEST, "RECRUITMENT_SELF400", "본인이 작성한 공고에는 지원할 수 없습니다."), APPLICATION_ALREADY_APPLIED(HttpStatus.CONFLICT, "APPLICATION409", "이미 지원한 공고입니다."), - APPLICATION_ALREADY_HANDLED(HttpStatus.BAD_REQUEST, "APPLICATION_ALREADY_HANDLED400", "이미 수락 또는 거절 처리된 지원입니다."); + APPLICATION_ALREADY_HANDLED(HttpStatus.BAD_REQUEST, "APPLICATION_ALREADY_HANDLED400", "이미 수락 또는 거절 처리된 지원입니다."), + RECRUITMENT_CLOSED_NOT_EDITABLE(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED_EDIT400", "마감된 공고는 수정할 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java index a820de3..165ab74 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java @@ -14,6 +14,7 @@ import com.slatto.domain.recruitment.enums.RecruitmentApplicationStatus; import com.slatto.domain.recruitment.enums.RecruitmentSortType; import com.slatto.domain.recruitment.enums.RecruitmentStatus; +import com.slatto.domain.recruitment.exception.RecruitmentErrorCode; import com.slatto.domain.recruitment.repository.RecruitmentApplicationRepository; import com.slatto.domain.recruitment.repository.RecruitmentBookmarkRepository; import com.slatto.domain.recruitment.repository.RecruitmentRepository; @@ -107,6 +108,12 @@ public RecruitmentDetailResponse updateRecruitment( validateWriter(recruitment, currentUserId); + // 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다. + // 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다. + if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) { + throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + recruitment.update( request.getTitle(), request.getCategory(), @@ -399,6 +406,14 @@ private RoleName getPrimaryRole(Long userId) { return roles.isEmpty() ? null : roles.get(0).getRoleName(); } + private boolean isClosed(Recruitment recruitment) { + return recruitmentConverter.resolveStatus( + recruitment.getClosedManually(), + recruitment.getDeadline(), + recruitmentConverter.currentDate() + ) == RecruitmentStatus.CLOSED; + } + private List getUserRegions(Long userId) { return locationRepository.findAllByUserIdAndRecruitmentIsNullOrderByIdAsc(userId) .stream() From e0e6823f5c77b338bf8d4c93485867b0e8a22bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:11:50 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A0=95=EC=B1=85=20=EA=B0=92=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=B0=8F=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=9E=AC=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 이미지 제한을 10MB 에서 2MB 로 낮추고 허용 형식을 jpg·jpeg·png·gif 로 조정 - 이름과 닉네임을 특수문자 없이 2~20자로 검증 - 소개는 200자 이하로 제한 - 탈퇴 시 비밀번호를 재확인한다. 세션이 탈취된 상태에서 탈퇴까지 되면 계정을 통째로 지워버릴 수 있다. 비밀번호가 없는 소셜 전용 계정은 확인할 값이 없어 필수로 걸지 않는다 --- .../domain/auth/dto/EmailSignupRequest.java | 5 +- .../user/dto/UserOnboardingRequest.java | 7 ++- .../user/dto/UserProfileUpdateRequest.java | 7 ++- .../domain/user/dto/UserWithdrawRequest.java | 4 ++ .../domain/user/exception/UserErrorCode.java | 3 +- .../domain/user/service/UserService.java | 13 +++- .../user/service/PortfolioUpdatedAtTest.java | 12 +++- .../service/UserProfileImageServiceTest.java | 3 +- .../domain/user/service/UserWithdrawTest.java | 59 ++++++++++++++++++- 9 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java b/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java index 6689d47..11ffb03 100644 --- a/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java +++ b/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java @@ -8,7 +8,10 @@ public record EmailSignupRequest( @NotBlank(message = "이름은 필수입니다.") - @Size(min = 1, max = 20, message = "이름은 1자 이상 20자 이하로 입력해야 합니다.") + @Pattern( + regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$", + message = "이름은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다." + ) String name, @NotBlank(message = "이메일은 필수입니다.") diff --git a/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java b/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java index b6cc951..966488c 100644 --- a/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java +++ b/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -23,7 +24,10 @@ public class UserOnboardingRequest { private Boolean agreedTerms; @NotBlank(message = "닉네임은 필수입니다.") - @Size(min = 1, max = 20, message = "닉네임은 1자 이상 20자 이하로 입력해야 합니다.") + @Pattern( + regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$", + message = "닉네임은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다." + ) private String nickname; @NotEmpty(message = "활동 역할은 1개 이상 선택해야 합니다.") @@ -35,6 +39,7 @@ public class UserOnboardingRequest { @NotEmpty(message = "관심 카테고리는 1개 이상 선택해야 합니다.") private List<@NotNull(message = "관심 카테고리는 필수입니다.") CategoryName> categories; + @Size(max = 200, message = "소개는 200자 이하로 입력해야 합니다.") private String bio; @Size(max = 500, message = "프로필 이미지 URL은 500자 이하로 입력해야 합니다.") diff --git a/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java b/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java index ea1d126..8aabadd 100644 --- a/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java +++ b/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java @@ -4,6 +4,7 @@ import com.slatto.domain.user.enums.RegionName; import com.slatto.domain.user.enums.RoleName; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -15,12 +16,16 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserProfileUpdateRequest { - @Size(min = 1, max = 20, message = "닉네임은 1자 이상 20자 이하로 입력해야 합니다.") + @Pattern( + regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$", + message = "닉네임은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다." + ) private String nickname; @Size(max = 500, message = "프로필 이미지 URL은 500자 이하로 입력해야 합니다.") private String profileImageUrl; + @Size(max = 200, message = "소개는 200자 이하로 입력해야 합니다.") private String bio; @Size(min = 1, message = "활동 지역은 1개 이상 선택해야 합니다.") diff --git a/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java b/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java index 1b962f4..5bb8448 100644 --- a/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java +++ b/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java @@ -13,4 +13,8 @@ public class UserWithdrawRequest { @NotNull(message = "탈퇴 동의 여부는 필수입니다.") @AssertTrue(message = "탈퇴에 동의해야 합니다.") private Boolean agreed; + + // 비밀번호가 설정된 계정만 재인증한다. 소셜로만 가입한 계정은 확인할 비밀번호가 없어 + // 필수로 걸면 탈퇴 자체가 막힌다. 소셜 재인증 방식은 미확정이라 이번 범위에 넣지 않는다. + private String password; } diff --git a/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java b/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java index 6acb3cf..5992f9d 100644 --- a/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java +++ b/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java @@ -12,7 +12,8 @@ public enum UserErrorCode implements BaseCode { ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "ONBOARDING409", "이미 온보딩을 완료한 유저입니다."), PROFILE_IMAGE_EMPTY(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_EMPTY400", "업로드할 프로필 이미지가 비어 있습니다."), PROFILE_IMAGE_INVALID_TYPE(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_INVALID_TYPE400", "지원하지 않는 프로필 이미지 형식입니다."), - PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_SIZE400", "프로필 이미지는 최대 10MB까지 업로드할 수 있습니다."); + PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_SIZE400", "프로필 이미지는 최대 2MB까지 업로드할 수 있습니다."), + WITHDRAW_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "USER_WITHDRAW_PASSWORD401", "비밀번호가 일치하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 27f5453..7fbd938 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -49,12 +50,12 @@ @Transactional(readOnly = true) public class UserService { - private static final long MAX_PROFILE_IMAGE_SIZE = 10L * 1024 * 1024; + private static final long MAX_PROFILE_IMAGE_SIZE = 2L * 1024 * 1024; private static final String PROFILE_IMAGE_STORAGE_KEY_FORMAT = "users/%d/profile-images/%s.%s"; private static final Map> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( "image/jpeg", Set.of("jpg", "jpeg"), "image/png", Set.of("png"), - "image/webp", Set.of("webp") + "image/gif", Set.of("gif") ); private final UserRepository userRepository; @@ -64,6 +65,7 @@ public class UserService { private final UserPortfolioRepository userPortfolioRepository; private final RecruitmentRepository recruitmentRepository; private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; private final StorageService storageService; @Value("${cloud.aws.s3.public-base-url:}") @@ -277,6 +279,13 @@ public UserPublicProfileResponse getPublicProfile(Long userId) { @Transactional public void withdraw(Long userId, UserWithdrawRequest request) { Users user = getUserOrThrow(userId); + + // 세션이 탈취된 상태에서 탈퇴까지 가능하면 계정을 통째로 지워버릴 수 있다. + if (user.hasPassword() + && !passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new BaseException(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); + } + LocalDateTime withdrawnAt = LocalDateTime.now(); userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt); diff --git a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java index 59038e3..e8d37c7 100644 --- a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java +++ b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java @@ -33,7 +33,8 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@Import({PortfolioService.class, UserService.class, YoutubeUrlParser.class}) +@Import({PortfolioService.class, UserService.class, YoutubeUrlParser.class, + PortfolioUpdatedAtTest.PasswordEncoderTestConfig.class}) @TestPropertySource(properties = { "spring.jpa.database=h2", "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", @@ -43,6 +44,15 @@ class PortfolioUpdatedAtTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @org.springframework.boot.test.context.TestConfiguration + static class PasswordEncoderTestConfig { + + @org.springframework.context.annotation.Bean + org.springframework.security.crypto.password.PasswordEncoder passwordEncoder() { + return new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder(); + } + } + // UserService가 프로필 이미지 업로드용 StorageService를 의존하므로, 이 JPA 슬라이스에서는 빈만 제공한다. @MockBean private StorageService storageService; diff --git a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java index 8abbfcb..0b02cf3 100644 --- a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java @@ -78,9 +78,10 @@ void setUp() { @Test void 허용하지_않은_이미지_형식은_S3에_업로드하지_않는다() { // MIME 타입과 확장자가 허용 목록에 없으면 저장 전 요청을 차단해야 한다. + // 허용 목록은 jpg·jpeg·png·gif 다. gif 가 허용으로 바뀌어 webp 로 예시를 바꿨다. Users user = user(1L, null); MockMultipartFile file = new MockMultipartFile( - "file", "green.gif", "image/gif", "profile-image".getBytes() + "file", "green.webp", "image/webp", "profile-image".getBytes() ); when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); diff --git a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java index f66b25c..f1c4805 100644 --- a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java @@ -15,8 +15,10 @@ import com.slatto.domain.user.enums.RoleName; import com.slatto.domain.user.enums.SocialType; import com.slatto.domain.user.repository.UserPortfolioRepository; +import com.slatto.domain.user.exception.UserErrorCode; import com.slatto.domain.user.repository.UserRepository; import com.slatto.domain.video.util.YoutubeUrlParser; +import com.slatto.global.exception.BaseException; import com.slatto.global.storage.StorageService; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; @@ -24,7 +26,11 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -32,9 +38,10 @@ import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DataJpaTest -@Import({UserService.class, YoutubeUrlParser.class}) +@Import({UserService.class, YoutubeUrlParser.class, UserWithdrawTest.PasswordEncoderTestConfig.class}) @TestPropertySource(properties = { "spring.jpa.database=h2", "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", @@ -44,6 +51,16 @@ class UserWithdrawTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String EMAIL = "tester@slatto.com"; + private static final String PASSWORD = "slatto!2026"; + + @TestConfiguration + static class PasswordEncoderTestConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + } // UserService 가 프로필 이미지 업로드용으로 의존한다. 이 슬라이스에서는 빈만 제공한다. @MockitoBean @@ -64,6 +81,9 @@ class UserWithdrawTest { @Autowired private RefreshTokenRepository refreshTokenRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; @@ -187,9 +207,44 @@ void deletesRefreshTokenOnWithdraw() { assertThat(refreshTokenRepository.findByToken("refresh-token-value")).isEmpty(); } + // 세션만으로 탈퇴가 되면 토큰이 탈취된 상황에서 계정을 통째로 지워버릴 수 있다. + @Test + @DisplayName("비밀번호가 있는 계정은 비밀번호가 틀리면 탈퇴할 수 없다") + void rejectsWithdrawWithWrongPassword() { + Users emailUser = userRepository.save( + Users.createEmailUser("email@slatto.com", "이메일유저", passwordEncoder.encode(PASSWORD)) + ); + entityManager.flush(); + entityManager.clear(); + + assertThatThrownBy(() -> userService.withdraw(emailUser.getId(), withdrawRequest("wrong!2026"))) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); + } + + // 소셜로만 가입한 계정은 확인할 비밀번호가 없다. 필수로 걸면 탈퇴 자체가 막힌다. + @Test + @DisplayName("비밀번호가 없는 소셜 계정은 비밀번호 없이 탈퇴할 수 있다") + void allowsWithdrawWithoutPasswordForSocialAccount() { + userService.withdraw(userId, withdrawRequest(null)); + entityManager.flush(); + entityManager.clear(); + + assertThat(userRepository.findById(userId).orElseThrow().getDeletedAt()).isNotNull(); + } + private UserWithdrawRequest withdrawRequest() { + return withdrawRequest(null); + } + + private UserWithdrawRequest withdrawRequest(String password) { try { - return OBJECT_MAPPER.readValue("{\"agreed\":true}", UserWithdrawRequest.class); + String json = password == null + ? "{\"agreed\":true}" + : "{\"agreed\":true,\"password\":\"" + password + "\"}"; + + return OBJECT_MAPPER.readValue(json, UserWithdrawRequest.class); } catch (Exception exception) { throw new IllegalStateException(exception); } From 4daaafc532772f52ca2b23a5cdf0ebaf828a34f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:13:56 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=97=88=EC=9A=A9=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=9D=84=20=EA=B8=B0=EC=A1=B4=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정책 문서는 gif 를 포함하지만 기존 구현의 webp 를 유지한다. 용량 제한 2MB 조정은 그대로 둔다. --- src/main/java/com/slatto/domain/user/service/UserService.java | 2 +- .../domain/user/service/UserProfileImageServiceTest.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 7fbd938..b2d3339 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -55,7 +55,7 @@ public class UserService { private static final Map> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( "image/jpeg", Set.of("jpg", "jpeg"), "image/png", Set.of("png"), - "image/gif", Set.of("gif") + "image/webp", Set.of("webp") ); private final UserRepository userRepository; diff --git a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java index 0b02cf3..8abbfcb 100644 --- a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java @@ -78,10 +78,9 @@ void setUp() { @Test void 허용하지_않은_이미지_형식은_S3에_업로드하지_않는다() { // MIME 타입과 확장자가 허용 목록에 없으면 저장 전 요청을 차단해야 한다. - // 허용 목록은 jpg·jpeg·png·gif 다. gif 가 허용으로 바뀌어 webp 로 예시를 바꿨다. Users user = user(1L, null); MockMultipartFile file = new MockMultipartFile( - "file", "green.webp", "image/webp", "profile-image".getBytes() + "file", "green.gif", "image/gif", "profile-image".getBytes() ); when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); From d304244cedd7eff92526381de016507c6fd6f690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:16:11 +0900 Subject: [PATCH 11/17] =?UTF-8?q?docs:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EA=B3=B5=EA=B3=A0=20=EB=93=B1=EB=A1=9D=C2=B7=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=EA=B3=BC=20=ED=83=88=ED=87=B4=20=EC=9E=AC?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=9D=84=20API=20=EC=84=A4=EB=AA=85=EC=97=90?= =?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 --- .../controller/RecruitmentController.java | 24 +++++++++++++++++-- .../user/controller/UserController.java | 5 +++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java index c87f2d3..57446e9 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java @@ -39,7 +39,15 @@ public class RecruitmentController { private final RecruitmentService recruitmentService; - @Operation(summary = "구인구직 공고 작성") + @Operation( + summary = "구인구직 공고 작성", + description = """ + 필수는 `title`, `recruitPart`, `description`, `contact` 네 개다. + `category`, `lengthType`, `location`, `shootingPeriod`, `pay`, `deadline` 은 비워도 등록된다. + + `title` 은 5~50자다. `deadline` 을 비우면 수동으로 마감할 때까지 모집이 유지된다. + """ + ) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createRecruitment( @@ -113,7 +121,19 @@ public ApiResponse getRecruitment( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "구인구직 공고 수정") + @Operation( + summary = "구인구직 공고 수정", + description = """ + 전달한 항목만 부분 수정된다. + + **마감된 공고는 내용을 수정할 수 없다.** 마감일이 지났거나 수동 마감한 공고에 + 내용 수정을 요청하면 `RECRUITMENT_CLOSED_EDIT400` 이 반환된다. + + 다만 `status` 를 `RECRUITING` 으로 보내는 요청은 통과한다. 상태 변경이 같은 API 라 + 전면 차단하면 마감을 되돌릴 방법이 없어지기 때문이다. 마감일이 지나 자동 마감된 + 공고를 되살리려면 `deadline` 도 함께 보내야 한다. + """ + ) @PatchMapping("/{recruitmentId}") public ApiResponse updateRecruitment( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index 005137f..b3b2dce 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -90,8 +90,11 @@ public ApiResponse uploadProfileImage( 프로젝트·공고 등 연관 데이터가 유저를 참조하고 있어 행 자체는 남기며, 이메일·닉네임·소셜 ID·프로필 이미지·소개는 지운다. - 포트폴리오도 함께 내려가고 리프레시 토큰은 삭제된다. + 포트폴리오와 작성한 공고가 함께 내려가고 리프레시 토큰은 삭제된다. 이메일이 비워지므로 같은 이메일로 다시 가입하면 새 계정으로 시작한다. + + **비밀번호가 설정된 계정은 `password` 로 재인증한다.** 틀리면 `USER_WITHDRAW_PASSWORD401` 이다. + 구글로만 가입해 비밀번호가 없는 계정은 `password` 를 보내지 않아도 된다. """ ) @DeleteMapping("/me") From c4e89a1275a09291bba45ad3b1a388810d76deed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:18:35 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=AF=B8=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=EC=9D=84=20500=20=EB=8C=80=EC=8B=A0=20401=20=EB=A1=9C?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BCryptPasswordEncoder.matches 는 raw 가 null 이면 IllegalArgumentException 을 던져 서버 오류로 나갔다. 비밀번호가 설정된 계정이 값을 보내지 않은 경우도 불일치와 같은 401 로 응답한다. --- .../slatto/domain/user/service/UserService.java | 4 +++- .../user/service/PortfolioUpdatedAtTest.java | 12 ++++++++---- .../domain/user/service/UserWithdrawTest.java | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index b2d3339..3fa9845 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -281,8 +281,10 @@ public void withdraw(Long userId, UserWithdrawRequest request) { Users user = getUserOrThrow(userId); // 세션이 탈취된 상태에서 탈퇴까지 가능하면 계정을 통째로 지워버릴 수 있다. + // matches 는 raw 가 null 이면 IllegalArgumentException 을 던져 500 이 나간다. 먼저 걸러낸다. if (user.hasPassword() - && !passwordEncoder.matches(request.getPassword(), user.getPassword())) { + && (request.getPassword() == null + || !passwordEncoder.matches(request.getPassword(), user.getPassword()))) { throw new BaseException(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); } diff --git a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java index e8d37c7..97fbfc4 100644 --- a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java +++ b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java @@ -23,8 +23,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import java.time.LocalDateTime; @@ -44,12 +48,12 @@ class PortfolioUpdatedAtTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - @org.springframework.boot.test.context.TestConfiguration + @TestConfiguration static class PasswordEncoderTestConfig { - @org.springframework.context.annotation.Bean - org.springframework.security.crypto.password.PasswordEncoder passwordEncoder() { - return new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder(); + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); } } diff --git a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java index f1c4805..0574360 100644 --- a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java @@ -223,6 +223,22 @@ void rejectsWithdrawWithWrongPassword() { .isEqualTo(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); } + // BCryptPasswordEncoder.matches 는 raw 가 null 이면 예외를 던져 500 이 나간다. + @Test + @DisplayName("비밀번호가 있는 계정이 비밀번호를 보내지 않으면 401 로 거부한다") + void rejectsWithdrawWithoutPassword() { + Users emailUser = userRepository.save( + Users.createEmailUser("nopass@slatto.com", "이메일유저", passwordEncoder.encode(PASSWORD)) + ); + entityManager.flush(); + entityManager.clear(); + + assertThatThrownBy(() -> userService.withdraw(emailUser.getId(), withdrawRequest(null))) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); + } + // 소셜로만 가입한 계정은 확인할 비밀번호가 없다. 필수로 걸면 탈퇴 자체가 막힌다. @Test @DisplayName("비밀번호가 없는 소셜 계정은 비밀번호 없이 탈퇴할 수 있다") From df50b7ad0c664240e60c7da4341458c11842d3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:34:15 +0900 Subject: [PATCH 13/17] =?UTF-8?q?docs:=20=EC=98=A8=EB=B3=B4=EB=94=A9=C2=B7?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95=C2=B7=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B7=9C=EC=B9=99=EC=9D=84=20API=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index b3b2dce..27db6fc 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -49,7 +49,15 @@ public ApiResponse getMyInfo(@AuthenticationPrincipal Long userI return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "온보딩 정보 저장", description = "온보딩에서 입력한 약관 동의·역할·지역·카테고리·프로필 정보를 저장한다. 최초 1회만 허용한다.") + @Operation( + summary = "온보딩 정보 저장", + description = """ + 온보딩에서 입력한 약관 동의·역할·지역·카테고리·프로필 정보를 저장한다. 최초 1회만 허용한다. + + `roles`, `regions`, `categories` 는 각각 1개 이상이어야 한다. 중복 값은 저장 시 제거된다. + `nickname` 은 특수문자 없이 2~20자, `bio` 는 200자 이하다. + """ + ) @PostMapping("/onboarding") public ApiResponse completeOnboarding( @AuthenticationPrincipal Long userId, @@ -60,7 +68,15 @@ public ApiResponse completeOnboarding( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로필 수정", description = "마이페이지에서 내 프로필 정보를 수정한다. 전달된 항목만 부분 수정된다.") + @Operation( + summary = "프로필 수정", + description = """ + 마이페이지에서 내 프로필 정보를 수정한다. 전달된 항목만 부분 수정된다. + + `nickname` 은 특수문자 없이 2~20자, `bio` 는 200자 이하다. + `locations` 를 전달하면 기존 활동 지역을 모두 지우고 전달한 값으로 교체한다. + """ + ) @PatchMapping("/me") public ApiResponse updateProfile( @AuthenticationPrincipal Long userId, @@ -71,7 +87,14 @@ public ApiResponse updateProfile( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로필 이미지 업로드", description = "프로필 이미지를 S3에 업로드하고 CDN 공개 URL로 교체한다.") + @Operation( + summary = "프로필 이미지 업로드", + description = """ + 프로필 이미지를 S3에 업로드하고 CDN 공개 URL로 교체한다. + + **최대 2MB**, jpg·jpeg·png·webp 만 허용한다. MIME 타입과 확장자가 모두 일치해야 한다. + """ + ) @PutMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadProfileImage( @AuthenticationPrincipal Long userId, From bb0446310b4e1050d8d1ea7a0ae8bfceb80d16ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:39:41 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20CORS=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20PUT=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로필 이미지 업로드가 PUT 이라 preflight 단계에서 차단되고 있었다. --- src/main/java/com/slatto/global/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java index cc33761..16d1b72 100644 --- a/src/main/java/com/slatto/global/config/SecurityConfig.java +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -93,7 +93,8 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(corsProperties.allowedOrigins()); configuration.setAllowCredentials(true); - configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE", "OPTIONS")); + // PUT 이 빠져 있어 프로필 이미지 업로드(PUT /users/me/profile-image)가 preflight 에서 막혔다. + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setMaxAge(3600L); From 944fb444077f674e7c73d83f0de4b9409cc55f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:49:39 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A7=80=EC=A0=81=20=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마감 공고 재개 조건 보완. status 만 RECRUITING 으로 보내도 적용될 마감일이 과거면 여전히 마감이라 거부한다. 이전에는 같은 요청에 실린 내용 변경까지 반영돼 마감 공고 수정 금지가 우회됐다 - 탈퇴 시 프로필 이미지 객체를 스토리지에서 삭제한다. URL 만 비우면 기존 공개 URL 을 아는 사람이 탈퇴 후에도 사진을 조회할 수 있었다 - 위 두 건과 마감 공고 내용 수정 차단을 테스트로 고정 --- .../service/RecruitmentService.java | 17 ++- .../domain/user/service/UserService.java | 6 + .../service/RecruitmentCloseEditTest.java | 124 ++++++++++++++++++ .../service/UserProfileImageServiceTest.java | 47 +++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java index 165ab74..e86956e 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java @@ -110,7 +110,7 @@ public RecruitmentDetailResponse updateRecruitment( // 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다. // 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다. - if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) { + if (isClosed(recruitment) && !isReopening(recruitment, request)) { throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); } @@ -406,6 +406,21 @@ private RoleName getPrimaryRole(Long userId) { return roles.isEmpty() ? null : roles.get(0).getRoleName(); } + // status 만 RECRUITING 으로 바꿔도 마감일이 과거면 여전히 마감이다. 그 상태로 통과시키면 + // 같은 요청에 실린 내용 변경까지 반영돼 마감 공고 수정 금지가 우회된다. + // 적용 후 실제로 모집중이 되는 요청만 재개로 인정한다. + private boolean isReopening(Recruitment recruitment, RecruitmentUpdateRequest request) { + if (request.getStatus() != RecruitmentStatus.RECRUITING) { + return false; + } + + LocalDate appliedDeadline = request.getDeadline() != null + ? request.getDeadline() + : recruitment.getDeadline(); + + return appliedDeadline == null || !appliedDeadline.isBefore(recruitmentConverter.currentDate()); + } + private boolean isClosed(Recruitment recruitment) { return recruitmentConverter.resolveStatus( recruitment.getClosedManually(), diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 3fa9845..2c1b267 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -290,11 +290,17 @@ public void withdraw(Long userId, UserWithdrawRequest request) { LocalDateTime withdrawnAt = LocalDateTime.now(); + // withdraw 가 URL 을 지우기 전에 키를 뽑아둔다. URL 만 비우면 스토리지 객체가 남아 + // 기존 공개 URL 을 아는 사람은 탈퇴 후에도 프로필 사진을 계속 볼 수 있다. + String profileImageStorageKey = extractManagedStorageKey(user.getProfileImageUrl()); + userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt); recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt); refreshTokenRepository.deleteByUser(user); user.withdraw(withdrawnAt); + + registerPreviousFileDeletionAfterCommit(profileImageStorageKey); } private List getUserRegions(Long userId) { diff --git a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java new file mode 100644 index 0000000..a026700 --- /dev/null +++ b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java @@ -0,0 +1,124 @@ +package com.slatto.domain.recruitment.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.recruitment.converter.RecruitmentConverter; +import com.slatto.domain.recruitment.dto.RecruitmentUpdateRequest; +import com.slatto.domain.recruitment.entity.Recruitment; +import com.slatto.domain.recruitment.exception.RecruitmentErrorCode; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.RegionName; +import com.slatto.domain.user.enums.RoleName; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.global.exception.BaseException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RecruitmentCloseEditTest { + + private static final Long WRITER_ID = 1L; + private static final Long RECRUITMENT_ID = 10L; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Mock + private RecruitmentRepository recruitmentRepository; + + // 마감 판정 로직을 그대로 써야 검증이 의미가 있어 실제 구현을 넣는다. + @Spy + private RecruitmentConverter recruitmentConverter = new RecruitmentConverter(); + + @InjectMocks + private RecruitmentService recruitmentService; + + @Test + @DisplayName("마감된 공고의 내용 수정은 거부한다") + void rejectsContentEditOnClosedRecruitment() { + givenRecruitment(LocalDate.now().minusDays(1)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment( + WRITER_ID, RECRUITMENT_ID, request("{\"title\":\"새 제목으로 변경\"}") + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + + // status 만 RECRUITING 으로 보내도 마감일이 과거면 여전히 마감이다. + // 이걸 통과시키면 같은 요청에 실린 내용 변경까지 반영돼 수정 금지가 우회된다. + @Test + @DisplayName("마감일이 과거인데 status 만 RECRUITING 으로 보내면 거부한다") + void rejectsReopenWithoutFutureDeadline() { + givenRecruitment(LocalDate.now().minusDays(1)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment( + WRITER_ID, RECRUITMENT_ID, + request("{\"status\":\"RECRUITING\",\"title\":\"우회로 바꾼 제목\"}") + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + + @Test + @DisplayName("과거 마감일을 그대로 다시 보내도 거부한다") + void rejectsReopenWithPastDeadline() { + givenRecruitment(LocalDate.now().minusDays(1)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment( + WRITER_ID, RECRUITMENT_ID, + request("{\"status\":\"RECRUITING\",\"deadline\":\"" + + LocalDate.now().minusDays(3) + "\"}") + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + + private void givenRecruitment(LocalDate deadline) { + Users writer = Users.createSocialUser("writer@slatto.com", "작성자", null, SocialType.GOOGLE, "social-1"); + ReflectionTestUtils.setField(writer, "id", WRITER_ID); + + Recruitment recruitment = Recruitment.create( + writer, + "단편영화 촬영감독 구합니다", + CategoryName.FILM_DRAMA, + LengthType.SHORT_FORM, + RoleName.CINEMATOGRAPHER, + RegionName.SEOUL, + "2026년 9월", + "협의", + "010-0000-0000", + "촬영 인력을 모집합니다.", + deadline + ); + ReflectionTestUtils.setField(recruitment, "id", RECRUITMENT_ID); + + when(recruitmentRepository.findActiveWithWriterById(RECRUITMENT_ID)) + .thenReturn(Optional.of(recruitment)); + } + + private RecruitmentUpdateRequest request(String json) { + try { + return OBJECT_MAPPER.readValue(json, RecruitmentUpdateRequest.class); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + +} diff --git a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java index 8abbfcb..1b87f6d 100644 --- a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java @@ -1,6 +1,11 @@ package com.slatto.domain.user.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; import com.slatto.domain.user.dto.UserProfileImageResponse; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.enums.SocialType; import com.slatto.domain.user.repository.LocationRepository; @@ -18,6 +23,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.Optional; @@ -45,6 +52,15 @@ class UserProfileImageServiceTest { @Mock private LocationRepository locationRepository; + @Mock + private UserPortfolioRepository userPortfolioRepository; + + @Mock + private RecruitmentRepository recruitmentRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock private StorageService storageService; @@ -90,6 +106,37 @@ void setUp() { verify(storageService, never()).upload(any(), anyString()); } + // URL 만 비우면 스토리지 객체가 남아 기존 공개 URL 을 아는 사람은 탈퇴 후에도 사진을 볼 수 있다. + @Test + void 탈퇴하면_프로필_이미지_객체도_삭제한다() { + String storageKey = "users/1/profile-images/abc.png"; + Users user = user(1L, "https://cdn.slatto.cloud/" + storageKey); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + // 삭제는 커밋 이후에 실행된다. 실제 트랜잭션이 없는 슬라이스라 동기화를 직접 열고 닫는다. + TransactionSynchronizationManager.initSynchronization(); + try { + userService.withdraw(1L, withdrawRequest()); + + TransactionSynchronizationManager.getSynchronizations() + .forEach(synchronization -> + synchronization.afterCompletion(TransactionSynchronization.STATUS_COMMITTED)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + verify(storageService).delete(storageKey); + assertThat(user.getProfileImageUrl()).isNull(); + } + + private UserWithdrawRequest withdrawRequest() { + try { + return new ObjectMapper().readValue("{\"agreed\":true}", UserWithdrawRequest.class); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + private Users user(Long id, String profileImageUrl) { Users user = Users.createSocialUser( "green@example.com", "그린", profileImageUrl, SocialType.GOOGLE, "google-green" From 23342f893aaae031805c7e157216d5c289325623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 19:57:46 +0900 Subject: [PATCH 16/17] =?UTF-8?q?chore:=20CORS=20=EC=98=88=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=B0=ED=8F=AC=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .env.example 은 로컬 개발용 템플릿이라 Vercel 프리뷰 주소가 들어갈 자리가 아니다. 외부 오리진이 로컬 API 를 호출할 수 없어 값으로서 의미가 없다. 배포 환경에 넣어야 하는 값이라는 안내와, 목록을 덮어쓰지 말라는 주의만 남긴다. --- .env.example | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 994c6eb..982d687 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,10 @@ COOKIE_SAME_SITE=Lax # CORS 허용 오리진. 콤마로 구분 # POST /api/v1/auth/refresh의 CSRF 방어에도 쓰인다. 이 목록에 없는 오리진의 재발급 요청은 403으로 차단된다 -# 프론트 로컬은 Next.js 3000, Vite 5173. Vercel 프리뷰는 브랜치마다 주소가 달라 개별 등록이 필요하다 -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://slate-to-fe-git-dev-kcleverp-7022s-projects.vercel.app +# 프론트 로컬은 Next.js 3000, Vite 5173 +# 배포 환경에는 프론트 운영 주소와 Vercel 프리뷰 주소를 넣는다. 프리뷰는 브랜치마다 주소가 달라 개별 등록이 필요하다 +# 값을 고칠 때는 기존 목록을 덮어쓰지 말고 콤마로 덧붙여야 한다. 목록 전체를 교체하는 변수다 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 # 인증번호 발송용 Gmail SMTP 계정 # MAIL_PASSWORD는 계정 비밀번호가 아니라 2단계 인증을 켠 뒤 발급한 16자리 앱 비밀번호다 From 9c644723dad2b095fac697786131c56c7d2ec4cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Sat, 8 Aug 2026 21:45:32 +0900 Subject: [PATCH 17/17] =?UTF-8?q?Revert=20"fix:=20CORS=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20PUT=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bb0446310b4e1050d8d1ea7a0ae8bfceb80d16ab. --- src/main/java/com/slatto/global/config/SecurityConfig.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java index 16d1b72..cc33761 100644 --- a/src/main/java/com/slatto/global/config/SecurityConfig.java +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -93,8 +93,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(corsProperties.allowedOrigins()); configuration.setAllowCredentials(true); - // PUT 이 빠져 있어 프로필 이미지 업로드(PUT /users/me/profile-image)가 preflight 에서 막혔다. - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setMaxAge(3600L);