Kakao 로그인을 SDK와 서버 커스텀 토큰으로 · sFPI 일 집계 직후 Stage 승급 판정
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user