Admin 쓰기 관문 강화 — 원장 · 지표 · 감사 기록과 서버 산출 필드 차단 · 마스터 전용 문서 · 거부 기록 · 권한 경계 시험

This commit is contained in:
Sn-Kinos
2026-09-26 20:46:41 +09:00
parent bb316c99e5
commit 65ba44a1d1
3 changed files with 161 additions and 7 deletions
@@ -0,0 +1,69 @@
import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnAdminWrite } from '../src/admin/admin.js';
/**
* `fn-admin-write` 권한 경계 — firestore.rules 에서 master 전용인 문서를 ops 가 이 함수로
* 우회해 쓰지 못한다. 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
*/
const run = (role: string | undefined, targetPath: string,
patch: Record<string, unknown> = { attendPop: 999 }) =>
fnAdminWrite.run({
data: { targetPath, reason: '시험', patch },
auth: { uid: `admin-${role ?? 'none'}`, token: role ? { role } : {} },
} as Parameters<typeof fnAdminWrite.run>[0]);
const denials = async (actorUid: string) =>
(await getFirestore().collection('adminActionLogs')
.where('actorUid', '==', actorUid).where('denied', '==', true).get()).size;
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
const db = getFirestore();
await db.recursiveDelete(db.collection('adminActionLogs'));
await db.doc('config/admin-write-test').delete();
await db.doc('notices/admin-write-test').delete();
});
afterAll(async () => { await shutdownAdmin(); });
it('ops 는 master 전용 문서(config)를 못 쓰고 거부가 기록된다 · master 는 쓴다', async () => {
await expect(run('ops', 'config/admin-write-test'))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
expect((await getFirestore().doc('config/admin-write-test').get()).exists).toBe(false);
expect(await denials('admin-ops')).toBe(1);
await run('master', 'config/admin-write-test');
expect((await getFirestore().doc('config/admin-write-test').get()).get('attendPop')).toBe(999);
});
it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는 호출은 거부가 기록된다', async () => {
await run('ops', 'notices/admin-write-test');
expect((await getFirestore().doc('notices/admin-write-test').get()).exists).toBe(true);
await expect(run(undefined, 'notices/admin-write-test'))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
expect(await denials('admin-none')).toBe(1);
});
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만', async () => {
const denied = (role: string, path: string, patch?: Record<string, unknown>) =>
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await denied('master', 'fandoms/f1/fpiEvents/e1');
await denied('master', 'fandoms/f1/eclipseRounds/r1');
await denied('master', 'fandoms/f1/fandomDailyStats/2026-09-20');
await denied('master', 'adminActionLogs/l1');
await denied('master', 'fandoms/admin-write-test', { fpiScore: 1 });
await denied('master', 'fandoms/admin-write-test', { growthPop: 1 });
await denied('master', 'users/u1', { userExp: 1 });
await denied('master', 'users/u1', { passportTier: 'DIAMOND' });
await denied('ops', 'fandoms/admin-write-test', { eclipseSchedule: [] });
expect(await denials('admin-master')).toBe(8);
await run('master', 'fandoms/admin-write-test', { eclipseSchedule: [{ hhmm: '09:00' }] });
await run('ops', 'fandoms/admin-write-test', { description: '운영 문구' });
const doc = await getFirestore().doc('fandoms/admin-write-test').get();
expect(doc.get('eclipseSchedule')).toEqual([{ hhmm: '09:00' }]);
expect(doc.get('fpiScore')).toBeUndefined();
await getFirestore().doc('fandoms/admin-write-test').delete();
});