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 attributes) { @@ -27,17 +29,17 @@ public static OAuthUserInfo from(String registrationId, Map attr case GOOGLE -> new OAuthUserInfo( provider, required(attributes.get("sub"), "sub"), - stringValue(attributes.get("email"))); + required(attributes.get("email"), "email")); // Kakao는 식별자를 최상위 id로 주고 이메일만 kakao_account 아래에 둔다. case KAKAO -> new OAuthUserInfo( provider, required(attributes.get("id"), "id"), - stringValue(nested(attributes, "kakao_account", "email"))); + required(nested(attributes, "kakao_account", "email"), "kakao_account.email")); // Naver는 본문 전체를 response로 한 겹 감싼다. 최상위에는 resultcode·message만 있다. case NAVER -> new OAuthUserInfo( provider, required(nested(attributes, "response", "id"), "response.id"), - stringValue(nested(attributes, "response", "email"))); + required(nested(attributes, "response", "email"), "response.email")); }; } @@ -47,12 +49,16 @@ public static OAuthUserInfo from(String registrationId, Map attr } /** - * 식별자는 없으면 로그인을 이어 갈 수 없다. user-name-attribute이므로 Spring이 앞단에서 걸러 - * 주지만, 그 보증이 설정에 있고 타입에는 없어 여기서 한 번 더 끊는다. 조용히 통과시키면 - * {@code provider_user_id} NOT NULL 위반이 되어 원인이 DB까지 내려간다. + * 없으면 진행할 수 없는 속성을 여기서 끊는다. 조용히 통과시키면 해당 컬럼의 NOT NULL 위반이 + * 되어 원인이 DB까지 내려간다. * - *

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 callback = completeLogin(port, registrationId, subject); assertThat(callback.statusCode()) - .as("이메일 없음은 실패가 아니다. body=%s", callback.body()) + .as("실패도 리다이렉트로 돌아간다. body=%s", callback.body()) .isBetween(300, 399); assertThat(callback.headers().firstValue("Location")) + .as("사유별로 error 값을 가르지 않는다(08 §3.2)") .get().asString() - .doesNotContain("error="); + .contains("error=OAUTH_FAILED"); + assertThat(callback.headers().allValues("Set-Cookie")) + .as("가입하지 않았으므로 인증 쿠키가 나가면 안 된다") + .noneMatch(header -> header.startsWith("access_token=") + || header.startsWith("refresh_token=")); assertThat(socialAccountRepository.findByProviderAndProviderUserId(expected, subject)) - .isPresent(); + .as("값 없는 계정을 남기지 않는다") + .isEmpty(); + assertThat(countMembers()) + .as("member도 만들어지지 않는다") + .isEqualTo(membersBefore); } finally { provider.useEmail(StubOAuthProvider.EMAIL); } diff --git a/src/test/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfoTest.java b/src/test/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfoTest.java index e91e3f7a..5db62204 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfoTest.java +++ b/src/test/java/com/pinlog/pinlogback/domain/auth/dto/OAuthUserInfoTest.java @@ -49,6 +49,14 @@ void rejectsMissingSub() { .isInstanceOf(IllegalStateException.class) .hasMessageContaining("sub"); } + + @Test + @DisplayName("email이 없으면 끊는다") + void rejectsMissingEmail() { + assertThatThrownBy(() -> OAuthUserInfo.from("google", Map.of("sub", "google-sub-1"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("email"); + } } @Nested @@ -68,13 +76,20 @@ void extractsNumericIdAsStringAndNestedEmail() { } @Test - @DisplayName("kakao_account가 없어도 가입은 성공한다") - void allowsMissingEmailWrapper() { - // 이메일 동의는 선택이고 철회도 가능하다. 이메일 없음은 실패가 아니다. - OAuthUserInfo info = OAuthUserInfo.from("kakao", Map.of("id", 42L)); + @DisplayName("kakao_account가 없으면 끊는다") + void rejectsMissingEmailWrapper() { + // 감싼 키가 통째로 없는 경우다. 이메일 자리를 찾지 못하는 것도 이메일 없음이다. + assertThatThrownBy(() -> OAuthUserInfo.from("kakao", Map.of("id", 42L))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("kakao_account.email"); + } - assertThat(info.providerUserId()).isEqualTo("42"); - assertThat(info.email()).isNull(); + @Test + @DisplayName("kakao_account 안에 email이 없으면 끊는다") + void rejectsMissingEmailInsideWrapper() { + assertThatThrownBy(() -> OAuthUserInfo.from("kakao", Map.of("id", 42L, "kakao_account", Map.of()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("kakao_account.email"); } @Test @@ -127,11 +142,12 @@ void rejectsMissingWrapper() { } @Test - @DisplayName("response 안에 email이 없어도 가입은 성공한다") - void allowsMissingEmailInsideWrapper() { - OAuthUserInfo info = OAuthUserInfo.from("naver", Map.of("response", Map.of("id", "naver-id-2"))); - - assertThat(info.email()).isNull(); + @DisplayName("response 안에 email이 없으면 끊는다") + void rejectsMissingEmailInsideWrapper() { + assertThatThrownBy(() -> + OAuthUserInfo.from("naver", Map.of("response", Map.of("id", "naver-id-2")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("response.email"); } } diff --git a/src/test/java/com/pinlog/pinlogback/domain/member/SocialAccountPersistenceTests.java b/src/test/java/com/pinlog/pinlogback/domain/member/SocialAccountPersistenceTests.java index f3a45698..953ad165 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/member/SocialAccountPersistenceTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/member/SocialAccountPersistenceTests.java @@ -49,7 +49,7 @@ void savesSocialAccountLinkedToMember() { void findsActiveAccountByProviderAndProviderUserId() { Member member = memberRepository.save(Member.create()); socialAccountRepository.save( - SocialAccount.create(member, SocialProvider.KAKAO, "kakao-42", null)); + SocialAccount.create(member, SocialProvider.KAKAO, "kakao-42", "kakao-42@example.com")); assertThat(socialAccountRepository.findByProviderAndProviderUserId(SocialProvider.KAKAO, "kakao-42")) .isPresent(); @@ -58,19 +58,15 @@ void findsActiveAccountByProviderAndProviderUserId() { } @Test - @DisplayName("이메일 없이도 저장되고 컬럼에 null로 남는다") - void emailIsOptional() { - // 공급자가 이메일을 제공하지 않거나 사용자가 동의하지 않으면 null이다(06 2.2). - // email에 NOT NULL이 걸려 있으면 아래 flush에서 실패한다. + @DisplayName("이메일 없는 계정은 저장되지 않는다") + void emailIsRequired() { + // 설정 화면이 이메일을 반드시 표시해야 하므로 값 없는 계정을 두지 않는다(06 §2.2). + // 정규화 계층이 먼저 끊지만, 그 층이 뚫려도 여기서 막힌다 — 마지막 방어선이다. Member member = memberRepository.save(Member.create()); + SocialAccount withoutEmail = SocialAccount.create(member, SocialProvider.NAVER, "naver-7", null); - SocialAccount saved = socialAccountRepository.saveAndFlush( - SocialAccount.create(member, SocialProvider.NAVER, "naver-7", null)); - - Long rowsWithNullEmail = jdbcTemplate.queryForObject( - "SELECT count(*) FROM core.social_account WHERE id = ? AND email IS NULL", Long.class, saved.getId()); - - assertThat(rowsWithNullEmail).isEqualTo(1L); + assertThatThrownBy(() -> socialAccountRepository.saveAndFlush(withoutEmail)) + .isInstanceOf(DataIntegrityViolationException.class); } @Test diff --git a/src/test/java/com/pinlog/pinlogback/integration/FlywayMigrationTests.java b/src/test/java/com/pinlog/pinlogback/integration/FlywayMigrationTests.java index 52cfe49e..ee66f52a 100644 --- a/src/test/java/com/pinlog/pinlogback/integration/FlywayMigrationTests.java +++ b/src/test/java/com/pinlog/pinlogback/integration/FlywayMigrationTests.java @@ -142,6 +142,19 @@ void contextOriginCreatedAtIsNotNull() { assertThat(isNullable).isEqualTo("NO"); } + @Test + void socialAccountEmailIsNotNull() { + // V6의 유일한 산출물이다. 설정 화면이 이메일을 반드시 표시해야 하므로 값 없는 계정을 + // 두지 않는다(06 §2.2). 애플리케이션 층의 거절이 뚫려도 여기서 막힌다. + String isNullable = jdbcTemplate.queryForObject( + "SELECT is_nullable FROM information_schema.columns" + + " WHERE table_schema = 'core' AND table_name = 'social_account'" + + " AND column_name = 'email'", + String.class); + + assertThat(isNullable).isEqualTo("NO"); + } + @Test void memberTableIsCreatedByBackendMigration() throws Exception { try (Connection connection = dataSource.getConnection();