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 });
});
});
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
const push = vi.hoisted(() => vi.fn(async () => true));
vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() }));
const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js');
const { closeDay } = await import('../src/fandom/fandom.js');
/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */
const STAGES: Array<[number, number, number]> = [
[1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000],
[5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000],
];
const id = 'test-stage-promotion';
const db = () => getFirestore();
const stage = async () => (await db().doc(`fandoms/${id}`).get()).get('stage');
const set = (data: Record<string, unknown>) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data });
beforeAll(async () => {
bootAdmin();
for (const [n, pop, sfpi] of STAGES) {
await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi });
}
});
beforeEach(async () => {
push.mockClear();
await db().recursiveDelete(db().doc(`fandoms/${id}`));
});
afterAll(async () => {
await db().recursiveDelete(db().doc(`fandoms/${id}`));
await shutdownAdmin();
});
it('조건 미달 · 한쪽만 충족이면 대기한다', async () => {
for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) {
await set({ stage: 1, growthPop: pop, sfpi });
expect(await promoteStages([id])).toEqual([]);
expect(await stage()).toBe(1);
}
expect(push).not.toHaveBeenCalled();
});
it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => {
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
await Promise.all([promoteStages([id]), promoteStages([id])]);
await promoteStages([id]);
expect(await stage()).toBe(2);
expect(push).toHaveBeenCalledTimes(1);
});
it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => {
// POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3.
await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 });
expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]);
await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 });
expect(await promoteStages([id])).toEqual([]);
await set({ stage: 0, growthPop: 1e12, sfpi: 1e12 });
expect(await promoteStages([id])).toEqual([]);
expect(await stage()).toBe(0);
});
it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => {
await set({ stage: 1, growthPop: 100_000, sfpi: 14_000, fpiScore: 1_000 });
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
expect(await stage()).toBe(2);
await closeDay(new Date('2030-01-02T00:00:30+09:00'));
expect((await db().doc(`fandoms/${id}`).get()).get('sfpi')).toBe(15_000);
expect(push).toHaveBeenCalledTimes(1);
});
it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => {
await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 });
const run = (role: string) => fnAdminPlanetEvolutionRun.run({
data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } },
} as unknown as Parameters<typeof fnAdminPlanetEvolutionRun.run>[0]);
await expect(run('ops')).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
expect(await stage()).toBe(1);
expect((await run('master')).promoted).toEqual({ fandomId: id, fromStage: 1, toStage: 2 });
const logs = await db().collection('adminActionLogs').where('actorUid', '==', 'evo-master').get();
expect(logs.docs.some((d) => d.get('reason') === 'PLANET_EVOLUTION_RUN' && d.get('patch.stage') === 2)).toBe(true);
});