import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAuth } from 'firebase-admin/auth'; import { bootAdmin, shutdownAdmin } from './support.js'; import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js'; /** 카카오 커스텀 토큰 — 토큰 검증 · 기존 oidc.kakao 사용자 매핑 · 신규 UID · SNS 연동. */ const APP_ID = '123456'; const KAKAO_ID = '987654321'; const OLD_UID = 'test-kakao-oidc-user'; const GOOGLE_UID = 'test-kakao-google-user'; type Req = Parameters[0]; const call = (data: Record, uid?: string) => fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req); const codeOf = async (p: Promise) => { try { await p; return 'ok'; } catch (e) { const d = (e as { details?: { code?: string; reason?: string } }).details; return d?.reason ?? d?.code; } }; /** 카카오 API 흉내. 토큰 'good' 만 우리 앱 것, 'other-app' 은 남의 앱 토큰이다. */ function stubKakao(email?: string) { vi.stubGlobal('fetch', vi.fn(async (url: string, init: { headers: Record }) => { const token = init.headers.Authorization?.replace('Bearer ', ''); if (token === 'expired') return new Response('{}', { status: 401 }); if (url.endsWith('/v1/user/access_token_info')) { return Response.json({ id: Number(KAKAO_ID), expires_in: 100, app_id: token === 'other-app' ? 1 : Number(APP_ID) }); } return Response.json({ id: Number(KAKAO_ID), kakao_account: { profile: { nickname: '카카오팬' }, ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}), }, }); })); } async function wipe() { const uids = [OLD_UID, GOOGLE_UID, kakaoUidOf(KAKAO_ID)]; await getAuth().deleteUsers(uids); } beforeAll(() => { if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) { throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.'); } process.env.KAKAO_APP_ID = APP_ID; bootAdmin(); }); afterAll(async () => { await wipe(); await shutdownAdmin(); }); beforeEach(async () => { await wipe(); stubKakao(); }); afterEach(() => { vi.unstubAllGlobals(); }); describe('토큰 검증', () => { it('만료·무효 토큰과 남의 앱 토큰은 E-AUTH-03', async () => { expect(await codeOf(call({ accessToken: 'expired' }))).toBe('E-AUTH-03'); expect(await codeOf(call({ accessToken: 'other-app' }))).toBe('E-AUTH-03'); }); }); describe('로그인', () => { it('OIDC 시절 oidc.kakao 가입자는 같은 UID 로 들어온다', async () => { await getAuth().createUser({ uid: OLD_UID }); await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: KAKAO_ID } }); const { token } = await call({ accessToken: 'good' }) as { token: string }; expect(JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid).toBe(OLD_UID); }); it('신규는 kakao:<회원번호> 로 만들고 oidc.kakao 를 붙인다 · 두 번째도 같은 UID', async () => { const first = await call({ accessToken: 'good' }) as { token: string }; await call({ accessToken: 'good' }); const uid = JSON.parse(Buffer.from(first.token.split('.')[1]!, 'base64url').toString()).uid; expect(uid).toBe(kakaoUidOf(KAKAO_ID)); const user = await getAuth().getUser(uid); expect(user.displayName).toBe('카카오팬'); expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[KAKAO_PROVIDER_ID, KAKAO_ID]]); }); }); describe('SNS 연동', () => { it('로그인 없이 link 는 E-AUTH-03', async () => { expect(await codeOf(call({ accessToken: 'good', link: true }))).toBe('E-AUTH-03'); }); it('Google 사용자에 붙인다 · 다른 UID 가 쓰는 카카오는 E-AUTH-08', async () => { await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); const user = await getAuth().getUser(GOOGLE_UID); expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true); await getAuth().createUser({ uid: OLD_UID }); expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08'); }); it('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다', async () => { await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); stubKakao('other@kakao.com'); expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); await getAuth().deleteUser(GOOGLE_UID); await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); stubKakao(); expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); const user = await getAuth().getUser(GOOGLE_UID); expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true); }); it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => { await getAuth().createUser({ uid: GOOGLE_UID }); const before = Math.floor(Date.now() / 1000) - 60; await getAuth().revokeRefreshTokens(GOOGLE_UID); const linkAs = (authTime: number) => fnAuthKakao.run( { data: { accessToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req, ); expect(await codeOf(linkAs(before))).toBe('session'); await getAuth().updateUser(GOOGLE_UID, { disabled: true }); expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session'); expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]); }); it('이미 다른 카카오가 붙어 있으면 E-AUTH-09 · 같은 카카오면 그대로 성공', async () => { await getAuth().createUser({ uid: GOOGLE_UID }); await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: '111' } }); expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09'); await getAuth().deleteUser(GOOGLE_UID); await getAuth().createUser({ uid: GOOGLE_UID }); expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); }); });