Kakao 로그인을 SDK와 서버 커스텀 토큰으로 · sFPI 일 집계 직후 Stage 승급 판정

This commit is contained in:
Sn-Kinos
2026-10-05 19:07:47 +09:00
parent 576dcfbdfd
commit 65669b969e
24 changed files with 1034 additions and 57 deletions
@@ -0,0 +1,106 @@
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<typeof fnAuthKakao.run>[0];
const call = (data: Record<string, unknown>, uid?: string) =>
fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
const codeOf = async (p: Promise<unknown>) => {
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<string, string> }) => {
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('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => {
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
stubKakao('other@kakao.com');
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email');
stubKakao('FAN@fanuzu.co.kr');
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
});
});