diff --git a/docs/account-deletion-api.md b/docs/account-deletion-api.md new file mode 100644 index 00000000..deabccb2 --- /dev/null +++ b/docs/account-deletion-api.md @@ -0,0 +1,180 @@ +# Account Deletion API + +Frontend integration guide for deleting an OnTime account with optional withdrawal feedback. + +## Summary + +Account deletion hard-deletes the user from OnTime. The request can optionally include feedback. If feedback is provided, the backend stores it separately from the `User` table so it remains available after the account is deleted. + +For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. + +## Authentication + +All endpoints require the current OnTime access token. + +```http +Authorization: Bearer {accessToken} +Content-Type: application/json +``` + +## Request Body + +The request body is optional for every deletion endpoint. + +```json +{ + "feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a", + "message": "I do not use the app anymore." +} +``` + +Fields: + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `feedbackId` | UUID string | No | Client-generated ID. If omitted, the backend generates one. | +| `message` | string | No | If missing, blank, or only whitespace, feedback is not saved and deletion still proceeds. | + +## General Account Deletion + +Use this endpoint for normal OnTime account deletion. + +```http +DELETE /users/me/delete +``` + +Example without feedback: + +```http +DELETE /users/me/delete +Authorization: Bearer {accessToken} +Content-Type: application/json + +{} +``` + +Example with feedback: + +```http +DELETE /users/me/delete +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a", + "message": "The notifications were not useful for me." +} +``` + +Success response: + +```json +{ + "status": "success", + "code": "200", + "message": "계정이 성공적으로 삭제되었습니다!", + "data": null +} +``` + +## Google Account Deletion + +Use this endpoint when the current account is linked through Google login. + +```http +DELETE /oauth2/google/me +``` + +Example: + +```http +DELETE /oauth2/google/me +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "message": "I am switching to another calendar app." +} +``` + +Success response: + +```text +구글 로그인 회원탈퇴 성공 +``` + +Behavior: + +- Revokes the stored Google OAuth token for OnTime. +- Deletes the local OnTime account. +- Saves optional feedback if `message` is nonblank. +- Does not delete the user's actual Google account. + +Expected frontend result after successful revoke: + +- If the user signs up or logs in with the same Google account again, Google may show an "OnTime에 다시 로그인하는 중입니다" confirmation screen. +- The Google account can still be preselected because the user is still signed in to Google on the device/browser. + +Important caveat: + +- The Google unlink only works if the backend has a valid Google refresh/access token saved in `socialLoginToken`. +- If the client never provided a real Google refresh token, Google revoke may fail and the endpoint may return an error before local deletion. + +## Apple Account Deletion + +Use this endpoint when the current account is linked through Apple login. + +```http +DELETE /oauth2/apple/me +``` + +Example: + +```http +DELETE /oauth2/apple/me +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "feedbackId": "85fc54e0-e6c7-4c7e-9312-7784a52bf120", + "message": "I want to restart with a fresh account." +} +``` + +Success response: + +```text +애플 로그인 회원탈퇴 성공 +``` + +Behavior: + +- Revokes the stored Apple OAuth token for OnTime. +- Deletes the local OnTime account. +- Saves optional feedback if `message` is nonblank. +- Does not delete the user's Apple ID. + +## What Gets Stored For Feedback + +When feedback is provided, the backend stores: + +| Stored Field | Notes | +| --- | --- | +| `feedbackId` | Client-provided UUID or backend-generated UUID | +| `deletedUserId` | Previous OnTime user ID | +| `socialType` | `GOOGLE`, `APPLE`, or null for non-social accounts | +| `emailHash` | SHA-256 hash of the normalized email, not plaintext email | +| `message` | User feedback text | +| `createdAt` | Server timestamp | + +Feedback is not linked by foreign key to the deleted user. + +## Frontend Recommendations + +- Treat feedback as optional. Do not block deletion if the user skips it. +- Generate a UUID for `feedbackId` if convenient, but it is safe to omit. +- Use `/oauth2/google/me` for Google-linked accounts if the product requirement is to unlink Google access. +- Use `/oauth2/apple/me` for Apple-linked accounts if the product requirement is to unlink Apple access. +- Use `/users/me/delete` for normal local deletion. +- After a successful deletion response, clear local auth state and navigate to the logged-out screen. +- Do not retry automatically on social revoke errors without showing the user, because the local account may not have been deleted. diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java index 7366a8c2..163ae182 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java @@ -1,5 +1,6 @@ package devkor.ontime_back.controller; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.OAuthAppleRequestDto; import devkor.ontime_back.dto.OAuthGoogleUserDto; import devkor.ontime_back.dto.OAuthKakaoUserDto; @@ -112,11 +113,11 @@ public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginR summary = "애플 소셜 로그인 회원탈퇴" ) @DeleteMapping("/apple/me") - public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception { + public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception { Long userId = userAuthService.getUserIdFromToken(request); log.info("userId: {}", userId); appleLoginService.revokeToken(userId); - userAuthService.deleteUser(userId); + userAuthService.deleteUser(userId, feedbackAddDto); return "애플 로그인 회원탈퇴 성공"; } @@ -124,12 +125,12 @@ public String appleDeleteUser(HttpServletRequest request, HttpServletResponse re summary = "구글 소셜 로그인 회원탈퇴" ) @DeleteMapping("/google/me") - public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception { + public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception { Long userId = userAuthService.getUserIdFromToken(request); log.info("userId: {}", userId); googleLoginService.revokeToken(userId); - userAuthService.deleteUser(userId); - return "애플 로그인 회원탈퇴 성공"; + userAuthService.deleteUser(userId, feedbackAddDto); + return "구글 로그인 회원탈퇴 성공"; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java index dd4e5f4d..13c4185b 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java @@ -1,6 +1,7 @@ package devkor.ontime_back.controller; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; import devkor.ontime_back.entity.User; @@ -107,11 +108,11 @@ public ResponseEntity> changePassword(HttpServletRequest @Operation( summary = "계정 삭제 (User 데이터 하드 삭제)", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "계정 삭제 요청 JSON 데이터는 없음. 헤더에 토큰만 있으면 됨", + description = "계정 삭제 요청 JSON 데이터는 선택사항. 탈퇴 피드백을 남기려면 feedbackId, message를 전달", content = @Content( schema = @Schema( type = "object", - example = "{}" + example = "{\"feedbackId\": \"d784cde3-9ff9-4054-872a-500bbcc2198a\", \"message\": \"탈퇴 피드백입니다.\"}" ) ) ) @@ -126,9 +127,9 @@ public ResponseEntity> changePassword(HttpServletRequest @ApiResponse(responseCode = "4XX", description = "계정 삭제 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)"))) }) @DeleteMapping("/users/me/delete") - public ResponseEntity> deleteUser(HttpServletRequest request) { + public ResponseEntity> deleteUser(HttpServletRequest request, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { Long userId = userAuthService.getUserIdFromToken(request); - userAuthService.deleteUser(userId); + userAuthService.deleteUser(userId, feedbackAddDto); String message = "계정이 성공적으로 삭제되었습니다!"; return ResponseEntity.ok(ApiResponseForm.success(null, message)); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java index 86858295..b653711a 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java @@ -1,12 +1,14 @@ package devkor.ontime_back.dto; -import lombok.Getter; -import lombok.ToString; +import lombok.*; import java.util.UUID; @ToString @Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class FeedbackAddDto { private UUID feedbackId; private String message; diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/AccountDeletionFeedback.java b/ontime-back/src/main/java/devkor/ontime_back/entity/AccountDeletionFeedback.java new file mode 100644 index 00000000..d21d9a64 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/AccountDeletionFeedback.java @@ -0,0 +1,32 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class AccountDeletionFeedback { + + @Id + private UUID feedbackId; + + private Long deletedUserId; + + @Enumerated(EnumType.STRING) + private SocialType socialType; + + @Column(length = 64) + private String emailHash; + + @Lob + @Column(columnDefinition = "TEXT") + private String message; + + private LocalDateTime createdAt; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java index 560c34f1..25bc5616 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java @@ -107,15 +107,12 @@ public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpSe FilterChain filterChain) throws ServletException, IOException { log.info("checkAccessTokenAndAuthentication() 호출"); jwtTokenProvider.extractAccessToken(request) - .ifPresent(accessToken -> { - jwtTokenProvider.extractEmail(accessToken) - .ifPresent(email -> userRepository.findByEmail(email) - .ifPresent(this::saveAuthentication) - ); - - jwtTokenProvider.extractUserId(accessToken) - .ifPresent(userId -> log.info("추출된 userId: {}", userId)); - }); + .ifPresent(accessToken -> jwtTokenProvider.extractUserId(accessToken) + .ifPresent(userId -> { + log.info("추출된 userId: {}", userId); + userRepository.findById(userId) + .ifPresent(this::saveAuthentication); + })); filterChain.doFilter(request, response); } @@ -192,4 +189,4 @@ private void handleInvalidTokenException(HttpServletResponse response, InvalidTo response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); } } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java index d48df727..92beb7aa 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java @@ -71,6 +71,7 @@ public String createRefreshToken() { return JWT.create() .withSubject(REFRESH_TOKEN_SUBJECT) .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod)) + .withJWTId(UUID.randomUUID().toString()) .sign(Algorithm.HMAC512(secretKey)); } @@ -199,4 +200,4 @@ public String createExpiredAccessToken(String email) { .sign(Algorithm.HMAC512(secretKey)); } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java index 0eea0280..928a90b7 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java @@ -18,10 +18,14 @@ import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import java.io.IOException; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; @Slf4j public class GoogleLoginFilter extends AbstractAuthenticationProcessingFilter { + private static final Map LOGIN_LOCKS = new ConcurrentHashMap<>(); + private final UserRepository userRepository; private final GoogleLoginService googleLoginService; @@ -42,13 +46,16 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ GoogleIdToken.Payload googlePayload = googleLoginService.verifyIdentityToken(oAuthGoogleRequestDto.getIdToken()); String googleUserId = googlePayload.getSubject(); - Optional existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId); + Object loginLock = LOGIN_LOCKS.computeIfAbsent(googleUserId, key -> new Object()); + synchronized (loginLock) { + Optional existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId); - if (existingUser.isPresent()) { - return googleLoginService.handleLogin(oAuthGoogleRequestDto, existingUser.get(), response); - } else { - OAuthGoogleUserDto oAuthGoogleUserDto = new OAuthGoogleUserDto(googleUserId, (String) googlePayload.get("name"), (String) googlePayload.get("picture"), googlePayload.getEmail()); - return googleLoginService.handleRegister(oAuthGoogleRequestDto, oAuthGoogleUserDto, response); + if (existingUser.isPresent()) { + return googleLoginService.handleLogin(oAuthGoogleRequestDto, existingUser.get(), response); + } else { + OAuthGoogleUserDto oAuthGoogleUserDto = new OAuthGoogleUserDto(googleUserId, (String) googlePayload.get("name"), (String) googlePayload.get("picture"), googlePayload.getEmail()); + return googleLoginService.handleRegister(oAuthGoogleRequestDto, oAuthGoogleUserDto, response); + } } } catch (Exception e) { diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java new file mode 100644 index 00000000..4683bb90 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java @@ -0,0 +1,11 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.AccountDeletionFeedback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface AccountDeletionFeedbackRepository extends JpaRepository { +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java index 9bb7d3b2..7a47fb77 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java @@ -1,13 +1,16 @@ package devkor.ontime_back.service; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; +import devkor.ontime_back.entity.AccountDeletionFeedback; import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; @@ -23,6 +26,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; import java.util.NoSuchElementException; import java.util.UUID; @@ -35,6 +42,7 @@ public class UserAuthService { private final UserRepository userRepository; private final UserSettingRepository userSettingRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; + private final AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @@ -161,12 +169,55 @@ public User changePassword(Long userId, ChangePasswordDto changePasswordDto) { @Transactional public Long deleteUser(Long userId) { + return deleteUser(userId, null); + } + + @Transactional + public Long deleteUser(Long userId, FeedbackAddDto feedbackAddDto) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다.")); + saveAccountDeletionFeedback(user, feedbackAddDto); userRepository.delete(user); return userId; } + private void saveAccountDeletionFeedback(User user, FeedbackAddDto feedbackAddDto) { + if (feedbackAddDto == null || feedbackAddDto.getMessage() == null || feedbackAddDto.getMessage().isBlank()) { + return; + } + + UUID feedbackId = feedbackAddDto.getFeedbackId() != null ? feedbackAddDto.getFeedbackId() : UUID.randomUUID(); + + AccountDeletionFeedback feedback = AccountDeletionFeedback.builder() + .feedbackId(feedbackId) + .deletedUserId(user.getId()) + .socialType(user.getSocialType()) + .emailHash(hashEmail(user.getEmail())) + .message(feedbackAddDto.getMessage()) + .createdAt(LocalDateTime.now()) + .build(); + + accountDeletionFeedbackRepository.save(feedback); + } + + private String hashEmail(String email) { + if (email == null) { + return null; + } + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedHash = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(encodedHash.length * 2); + for (byte b : encodedHash) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available.", e); + } + } + } diff --git a/ontime-back/src/main/resources/db/migration/V9__create_account_deletion_feedback.sql b/ontime-back/src/main/resources/db/migration/V9__create_account_deletion_feedback.sql new file mode 100644 index 00000000..e1433dc6 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V9__create_account_deletion_feedback.sql @@ -0,0 +1,8 @@ +CREATE TABLE account_deletion_feedback ( + feedback_id BINARY(16) PRIMARY KEY, + deleted_user_id BIGINT, + social_type VARCHAR(255), + email_hash VARCHAR(64), + message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java index 83a30298..672db798 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java @@ -4,6 +4,7 @@ import devkor.ontime_back.ControllerTestSupport; import devkor.ontime_back.TestSecurityConfig; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; @@ -100,7 +101,7 @@ void changePassword() throws Exception { void deleteUser() throws Exception { // given when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); - when(userAuthService.deleteUser(any(Long.class))).thenReturn(1L); + when(userAuthService.deleteUser(any(Long.class), any())).thenReturn(1L); // when // then mockMvc.perform( @@ -114,4 +115,29 @@ void deleteUser() throws Exception { .andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.message").value("계정이 성공적으로 삭제되었습니다!")); } -} \ No newline at end of file + + @DisplayName("탈퇴 피드백을 포함해 계정 삭제에 성공한다.") + @Test + void deleteUserWithFeedback() throws Exception { + // given + FeedbackAddDto request = FeedbackAddDto.builder() + .feedbackId(UUID.randomUUID()) + .message("탈퇴 피드백입니다.") + .build(); + + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.deleteUser(any(Long.class), any(FeedbackAddDto.class))).thenReturn(1L); + + // when // then + mockMvc.perform( + delete("/users/me/delete") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("계정이 성공적으로 삭제되었습니다!")); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java new file mode 100644 index 00000000..6b88b22c --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java @@ -0,0 +1,28 @@ +package devkor.ontime_back.global.jwt; + +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class JwtTokenProviderTest { + + @DisplayName("동일한 시각에 발급한 리프레시 토큰도 서로 다르다") + @Test + void createRefreshTokenGeneratesUniqueTokens() { + // given + JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(mock(UserRepository.class)); + ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", "test-secret-key-that-is-long-enough"); + ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpirationPeriod", 3600000L); + + // when + String refreshToken1 = jwtTokenProvider.createRefreshToken(); + String refreshToken2 = jwtTokenProvider.createRefreshToken(); + + // then + assertThat(refreshToken1).isNotEqualTo(refreshToken2); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java index 2504eac3..b2b0da82 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java @@ -2,12 +2,16 @@ import com.google.firebase.auth.UserInfo; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; +import devkor.ontime_back.entity.AccountDeletionFeedback; import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.repository.UserSettingRepository; import devkor.ontime_back.response.GeneralException; @@ -44,6 +48,8 @@ class UserAuthServiceTest { private UserRepository userRepository; @Autowired private UserSettingRepository userSettingRepository; + @Autowired + private AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; @Autowired private PasswordEncoder passwordEncoder; @@ -53,6 +59,7 @@ class UserAuthServiceTest { @AfterEach void tearDown() { + accountDeletionFeedbackRepository.deleteAllInBatch(); userSettingRepository.deleteAllInBatch(); userRepository.deleteAllInBatch(); } @@ -279,6 +286,65 @@ void deleteUser(){ assertThat(userRepository.findById(targetUserId)).isEmpty(); } + @DisplayName("계정 삭제 시 선택 피드백을 익명화하여 저장한다") + @Test + void deleteUserWithFeedback(){ + // given + User addedUser = User.builder() + .email("USER@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .socialType(SocialType.GOOGLE) + .build(); + userRepository.save(addedUser); + + UUID feedbackId = UUID.randomUUID(); + FeedbackAddDto feedbackAddDto = FeedbackAddDto.builder() + .feedbackId(feedbackId) + .message("탈퇴 이유입니다.") + .build(); + + Long targetUserId = addedUser.getId(); + + // when + Long deletedUserId = userAuthService.deleteUser(targetUserId, feedbackAddDto); + + // then + assertThat(deletedUserId).isEqualTo(targetUserId); + assertThat(userRepository.findById(targetUserId)).isEmpty(); + + AccountDeletionFeedback feedback = accountDeletionFeedbackRepository.findById(feedbackId) + .orElseThrow(); + assertThat(feedback.getDeletedUserId()).isEqualTo(targetUserId); + assertThat(feedback.getSocialType()).isEqualTo(SocialType.GOOGLE); + assertThat(feedback.getMessage()).isEqualTo("탈퇴 이유입니다."); + assertThat(feedback.getEmailHash()).hasSize(64); + assertThat(feedback.getEmailHash()).doesNotContain("USER@example.com"); + assertThat(feedback.getCreatedAt()).isNotNull(); + } + + @DisplayName("계정 삭제 시 피드백이 없어도 삭제된다") + @Test + void deleteUserWithoutFeedback(){ + // given + User addedUser = User.builder() + .email("user@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .build(); + userRepository.save(addedUser); + + Long targetUserId = addedUser.getId(); + + // when + Long deletedUserId = userAuthService.deleteUser(targetUserId, null); + + // then + assertThat(deletedUserId).isEqualTo(targetUserId); + assertThat(userRepository.findById(targetUserId)).isEmpty(); + assertThat(accountDeletionFeedbackRepository.findAll()).isEmpty(); + } + private UserSignUpDto getUserSignUpDto(String email, String password, String name) { @@ -289,4 +355,4 @@ private UserSignUpDto getUserSignUpDto(String email, String password, String nam .build(); return userSignUpDto; } -} \ No newline at end of file +}