import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAuth } from 'firebase-admin/auth'; import { getFirestore } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; import { fnAuthLine, LINE_PROVIDER_ID, lineUidOf } from '../src/account/line.js'; /** LINE 커스텀 토큰 — ID 토큰 · 액세스 토큰 검증 · 기존 oidc.line 사용자 매핑 · 신규 UID · 이메일(C-143) · SNS 연동. */ const CHANNEL = '2000000001'; const LINE_ID = 'U0123456789abcdef0123456789abcdef'; const OLD_UID = 'test-line-oidc-user'; const GOOGLE_UID = 'test-line-google-user'; type Req = Parameters[0]; const call = (data: Record, uid?: string) => fnAuthLine.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; } }; const uidOf = (token: string) => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string; /** * LINE API 흉내. ID 토큰 'good' · 액세스 토큰 'good' 만 우리 채널 것, 'other-app' 은 남의 채널, 'expired' 는 만료. * 실제 LINE 처럼 ID 토큰 검증은 보낸 client_id 와 aud 가 다르면 400 이다. */ function stubLine(email?: string) { vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; body?: string; headers?: Record }) => { if (init?.method === 'POST' && url.endsWith('/oauth2/v2.1/verify')) { const form = new URLSearchParams(init.body); const token = form.get('id_token'); if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 }); const aud = token === 'other-app' ? '1' : CHANNEL; if (form.get('client_id') !== aud) return Response.json({ error: 'invalid_request' }, { status: 400 }); return Response.json({ iss: 'https://access.line.me', sub: LINE_ID, aud, name: 'LINE팬', ...(email ? { email } : {}) }); } if (url.includes('/oauth2/v2.1/verify?access_token=')) { const token = new URL(url).searchParams.get('access_token'); if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 }); return Response.json({ scope: 'profile openid', client_id: token === 'other-app' ? '1' : CHANNEL, expires_in: 100 }); } if (url.endsWith('/v2/profile')) return Response.json({ userId: LINE_ID, displayName: 'LINE팬' }); return new Response('{}', { status: 404 }); })); } async function wipe() { await getAuth().deleteUsers([OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]); for (const uid of [OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]) await getFirestore().doc(`users/${uid}`).delete(); } beforeAll(() => { if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) { throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.'); } process.env.LINE_CHANNEL_ID = CHANNEL; bootAdmin(); }); afterAll(async () => { await wipe(); await shutdownAdmin(); }); beforeEach(async () => { await wipe(); stubLine(); }); afterEach(() => { vi.unstubAllGlobals(); }); describe('토큰 검증', () => { it('만료 · 남의 채널 토큰(ID · 액세스 모두)과 토큰 없음은 E-AUTH-03', async () => { for (const key of ['idToken', 'accessToken']) { expect(await codeOf(call({ [key]: 'expired' }))).toBe('E-AUTH-03'); expect(await codeOf(call({ [key]: 'other-app' }))).toBe('E-AUTH-03'); } expect(await codeOf(call({}))).toBe('E-AUTH-03'); }); it('ID 토큰이 있으면 ID 토큰으로 검증한다(액세스 토큰이 남의 채널 것이어도 보지 않는다)', async () => { const { token } = await call({ idToken: 'good', accessToken: 'other-app' }) as { token: string }; expect(uidOf(token)).toBe(lineUidOf(LINE_ID)); }); }); describe('로그인', () => { it('OIDC 시절 oidc.line 가입자는 같은 UID 로 들어온다', async () => { await getAuth().createUser({ uid: OLD_UID }); await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: LINE_ID } }); const { token } = await call({ idToken: 'good' }) as { token: string }; expect(uidOf(token)).toBe(OLD_UID); }); it('신규는 line: 로 만들고 oidc.line 을 붙인다 · 액세스 토큰만으로도 같은 UID', async () => { const first = await call({ idToken: 'good' }) as { token: string }; const second = await call({ accessToken: 'good' }) as { token: string }; expect(uidOf(first.token)).toBe(lineUidOf(LINE_ID)); expect(uidOf(second.token)).toBe(lineUidOf(LINE_ID)); const user = await getAuth().getUser(lineUidOf(LINE_ID)); expect(user.displayName).toBe('LINE팬'); expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[LINE_PROVIDER_ID, LINE_ID]]); }); it('이메일은 LINE 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다(C-143)', async () => { await call({ idToken: 'good' }); expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBeUndefined(); stubLine('fan@line.me'); await call({ idToken: 'good' }); expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBe('fan@line.me'); }); it('탈퇴한 line: 문서가 있으면 새 UID 로 만든다', async () => { await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).set({ status: 'withdrawn' }); const { token } = await call({ idToken: 'good' }) as { token: string }; const uid = uidOf(token); expect(uid.startsWith(`${lineUidOf(LINE_ID)}:`)).toBe(true); await getAuth().deleteUser(uid); }); }); describe('SNS 연동', () => { it('로그인 없이 link 는 E-AUTH-03', async () => { expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03'); }); it('Google 사용자에 붙인다(이메일이 달라도 · 없어도 — Q-E8) · 다른 UID 가 쓰는 LINE 은 E-AUTH-08', async () => { await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); stubLine('other@line.me'); expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); const user = await getAuth().getUser(GOOGLE_UID); expect(user.providerData.some((d) => d.providerId === LINE_PROVIDER_ID && d.uid === LINE_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 }); const before = Math.floor(Date.now() / 1000) - 60; await getAuth().revokeRefreshTokens(GOOGLE_UID); const linkAs = (authTime: number) => fnAuthLine.run( { data: { idToken: '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('탈퇴 상태 계정으로는 붙이지 않는다', async () => { await getAuth().createUser({ uid: GOOGLE_UID }); await getFirestore().doc(`users/${GOOGLE_UID}`).set({ status: 'withdrawn' }); expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('session'); }); it('이미 다른 LINE 이 붙어 있으면 E-AUTH-09 · 같은 LINE 이면 그대로 성공', async () => { await getAuth().createUser({ uid: GOOGLE_UID }); await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: 'U-other' } }); expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09'); await getAuth().deleteUser(GOOGLE_UID); await getAuth().createUser({ uid: GOOGLE_UID }); expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); }); });