diff --git a/docs/backend/WORKLOG.md b/docs/backend/WORKLOG.md index 0b10a568..6cc4458a 100644 --- a/docs/backend/WORKLOG.md +++ b/docs/backend/WORKLOG.md @@ -87,3 +87,4 @@ | 2026-07-29 | 운영 런타임 Secret 5개를 `pinlog-secrets-prod` Environment 경계에서만 읽어 SHA 고정 Infra action으로 넘기는 수동 workflow를 추가했다. bridge token을 포함한 참조 6개 집합·최소 권한·`github.sha` checkout/revision은 정적 계약 테스트로 고정했고 일반 `backend-ci`에는 Secret 접근을 추가하지 않았다 (S15P11A705-154) | [BI-26](implements/BI-26-2026-07-29-runtime-secret-workflow.md) | | 2026-07-30 | back#98 리뷰 반영. `dev`가 `required_conversation_resolution`이라 **미해결 스레드가 그대로 병합 게이트**여서, 판정이 `COMMENTED`·내용이 `nit`인 줄 단위 지적 2건과 리뷰가 지목한 테스트 구멍 3건을 닫았다. 고친 둘은 **javadoc이 약속한 범위와 실제 방어 범위가 어긋난 자리**라는 점에서 성격이 같다 — ① `AiSearchClient` 생성자 javadoc이 "시크릿 검사를 여기 두지 않는 이유"로 든 논거는 "같은 키를 읽는 `AiProcessClient`가 이미 검사한다"인데, `embedding-profile`은 **이 클라이언트만 읽는 새 키**라 그 논거가 적용되지 않는다. 빈 문자열이면 FastAPI가 Profile 대조에서 422를 주므로 결과는 모든 검색이 503이고, `application.yml` 기본값은 변수를 **설정하지 않은** 경우만 막는다(`PINLOG_AI_EMBEDDING_PROFILE=`처럼 빈 값으로 정의하면 빈 문자열이 이긴다 — BT-05로 이미 겪은 형태). `requireSecret`과 같은 기준(운영 기동 실패/그 외 경고)으로 기동 시점에 끊었다. 기각된 (c)(기동 시 FastAPI 조회)가 **아니다** — 상대에게 묻지 않고 우리 값 유무만 본다. ② `distinctByRecord`는 "상대 결함이 우리 500이 되지 않게 한다"고 적어 두고 `match` **자체가 null**인 경우가 빠져 있었다. `AiSearchClient`가 최상위 `results == null`을 이미 방어하는데 그것도 계약상 올 수 없는 형태라 **층이 어긋난** 것이라, 원소 쪽 층을 맞췄다. 테스트 구멍 셋(`SEARCH_QUERY_MAX` 501자 미검증 · `PRIVATE_ONLY` 한 번도 미투입 · `insertPreset`의 `active`가 죽은 파라미터)은 **코드는 이미 맞는데 지키는 단언이 없던** 자리라 평소의 RED가 안 나온다 — 세 가드를 일부러 부순 뒤(화이트리스트를 `IN ('PUBLIC')`으로 좁히고 `is_active` 조건을 지우고 `@Size`를 떼고) **새 테스트만 실패하고 기존 24개는 전부 통과하는 것**을 관측해 RED를 대신했다. 리뷰가 "그렇게 고쳐도 전부 초록"이라고 한 것이 그대로 재현됐다. `match == null`은 보통의 RED였다(대역 `{"results":[null]}` → **500** 관측 → 가드 → 200). `docs/ai/spec/ai-integration.md` §2의 낡은 줄 2개(패키지 위치 · "Bean 하나")는 **위임 범위 밖이라 고치지 않고 남겼다**(CLAUDE.md 9). `clean check` **370개 통과**(checkstyle·jacoco 포함) (S15P11A705-135) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) | | 2026-07-30 | 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 붙였다(S15P11A705-159). **이 저장소에 스케줄링이 처음 들어온다** — `@Scheduled`가 0건이었다. AI 연동의 실패 경로 네 곳이 모두 *"재스캔이 복구한다"*를 안전망으로 전제하고 있었는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히 `PENDING`으로 남았다(상태만 보면 정상과 구별되지 않는다). Bean을 셋으로 가른 것은 **트랜잭션 프록시 때문**이다 — 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 `FOR UPDATE SKIP LOCKED`의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라진다. 명세가 근거로 든 것을 **실측으로 뒤집은 지점이 하나 있다**: "Finalize를 먼저 두는 이유는 방금 `retry_count`를 3으로 올린 행이 곧바로 종결되기 때문"이라는데, `runOnce`의 두 줄을 맞바꿔도 테스트가 통과했다 — 증가가 `updated_at`을 함께 갱신해 그 행이 **만료 상태에서 벗어나** Finalizer 후보 조건에 걸리지 않는다. 창을 실제로 확보하는 것은 순서가 아니라 만료 조건 + `updated_at` 갱신이고, 순서는 심층 방어로 남겨 `InOrder` 단위 테스트로 고정했다(그 사실을 BI-28에 적었다). `SKIP LOCKED`는 주장으로 두지 않고 **다른 커넥션이 행을 붙잡은 채 회차를 돌려** 실제로 건너뛰는지 봤다 — 없으면 테스트가 매달리므로 별 스레드 + 15초 타임아웃으로 실패로 드러나게 했다. 함정 둘: `@Scheduled(fixedDelayString)`은 Boot의 완화된 바인딩을 쓰지 않아 `5m`이면 기동이 실패한다(`PT5M`로 두고 `ConfigurationContractTests`가 고정), Spring은 스케줄러를 **작업별로 고르지 않아** 전용 스케줄러라도 Bean 이름 `taskScheduler`를 점유해야 해석이 확정된다(BD-40). RED 6건 확인. `clean check` 386개 통과 | [BD-40](decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) · [BI-28](implements/BI-28-2026-07-30-ai-rescan-scheduler.md) · [패키지 구조](../development/package-structure.md) | +| 2026-07-30 | 소셜 로그인 이메일을 필수로 만들었다(#97). 프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다는 결정이고, **공용 계약이 먼저 바뀌어야 했다** — `06 §2.2`가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(`CLAUDE.md` 9번). docs#28을 먼저 병합했고 거기서 두 가지를 함께 정리했다: **1차 보장은 공급자 콘솔의 필수 동의 설정**(사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로 이 구현이 막는 것은 일상 흐름이 아니라 방어선), 그리고 **마스킹은 치환이며 NULL이 아니다**(적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. `provider_user_id`가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다). **구현하며 드러난 것 셋.** ① **뒤집을 테스트가 예상보다 많았다** — 사전 조사로 3건을 찾았는데 `clean check`에서 `SocialAccountPersistenceTests`가 걸렸다. `emailIsOptional`이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 **준비 코드에 null 이메일**이 섞여 함께 깨졌다 — `useEmail(null)`·`isNull()` 검색으로는 안 걸리는 형태다 ② **두 방어선이 독립임을 뮤테이션이 보여 줬다** — 정규화의 `required(...)`만 되돌리면 단위 테스트 4건은 실패하지만 **콜백 테스트 3건은 통과한다**. DB `NOT NULL`이 대신 잡아 같은 `OAUTH_FAILED`로 귀결하기 때문이다. 결함이 아니라 층이 갈린 결과다 — 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향(`ALTER` 주석 처리)은 `FlywayMigrationTests`가 잡는다 ③ **백필을 넣지 않았다** — 운영 DB에 NULL 행이 없고, 있었다면 **채울 값이 없다**(이메일은 공급자가 주는 값이다). 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다. 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 **기존 결정대로 `OAUTH_FAILED`로 묶인다**고 답하고 `08 §3.2`에 명시했다 — 사용자가 우리 화면에서 고칠 수 있는 실패가 아니고, 필수 동의 설정에서는 도달하지 않으므로 값을 가르면 발생하지 않는 분기가 남는다. `clean check` **342개 통과** (S15P11A705-152) | [BI-27](implements/BI-27-2026-07-30-social-login-email-required.md) · [docs#28](https://github.com/Team-PinLog/docs/pull/28) | diff --git a/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md b/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md new file mode 100644 index 00000000..dfa751f1 --- /dev/null +++ b/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md @@ -0,0 +1,55 @@ +# BI-27. 소셜 로그인 이메일 필수화 + +- **상태**: ✅ 완료 +- **날짜**: 2026-07-30 +- **관련**: S15P11A705-152, [back#97](https://github.com/Team-PinLog/back/issues/97), + [docs#28](https://github.com/Team-PinLog/docs/pull/28)(공용 계약 개정 — 선행), + [BI-24](BI-24-2026-07-29-kakao-naver-login.md)(이메일 `null` 허용을 계약으로 고정했던 기록) + +## 산출 + +- `V6__social_account_email_not_null.sql` — `core.social_account.email`에 `NOT NULL`. +- `OAuthUserInfo`의 세 공급자 분기 모두 이메일을 `required(...)`로 끊는다. record 컴포넌트에서 `@Nullable` 제거. +- `SocialAccount` — `@Column(nullable = false)`, 팩토리 파라미터에서 `@Nullable` 제거. +- 이메일 `null`을 계약으로 고정하던 테스트를 반대 방향으로 뒤집고, 스키마 제약을 지키는 단언을 추가했다. + +## 계약이 먼저 바뀌어야 했다 + +`06 §2.2`가 *"미동의·미제공 시 `null`일 수 있다"*로 정하고 있었다. 구현만 바꾸면 공용 계약을 위반하므로([`CLAUDE.md`](../../../CLAUDE.md) 9번) [docs#28](https://github.com/Team-PinLog/docs/pull/28)을 먼저 올려 병합했다. 그 PR에서 함께 정리한 것이 이 구현의 전제다. + +- **1차 보장은 공급자 콘솔의 필수 동의 설정이다.** 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없다. 따라서 이 구현이 막는 경로는 **사용자가 일상적으로 밟는 흐름이 아니라 방어선**이다. +- **마스킹은 치환이며 `NULL`이 아니다.** 이걸 계약에 적지 않으면 탈퇴 구현이 `NULL`을 넣어 이 제약과 부딪힌다. 같은 테이블의 `provider_user_id`가 이미 `NOT NULL`이면서 마스킹 대상이라 전제는 원래 있었고, 글로 옮긴 것이다. + +## 드러난 것 + +**① 뒤집을 테스트가 예상보다 많았다.** 착수 전에 찾아 둔 것은 `OAuthUserInfoTest` 2건과 `KakaoNaverLoginCallbackTests` 1건이었는데, `clean check`에서 `SocialAccountPersistenceTests`가 걸렸다. `emailIsOptional`이 *"이메일 없이도 저장되고 컬럼에 null로 남는다"*를 영속성 층에서 고정하고 있었다. + +같은 파일의 **무관해 보이던 테스트도 함께 깨졌다** — `findsActiveAccountByProviderAndProviderUserId`가 조회 대상을 만들면서 이메일을 `null`로 넣고 있었다. 계약을 검증하는 테스트가 아니라 **준비 코드에 `null`이 섞여 있던 것**이라, 사전 조사(`useEmail(null)`·`isNull()` 검색)로는 걸리지 않았다. + +**② 두 방어선이 실제로 독립이라는 것을 뮤테이션 검사가 보여 줬다.** 정규화의 `required(...)`를 되돌려 DB `NOT NULL`만 남긴 상태에서: + +| 테스트 | 결과 | +|---|---| +| `OAuthUserInfoTest` 이메일 4건 | **실패** — 예외를 단언하므로 | +| 콜백 테스트 3건(Google·Kakao·Naver) | **통과** | + +콜백 테스트가 통과한 이유는 DB 제약이 대신 잡아 **같은 `OAUTH_FAILED`로 귀결**했기 때문이다. 즉 HTTP 경계에서는 어느 층이 막았는지 구별되지 않는다. 이건 결함이 아니라 층이 갈린 결과다 — **콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를** 고정한다. 반대 방향(마이그레이션의 `ALTER`를 주석 처리)에서는 `FlywayMigrationTests.socialAccountEmailIsNotNull`이 잡는다. + +**③ 백필을 넣지 않았다.** 운영 DB에 이 컬럼이 `NULL`인 행이 없다. 있었다면 **채울 값이 없다** — 이메일은 공급자가 주는 값이고 서버가 만들어 낼 수 있는 것이 아니다. 임의 값을 넣으면 "표시할 이메일"이라는 목적 자체가 깨진다. 그런 행이 있는 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 그 판단을 SQL 주석에 남겼다. + +## 감수하는 것 + +**사용자가 공급자 쪽에서 동의를 철회하면 이후 재로그인이 실패한다.** 이미 저장된 값은 남아 있어 기존 세션과 화면 표시는 영향받지 않는다. 콘솔이 필수 동의라 재동의를 요구할 것으로 보지만 실측하지 않았다. + +**실패 사유가 `OAUTH_FAILED`로 뭉개진다.** 프론트에서 "이메일 동의가 필요합니다"를 안내할 수 없다. 사유별로 `error` 값을 가르지 않는 것은 기존 결정이고([`OAuthLoginFailureHandler`](../../../src/main/java/com/pinlog/pinlogback/global/security/oauth/OAuthLoginFailureHandler.java)), 이 실패는 사용자가 우리 화면에서 고칠 수 있는 것이 아니라 유지했다. 프론트에 확인해 분기 추가가 필요하지 않다는 답을 받았고 `08 §3.2`에 명시했다. + +## 남은 것 + +- **탈퇴 구현([S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65))이 마스킹 치환값을 정한다.** 계약이 정한 것은 `NULL`이 아니라는 것과 원본을 되돌릴 수 없어야 한다는 것까지다. 치환값이 회원끼리 겹쳐도 무해하다(유니크가 활성행만 대상이고 마스킹 시점엔 `deleted_at`이 채워져 인덱스 밖이다). +- **번호가 `BI-25`에서 `BI-27`로 두 칸 밀렸다.** 세 PR이 `BI-25`를 동시에 선점했고 머지 순서대로 확정됐다 — [#98](https://github.com/Team-PinLog/back/pull/98)이 `BI-25`, [#100](https://github.com/Team-PinLog/back/pull/100)이 `BI-26`, 이 기록이 `BI-27`이다. `#100`이 머지되기 전에 `BI-27`을 미리 배정해 재번호를 한 번만 했다. 규약대로 "미머지 브랜치의 파일명 선점은 예약이 아니다"가 실제로 세 번 적용된 사례다. + +## 검증 + +`./gradlew clean check --no-daemon` — **342개 통과, 실패 0.** + +뮤테이션 검사 2종 — 마이그레이션의 `ALTER` 주석 처리 시 `socialAccountEmailIsNotNull` 실패, 정규화의 `required(...)` 되돌림 시 `OAuthUserInfoTest` 이메일 4건 실패. 각각 확인 후 원복했다. diff --git a/docs/backend/implements/README.md b/docs/backend/implements/README.md index b264edd6..2476f6ca 100644 --- a/docs/backend/implements/README.md +++ b/docs/backend/implements/README.md @@ -41,4 +41,5 @@ | BI-24 | Kakao·Naver 소셜 로그인 추가 — 공급자별 사용자 정보 정규화 (S15P11A705-64) | ✅ 완료 | [BI-24](BI-24-2026-07-29-kakao-naver-login.md) | | BI-25 | 개인 자연어 검색 백엔드 연동 — FastAPI 호출·Core 재검증·Record 단위 조립 (S15P11A705-135) | ✅ 완료 | [BI-25](BI-25-2026-07-29-personal-search-backend-integration.md) | | BI-26 | 운영 Secret 5개를 Infra SealedSecret PR로 전달하는 수동 workflow (S15P11A705-154) | ✅ 완료 | [BI-26](BI-26-2026-07-29-runtime-secret-workflow.md) | +| BI-27 | 소셜 로그인 이메일 필수화 — 컬럼 NOT NULL·정규화 거절 (S15P11A705-152) | ✅ 완료 | [BI-27](BI-27-2026-07-30-social-login-email-required.md) | | BI-28 | 재스캔 Scheduler와 FAILED Finalizer — 유실·정지된 AI 처리 복구 (S15P11A705-159) | ✅ 완료 | [BI-28](BI-28-2026-07-30-ai-rescan-scheduler.md) | diff --git a/src/main/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfo.java index ef88a4a1..480dfd45 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfo.java +++ b/src/main/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfo.java @@ -12,12 +12,14 @@ * *
식별 기준은 공급자가 발급한 식별자다. 이메일·닉네임은 바뀔 수 있어 쓰지 않는다(06 2.2). * - * @param email 공급자가 제공하지 않거나 사용자가 동의하지 않으면 null이다. + *
이메일도 필수다. 설정 화면이 이 값을 반드시 표시해야 하므로 값 없는 계정을 두지
+ * 않는다(06 §2.2). 공급자 콘솔이 이메일을 필수 동의로 두고 있어 사용자가 이 경로를 밟지는
+ * 않지만, 그 설정이 깨졌을 때 값 없는 계정이 조용히 만들어지는 것을 여기서 막는다.
*/
public record OAuthUserInfo(
SocialProvider provider,
String providerUserId,
- @Nullable String email
+ String email
) {
public static OAuthUserInfo from(String registrationId, Map Naver는 예외다 — user-name-attribute가 감싼 키({@code response})라서 Spring이 검사하는
- * 것은 감싼 Map의 존재뿐이고 그 안의 {@code id}는 보지 않는다. 이 검사가 유일한 방어선이다.
+ * 식별자는 user-name-attribute이므로 Spring이 앞단에서 걸러 주지만, 그 보증이 설정에
+ * 있고 타입에는 없어 한 번 더 확인한다. Naver는 예외다 — user-name-attribute가 감싼 키
+ * ({@code response})라서 Spring이 검사하는 것은 감싼 Map의 존재뿐이고 그 안의 {@code id}는
+ * 보지 않는다. 그쪽은 이 검사가 유일한 방어선이다.
+ *
+ * 이메일은 세 공급자 모두 Spring이 보지 않는다. 필수 동의 설정이 1차 보장이고
+ * 이 검사가 그 설정에 의존하는 상태를 코드로 확인하는 자리다(06 §2.2).
*/
private static String required(@Nullable Object attribute, String path) {
String value = stringValue(attribute);
diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java b/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java
index b13b5ebf..5a5049d5 100644
--- a/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java
+++ b/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java
@@ -2,7 +2,6 @@
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
-import org.jspecify.annotations.Nullable;
import com.pinlog.pinlogback.global.common.BaseEntity;
@@ -50,7 +49,7 @@ public class SocialAccount extends BaseEntity {
@Column(name = "provider_user_id", nullable = false, length = 255)
private String providerUserId;
- @Column(name = "email", length = 255)
+ @Column(name = "email", nullable = false, length = 255)
private String email;
protected SocialAccount() {
@@ -64,10 +63,10 @@ private SocialAccount(Member member, SocialProvider provider, String providerUse
}
/**
- * @param email 공급자가 제공하지 않거나 사용자가 동의하지 않으면 null이다.
+ * @param email 필수다. 값 없는 계정을 두지 않으므로 정규화 계층이 먼저 끊는다(06 §2.2).
*/
public static SocialAccount create(
- Member member, SocialProvider provider, String providerUserId, @Nullable String email) {
+ Member member, SocialProvider provider, String providerUserId, String email) {
return new SocialAccount(member, provider, providerUserId, email);
}
diff --git a/src/main/resources/db/migration/V6__social_account_email_not_null.sql b/src/main/resources/db/migration/V6__social_account_email_not_null.sql
new file mode 100644
index 00000000..58fd8d28
--- /dev/null
+++ b/src/main/resources/db/migration/V6__social_account_email_not_null.sql
@@ -0,0 +1,11 @@
+-- 설정 화면이 이메일을 반드시 표시해야 하므로 값 없는 계정을 두지 않는다(06 §2.2).
+--
+-- 백필을 넣지 않는 이유: 이 컬럼이 NULL인 행은 운영에 없다. 있었다면 채울 값이 없다 —
+-- 이메일은 공급자가 주는 값이고 서버가 만들어 낼 수 있는 것이 아니다. 그런 행이 있는
+-- 환경에서는 이 마이그레이션이 실패하는 편이 맞다. 무엇으로 채울지는 데이터를 보고
+-- 사람이 정할 문제이고, 여기서 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨진다.
+--
+-- 탈퇴 시 마스킹은 이 제약과 부딪히지 않는다. 마스킹은 치환이며 NULL로 만드는 것이
+-- 아니다(06 §2.2). 같은 테이블의 provider_user_id가 이미 NOT NULL이면서 마스킹 대상이다.
+ALTER TABLE core.social_account
+ ALTER COLUMN email SET NOT NULL;
diff --git a/src/test/java/com/pinlog/pinlogback/domain/auth/GoogleLoginCallbackTests.java b/src/test/java/com/pinlog/pinlogback/domain/auth/GoogleLoginCallbackTests.java
index bc78a722..72065ca7 100644
--- a/src/test/java/com/pinlog/pinlogback/domain/auth/GoogleLoginCallbackTests.java
+++ b/src/test/java/com/pinlog/pinlogback/domain/auth/GoogleLoginCallbackTests.java
@@ -67,6 +67,24 @@ void callbackReusesExistingMember() throws Exception {
assertThat(countMembers()).isEqualTo(membersAfterSignup);
}
+ @Test
+ @DisplayName("이메일이 없는 응답이면 가입하지 않고 실패로 돌아간다")
+ void callbackWithoutEmailDoesNotSignUp() throws Exception {
+ // 이메일 필수는 공급자와 무관한 계약이다(06 §2.2). Google도 같은 방어선을 지난다.
+ long membersBefore = countMembers();
+ provider.useEmail(null);
+ try {
+ var callback = completeLogin(port, "google-sub-no-email");
+
+ assertThat(callback.headers().firstValue("Location"))
+ .get().asString()
+ .contains("error=OAUTH_FAILED");
+ assertThat(countMembers()).isEqualTo(membersBefore);
+ } finally {
+ provider.useEmail(StubOAuthProvider.EMAIL);
+ }
+ }
+
@Test
@DisplayName("인가 요청을 세션에 저장하지 않는다")
void loginDoesNotCreateHttpSession() throws Exception {
diff --git a/src/test/java/com/pinlog/pinlogback/domain/auth/KakaoNaverLoginCallbackTests.java b/src/test/java/com/pinlog/pinlogback/domain/auth/KakaoNaverLoginCallbackTests.java
index e05f9275..05c540e3 100644
--- a/src/test/java/com/pinlog/pinlogback/domain/auth/KakaoNaverLoginCallbackTests.java
+++ b/src/test/java/com/pinlog/pinlogback/domain/auth/KakaoNaverLoginCallbackTests.java
@@ -61,23 +61,34 @@ void callbackReusesExistingMember(String registrationId, String subject) throws
@ParameterizedTest(name = "{0}")
@CsvSource({"kakao, KAKAO, 811003", "naver, NAVER, naver-id-no-email"})
- @DisplayName("이메일이 없는 응답에서도 가입이 성공한다")
- void callbackSucceedsWithoutEmail(String registrationId, SocialProvider expected, String subject)
+ @DisplayName("이메일이 없는 응답이면 가입하지 않고 실패로 돌아간다")
+ void callbackWithoutEmailDoesNotSignUp(String registrationId, SocialProvider expected, String subject)
throws Exception {
- // 공급자가 이메일을 주지 않거나 사용자가 동의하지 않으면 속성 자체가 없다(06 2.2).
- // 이 경로가 막히면 Kakao에서 가입이 통째로 실패한다 — 이메일 동의는 선택 항목이다.
+ // 이메일은 필수다(06 §2.2). 설정 화면이 이 값을 반드시 표시해야 하므로 값 없는 계정을
+ // 두지 않는다. 공급자 콘솔이 이메일을 필수 동의로 두고 있어 사용자가 이 경로를 밟지는
+ // 않지만, 그 설정이 깨졌을 때 값 없는 계정이 조용히 만들어지는 것을 여기서 막는다.
+ long membersBefore = countMembers();
provider.useEmail(null);
try {
HttpResponse