diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/AppleNotificationController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/AppleNotificationController.java new file mode 100644 index 00000000..b1e32218 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/AppleNotificationController.java @@ -0,0 +1,87 @@ +package makeus.cmc.malmo.adaptor.in.web.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.application.port.in.member.AppleNotificationUseCase; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Apple Sign in with Apple Server-to-Server 알림을 처리하는 Webhook 컨트롤러 + * + * @see Apple Documentation + */ +@Slf4j +@Tag(name = "Apple Webhook", description = "Apple Server-to-Server 알림 처리") +@RestController +@RequestMapping("/webhook/apple") +@RequiredArgsConstructor +public class AppleNotificationController { + + private final AppleNotificationUseCase appleNotificationUseCase; + + /** + * JSON 형식의 Apple 알림을 처리합니다. + * Content-Type: application/json + */ + @Operation( + summary = "Apple Server-to-Server 알림 수신 (JSON)", + description = "Sign in with Apple 계정 변경 알림을 JSON 형식으로 처리합니다." + ) + @PostMapping(value = "/notifications", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity handleNotificationJson( + @RequestBody AppleNotificationRequest request + ) { + return processPayload(request.getSignedPayload()); + } + + /** + * Form URL Encoded 형식의 Apple 알림을 처리합니다. + * Content-Type: application/x-www-form-urlencoded + */ + @Operation( + summary = "Apple Server-to-Server 알림 수신 (Form)", + description = "Sign in with Apple 계정 변경 알림을 Form 형식으로 처리합니다." + ) + @PostMapping(value = "/notifications", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity handleNotificationForm( + @RequestParam("signed_payload") String signedPayload + ) { + return processPayload(signedPayload); + } + + /** + * 공통 페이로드 처리 로직 + * Apple의 재시도를 방지하기 위해 항상 200 OK를 반환합니다. + */ + private ResponseEntity processPayload(String signedPayload) { + if (signedPayload == null || signedPayload.isBlank()) { + log.warn("Apple notification received with empty payload"); + return ResponseEntity.ok().build(); + } + + try { + log.info("Processing Apple notification"); + appleNotificationUseCase.processNotification(signedPayload); + } catch (Exception e) { + // Apple의 재시도를 방지하기 위해 예외가 발생해도 200 OK 반환 + log.error("Failed to process Apple notification: {}", e.getMessage(), e); + } + + return ResponseEntity.ok().build(); + } + + @Data + public static class AppleNotificationRequest { + @JsonProperty("signed_payload") + private String signedPayload; + } +} + + + diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/oidc/AppleNotificationValidator.java b/src/main/java/makeus/cmc/malmo/adaptor/out/oidc/AppleNotificationValidator.java new file mode 100644 index 00000000..379c1424 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/oidc/AppleNotificationValidator.java @@ -0,0 +1,141 @@ +package makeus.cmc.malmo.adaptor.out.oidc; + +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.adaptor.out.exception.OidcIdTokenException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.security.interfaces.RSAPublicKey; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * Apple Server-to-Server Notification JWT를 검증하고 파싱합니다. + */ +@Slf4j +@Component +public class AppleNotificationValidator { + + private final JwkProvider jwkProvider; + private final ObjectMapper objectMapper; + private final String expectedAudience; + + private static final String APPLE_ISS = "https://appleid.apple.com"; + private static final String JWKS_URI = "https://appleid.apple.com/auth/keys"; + + /** + * 프로덕션용 생성자 - Apple JWKS URI에서 키를 가져옵니다. + */ + @Autowired + public AppleNotificationValidator( + ObjectMapper objectMapper, + @Value("${apple.oidc.aud}") String expectedAudience) { + try { + this.jwkProvider = new JwkProviderBuilder(new URL(JWKS_URI)) + .cached(10, 60, TimeUnit.MINUTES) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build(); + this.objectMapper = objectMapper; + this.expectedAudience = expectedAudience; + } catch (Exception e) { + throw new RuntimeException("Failed to initialize AppleNotificationValidator", e); + } + } + + /** + * 테스트용 생성자 - JwkProvider를 주입받습니다. + * 주로 단위 테스트에서 mock을 주입할 때 사용합니다. + */ + AppleNotificationValidator( + JwkProvider jwkProvider, + ObjectMapper objectMapper, + String expectedAudience) { + this.jwkProvider = jwkProvider; + this.objectMapper = objectMapper; + this.expectedAudience = expectedAudience; + } + + /** + * Apple Server-to-Server 알림 JWT를 검증하고 파싱합니다. + * + * @param signedPayload Apple이 보낸 JWT 형식의 서명된 페이로드 + * @return 파싱된 알림 클레임 + * @throws OidcIdTokenException 검증 실패 시 + */ + public AppleNotificationClaims validateAndParse(String signedPayload) { + try { + DecodedJWT jwt = JWT.decode(signedPayload); + + // 1. JWKS를 사용한 서명 검증 + Jwk jwk = jwkProvider.get(jwt.getKeyId()); + Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null); + algorithm.verify(jwt); + + // 2. issuer 검증 (반드시 Apple이어야 함) + if (!APPLE_ISS.equals(jwt.getIssuer())) { + throw new OidcIdTokenException("Invalid issuer for Apple notification"); + } + + // 3. audience 검증 (앱의 client_id) + if (!jwt.getAudience().contains(expectedAudience)) { + throw new OidcIdTokenException("Invalid audience for Apple notification"); + } + + // 4. 만료 시간 검증 + if (jwt.getExpiresAt() != null && jwt.getExpiresAt().before(new Date())) { + throw new OidcIdTokenException("Expired Apple notification token"); + } + + // 5. events 클레임 파싱 + String eventsPayload = jwt.getClaim("events").asString(); + JsonNode eventsNode = objectMapper.readTree(eventsPayload); + + return AppleNotificationClaims.builder() + .jti(jwt.getId()) + .iat(jwt.getIssuedAt() != null ? jwt.getIssuedAt().getTime() : null) + .eventType(eventsNode.get("type").asText()) + .sub(eventsNode.get("sub").asText()) + .eventTime(eventsNode.has("event_time") + ? eventsNode.get("event_time").asLong() : null) + .email(eventsNode.has("email") + ? eventsNode.get("email").asText() : null) + .isPrivateEmail(eventsNode.has("is_private_email") + ? eventsNode.get("is_private_email").asBoolean() : null) + .build(); + + } catch (OidcIdTokenException e) { + throw e; + } catch (Exception e) { + log.error("Failed to validate Apple notification JWT: {}", e.getMessage(), e); + throw new OidcIdTokenException("Failed to validate Apple notification", e); + } + } + + /** + * Apple 알림에서 파싱된 클레임 + */ + @Getter + @Builder + public static class AppleNotificationClaims { + private final String jti; // JWT ID (중복 방지용) + private final Long iat; // 발행 시간 + private final String eventType; // email-enabled, consent-revoked, account-delete 등 + private final String sub; // 사용자의 Apple providerId + private final Long eventTime; // 이벤트 발생 시간 + private final String email; // 변경된 이메일 (있는 경우) + private final Boolean isPrivateEmail; + } +} + diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java index 43d0f330..d8702665 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/member/MemberEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; @@ -9,6 +10,7 @@ import makeus.cmc.malmo.adaptor.out.persistence.entity.value.CoupleEntityId; import makeus.cmc.malmo.adaptor.out.persistence.entity.value.InviteCodeEntityValue; import makeus.cmc.malmo.domain.value.state.MemberState; +import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.MemberRole; import makeus.cmc.malmo.domain.value.type.Provider; @@ -54,6 +56,10 @@ public class MemberEntity extends BaseTimeEntity { private String email; + @Builder.Default + @Enumerated(value = EnumType.STRING) + private EmailForwardingStatus emailForwardingStatus = EmailForwardingStatus.ENABLED; + @Embedded private InviteCodeEntityValue inviteCodeEntityValue; diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java index 23dba930..d14eefe0 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/MemberMapper.java @@ -28,6 +28,7 @@ public Member toDomain(MemberEntity entity) { entity.getAnxietyRate(), entity.getNickname(), entity.getEmail(), + entity.getEmailForwardingStatus(), entity.getInviteCodeEntityValue() != null ? InviteCodeValue.of(entity.getInviteCodeEntityValue().getValue()) : null, entity.getStartLoveDate(), entity.getOauthToken(), @@ -52,6 +53,7 @@ public MemberEntity toEntity(Member domain) { .avoidanceRate(domain.getAvoidanceRate()) .anxietyRate(domain.getAnxietyRate()) .email(domain.getEmail()) + .emailForwardingStatus(domain.getEmailForwardingStatus()) .nickname(domain.getNickname()) .inviteCodeEntityValue( domain.getInviteCode() != null ? InviteCodeEntityValue.of(domain.getInviteCode().getValue()) : null diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/redis/AppleNotificationJtiStore.java b/src/main/java/makeus/cmc/malmo/adaptor/out/redis/AppleNotificationJtiStore.java new file mode 100644 index 00000000..09960c11 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/redis/AppleNotificationJtiStore.java @@ -0,0 +1,48 @@ +package makeus.cmc.malmo.adaptor.out.redis; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Apple Server-to-Server Notification의 jti(JWT ID) 중복 처리 방지를 위한 Redis 저장소 + * SETNX + TTL을 사용하여 원자적으로 중복 체크와 저장을 수행합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleNotificationJtiStore { + + private final StringRedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "apple:notification:jti:"; + private static final Duration TTL = Duration.ofDays(1); // Apple 알림은 24시간 내 재시도 + + /** + * JTI가 이미 처리되었는지 확인하고, 처리되지 않았다면 저장합니다. + * Redis SETNX를 사용하여 원자적으로 수행됩니다. + * + * @param jti Apple 알림의 JWT ID + * @return true if this is a new JTI (should process), false if already processed (should skip) + */ + public boolean tryMarkAsProcessed(String jti) { + String key = KEY_PREFIX + jti; + + // SETNX: 키가 없을 때만 설정 (atomic operation) + Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "processed", TTL); + + if (Boolean.TRUE.equals(isNew)) { + log.debug("New Apple notification JTI: {}", jti); + return true; + } else { + log.info("Duplicate Apple notification JTI detected: {}", jti); + return false; + } + } +} + + + diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/member/AppleNotificationUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/member/AppleNotificationUseCase.java new file mode 100644 index 00000000..1fc66879 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/member/AppleNotificationUseCase.java @@ -0,0 +1,19 @@ +package makeus.cmc.malmo.application.port.in.member; + +/** + * Apple Server-to-Server Notification 처리를 위한 UseCase + * + * @see Apple Documentation + */ +public interface AppleNotificationUseCase { + + /** + * Apple로부터 수신한 서명된 페이로드를 처리합니다. + * + * @param signedPayload Apple이 보낸 JWT 형식의 서명된 페이로드 + */ + void processNotification(String signedPayload); +} + + + diff --git a/src/main/java/makeus/cmc/malmo/application/service/member/AppleNotificationService.java b/src/main/java/makeus/cmc/malmo/application/service/member/AppleNotificationService.java new file mode 100644 index 00000000..8b35df22 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/service/member/AppleNotificationService.java @@ -0,0 +1,165 @@ +package makeus.cmc.malmo.application.service.member; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator; +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator.AppleNotificationClaims; +import makeus.cmc.malmo.adaptor.out.redis.AppleNotificationJtiStore; +import makeus.cmc.malmo.application.helper.member.MemberCommandHelper; +import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; +import makeus.cmc.malmo.application.port.in.member.AppleNotificationUseCase; +import makeus.cmc.malmo.application.port.out.member.UnlinkApplePort; +import makeus.cmc.malmo.domain.model.member.Member; +import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; +import makeus.cmc.malmo.domain.value.type.Provider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * Apple Server-to-Server Notification 처리 서비스 + * + * @see Apple Documentation + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleNotificationService implements AppleNotificationUseCase { + + private final AppleNotificationValidator notificationValidator; + private final AppleNotificationJtiStore jtiStore; + private final MemberQueryHelper memberQueryHelper; + private final MemberCommandHelper memberCommandHelper; + private final UnlinkApplePort unlinkApplePort; + + private static final String EVENT_CONSENT_REVOKED = "consent-revoked"; + private static final String EVENT_ACCOUNT_DELETE = "account-delete"; + private static final String EVENT_EMAIL_ENABLED = "email-enabled"; + private static final String EVENT_EMAIL_DISABLED = "email-disabled"; + + @Override + @Transactional + public void processNotification(String signedPayload) { + // 1. JWT 검증 및 파싱 (JWKS 서명 검증 포함) + AppleNotificationClaims claims = notificationValidator.validateAndParse(signedPayload); + + // 2. JTI 중복 체크 (이미 처리된 알림이면 스킵) + if (!jtiStore.tryMarkAsProcessed(claims.getJti())) { + log.info("Skipping duplicate Apple notification: jti={}", claims.getJti()); + return; + } + + // 3. 사용자 조회 + Optional memberOpt = memberQueryHelper.getMemberByProviderId( + Provider.APPLE, claims.getSub() + ); + + if (memberOpt.isEmpty()) { + log.warn("Apple notification for unknown user: sub={}", claims.getSub()); + return; + } + + Member member = memberOpt.get(); + + // 4. 이벤트 타입별 처리 + switch (claims.getEventType()) { + case EVENT_CONSENT_REVOKED: + handleConsentRevoked(member); + break; + case EVENT_ACCOUNT_DELETE: + handleAccountDelete(member); + break; + case EVENT_EMAIL_ENABLED: + case EVENT_EMAIL_DISABLED: + handleEmailChange(member, claims); + break; + default: + log.warn("Unknown Apple event type: {}", claims.getEventType()); + } + } + + /** + * 사용자가 앱 연결 해제 시 처리 + * - 소프트 삭제 + * - 토큰 무효화 + * - Apple refresh token revoke (있는 경우) + */ + private void handleConsentRevoked(Member member) { + log.info("User revoked consent: memberId={}", member.getId()); + + // 토큰 무효화 (refreshToken, firebaseToken) + member.logOut(); + + // 소프트 삭제 + member.delete(); + + memberCommandHelper.saveMember(member); + + // Apple refresh token revoke (있는 경우) + revokeAppleTokenIfExists(member); + } + + /** + * Apple 계정 삭제 시 처리 + * - 소프트 삭제 + * - 토큰 무효화 + */ + private void handleAccountDelete(Member member) { + log.info("Apple account deleted: memberId={}", member.getId()); + + // 토큰 무효화 + member.logOut(); + + // 소프트 삭제 + member.delete(); + + memberCommandHelper.saveMember(member); + } + + /** + * 이메일 포워딩 변경 시 처리 + * - email-enabled: 상태를 ENABLED로 변경하고 새 이메일이 있으면 업데이트 + * - email-disabled: 상태를 DISABLED로 변경 + */ + private void handleEmailChange(Member member, AppleNotificationClaims claims) { + log.info("Email forwarding changed: memberId={}, event={}, email={}", + member.getId(), claims.getEventType(), claims.getEmail()); + + if (EVENT_EMAIL_ENABLED.equals(claims.getEventType())) { + // 상태를 ENABLED로 변경 + member.updateEmailForwardingStatus(EmailForwardingStatus.ENABLED); + // 새 이메일이 있으면 업데이트 + if (claims.getEmail() != null) { + member.updateEmail(claims.getEmail()); + log.info("Member email updated: memberId={}, newEmail={}", member.getId(), claims.getEmail()); + } + memberCommandHelper.saveMember(member); + } else if (EVENT_EMAIL_DISABLED.equals(claims.getEventType())) { + // 상태를 DISABLED로 변경 + member.updateEmailForwardingStatus(EmailForwardingStatus.DISABLED); + memberCommandHelper.saveMember(member); + log.info("Member email forwarding disabled: memberId={}", member.getId()); + } + } + + /** + * Apple refresh token이 있으면 revoke 호출 + */ + private void revokeAppleTokenIfExists(Member member) { + String oauthToken = member.getOauthToken(); + if (oauthToken != null && !oauthToken.isBlank()) { + try { + unlinkApplePort.unlink(oauthToken); + log.info("Apple refresh token revoked: memberId={}", member.getId()); + } catch (Exception e) { + // revoke 실패해도 사용자 삭제는 진행되어야 함 + log.error("Failed to revoke Apple refresh token: memberId={}, error={}", + member.getId(), e.getMessage()); + } + } + } +} + + + diff --git a/src/main/java/makeus/cmc/malmo/config/SecurityConfig.java b/src/main/java/makeus/cmc/malmo/config/SecurityConfig.java index caa31216..fa204672 100644 --- a/src/main/java/makeus/cmc/malmo/config/SecurityConfig.java +++ b/src/main/java/makeus/cmc/malmo/config/SecurityConfig.java @@ -59,7 +59,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authorize -> authorize .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/login/**", "/refresh", "/terms", "/test", "/love-types/**").permitAll() + .requestMatchers("/login/**", "/refresh", "/terms", "/test", "/love-types/**", "/webhook/apple/**").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**", "/v3/api-docs/**", "/v3/api-docs", "/webjars/**", "/actuator/prometheus").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") diff --git a/src/main/java/makeus/cmc/malmo/config/TestSecurityConfig.java b/src/main/java/makeus/cmc/malmo/config/TestSecurityConfig.java index 59625dae..d3b634ac 100644 --- a/src/main/java/makeus/cmc/malmo/config/TestSecurityConfig.java +++ b/src/main/java/makeus/cmc/malmo/config/TestSecurityConfig.java @@ -59,7 +59,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authorize -> authorize .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/login/**", "/refresh", "/terms", "/test", "/love-types/**").permitAll() + .requestMatchers("/login/**", "/refresh", "/terms", "/test", "/love-types/**", "/webhook/apple/**").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**", "/v3/api-docs/**", "/v3/api-docs", "/webjars/**", "/actuator/prometheus").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") diff --git a/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java b/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java index 3abbf08c..3d7b70c9 100644 --- a/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java +++ b/src/main/java/makeus/cmc/malmo/domain/model/member/Member.java @@ -6,6 +6,7 @@ import makeus.cmc.malmo.domain.value.id.CoupleId; import makeus.cmc.malmo.domain.value.id.InviteCodeValue; import makeus.cmc.malmo.domain.value.state.MemberState; +import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; import makeus.cmc.malmo.domain.value.type.LoveTypeCategory; import makeus.cmc.malmo.domain.value.type.MemberRole; import makeus.cmc.malmo.domain.value.type.Provider; @@ -29,6 +30,7 @@ public class Member { private float anxietyRate; private String nickname; private String email; + private EmailForwardingStatus emailForwardingStatus; private InviteCodeValue inviteCode; /** @@ -54,6 +56,7 @@ public static Member createMember(Provider provider, String providerId, MemberRo .memberRole(memberRole) .memberState(memberState) .email(email) + .emailForwardingStatus(EmailForwardingStatus.ENABLED) // 기본값 ENABLED .inviteCode(inviteCode) .oauthToken(oauthToken) .build(); @@ -73,6 +76,7 @@ public static Member from( float anxietyRate, String nickname, String email, + EmailForwardingStatus emailForwardingStatus, InviteCodeValue inviteCode, LocalDate startLoveDate, String oauthToken, @@ -95,6 +99,7 @@ public static Member from( .anxietyRate(anxietyRate) .nickname(nickname) .email(email) + .emailForwardingStatus(emailForwardingStatus) .inviteCode(inviteCode) .startLoveDate(startLoveDate) .oauthToken(oauthToken) @@ -154,6 +159,13 @@ public void updateEmail(String email) { this.email = email; } + /** + * Apple 이메일 포워딩 상태를 업데이트합니다. + */ + public void updateEmailForwardingStatus(EmailForwardingStatus status) { + this.emailForwardingStatus = status; + } + public void delete() { this.memberState = MemberState.DELETED; this.deletedAt = LocalDateTime.now(); diff --git a/src/main/java/makeus/cmc/malmo/domain/value/type/EmailForwardingStatus.java b/src/main/java/makeus/cmc/malmo/domain/value/type/EmailForwardingStatus.java new file mode 100644 index 00000000..4acd28ff --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/value/type/EmailForwardingStatus.java @@ -0,0 +1,19 @@ +package makeus.cmc.malmo.domain.value.type; + +/** + * Apple Sign in with Apple 이메일 포워딩 상태 + * + * @see Apple Documentation + */ +public enum EmailForwardingStatus { + /** + * 이메일 포워딩이 활성화됨 + */ + ENABLED, + + /** + * 이메일 포워딩이 비활성화됨 + */ + DISABLED +} + diff --git a/src/test/java/makeus/cmc/malmo/adaptor/out/oidc/AppleNotificationValidatorTest.java b/src/test/java/makeus/cmc/malmo/adaptor/out/oidc/AppleNotificationValidatorTest.java new file mode 100644 index 00000000..edb020b7 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/adaptor/out/oidc/AppleNotificationValidatorTest.java @@ -0,0 +1,303 @@ +package makeus.cmc.malmo.adaptor.out.oidc; + +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.fasterxml.jackson.databind.ObjectMapper; +import makeus.cmc.malmo.adaptor.out.exception.OidcIdTokenException; +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator.AppleNotificationClaims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AppleNotificationValidator 단위 테스트") +class AppleNotificationValidatorTest { + + @Mock + private JwkProvider jwkProvider; + + @Mock + private Jwk jwk; + + private AppleNotificationValidator validator; + private ObjectMapper objectMapper; + + private RSAPublicKey publicKey; + private RSAPrivateKey privateKey; + + private static final String APPLE_ISS = "https://appleid.apple.com"; + private static final String EXPECTED_AUD = "com.malmo.app"; + private static final String KEY_ID = "test-key-id"; + + @BeforeEach + void setUp() throws Exception { + objectMapper = new ObjectMapper(); + validator = new AppleNotificationValidator(jwkProvider, objectMapper, EXPECTED_AUD); + + // RSA 키 쌍 생성 + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + publicKey = (RSAPublicKey) keyPair.getPublic(); + privateKey = (RSAPrivateKey) keyPair.getPrivate(); + } + + private String createValidSignedPayload(String eventType, String sub, Long eventTime) throws Exception { + Map events = Map.of( + "type", eventType, + "sub", sub, + "event_time", eventTime + ); + String eventsJson = objectMapper.writeValueAsString(events); + + return JWT.create() + .withIssuer(APPLE_ISS) + .withAudience(EXPECTED_AUD) + .withIssuedAt(Date.from(Instant.now())) + .withExpiresAt(Date.from(Instant.now().plusSeconds(3600))) + .withJWTId(UUID.randomUUID().toString()) + .withKeyId(KEY_ID) + .withClaim("events", eventsJson) + .sign(Algorithm.RSA256(publicKey, privateKey)); + } + + @Nested + @DisplayName("유효한 알림 검증 테스트") + class ValidNotificationTest { + + @Test + @DisplayName("consent-revoked 이벤트를 정상적으로 파싱한다") + void consent_revoked_이벤트를_정상적으로_파싱한다() throws Exception { + // given + String sub = "000123.abcdef1234567890.1234"; + Long eventTime = Instant.now().getEpochSecond(); + String signedPayload = createValidSignedPayload("consent-revoked", sub, eventTime); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when + AppleNotificationClaims claims = validator.validateAndParse(signedPayload); + + // then + assertThat(claims.getEventType()).isEqualTo("consent-revoked"); + assertThat(claims.getSub()).isEqualTo(sub); + assertThat(claims.getEventTime()).isEqualTo(eventTime); + assertThat(claims.getJti()).isNotNull(); + } + + @Test + @DisplayName("account-delete 이벤트를 정상적으로 파싱한다") + void account_delete_이벤트를_정상적으로_파싱한다() throws Exception { + // given + String sub = "000456.xyz9876543210.5678"; + Long eventTime = Instant.now().getEpochSecond(); + String signedPayload = createValidSignedPayload("account-delete", sub, eventTime); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when + AppleNotificationClaims claims = validator.validateAndParse(signedPayload); + + // then + assertThat(claims.getEventType()).isEqualTo("account-delete"); + assertThat(claims.getSub()).isEqualTo(sub); + } + + @Test + @DisplayName("email-enabled 이벤트를 정상적으로 파싱한다") + void email_enabled_이벤트를_정상적으로_파싱한다() throws Exception { + // given + String sub = "000789.email1234567890.9012"; + Long eventTime = Instant.now().getEpochSecond(); + String signedPayload = createValidSignedPayload("email-enabled", sub, eventTime); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when + AppleNotificationClaims claims = validator.validateAndParse(signedPayload); + + // then + assertThat(claims.getEventType()).isEqualTo("email-enabled"); + assertThat(claims.getSub()).isEqualTo(sub); + } + } + + @Nested + @DisplayName("유효하지 않은 알림 검증 테스트") + class InvalidNotificationTest { + + @Test + @DisplayName("issuer가 Apple이 아니면 예외를 던진다") + void issuer가_Apple이_아니면_예외를_던진다() throws Exception { + // given + Map events = Map.of("type", "consent-revoked", "sub", "test-sub", "event_time", 123456789L); + String eventsJson = objectMapper.writeValueAsString(events); + + String invalidPayload = JWT.create() + .withIssuer("https://invalid-issuer.com") // 잘못된 issuer + .withAudience(EXPECTED_AUD) + .withIssuedAt(Date.from(Instant.now())) + .withExpiresAt(Date.from(Instant.now().plusSeconds(3600))) + .withJWTId(UUID.randomUUID().toString()) + .withKeyId(KEY_ID) + .withClaim("events", eventsJson) + .sign(Algorithm.RSA256(publicKey, privateKey)); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when & then + assertThatThrownBy(() -> validator.validateAndParse(invalidPayload)) + .isInstanceOf(OidcIdTokenException.class) + .hasMessageContaining("Invalid issuer"); + } + + @Test + @DisplayName("audience가 앱 클라이언트 ID와 다르면 예외를 던진다") + void audience가_앱_클라이언트_ID와_다르면_예외를_던진다() throws Exception { + // given + Map events = Map.of("type", "consent-revoked", "sub", "test-sub", "event_time", 123456789L); + String eventsJson = objectMapper.writeValueAsString(events); + + String invalidPayload = JWT.create() + .withIssuer(APPLE_ISS) + .withAudience("com.wrong.app") // 잘못된 audience + .withIssuedAt(Date.from(Instant.now())) + .withExpiresAt(Date.from(Instant.now().plusSeconds(3600))) + .withJWTId(UUID.randomUUID().toString()) + .withKeyId(KEY_ID) + .withClaim("events", eventsJson) + .sign(Algorithm.RSA256(publicKey, privateKey)); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when & then + assertThatThrownBy(() -> validator.validateAndParse(invalidPayload)) + .isInstanceOf(OidcIdTokenException.class) + .hasMessageContaining("Invalid audience"); + } + + @Test + @DisplayName("만료된 토큰이면 예외를 던진다") + void 만료된_토큰이면_예외를_던진다() throws Exception { + // given + Map events = Map.of("type", "consent-revoked", "sub", "test-sub", "event_time", 123456789L); + String eventsJson = objectMapper.writeValueAsString(events); + + String expiredPayload = JWT.create() + .withIssuer(APPLE_ISS) + .withAudience(EXPECTED_AUD) + .withIssuedAt(Date.from(Instant.now().minusSeconds(7200))) + .withExpiresAt(Date.from(Instant.now().minusSeconds(3600))) // 만료됨 + .withJWTId(UUID.randomUUID().toString()) + .withKeyId(KEY_ID) + .withClaim("events", eventsJson) + .sign(Algorithm.RSA256(publicKey, privateKey)); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when & then + assertThatThrownBy(() -> validator.validateAndParse(expiredPayload)) + .isInstanceOf(OidcIdTokenException.class) + .hasMessageContaining("Expired"); + } + + @Test + @DisplayName("서명 검증에 실패하면 예외를 던진다") + void 서명_검증에_실패하면_예외를_던진다() throws Exception { + // given + String validPayload = createValidSignedPayload("consent-revoked", "test-sub", 123456789L); + + // 다른 키로 검증 시도 + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair differentKeyPair = keyGen.generateKeyPair(); + RSAPublicKey differentPublicKey = (RSAPublicKey) differentKeyPair.getPublic(); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(differentPublicKey); // 다른 공개키 + + // when & then + assertThatThrownBy(() -> validator.validateAndParse(validPayload)) + .isInstanceOf(OidcIdTokenException.class); + } + + @Test + @DisplayName("유효하지 않은 JWT 형식이면 예외를 던진다") + void 유효하지_않은_JWT_형식이면_예외를_던진다() { + // given + String invalidJwt = "this-is-not-a-valid-jwt"; + + // when & then + assertThatThrownBy(() -> validator.validateAndParse(invalidJwt)) + .isInstanceOf(OidcIdTokenException.class); + } + } + + @Nested + @DisplayName("이벤트 파싱 테스트") + class EventsParsingTest { + + @Test + @DisplayName("email 필드가 포함된 이벤트를 파싱한다") + void email_필드가_포함된_이벤트를_파싱한다() throws Exception { + // given + Map events = Map.of( + "type", "email-enabled", + "sub", "test-sub", + "event_time", 123456789L, + "email", "user@privaterelay.appleid.com", + "is_private_email", true + ); + String eventsJson = objectMapper.writeValueAsString(events); + + String signedPayload = JWT.create() + .withIssuer(APPLE_ISS) + .withAudience(EXPECTED_AUD) + .withIssuedAt(Date.from(Instant.now())) + .withExpiresAt(Date.from(Instant.now().plusSeconds(3600))) + .withJWTId(UUID.randomUUID().toString()) + .withKeyId(KEY_ID) + .withClaim("events", eventsJson) + .sign(Algorithm.RSA256(publicKey, privateKey)); + + given(jwkProvider.get(anyString())).willReturn(jwk); + given(jwk.getPublicKey()).willReturn(publicKey); + + // when + AppleNotificationClaims claims = validator.validateAndParse(signedPayload); + + // then + assertThat(claims.getEmail()).isEqualTo("user@privaterelay.appleid.com"); + assertThat(claims.getIsPrivateEmail()).isTrue(); + } + } +} + + + diff --git a/src/test/java/makeus/cmc/malmo/adaptor/out/redis/AppleNotificationJtiStoreTest.java b/src/test/java/makeus/cmc/malmo/adaptor/out/redis/AppleNotificationJtiStoreTest.java new file mode 100644 index 00000000..22e2971f --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/adaptor/out/redis/AppleNotificationJtiStoreTest.java @@ -0,0 +1,135 @@ +package makeus.cmc.malmo.adaptor.out.redis; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AppleNotificationJtiStore 단위 테스트") +class AppleNotificationJtiStoreTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private AppleNotificationJtiStore jtiStore; + + private static final String TEST_JTI = "test-jti-12345"; + private static final String EXPECTED_KEY = "apple:notification:jti:" + TEST_JTI; + + @BeforeEach + void setUp() { + jtiStore = new AppleNotificationJtiStore(redisTemplate); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + } + + @Nested + @DisplayName("tryMarkAsProcessed 테스트") + class TryMarkAsProcessedTest { + + @Test + @DisplayName("새로운 jti인 경우 true를 반환하고 Redis에 저장한다") + void 새로운_jti인_경우_true를_반환하고_Redis에_저장한다() { + // given + given(valueOperations.setIfAbsent(eq(EXPECTED_KEY), eq("processed"), any(Duration.class))) + .willReturn(true); + + // when + boolean result = jtiStore.tryMarkAsProcessed(TEST_JTI); + + // then + assertThat(result).isTrue(); + verify(valueOperations).setIfAbsent(eq(EXPECTED_KEY), eq("processed"), eq(Duration.ofDays(1))); + } + + @Test + @DisplayName("이미 처리된 jti인 경우 false를 반환한다") + void 이미_처리된_jti인_경우_false를_반환한다() { + // given + given(valueOperations.setIfAbsent(eq(EXPECTED_KEY), eq("processed"), any(Duration.class))) + .willReturn(false); + + // when + boolean result = jtiStore.tryMarkAsProcessed(TEST_JTI); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Redis 응답이 null인 경우 false를 반환한다") + void Redis_응답이_null인_경우_false를_반환한다() { + // given + given(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .willReturn(null); + + // when + boolean result = jtiStore.tryMarkAsProcessed(TEST_JTI); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("서로 다른 jti는 독립적으로 처리된다") + void 서로_다른_jti는_독립적으로_처리된다() { + // given + String jti1 = "jti-1"; + String jti2 = "jti-2"; + String key1 = "apple:notification:jti:" + jti1; + String key2 = "apple:notification:jti:" + jti2; + + given(valueOperations.setIfAbsent(eq(key1), eq("processed"), any(Duration.class))) + .willReturn(true); + given(valueOperations.setIfAbsent(eq(key2), eq("processed"), any(Duration.class))) + .willReturn(true); + + // when + boolean result1 = jtiStore.tryMarkAsProcessed(jti1); + boolean result2 = jtiStore.tryMarkAsProcessed(jti2); + + // then + assertThat(result1).isTrue(); + assertThat(result2).isTrue(); + } + } + + @Nested + @DisplayName("TTL 설정 테스트") + class TtlTest { + + @Test + @DisplayName("TTL은 1일로 설정된다") + void TTL은_1일로_설정된다() { + // given + given(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .willReturn(true); + + // when + jtiStore.tryMarkAsProcessed(TEST_JTI); + + // then + verify(valueOperations).setIfAbsent(anyString(), anyString(), eq(Duration.ofDays(1))); + } + } +} + + + diff --git a/src/test/java/makeus/cmc/malmo/config/TestMockConfig.java b/src/test/java/makeus/cmc/malmo/config/TestMockConfig.java new file mode 100644 index 00000000..d88ea35e --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/config/TestMockConfig.java @@ -0,0 +1,36 @@ +package makeus.cmc.malmo.config; + +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator; +import makeus.cmc.malmo.adaptor.out.redis.AppleNotificationJtiStore; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +import static org.mockito.Mockito.mock; + +/** + * 테스트 환경에서 네트워크 의존성이 있는 빈들을 Mock으로 대체합니다. + * + * AppleNotificationValidator는 생성자에서 Apple JWKS URI에 연결을 시도하므로 + * 테스트 환경에서는 Mock으로 대체합니다. + * + * @Profile("test") 덕분에 test 프로파일에서 자동으로 로드됩니다. + */ +@Configuration +@Profile("test") +public class TestMockConfig { + + @Bean + @Primary + public AppleNotificationValidator appleNotificationValidator() { + return mock(AppleNotificationValidator.class); + } + + @Bean + @Primary + public AppleNotificationJtiStore appleNotificationJtiStore() { + return mock(AppleNotificationJtiStore.class); + } +} + diff --git a/src/test/java/makeus/cmc/malmo/integration_test/AppleNotificationIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/AppleNotificationIntegrationTest.java new file mode 100644 index 00000000..4b889e64 --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/integration_test/AppleNotificationIntegrationTest.java @@ -0,0 +1,115 @@ +package makeus.cmc.malmo.integration_test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator; +import makeus.cmc.malmo.adaptor.out.redis.AppleNotificationJtiStore; +import makeus.cmc.malmo.application.port.in.member.AppleNotificationUseCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Transactional +@DisplayName("Apple Server-to-Server Notification 통합 테스트") +public class AppleNotificationIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AppleNotificationUseCase appleNotificationUseCase; + + @MockBean + private AppleNotificationValidator appleNotificationValidator; + + @MockBean + private AppleNotificationJtiStore appleNotificationJtiStore; + + private static final String WEBHOOK_ENDPOINT = "/webhook/apple/notifications"; + private static final String SAMPLE_SIGNED_PAYLOAD = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmV4YW1wbGUuYXBwIiwiaWF0IjoxNjE2NDM5MDIyLCJqdGkiOiJ0ZXN0LWp0aS0xMjM0NSIsImV2ZW50cyI6eyJ0eXBlIjoiY29uc2VudC1yZXZva2VkIiwic3ViIjoiMDAwMTIzLjEyMzQ1Njc4OTBhYmNkZWYuMTIzNCIsImV2ZW50X3RpbWUiOjE2MTY0MzkwMjJ9fQ.test-signature"; + + @Nested + @DisplayName("Webhook 엔드포인트 테스트") + class WebhookEndpointTest { + + @Test + @DisplayName("JSON 형식의 signed_payload를 받으면 200 OK를 반환한다") + void JSON_형식의_signed_payload를_받으면_200_OK를_반환한다() throws Exception { + // given + doNothing().when(appleNotificationUseCase).processNotification(eq(SAMPLE_SIGNED_PAYLOAD)); + + Map requestBody = Map.of("signed_payload", SAMPLE_SIGNED_PAYLOAD); + + // when & then + mockMvc.perform(post(WEBHOOK_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()); + + verify(appleNotificationUseCase).processNotification(eq(SAMPLE_SIGNED_PAYLOAD)); + } + + @Test + @DisplayName("Form URL Encoded 형식의 signed_payload를 받으면 200 OK를 반환한다") + void Form_URL_Encoded_형식의_signed_payload를_받으면_200_OK를_반환한다() throws Exception { + // given + doNothing().when(appleNotificationUseCase).processNotification(eq(SAMPLE_SIGNED_PAYLOAD)); + + // when & then + mockMvc.perform(post(WEBHOOK_ENDPOINT) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("signed_payload", SAMPLE_SIGNED_PAYLOAD)) + .andExpect(status().isOk()); + + verify(appleNotificationUseCase).processNotification(eq(SAMPLE_SIGNED_PAYLOAD)); + } + + @Test + @DisplayName("인증 없이 Webhook 엔드포인트에 접근할 수 있다") + void 인증_없이_Webhook_엔드포인트에_접근할_수_있다() throws Exception { + // given + doNothing().when(appleNotificationUseCase).processNotification(eq(SAMPLE_SIGNED_PAYLOAD)); + + Map requestBody = Map.of("signed_payload", SAMPLE_SIGNED_PAYLOAD); + + // when & then - Authorization 헤더 없이 호출 + mockMvc.perform(post(WEBHOOK_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("빈 signed_payload가 오면 200 OK를 반환하되 처리하지 않는다") + void 빈_signed_payload가_오면_200_OK를_반환한다() throws Exception { + // given + Map requestBody = Map.of("signed_payload", ""); + + // when & then - 빈 페이로드도 200 반환 (Apple 재시도 방지) + mockMvc.perform(post(WEBHOOK_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()); + } + } +} + diff --git a/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java index ab478832..80bb2edb 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/CoupleIntegrationTest.java @@ -23,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; diff --git a/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java b/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java index 1193f67c..7a2502bd 100644 --- a/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java +++ b/src/test/java/makeus/cmc/malmo/mapper/MemberMapperTest.java @@ -154,6 +154,7 @@ private Member createCompleteMember() { 0.3f, "testuser", "test@example.com", + null, // emailForwardingStatus InviteCodeValue.of("invite_code"), LocalDate.now(), "oauth_token", diff --git a/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java b/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java new file mode 100644 index 00000000..fc1f531d --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/service/AppleNotificationServiceTest.java @@ -0,0 +1,347 @@ +package makeus.cmc.malmo.service; + +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator; +import makeus.cmc.malmo.adaptor.out.oidc.AppleNotificationValidator.AppleNotificationClaims; +import makeus.cmc.malmo.adaptor.out.redis.AppleNotificationJtiStore; +import makeus.cmc.malmo.application.helper.member.MemberCommandHelper; +import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; +import makeus.cmc.malmo.application.port.out.member.UnlinkApplePort; +import makeus.cmc.malmo.application.service.member.AppleNotificationService; +import makeus.cmc.malmo.domain.model.member.Member; +import makeus.cmc.malmo.domain.value.id.InviteCodeValue; +import makeus.cmc.malmo.domain.value.state.MemberState; +import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus; +import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.Provider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AppleNotificationService 단위 테스트") +class AppleNotificationServiceTest { + + @Mock + private AppleNotificationValidator notificationValidator; + + @Mock + private AppleNotificationJtiStore jtiStore; + + @Mock + private MemberQueryHelper memberQueryHelper; + + @Mock + private MemberCommandHelper memberCommandHelper; + + @Mock + private UnlinkApplePort unlinkApplePort; + + @Captor + private ArgumentCaptor memberCaptor; + + private AppleNotificationService appleNotificationService; + + private static final String SIGNED_PAYLOAD = "test-signed-payload"; + private static final String JTI = "test-jti-12345"; + private static final String SUB = "000123.abcdef1234567890.1234"; + + @BeforeEach + void setUp() { + appleNotificationService = new AppleNotificationService( + notificationValidator, + jtiStore, + memberQueryHelper, + memberCommandHelper, + unlinkApplePort + ); + } + + private Member createTestMember(String providerId, String oauthToken) { + return Member.from( + 1L, + Provider.APPLE, + providerId, + MemberRole.MEMBER, + MemberState.ALIVE, + true, + "firebase-token", + "refresh-token", + null, + 0.0f, + 0.0f, + "nickname", + "test@email.com", + null, // emailForwardingStatus + InviteCodeValue.of("INVITE123"), + null, + oauthToken, + null, + null, + null, + null + ); + } + + private AppleNotificationClaims createClaims(String eventType, String sub, String jti) { + return createClaims(eventType, sub, jti, null); + } + + private AppleNotificationClaims createClaims(String eventType, String sub, String jti, String email) { + return AppleNotificationClaims.builder() + .jti(jti) + .iat(System.currentTimeMillis()) + .eventType(eventType) + .sub(sub) + .eventTime(System.currentTimeMillis() / 1000) + .email(email) + .build(); + } + + @Nested + @DisplayName("중복 jti 처리 테스트") + class DuplicateJtiTest { + + @Test + @DisplayName("중복 jti인 경우 회원 조회 없이 종료한다") + void 중복_jti인_경우_회원_조회_없이_종료한다() { + // given + AppleNotificationClaims claims = createClaims("consent-revoked", SUB, JTI); + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(false); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(memberQueryHelper, never()).getMemberByProviderId(any(), any()); + verify(memberCommandHelper, never()).saveMember(any()); + } + } + + @Nested + @DisplayName("회원 미존재 처리 테스트") + class MemberNotFoundTest { + + @Test + @DisplayName("회원이 존재하지 않으면 저장 없이 종료한다") + void 회원이_존재하지_않으면_저장_없이_종료한다() { + // given + AppleNotificationClaims claims = createClaims("consent-revoked", SUB, JTI); + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.empty()); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(memberCommandHelper, never()).saveMember(any()); + } + } + + @Nested + @DisplayName("consent-revoked 이벤트 처리 테스트") + class ConsentRevokedTest { + + @Test + @DisplayName("consent-revoked 이벤트 수신 시 회원을 소프트 삭제한다") + void consent_revoked_이벤트_수신_시_회원을_소프트_삭제한다() { + // given + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("consent-revoked", SUB, JTI); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(memberCommandHelper).saveMember(memberCaptor.capture()); + Member savedMember = memberCaptor.getValue(); + assertThat(savedMember.getMemberState()).isEqualTo(MemberState.DELETED); + assertThat(savedMember.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("consent-revoked 이벤트 수신 시 oauthToken이 있으면 Apple revoke를 호출한다") + void consent_revoked_이벤트_수신_시_oauthToken이_있으면_Apple_revoke를_호출한다() { + // given + String oauthToken = "apple-refresh-token"; + Member member = createTestMember(SUB, oauthToken); + AppleNotificationClaims claims = createClaims("consent-revoked", SUB, JTI); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + doNothing().when(unlinkApplePort).unlink(oauthToken); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(unlinkApplePort).unlink(oauthToken); + } + + @Test + @DisplayName("consent-revoked 이벤트 수신 시 oauthToken이 없으면 Apple revoke를 호출하지 않는다") + void consent_revoked_이벤트_수신_시_oauthToken이_없으면_Apple_revoke를_호출하지_않는다() { + // given + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("consent-revoked", SUB, JTI); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(unlinkApplePort, never()).unlink(any()); + } + } + + @Nested + @DisplayName("account-delete 이벤트 처리 테스트") + class AccountDeleteTest { + + @Test + @DisplayName("account-delete 이벤트 수신 시 회원을 소프트 삭제한다") + void account_delete_이벤트_수신_시_회원을_소프트_삭제한다() { + // given + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("account-delete", SUB, JTI); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(memberCommandHelper).saveMember(memberCaptor.capture()); + Member savedMember = memberCaptor.getValue(); + assertThat(savedMember.getMemberState()).isEqualTo(MemberState.DELETED); + } + } + + @Nested + @DisplayName("email 이벤트 처리 테스트") + class EmailEventTest { + + @Test + @DisplayName("email-enabled 이벤트 수신 시 새 이메일로 업데이트하고 상태를 ENABLED로 변경한다") + void email_enabled_이벤트_수신_시_새_이메일로_업데이트하고_상태를_ENABLED로_변경한다() { + // given + String newEmail = "newemail@privaterelay.appleid.com"; + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("email-enabled", SUB, JTI, newEmail); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then - 이메일 업데이트 및 상태 변경 후 저장 + verify(memberCommandHelper).saveMember(memberCaptor.capture()); + Member savedMember = memberCaptor.getValue(); + assertThat(savedMember.getEmail()).isEqualTo(newEmail); + assertThat(savedMember.getEmailForwardingStatus()).isEqualTo(EmailForwardingStatus.ENABLED); + assertThat(savedMember.getMemberState()).isEqualTo(MemberState.ALIVE); // 삭제되지 않음 + } + + @Test + @DisplayName("email-enabled 이벤트 수신 시 이메일이 없어도 상태는 ENABLED로 변경한다") + void email_enabled_이벤트_수신_시_이메일이_없어도_상태는_ENABLED로_변경한다() { + // given + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("email-enabled", SUB, JTI, null); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then - 상태는 변경됨 + verify(memberCommandHelper).saveMember(memberCaptor.capture()); + Member savedMember = memberCaptor.getValue(); + assertThat(savedMember.getEmailForwardingStatus()).isEqualTo(EmailForwardingStatus.ENABLED); + } + + @Test + @DisplayName("email-disabled 이벤트 수신 시 상태를 DISABLED로 변경한다") + void email_disabled_이벤트_수신_시_상태를_DISABLED로_변경한다() { + // given + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("email-disabled", SUB, JTI); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then - 상태 변경 확인 + verify(memberCommandHelper).saveMember(memberCaptor.capture()); + Member savedMember = memberCaptor.getValue(); + assertThat(savedMember.getEmailForwardingStatus()).isEqualTo(EmailForwardingStatus.DISABLED); + assertThat(savedMember.getMemberState()).isEqualTo(MemberState.ALIVE); // 삭제되지 않음 + } + } + + @Nested + @DisplayName("토큰/세션 무효화 테스트") + class TokenInvalidationTest { + + @Test + @DisplayName("consent-revoked 이벤트 수신 시 refreshToken과 firebaseToken을 무효화한다") + void consent_revoked_이벤트_수신_시_토큰을_무효화한다() { + // given + Member member = createTestMember(SUB, null); + AppleNotificationClaims claims = createClaims("consent-revoked", SUB, JTI); + + given(notificationValidator.validateAndParse(SIGNED_PAYLOAD)).willReturn(claims); + given(jtiStore.tryMarkAsProcessed(JTI)).willReturn(true); + given(memberQueryHelper.getMemberByProviderId(Provider.APPLE, SUB)).willReturn(Optional.of(member)); + given(memberCommandHelper.saveMember(any())).willReturn(member); + + // when + appleNotificationService.processNotification(SIGNED_PAYLOAD); + + // then + verify(memberCommandHelper).saveMember(memberCaptor.capture()); + Member savedMember = memberCaptor.getValue(); + assertThat(savedMember.getRefreshToken()).isNull(); + assertThat(savedMember.getFirebaseToken()).isNull(); + } + } +} +