feat: 구글 소셜 로그인 구현 (OAuth 2.0) - #39
Conversation
- GET /api/auth/google/login — state 생성 후 구글 인가 서버로 리다이렉트 - GET /api/auth/google/callback — state CSRF 검증, 토큰/유저 조회, DB upsert, JWT 발급 - fetchWithTimeout으로 외부 API 타임아웃 처리 - nickname P2002 race condition 방어 처리 - 에러 리다이렉트를 request origin 기반으로 처리 - JwtPayload provider 타입에 google 추가 - 단위 테스트 14개 작성 - .env.example에 GOOGLE_REDIRECT_URI 추가 Close #37 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GOOGLE_CLIENT_ID → GOOGLE_LOGIN_CLIENT_ID GOOGLE_CLIENT_SECRET → GOOGLE_LOGIN_CLIENT_SECRET GOOGLE_REDIRECT_URI → GOOGLE_LOGIN_REDIRECT_URI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary by CodeRabbit릴리스 노트
WalkthroughGoogle OAuth 2.0 기반 소셜 로그인 기능을 구현. 로그인 초기화 엔드포인트에서 state 생성 후 Google 인증 서버로 리다이렉트하고, 콜백 엔드포인트에서 CSRF 검증·토큰 교환·사용자 조회/생성·JWT 발급을 처리한다. Google API 통신 유틸리티는 5초 타임아웃 보호를 적용하며 단위 테스트로 검증됨. ChangesGoogle 소셜 로그인 인증 플로우
Sequence DiagramssequenceDiagram
participant Client
participant LoginRoute as /api/auth/google/login
participant GoogleAuth as Google 인증 서버
participant CallbackRoute as /api/auth/google/callback
participant GoogleLib as google.ts
participant Prisma
participant JWT as jwt.ts
Client->>LoginRoute: GET 로그인 시작
LoginRoute->>LoginRoute: UUID state 생성
LoginRoute->>LoginRoute: google_oauth_state 쿠키 저장 (10분)
LoginRoute-->>GoogleAuth: 302 리다이렉트 (clientId, redirectUri, state, scope)
GoogleAuth-->>Client: 인증 화면 표시
Client->>GoogleAuth: 사용자 인증
GoogleAuth-->>CallbackRoute: 302 리다이렉트 (code, state)
CallbackRoute->>CallbackRoute: state CSRF 검증 (쿠키 vs 쿼리)
CallbackRoute->>GoogleLib: getGoogleToken(code)
GoogleLib-->>CallbackRoute: accessToken
CallbackRoute->>GoogleLib: getGoogleUser(accessToken)
GoogleLib-->>CallbackRoute: {id, email, name}
CallbackRoute->>Prisma: 조회 SocialAccount(googleId)
alt 기존 사용자
Prisma-->>CallbackRoute: user 찾음
else 신규 사용자
CallbackRoute->>Prisma: upsert User + SocialAccount<br/>(닉네임 충돌: google_{id} 대체)<br/>(P2012 재시도: suffix 추가)
Prisma-->>CallbackRoute: user 생성됨
end
CallbackRoute->>JWT: signAccessToken(userId, 'google', profileCompleted)
JWT-->>CallbackRoute: jwtToken
CallbackRoute->>CallbackRoute: 쿠키 저장 (httpOnly, secure, sameSite=lax)
CallbackRoute->>CallbackRoute: google_oauth_state 쿠키 삭제
CallbackRoute-->>Client: 302 리다이렉트 (성공/프로필 완성 필요)
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes 근거:
Possibly Related PRs
Suggested Labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/api/auth/google/callback/route.ts`:
- Around line 45-48: The STATE_COOKIE nonce is not cleared on failure paths, so
update the Google callback flow to always remove STATE_COOKIE whenever state
validation fails or any error branch returns a failure redirect; specifically,
invoke the common cookie-cleanup helper (or call
response.cookies.delete(STATE_COOKIE) via the same helper) before returning
NextResponse.redirect(...) in the state-mismatch branch and the generic failure
branch (the places using req.cookies.get(...) and NextResponse.redirect(...));
ensure the cleanup is idempotent/safe for missing cookies and use the same
helper to keep behavior consistent across all failure returns.
- Around line 60-77: The user.create catch currently treats all Prisma P2002
errors as nickname collisions and always retries with a suffixed nickname;
change the handling so you inspect createErr.meta?.target (from the
prisma.user.create catch) and only perform the suffix retry when the target
includes "nickname"; for other P2002 targets such as "email" rethrow the error
(or propagate it) instead of retrying, ensuring the retry path that modifies
nickname is only taken when createErr.meta?.target indicates the nickname unique
constraint failed.
In `@src/lib/auth/__tests__/google.test.ts`:
- Around line 59-156: Add tests to cover timeout and empty-input boundary cases
for getGoogleToken and getGoogleUser: (1) simulate a timeout by mocking
global.fetch (or the internal fetchWithTimeout) to reject with an
AbortError-like error and assert getGoogleToken/getGoogleUser reject with the
same/translated error (to catch abort conversion); (2) add input-guard tests
that call getGoogleToken with an empty/undefined code and getGoogleUser with an
empty/undefined accessToken and assert they throw the expected validation
errors; target the test suite names/subjects getGoogleToken and getGoogleUser
and mock/spy on global.fetch (or fetchWithTimeout) to trigger the timeout and
error conditions.
In `@src/lib/auth/google.ts`:
- Around line 58-60: Add input guards that validate required parameters at the
top of the Google auth helpers: in getGoogleToken(code) check that code is a
non-empty string and throw a clear, immediate error (e.g. "Missing required
parameter: code") instead of letting the request proceed, and do the same for
the function that accepts accessToken (e.g. getGoogleProfile(accessToken) or the
access-token-consuming helper) to throw "Missing required parameter:
accessToken" when empty; this keeps client errors local and prevents unnecessary
calls to Google APIs.
- Line 88: The current return simply casts res.json() to
Promise<GoogleTokenResponse> without runtime checks; update the function that
calls res.json() (the method returning GoogleTokenResponse) to await and parse
the JSON, verify that parsed.access_token exists (and other required fields if
needed), and if missing reject/throw a descriptive error (e.g., throw new
Error("Missing access_token in Google token response")) so callers (like the
callback handler) fail fast instead of later; keep the function's outward type
but ensure it returns the validated object when access_token is present.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: a8fa7ae2-a6f5-434a-914d-4a51571c4943
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json,!**/*.json,!package-lock.json
📒 Files selected for processing (6)
.env.examplesrc/app/api/auth/google/callback/route.tssrc/app/api/auth/google/login/route.tssrc/lib/auth/__tests__/google.test.tssrc/lib/auth/google.tssrc/lib/auth/jwt.ts
| const savedState = req.cookies.get(STATE_COOKIE)?.value; | ||
| if (!savedState || savedState !== state) { | ||
| return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`); | ||
| } |
There was a problem hiding this comment.
검증이 끝난 state를 실패 경로에서 지우지 않아 재사용 창이 남습니다.
Why
Line 46에서 state 검증이 끝났으면 이 nonce는 이미 소모된 값입니다. 그런데 이후 예외가 나면 Line 170에서 실패 리다이렉트만 하고 쿠키를 남겨 둡니다. 그러면 같은 브라우저에서 동일 콜백 URL을 새 로그인 시작 없이 다시 태울 수 있어 CSRF 토큰의 일회성 보장이 약해집니다.
How
실패 응답도 공통 helper를 거쳐 STATE_COOKIE를 항상 정리하세요. 삭제는 존재하지 않는 쿠키에도 안전하므로, 모든 실패 분기에 재사용해도 부작용이 없습니다.
코드 스니펫
const LOGIN_FAILURE_REDIRECT = "/login?error=google_login_failed";
+
+function redirectWithStateCleanup(origin: string, path: string) {
+ const res = NextResponse.redirect(`${origin}${path}`);
+ res.cookies.delete(STATE_COOKIE);
+ return res;
+}
export async function GET(req: NextRequest) {
@@
const savedState = req.cookies.get(STATE_COOKIE)?.value;
if (!savedState || savedState !== state) {
- return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`);
+ return redirectWithStateCleanup(origin, LOGIN_FAILURE_REDIRECT);
}
@@
} catch (err) {
console.error(
"[auth.google.callback] 오류:",
err instanceof Error ? err.message : "알 수 없는 오류",
);
- return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`);
+ return redirectWithStateCleanup(origin, LOGIN_FAILURE_REDIRECT);
}
}Also applies to: 165-170
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/google/callback/route.ts` around lines 45 - 48, The
STATE_COOKIE nonce is not cleared on failure paths, so update the Google
callback flow to always remove STATE_COOKIE whenever state validation fails or
any error branch returns a failure redirect; specifically, invoke the common
cookie-cleanup helper (or call response.cookies.delete(STATE_COOKIE) via the
same helper) before returning NextResponse.redirect(...) in the state-mismatch
branch and the generic failure branch (the places using req.cookies.get(...) and
NextResponse.redirect(...)); ensure the cleanup is idempotent/safe for missing
cookies and use the same helper to keep behavior consistent across all failure
returns.
| const socialAccount = await prisma.socialAccount.findUnique({ | ||
| where: { | ||
| provider_providerUserId: { | ||
| provider: "google", | ||
| providerUserId: googleId, | ||
| }, | ||
| }, | ||
| include: { | ||
| user: { | ||
| select: { | ||
| id: true, | ||
| email: true, | ||
| nickname: true, | ||
| profileCompleted: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
SCHEMA_FILE="$(fd -i 'schema.prisma' | head -n 1)"
echo "== schema.prisma =="
echo "$SCHEMA_FILE"
echo
echo "== User / SocialAccount 유니크 제약 확인 =="
rg -n -A20 -B5 'model User|model SocialAccount|`@unique`|@@unique' "$SCHEMA_FILE"
echo
echo "== 콜백 라우트의 P2002 처리 확인 =="
rg -n -A25 -B10 'provider_providerUserId|P2002|socialAccount\.findUnique|user\.create' src/app/api/auth/google/callback/route.tsRepository: Siul49/moim
Length of output: 3648
[major] P2002를 닉네임 충돌로만 가정한 재시도 로직을 분기 처리하세요 (user.create catch)
Why
prisma/schema.prisma 기준 User.nickname과 User.email 둘 다 @unique입니다. 그런데 src/app/api/auth/google/callback/route.ts의 prisma.user.create(...)에서 발생한 P2002를 전부 닉네임 중복으로 보고 suffix 재시도를 하고 있어, 실제 원인이 email 유니크 충돌이면 재시도 자체가 계속 실패할 수 있습니다.
How
createErr.meta?.target으로 어떤 unique 필드 충돌인지 판별하고, nickname일 때만 suffix 재시도를 하세요. 그 외 타겟이면 그대로 에러를 전파(또는 필요한 경우에만 별도 처리)하세요.
핵심 수정(diff)
try {
user = await prisma.user.create(createUserData(finalNickname));
} catch (createErr) {
if (
createErr instanceof Prisma.PrismaClientKnownRequestError &&
createErr.code === "P2002"
) {
- const randomSuffix = Math.random().toString(36).slice(2, 7);
- user = await prisma.user.create(
- createUserData(`google_${googleId}_${randomSuffix}`),
- );
+ const target = Array.isArray(createErr.meta?.target)
+ ? createErr.meta?.target.map(String)
+ : [String(createErr.meta?.target ?? "")];
+
+ if (target.includes("nickname")) {
+ const randomSuffix = Math.random().toString(36).slice(2, 7);
+ user = await prisma.user.create(
+ createUserData(`google_${googleId}_${randomSuffix}`),
+ );
+ } else {
+ throw createErr;
+ }
} else {
throw createErr;
}
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/google/callback/route.ts` around lines 60 - 77, The
user.create catch currently treats all Prisma P2002 errors as nickname
collisions and always retries with a suffixed nickname; change the handling so
you inspect createErr.meta?.target (from the prisma.user.create catch) and only
perform the suffix retry when the target includes "nickname"; for other P2002
targets such as "email" rethrow the error (or propagate it) instead of retrying,
ensuring the retry path that modifies nickname is only taken when
createErr.meta?.target indicates the nickname unique constraint failed.
| describe("getGoogleToken", () => { | ||
| beforeEach(() => { | ||
| process.env.GOOGLE_LOGIN_CLIENT_ID = "test_client_id"; | ||
| process.env.GOOGLE_LOGIN_CLIENT_SECRET = "test_client_secret"; | ||
| process.env.GOOGLE_LOGIN_REDIRECT_URI = | ||
| "http://localhost:3000/api/auth/google/callback"; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| test("토큰 발급에 성공하면 access_token을 반환한다", async () => { | ||
| const mockResponse = { | ||
| access_token: "google_access_token_mock", | ||
| expires_in: 3599, | ||
| token_type: "Bearer", | ||
| scope: "openid email profile", | ||
| }; | ||
|
|
||
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: () => Promise.resolve(mockResponse), | ||
| } as Response); | ||
|
|
||
| const result = await getGoogleToken("auth_code"); | ||
| expect(result.access_token).toBe("google_access_token_mock"); | ||
| }); | ||
|
|
||
| test("fetch가 ok:false이면 에러를 던진다", async () => { | ||
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | ||
| ok: false, | ||
| status: 400, | ||
| } as Response); | ||
|
|
||
| await expect(getGoogleToken("bad_code")).rejects.toThrow( | ||
| "구글 토큰 발급 실패", | ||
| ); | ||
| }); | ||
|
|
||
| test("환경변수가 없으면 에러를 던진다", async () => { | ||
| delete process.env.GOOGLE_LOGIN_CLIENT_ID; | ||
| await expect(getGoogleToken("code")).rejects.toThrow( | ||
| "구글 환경변수가 설정되지 않았습니다.", | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ────────────────────────────────────────────── | ||
| // getGoogleUser | ||
| // ────────────────────────────────────────────── | ||
| describe("getGoogleUser", () => { | ||
| beforeEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| test("사용자 정보를 정상 반환한다", async () => { | ||
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: () => | ||
| Promise.resolve({ | ||
| id: "google_user_id_123", | ||
| email: "user@gmail.com", | ||
| name: "홍길동", | ||
| verified_email: true, | ||
| }), | ||
| } as Response); | ||
|
|
||
| const user = await getGoogleUser("access_token"); | ||
| expect(user.id).toBe("google_user_id_123"); | ||
| expect(user.email).toBe("user@gmail.com"); | ||
| expect(user.name).toBe("홍길동"); | ||
| }); | ||
|
|
||
| test("fetch가 ok:false이면 에러를 던진다", async () => { | ||
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | ||
| ok: false, | ||
| status: 401, | ||
| } as Response); | ||
|
|
||
| await expect(getGoogleUser("invalid_token")).rejects.toThrow( | ||
| "구글 사용자 정보 조회 실패", | ||
| ); | ||
| }); | ||
|
|
||
| test("응답에 id가 없으면 에러를 던진다", async () => { | ||
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: () => | ||
| Promise.resolve({ | ||
| email: "user@gmail.com", | ||
| name: "홍길동", | ||
| // id 누락 | ||
| }), | ||
| } as Response); | ||
|
|
||
| await expect(getGoogleUser("access_token")).rejects.toThrow( | ||
| "구글 사용자 정보 조회 오류: id 누락", | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
타임아웃·빈 입력 경계값 테스트가 빠져 회귀를 놓칠 수 있습니다.
Why: 현재 스위트는 happy path 중심이라 fetchWithTimeout의 Abort 변환 에러와 입력 가드(빈 code/accessToken)가 깨져도 탐지하지 못합니다.
How: 공개 인터페이스 기준으로 아래 케이스를 추가하세요(구현 세부가 아닌 에러 계약 검증).
최소 추가 예시
describe("getGoogleToken", () => {
@@
+ test("code가 빈 문자열이면 즉시 에러를 던진다", async () => {
+ await expect(getGoogleToken(" ")).rejects.toThrow("인가 코드가 비어 있습니다.");
+ });
+
+ test("fetch 타임아웃(AbortError)을 요청 시간 초과 에러로 변환한다", async () => {
+ vi.spyOn(global, "fetch").mockRejectedValueOnce(
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
+ );
+ await expect(getGoogleToken("auth_code")).rejects.toThrow("요청 시간 초과");
+ });
});
@@
describe("getGoogleUser", () => {
@@
+ test("accessToken이 빈 문자열이면 즉시 에러를 던진다", async () => {
+ await expect(getGoogleUser("")).rejects.toThrow("accessToken이 비어 있습니다.");
+ });
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| describe("getGoogleToken", () => { | |
| beforeEach(() => { | |
| process.env.GOOGLE_LOGIN_CLIENT_ID = "test_client_id"; | |
| process.env.GOOGLE_LOGIN_CLIENT_SECRET = "test_client_secret"; | |
| process.env.GOOGLE_LOGIN_REDIRECT_URI = | |
| "http://localhost:3000/api/auth/google/callback"; | |
| vi.restoreAllMocks(); | |
| }); | |
| test("토큰 발급에 성공하면 access_token을 반환한다", async () => { | |
| const mockResponse = { | |
| access_token: "google_access_token_mock", | |
| expires_in: 3599, | |
| token_type: "Bearer", | |
| scope: "openid email profile", | |
| }; | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: true, | |
| json: () => Promise.resolve(mockResponse), | |
| } as Response); | |
| const result = await getGoogleToken("auth_code"); | |
| expect(result.access_token).toBe("google_access_token_mock"); | |
| }); | |
| test("fetch가 ok:false이면 에러를 던진다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: false, | |
| status: 400, | |
| } as Response); | |
| await expect(getGoogleToken("bad_code")).rejects.toThrow( | |
| "구글 토큰 발급 실패", | |
| ); | |
| }); | |
| test("환경변수가 없으면 에러를 던진다", async () => { | |
| delete process.env.GOOGLE_LOGIN_CLIENT_ID; | |
| await expect(getGoogleToken("code")).rejects.toThrow( | |
| "구글 환경변수가 설정되지 않았습니다.", | |
| ); | |
| }); | |
| }); | |
| // ────────────────────────────────────────────── | |
| // getGoogleUser | |
| // ────────────────────────────────────────────── | |
| describe("getGoogleUser", () => { | |
| beforeEach(() => { | |
| vi.restoreAllMocks(); | |
| }); | |
| test("사용자 정보를 정상 반환한다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: true, | |
| json: () => | |
| Promise.resolve({ | |
| id: "google_user_id_123", | |
| email: "user@gmail.com", | |
| name: "홍길동", | |
| verified_email: true, | |
| }), | |
| } as Response); | |
| const user = await getGoogleUser("access_token"); | |
| expect(user.id).toBe("google_user_id_123"); | |
| expect(user.email).toBe("user@gmail.com"); | |
| expect(user.name).toBe("홍길동"); | |
| }); | |
| test("fetch가 ok:false이면 에러를 던진다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: false, | |
| status: 401, | |
| } as Response); | |
| await expect(getGoogleUser("invalid_token")).rejects.toThrow( | |
| "구글 사용자 정보 조회 실패", | |
| ); | |
| }); | |
| test("응답에 id가 없으면 에러를 던진다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: true, | |
| json: () => | |
| Promise.resolve({ | |
| email: "user@gmail.com", | |
| name: "홍길동", | |
| // id 누락 | |
| }), | |
| } as Response); | |
| await expect(getGoogleUser("access_token")).rejects.toThrow( | |
| "구글 사용자 정보 조회 오류: id 누락", | |
| ); | |
| }); | |
| }); | |
| describe("getGoogleToken", () => { | |
| beforeEach(() => { | |
| process.env.GOOGLE_LOGIN_CLIENT_ID = "test_client_id"; | |
| process.env.GOOGLE_LOGIN_CLIENT_SECRET = "test_client_secret"; | |
| process.env.GOOGLE_LOGIN_REDIRECT_URI = | |
| "http://localhost:3000/api/auth/google/callback"; | |
| vi.restoreAllMocks(); | |
| }); | |
| test("토큰 발급에 성공하면 access_token을 반환한다", async () => { | |
| const mockResponse = { | |
| access_token: "google_access_token_mock", | |
| expires_in: 3599, | |
| token_type: "Bearer", | |
| scope: "openid email profile", | |
| }; | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: true, | |
| json: () => Promise.resolve(mockResponse), | |
| } as Response); | |
| const result = await getGoogleToken("auth_code"); | |
| expect(result.access_token).toBe("google_access_token_mock"); | |
| }); | |
| test("fetch가 ok:false이면 에러를 던진다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: false, | |
| status: 400, | |
| } as Response); | |
| await expect(getGoogleToken("bad_code")).rejects.toThrow( | |
| "구글 토큰 발급 실패", | |
| ); | |
| }); | |
| test("환경변수가 없으면 에러를 던진다", async () => { | |
| delete process.env.GOOGLE_LOGIN_CLIENT_ID; | |
| await expect(getGoogleToken("code")).rejects.toThrow( | |
| "구글 환경변수가 설정되지 않았습니다.", | |
| ); | |
| }); | |
| test("code가 빈 문자열이면 즉시 에러를 던진다", async () => { | |
| await expect(getGoogleToken(" ")).rejects.toThrow("인가 코드가 비어 있습니다."); | |
| }); | |
| test("fetch 타임아웃(AbortError)을 요청 시간 초과 에러로 변환한다", async () => { | |
| vi.spyOn(global, "fetch").mockRejectedValueOnce( | |
| Object.assign(new Error("aborted"), { name: "AbortError" }), | |
| ); | |
| await expect(getGoogleToken("auth_code")).rejects.toThrow("요청 시간 초과"); | |
| }); | |
| }); | |
| // ────────────────────────────────────────────── | |
| // getGoogleUser | |
| // ────────────────────────────────────────────── | |
| describe("getGoogleUser", () => { | |
| beforeEach(() => { | |
| vi.restoreAllMocks(); | |
| }); | |
| test("사용자 정보를 정상 반환한다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: true, | |
| json: () => | |
| Promise.resolve({ | |
| id: "google_user_id_123", | |
| email: "user@gmail.com", | |
| name: "홍길동", | |
| verified_email: true, | |
| }), | |
| } as Response); | |
| const user = await getGoogleUser("access_token"); | |
| expect(user.id).toBe("google_user_id_123"); | |
| expect(user.email).toBe("user@gmail.com"); | |
| expect(user.name).toBe("홍길동"); | |
| }); | |
| test("fetch가 ok:false이면 에러를 던진다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: false, | |
| status: 401, | |
| } as Response); | |
| await expect(getGoogleUser("invalid_token")).rejects.toThrow( | |
| "구글 사용자 정보 조회 실패", | |
| ); | |
| }); | |
| test("응답에 id가 없으면 에러를 던진다", async () => { | |
| vi.spyOn(global, "fetch").mockResolvedValueOnce({ | |
| ok: true, | |
| json: () => | |
| Promise.resolve({ | |
| email: "user@gmail.com", | |
| name: "홍길동", | |
| // id 누락 | |
| }), | |
| } as Response); | |
| await expect(getGoogleUser("access_token")).rejects.toThrow( | |
| "구글 사용자 정보 조회 오류: id 누락", | |
| ); | |
| }); | |
| test("accessToken이 빈 문자열이면 즉시 에러를 던진다", async () => { | |
| await expect(getGoogleUser("")).rejects.toThrow("accessToken이 비어 있습니다."); | |
| }); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/__tests__/google.test.ts` around lines 59 - 156, Add tests to
cover timeout and empty-input boundary cases for getGoogleToken and
getGoogleUser: (1) simulate a timeout by mocking global.fetch (or the internal
fetchWithTimeout) to reject with an AbortError-like error and assert
getGoogleToken/getGoogleUser reject with the same/translated error (to catch
abort conversion); (2) add input-guard tests that call getGoogleToken with an
empty/undefined code and getGoogleUser with an empty/undefined accessToken and
assert they throw the expected validation errors; target the test suite
names/subjects getGoogleToken and getGoogleUser and mock/spy on global.fetch (or
fetchWithTimeout) to trigger the timeout and error conditions.
| export async function getGoogleToken( | ||
| code: string, | ||
| ): Promise<GoogleTokenResponse> { |
There was a problem hiding this comment.
빈 code/accessToken 선검증이 없어 외부 호출 실패로 전파됩니다.
Why: 입력 공백은 클라이언트 오류인데 현재는 Google API까지 호출해 실패 원인 분리가 안 되고 장애 분석이 어려워집니다.
How: 함수 진입부에서 즉시 가드 후 명시적 에러를 던지세요.
최소 수정 예시
export async function getGoogleToken(
code: string,
): Promise<GoogleTokenResponse> {
+ if (!code.trim()) {
+ throw new Error("인가 코드가 비어 있습니다.");
+ }
const clientId = process.env.GOOGLE_LOGIN_CLIENT_ID;
const clientSecret = process.env.GOOGLE_LOGIN_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_LOGIN_REDIRECT_URI;
@@
export async function getGoogleUser(accessToken: string): Promise<GoogleUser> {
+ if (!accessToken.trim()) {
+ throw new Error("accessToken이 비어 있습니다.");
+ }
const res = await fetchWithTimeout(Also applies to: 91-91
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/google.ts` around lines 58 - 60, Add input guards that validate
required parameters at the top of the Google auth helpers: in
getGoogleToken(code) check that code is a non-empty string and throw a clear,
immediate error (e.g. "Missing required parameter: code") instead of letting the
request proceed, and do the same for the function that accepts accessToken (e.g.
getGoogleProfile(accessToken) or the access-token-consuming helper) to throw
"Missing required parameter: accessToken" when empty; this keeps client errors
local and prevents unnecessary calls to Google APIs.
| throw new Error(`구글 토큰 발급 실패 (status: ${res.status})`); | ||
| } | ||
|
|
||
| return res.json() as Promise<GoogleTokenResponse>; |
There was a problem hiding this comment.
토큰 응답을 캐스팅만 하고 필수 필드 검증을 생략했습니다.
Why: res.json() as Promise<GoogleTokenResponse>는 런타임 보장을 못 해서 access_token 누락 시 콜백 흐름(Line 50-57 in src/app/api/auth/google/callback/route.ts)에서 늦게 터집니다.
How: 파싱 후 access_token 존재를 확인하고 없으면 즉시 실패 처리하세요.
최소 수정 예시
- return res.json() as Promise<GoogleTokenResponse>;
+ const data = (await res.json()) as Partial<GoogleTokenResponse>;
+ if (!data.access_token) {
+ throw new Error("구글 토큰 응답 오류: access_token 누락");
+ }
+ return data as GoogleTokenResponse;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return res.json() as Promise<GoogleTokenResponse>; | |
| const data = (await res.json()) as Partial<GoogleTokenResponse>; | |
| if (!data.access_token) { | |
| throw new Error("구글 토큰 응답 오류: access_token 누락"); | |
| } | |
| return data as GoogleTokenResponse; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/google.ts` at line 88, The current return simply casts
res.json() to Promise<GoogleTokenResponse> without runtime checks; update the
function that calls res.json() (the method returning GoogleTokenResponse) to
await and parse the JSON, verify that parsed.access_token exists (and other
required fields if needed), and if missing reject/throw a descriptive error
(e.g., throw new Error("Missing access_token in Google token response")) so
callers (like the callback handler) fail fast instead of later; keep the
function's outward type but ensure it returns the validated object when
access_token is present.
🚀 작업 내용 (What)
📣 핵심 변경 이유 (Why)
📸 스크린샷 (Visuals, 선택)
🔗 관련 이슈 (Issue)
Close #37