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();
+ }
+ }
+}
+