import { assertFails, assertSucceeds, } from '@firebase/rules-unit-testing'; import { collection, collectionGroup, deleteDoc, doc, getCountFromServer, getDoc, getDocs, orderBy, query, serverTimestamp, setDoc, updateDoc, where, } from 'firebase/firestore'; import { afterAll, beforeAll, beforeEach, describe, expect, test, } from 'vitest'; import { createTestEnvironment, seedDocuments, userDocument, } from '../support/environment.mjs'; let testEnvironment; const dbFor = (uid, claims = {}) => testEnvironment.authenticatedContext(uid, claims).firestore(); const anonymousDb = () => testEnvironment.unauthenticatedContext().firestore(); beforeAll(async () => { testEnvironment = await createTestEnvironment(); }); beforeEach(async () => { await testEnvironment.clearFirestore(); }); afterAll(async () => { await testEnvironment.cleanup(); }); describe('원칙 1 — POP·FPI·원장은 클라이언트 쓰기 불가', () => { test('본인 지갑 잔액 직접 증액을 거부한다', async () => { await seedDocuments(testEnvironment, { 'users/alice/wallet/main': { balance: 100 }, }); await assertFails(updateDoc(doc(dbFor('alice'), 'users/alice/wallet/main'), { balance: 999 })); }); test('POP 원장 문서 생성을 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'users/alice/popTransactions/forged'), { amount: 1000, reason: 'QUEST_REWARD', }), ); }); test('POP Lot 잔량 조작을 거부한다', async () => { await seedDocuments(testEnvironment, { 'users/alice/popLots/lot-1': { remaining: 100 }, }); await assertFails( updateDoc(doc(dbFor('alice'), 'users/alice/popLots/lot-1'), { remaining: 999_999, }), ); }); test('FPI 이벤트 위조를 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'fandoms/fandom-a/fpiEvents/forged'), { uid: 'alice', score: 999_999, }), ); }); test('누적 기여 통계 직접 조작을 거부한다', async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice'), }); await assertFails( updateDoc(doc(dbFor('alice'), 'users/alice'), { totalContributedPop: 999_999 }), ); }); test('다른 사용자의 지갑 쓰기를 거부한다', async () => { await seedDocuments(testEnvironment, { 'users/bob/wallet/main': { balance: 100 }, }); await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob/wallet/main'), { balance: 0 })); }); test('행성 성장 단계 조작을 거부한다', async () => { await seedDocuments(testEnvironment, { 'fandoms/fandom-a/planets/alice': { ownerUid: 'alice', stage: 1, colorKey: 'blue' }, }); await assertFails( updateDoc(doc(dbFor('alice'), 'fandoms/fandom-a/planets/alice'), { stage: 7 }), ); }); }); describe('행성 외형 — 생성 후 사용자 변경 없음(원장 v1.2 C-50)', () => { test('소유자도 형태 · 컬러 · 테마 · 효과를 생성 후 바꿀 수 없고 직접 생성도 못 한다', async () => { await seedDocuments(testEnvironment, { 'fandoms/fandom-a/planets/alice': { ownerUid: 'alice', planetType: 'rocky', colorKey: 'blue' }, }); const mine = doc(dbFor('alice'), 'fandoms/fandom-a/planets/alice'); for (const patch of [{ planetType: 'gas' }, { colorKey: 'red' }, { themeId: 't1' }, { effectLevel: 3 }, { updatedAt: new Date() }]) { await assertFails(updateDoc(mine, patch)); } await assertFails(setDoc(doc(dbFor('bob'), 'fandoms/fandom-a/planets/bob'), { ownerUid: 'bob', planetType: 'rocky', colorKey: 'blue' })); await assertSucceeds(getDoc(mine)); }); }); describe('단결형 아이템 접근 경계', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'fandoms/fandom-a/solidarityBoosts/boost-1': { triggerUid: 'alice', multiplier: 2, startsAt: '2026-08-14T00:00:00Z', endsAt: '2026-08-14T01:00:00Z', scope: 'fandom', status: 'active', }, }); }); test('로그인 사용자는 단결형 활성 상태를 읽을 수 있다', async () => { await assertSucceeds( getDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/boost-1')), ); }); test('미인증 사용자는 단결형 활성 상태를 읽을 수 없다', async () => { await assertFails( getDoc(doc(anonymousDb(), 'fandoms/fandom-a/solidarityBoosts/boost-1')), ); }); test('일반 사용자는 단결형 상태를 생성할 수 없다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/forged'), { triggerUid: 'alice', multiplier: 999, status: 'active', }), ); }); test('master도 단결형 상태를 수정할 수 없다', async () => { await assertFails( updateDoc( doc(dbFor('master-user', { role: 'master' }), 'fandoms/fandom-a/solidarityBoosts/boost-1'), { multiplier: 999 }, ), ); }); test('일반 사용자는 단결형 상태를 삭제할 수 없다', async () => { await assertFails( deleteDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/boost-1')), ); }); }); describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice', { displayName: 'Alice' }), 'users/bob': userDocument('bob', { displayName: 'Bob' }), }); }); test('다른 사용자의 프로필 읽기를 거부한다', async () => { await assertFails(getDoc(doc(dbFor('alice'), 'users/bob'))); }); test('다른 사용자의 프로필 쓰기를 거부한다', async () => { await assertFails(updateDoc(doc(dbFor('alice'), 'users/bob'), { displayName: 'Forged' })); }); test('허용 필드와 서버 통계를 섞은 본인 업데이트를 전부 거부한다', async () => { await assertFails( updateDoc(doc(dbFor('alice'), 'users/alice'), { displayName: 'New Alice', totalContributedPop: 999_999, }), ); }); test('미인증 프로필 접근을 거부한다', async () => { await assertFails(getDoc(doc(anonymousDb(), 'users/alice'))); }); test('LIVE 접속 시각은 서버 시각으로만 본인이 쓴다', async () => { await assertSucceeds( updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: serverTimestamp() }), ); await assertFails( updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: new Date('2099-01-01T00:00:00Z') }), ); await assertFails( updateDoc(doc(dbFor('alice'), 'users/bob'), { lastSeenAt: serverTimestamp() }), ); // 60초 안의 재기록은 거부 · 60초가 지났으면 허용. await assertFails( updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: serverTimestamp() }), ); await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice', { lastSeenAt: new Date(Date.now() - 61_000) }), }); await assertSucceeds( updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: serverTimestamp() }), ); }); test('가입 팬덤 목록(fandomIds)은 본인도 쓸 수 없다', async () => { await assertFails( updateDoc(doc(dbFor('alice'), 'users/alice'), { fandomIds: ['forged'] }), ); }); test('허용된 본인 프로필 필드만 바꾸면 허용한다', async () => { await assertSucceeds( updateDoc(doc(dbFor('alice'), 'users/alice'), { displayName: 'New Alice', locale: 'ko', updatedAt: '2026-08-08T01:00:00Z', }), ); }); }); describe('성장 귀속 행 — users/{uid}/growthEvents (HOME 주간 정보 A-37)', () => { test('본인만 읽고 누구도 직접 쓰지 못한다', async () => { await seedDocuments(testEnvironment, { 'users/alice/growthEvents/tx-1': { fandomIdAtPayout: 'fandom-a', amount: 10 }, }); await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/growthEvents/tx-1'))); await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/growthEvents/tx-1'))); await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/growthEvents/tx-2'), { amount: 999 })); }); }); describe('가입 목록 — users/{uid}/memberships (C-15)', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice/memberships/fandom-a': { fandomId: 'fandom-a' }, }); }); test('본인만 읽고 누구도 직접 쓰지 못한다', async () => { await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/memberships/fandom-a'))); await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/memberships/fandom-a'))); await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/memberships/fandom-b'), { fandomId: 'fandom-b' })); await assertFails(deleteDoc(doc(dbFor('alice'), 'users/alice/memberships/fandom-a'))); }); }); describe('검증 활동 표식 — users/{uid}/verifiedCompletions (MY 완료 퀘스트)', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice/verifiedCompletions/q1_20261005': { questId: 'q1' }, }); }); test('본인만 세고 누구도 직접 쓰지 못한다', async () => { await assertSucceeds(getCountFromServer(collection(dbFor('alice'), 'users/alice/verifiedCompletions'))); await assertFails(getDocs(collection(dbFor('bob'), 'users/alice/verifiedCompletions'))); await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/verifiedCompletions/q2_20261005'), { questId: 'q2' })); }); }); describe('G-02 — 약관 동의 이력 무결성 회귀', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice/consents/terms-v1': { termsVersion: '1.0', agreedAt: '2026-08-08T00:00:00Z', }, 'users/bob/consents/terms-v1': { termsVersion: '1.0', agreedAt: '2026-08-08T00:00:00Z', }, }); }); test.each([ [ '생성을', () => setDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v2'), { termsVersion: '2.0', agreedAt: '2026-08-08T01:00:00Z', }), ], [ '수정을', () => updateDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v1'), { agreedAt: '2099-12-31T00:00:00Z', }), ], [ '삭제를', () => deleteDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v1')), ], ])('본인의 consents 문서 %s 거부한다', async (_operation, attempt) => { await assertFails(attempt()); }); test('다른 사용자의 consents 문서 읽기를 거부한다', async () => { await assertFails(getDoc(doc(dbFor('alice'), 'users/bob/consents/terms-v1'))); }); test('본인의 consents 문서 읽기를 허용한다', async () => { await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/consents/terms-v1'))); }); }); describe('F-01 — 컬렉션 그룹 쿼리 회귀', () => { test('로그인 사용자의 donations 컬렉션 그룹 조회를 허용한다', async () => { await seedDocuments(testEnvironment, { 'campaigns/campaign-a/donations/donation-1': { donorUid: 'alice', amount: 10 }, }); await assertSucceeds(getDocs(collectionGroup(dbFor('alice'), 'donations'))); }); test('미인증 donations 컬렉션 그룹 조회를 거부한다', async () => { await seedDocuments(testEnvironment, { 'campaigns/campaign-a/donations/donation-1': { donorUid: 'alice', amount: 10 }, }); await assertFails(getDocs(collectionGroup(anonymousDb(), 'donations'))); }); test('로그인 사용자의 answers 컬렉션 그룹 조회를 허용한다', async () => { await seedDocuments(testEnvironment, { 'questions/question-1/answers/answer-1': { authorUid: 'alice', body: 'answer' }, }); await assertSucceeds(getDocs(collectionGroup(dbFor('alice'), 'answers'))); }); test.each([ ['popTransactions', 'users/alice/popTransactions/tx-1'], ['activityLogs', 'users/alice/activityLogs/log-1'], ['questProgress', 'users/alice/questProgress/quest-1'], ['comments', 'posts/post-1/comments/comment-1'], ])('viewer의 %s 컬렉션 그룹 조회를 허용한다', async (group, path) => { await seedDocuments(testEnvironment, { [path]: { ownerUid: 'alice' } }); await assertSucceeds(getDocs(collectionGroup(dbFor('viewer', { role: 'viewer' }), group))); }); test.each([ ['popTransactions', 'users/alice/popTransactions/tx-1'], ['activityLogs', 'users/alice/activityLogs/log-1'], ['questProgress', 'users/alice/questProgress/quest-1'], ['comments', 'posts/post-1/comments/comment-1'], ])('일반 사용자의 %s 컬렉션 그룹 조회를 계속 거부한다', async (group, path) => { await seedDocuments(testEnvironment, { [path]: { ownerUid: 'alice' } }); await assertFails(getDocs(collectionGroup(dbFor('ordinary-user'), group))); }); }); describe('F-02 — 캠페인 집계 필드 위조 회귀', () => { const validCampaign = { fandomId: 'fandom-a', creatorUid: 'alice', creatorName: 'Alice', title: 'Campaign', summary: 'Summary', body: 'Body', coverImageUrl: 'https://example.test/cover.png', campaignType: 'other', targetPop: 1000, endsAt: '2026-09-01T00:00:00Z', status: 'draft', raisedPop: 0, achievedRate: 0, donorCount: 0, refundState: 'none', createdAt: '2026-08-08T00:00:00Z', updatedAt: '2026-08-08T00:00:00Z', }; beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice', { displayName: 'Alice' }), }); }); // 개설은 fn-campaign-create 전용이다 — 등록비 · 자격 · review 진입이 한 번에 선다. test('정상 초기값이어도 클라이언트 직접 생성은 거부한다', async () => { await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign)); await assertFails( setDoc(doc(dbFor('alice'), 'campaigns/review'), { ...validCampaign, status: 'review' }), ); }); test('집계 필드 위조를 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'campaigns/forged'), { ...validCampaign, achievedRate: 1, donorCount: 99_999, }), ); }); test('화이트리스트 밖 집계 필드 추가를 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'campaigns/extra-field'), { ...validCampaign, serverAggregate: 999_999, }), ); }); }); describe('F-05 — 작성자 표시명 사칭 회귀', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice', { displayName: 'Alice', nickname: 'Alice' }), }); }); test('정상 게시물 생성을 허용한다', async () => { await assertSucceeds( setDoc(doc(dbFor('alice'), 'posts/valid'), { fandomId: 'fandom-a', authorUid: 'alice', authorName: 'Alice', body: 'hello', imageUrls: [], likeCount: 0, commentCount: 0, visibility: 'visible', createdAt: serverTimestamp(), updatedAt: serverTimestamp(), }), ); }); test('게시물 authorName 사칭을 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'posts/forged'), { fandomId: 'fandom-a', authorUid: 'alice', authorName: 'FANUZU 운영팀', body: 'forged', imageUrls: [], likeCount: 0, commentCount: 0, visibility: 'visible', createdAt: '2026-08-08T00:00:00Z', updatedAt: '2026-08-08T00:00:00Z', }), ); }); test('댓글 authorName 사칭을 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'posts/post-1/comments/forged'), { authorUid: 'alice', authorName: 'FANUZU 운영팀', body: 'forged', visibility: 'visible', createdAt: '2026-08-08T00:00:00Z', updatedAt: '2026-08-08T00:00:00Z', }), ); }); test('답변 authorName 은 공개 닉네임만 받는다 — 공급자 실명(displayName)은 거부(M-7)', async () => { await seedDocuments(testEnvironment, { 'users/named': userDocument('named', { displayName: '김실명', nickname: '닉네임' }), 'users/nonick': userDocument('nonick', { displayName: '박실명' }), // 줄바꿈 뒤의 @ 도 이메일 형태로 본다('(?s)'). 'users/multiline': userDocument('multiline', { nickname: 'me\nx@example.com' }), 'questions/m7-q': { authorUid: 'alice', status: 'open' }, }); const answer = (uid, authorName) => ({ authorUid: uid, authorName, body: '답', sourceUrls: [], accepted: false, visibility: 'visible', createdAt: serverTimestamp(), }); await assertFails(setDoc(doc(dbFor('named'), 'questions/m7-q/answers/a'), answer('named', '김실명'))); await assertSucceeds(setDoc(doc(dbFor('named'), 'questions/m7-q/answers/b'), answer('named', '닉네임'))); await assertFails(setDoc(doc(dbFor('nonick'), 'questions/m7-q/answers/c'), answer('nonick', '박실명'))); await assertSucceeds(setDoc(doc(dbFor('nonick'), 'questions/m7-q/answers/d'), answer('nonick', '팬'))); await assertFails(setDoc(doc(dbFor('multiline'), 'questions/m7-q/answers/e'), answer('multiline', 'me\nx@example.com'))); await assertSucceeds(setDoc(doc(dbFor('multiline'), 'questions/m7-q/answers/f'), answer('multiline', '팬'))); }); test('답변 authorName 사칭을 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'questions/question-1/answers/forged'), { authorUid: 'alice', authorName: 'FANUZU 운영팀', body: 'forged', sourceUrls: [], accepted: false, visibility: 'visible', createdAt: '2026-08-08T00:00:00Z', updatedAt: '2026-08-08T00:00:00Z', }), ); }); }); describe('현상금 Q&A 답변 — 열린 질문 · 서버 시각 · 채택 후 잠금', () => { const answer = (overrides = {}) => ({ authorUid: 'bob', authorName: 'Bob', body: '답', accepted: false, visibility: 'visible', createdAt: serverTimestamp(), ...overrides, }); beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice', { displayName: 'Alice' }), 'users/alice': userDocument('alice', { displayName: '김실명', nickname: 'Alice' }), 'users/bob': userDocument('bob', { displayName: '박실명', nickname: 'Bob' }), 'questions/open-q': { authorUid: 'alice', status: 'open' }, 'questions/accepted-q': { authorUid: 'alice', status: 'accepted' }, 'questions/expired-q': { authorUid: 'alice', status: 'expired' }, 'questions/open-q/answers/bob-open': { authorUid: 'bob', authorName: 'Bob', body: '원래', accepted: false, }, 'questions/accepted-q/answers/bob-accepted': { authorUid: 'bob', authorName: 'Bob', body: '원래', accepted: true, }, }); }); test('열린 질문에 서버 시각으로 답변을 단다', async () => { await assertSucceeds(setDoc(doc(dbFor('bob'), 'questions/open-q/answers/a1'), answer())); }); test('질문자도 자기 질문에 답변을 달 수 있다(채택만 서버가 막는다 · E-QNA-01)', async () => { await assertSucceeds( setDoc(doc(dbFor('alice'), 'questions/open-q/answers/a2'), answer({ authorUid: 'alice', authorName: 'Alice' })), ); }); test('채택 · 만료된 질문과 없는 질문에는 답변을 달 수 없다', async () => { for (const q of ['accepted-q', 'expired-q', 'missing-q']) { await assertFails(setDoc(doc(dbFor('bob'), `questions/${q}/answers/a3`), answer())); } }); test('createdAt 위조를 거부한다', async () => { await assertFails( setDoc(doc(dbFor('bob'), 'questions/open-q/answers/a4'), answer({ createdAt: '2020-01-01T00:00:00Z' })), ); }); test('채택 전 답변은 작성자가 고치고, 채택 후에는 못 고친다', async () => { await assertSucceeds(updateDoc(doc(dbFor('bob'), 'questions/open-q/answers/bob-open'), { body: '수정' })); await assertFails(updateDoc(doc(dbFor('bob'), 'questions/accepted-q/answers/bob-accepted'), { body: '바꿔치기' })); }); test('작성자도 accepted 를 스스로 켤 수 없다', async () => { await assertFails(updateDoc(doc(dbFor('bob'), 'questions/open-q/answers/bob-open'), { accepted: true })); }); }); describe('F-14 — posts 허용 필드 회귀', () => { test('작성자도 스키마에 없는 title 필드를 추가할 수 없다', async () => { await seedDocuments(testEnvironment, { 'posts/post-1': { authorUid: 'alice', body: 'before', imageUrls: [], updatedAt: '2026-08-08T00:00:00Z', }, }); await assertFails( updateDoc(doc(dbFor('alice'), 'posts/post-1'), { title: '스키마 밖 필드', }), ); }); }); describe('F-06 — users 클라이언트 생성 경로 회귀', () => { test('일반 사용자의 users 문서 생성을 거부한다', async () => { await assertFails( setDoc( doc(dbFor('alice'), 'users/alice'), userDocument('alice', { totalContributedPop: 999_999 }), ), ); }); test('master 클레임도 users 문서를 직접 생성할 수 없다', async () => { await assertFails( setDoc( doc(dbFor('master-user', { role: 'master' }), 'users/new-user'), userDocument('new-user'), ), ); }); }); describe('F-07 — 결과보고 대상 상태 회귀', () => { test('실패 캠페인 결과보고 생성을 거부한다', async () => { await seedDocuments(testEnvironment, { 'campaigns/failed': { creatorUid: 'alice', status: 'fail' }, }); await assertFails( setDoc(doc(dbFor('alice'), 'campaigns/failed/reports/report-1'), { body: 'report' }), ); }); // 제출은 fn-campaign-report-submit 이 보고 문서 · 상태 `report` · 후원자 알림을 함께 쓴다. // 개설자가 직접 만들면 상태가 success 로 남아 보고가 반쪽이 된다. test('성공 캠페인이어도 클라이언트 직접 생성은 거부한다(서버 경유)', async () => { await seedDocuments(testEnvironment, { 'campaigns/success': { creatorUid: 'alice', status: 'success' }, }); await assertFails( setDoc(doc(dbFor('alice'), 'campaigns/success/reports/report-1'), { body: 'report' }), ); }); test('보고가 게시된 캠페인과 보고는 로그인 없이 읽힌다', async () => { await seedDocuments(testEnvironment, { 'campaigns/reported': { creatorUid: 'alice', status: 'report' }, 'campaigns/reported/reports/main': { body: 'report', photoUrls: [] }, }); await assertSucceeds(getDoc(doc(anonymousDb(), 'campaigns/reported'))); await assertSucceeds(getDoc(doc(anonymousDb(), 'campaigns/reported/reports/main'))); }); }); describe('캠페인 좋아요 — likes/{uid}', () => { test('공개 캠페인에 본인 좋아요만 만들고 지울 수 있다', async () => { await seedDocuments(testEnvironment, { 'campaigns/funding': { creatorUid: 'bob', status: 'funding' }, 'campaigns/draft': { creatorUid: 'bob', status: 'draft' }, }); const mine = doc(dbFor('alice'), 'campaigns/funding/likes/alice'); await assertSucceeds(setDoc(mine, { createdAt: new Date() })); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/funding/likes/bob'), { createdAt: new Date() })); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/draft/likes/alice'), { createdAt: new Date() })); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/funding/likes/alice'), { createdAt: new Date(), count: 99 })); await assertSucceeds(deleteDoc(mine)); }); }); describe('F-08 — 미승인 캠페인 열람 회귀', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'campaigns/review': { creatorUid: 'alice', status: 'review' }, 'campaigns/funding': { creatorUid: 'alice', status: 'funding' }, }); }); test('미인증 사용자의 review 캠페인 열람을 거부한다', async () => { await assertFails(getDoc(doc(anonymousDb(), 'campaigns/review'))); }); test('일반 타 사용자의 review 캠페인 열람을 거부한다', async () => { await assertFails(getDoc(doc(dbFor('bob'), 'campaigns/review'))); }); test('개설자와 ops의 review 캠페인 열람을 허용한다', async () => { await assertSucceeds(getDoc(doc(dbFor('alice'), 'campaigns/review'))); await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'campaigns/review'))); }); test('미인증 사용자의 funding 캠페인 열람은 계속 허용한다', async () => { await assertSucceeds(getDoc(doc(anonymousDb(), 'campaigns/funding'))); }); }); describe('B-58 — 회사 귀책 취소 캠페인은 후원자에게만 열린다', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'campaigns/cancelled': { creatorUid: 'alice', status: 'cancelled' }, 'campaigns/cancelled/backers/bob': { uid: 'bob' }, 'campaigns/cancelled/donations/tx-1': { donorUid: 'bob', amount: 10 }, 'campaigns/withdrawn': { creatorUid: 'alice', status: 'withdrawn' }, 'campaigns/withdrawn/backers/bob': { uid: 'bob' }, }); }); test('후원자(backers 표식)는 cancelled 캠페인을 읽는다', async () => { await assertSucceeds(getDoc(doc(dbFor('bob'), 'campaigns/cancelled'))); }); test('후원하지 않은 사용자와 미인증 사용자는 cancelled 캠페인을 읽지 못한다', async () => { await assertFails(getDoc(doc(dbFor('carol'), 'campaigns/cancelled'))); await assertFails(getDoc(doc(anonymousDb(), 'campaigns/cancelled'))); }); test('개설자와 ops는 cancelled 캠페인을 계속 읽는다', async () => { await assertSucceeds(getDoc(doc(dbFor('alice'), 'campaigns/cancelled'))); await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'campaigns/cancelled'))); }); test('withdrawn 캠페인은 표식이 있어도 비공개다', async () => { await assertFails(getDoc(doc(dbFor('bob'), 'campaigns/withdrawn'))); await assertFails(getDoc(doc(anonymousDb(), 'campaigns/withdrawn'))); }); test('backers 표식은 본인만 읽고 누구도 직접 쓰지 못한다', async () => { await assertSucceeds(getDoc(doc(dbFor('bob'), 'campaigns/cancelled/backers/bob'))); await assertFails(getDoc(doc(dbFor('carol'), 'campaigns/cancelled/backers/bob'))); await assertFails(setDoc(doc(dbFor('carol'), 'campaigns/cancelled/backers/carol'), { uid: 'carol' })); await assertFails( setDoc(doc(dbFor('master-user', { role: 'master' }), 'campaigns/cancelled/backers/carol'), { uid: 'carol' }), ); }); }); describe('원칙 3 — 권한은 커스텀 클레임 role로만 판정', () => { test('users 문서에 role=master를 심어도 관리자 권한을 얻지 못한다', async () => { await seedDocuments(testEnvironment, { 'users/forged-admin': userDocument('forged-admin', { role: 'master' }), 'adminActionLogs/log-1': { action: 'seed' }, 'admins/real-admin': { role: 'master' }, }); const forgedDb = dbFor('forged-admin'); await assertFails(getDoc(doc(forgedDb, 'adminActionLogs/log-1'))); await assertFails(getDoc(doc(forgedDb, 'admins/real-admin'))); await assertFails(setDoc(doc(forgedDb, 'config/pop'), { reward: 999_999 })); }); test('viewer는 관리자 조회 리소스를 읽을 수 있다', async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice'), 'adminActionLogs/log-1': { action: 'seed' }, 'admins/admin-1': { role: 'master' }, 'adRewardLogs/reward-1': { uid: 'alice' }, }); const viewerDb = dbFor('viewer-user', { role: 'viewer' }); await assertSucceeds(getDoc(doc(viewerDb, 'users/alice'))); await assertSucceeds(getDoc(doc(viewerDb, 'adminActionLogs/log-1'))); await assertSucceeds(getDoc(doc(viewerDb, 'admins/admin-1'))); await assertSucceeds(getDoc(doc(viewerDb, 'adRewardLogs/reward-1'))); }); test('viewer의 관리자 쓰기를 거부한다', async () => { const viewerDb = dbFor('viewer-user', { role: 'viewer' }); await assertFails(setDoc(doc(viewerDb, 'notices/notice-1'), { title: 'forged' })); await assertFails(setDoc(doc(viewerDb, 'config/pop'), { reward: 999_999 })); }); const directAdminOperations = [ { name: '사용자 제재', minimumRole: 'ops', seed: { 'users/alice': userDocument('alice') }, run: (db) => updateDoc(doc(db, 'users/alice'), { status: 'suspended', banReason: 'test', updatedAt: '2026-08-08T01:00:00Z', }), }, { name: '아티스트 관리', minimumRole: 'ops', run: (db) => setDoc(doc(db, 'artists/artist-1'), { name: 'Artist' }), }, { name: '장소 관리', minimumRole: 'ops', run: (db) => setDoc(doc(db, 'places/place-1'), { name: 'Place' }), }, { name: '캠페인 승인', minimumRole: 'ops', seed: { 'campaigns/campaign-1': { creatorUid: 'alice', status: 'review' } }, run: (db) => updateDoc(doc(db, 'campaigns/campaign-1'), { status: 'funding', reviewNote: 'approved', reviewedBy: 'admin', updatedAt: '2026-08-08T01:00:00Z', }), }, { name: '콘텐츠 숨김', minimumRole: 'ops', seed: { 'posts/post-1': { authorUid: 'alice', visibility: 'visible' } }, run: (db) => updateDoc(doc(db, 'posts/post-1'), { visibility: 'hidden', moderationNote: 'test', updatedAt: '2026-08-08T01:00:00Z', }), }, { name: '신고 처리', minimumRole: 'ops', seed: { 'reports/report-1': { reporterUid: 'alice', status: 'pending' } }, run: (db) => updateDoc(doc(db, 'reports/report-1'), { status: 'resolved', handledBy: 'admin', handleNote: 'test', updatedAt: '2026-08-08T01:00:00Z', }), }, { name: '문의 처리', minimumRole: 'ops', seed: { 'inquiries/inquiry-1': { authorUid: 'alice', status: 'pending' } }, run: (db) => updateDoc(doc(db, 'inquiries/inquiry-1'), { status: 'answered', answerBody: 'answer', handledBy: 'admin', updatedAt: '2026-08-08T01:00:00Z', }), }, { name: '스테이션 링크 관리', minimumRole: 'ops', run: (db) => setDoc(doc(db, 'stationLinks/link-1'), { url: 'https://example.test' }), }, { name: '제안 링크 심사', minimumRole: 'ops', seed: { 'stationSuggestions/suggestion-1': { authorUid: 'alice', status: 'pending' } }, run: (db) => updateDoc(doc(db, 'stationSuggestions/suggestion-1'), { status: 'approved', reviewNote: 'approved', updatedAt: '2026-08-08T01:00:00Z', }), }, { name: '공지 관리', minimumRole: 'ops', run: (db) => setDoc(doc(db, 'notices/notice-1'), { title: 'Notice' }), }, { name: '재화·FPI 설정', minimumRole: 'master', run: (db) => setDoc(doc(db, 'config/pop'), { reward: 10 }), }, { name: '점검·버전 설정', minimumRole: 'master', run: (db) => setDoc(doc(db, 'appConfig/current'), { maintenance: false }), }, { name: '다국어 문자열 관리', minimumRole: 'master', run: (db) => setDoc(doc(db, 'i18n/ko'), { hello: '안녕' }), }, { name: '행성 성장 단계 관리', minimumRole: 'master', run: (db) => setDoc(doc(db, 'planetStages/1'), { threshold: 100 }), }, { name: '퀘스트 파라미터 관리', minimumRole: 'master', run: (db) => setDoc(doc(db, 'quests/quest-1'), { reward: 10 }), }, { name: '배지 마스터 관리', minimumRole: 'master', run: (db) => setDoc(doc(db, 'badges/badge-1'), { name: 'Badge' }), }, ]; for (const operation of directAdminOperations) { test(`RBAC: viewer는 ${operation.name}을 수행할 수 없다`, async () => { if (operation.seed) await seedDocuments(testEnvironment, operation.seed); await assertFails(operation.run(dbFor('viewer-user', { role: 'viewer' }))); }); test(`RBAC: ops의 ${operation.name} 권한이 매트릭스와 일치한다`, async () => { if (operation.seed) await seedDocuments(testEnvironment, operation.seed); const attempt = operation.run(dbFor('ops-user', { role: 'ops' })); if (operation.minimumRole === 'ops') { await assertSucceeds(attempt); } else { await assertFails(attempt); } }); test(`RBAC: master는 ${operation.name}을 수행할 수 있다`, async () => { if (operation.seed) await seedDocuments(testEnvironment, operation.seed); await assertSucceeds(operation.run(dbFor('master-user', { role: 'master' }))); }); } test('운영 이상만 사전예약·행성개설 신청을 읽을 수 있다', async () => { await seedDocuments(testEnvironment, { 'preRegistrations/pre-1': { socialUid: 'alice' }, 'planetApplications/application-1': { applicantUid: 'alice' }, }); await assertFails(getDoc(doc(dbFor('viewer-user', { role: 'viewer' }), 'preRegistrations/pre-1'))); await assertFails(getDoc(doc(dbFor('viewer-user', { role: 'viewer' }), 'planetApplications/application-1'))); await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'preRegistrations/pre-1'))); await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'planetApplications/application-1'))); }); test.each([ ['POP 지갑', 'users/alice/wallet/main', { balance: 1 }], ['POP 원장', 'users/alice/popTransactions/tx-1', { amount: 1 }], ['FPI 이벤트', 'fandoms/fandom-a/fpiEvents/event-1', { score: 1 }], ['캠페인 후원·환불', 'campaigns/campaign-1/donations/donation-1', { amount: 1 }], ['사전예약', 'preRegistrations/pre-1', { socialUid: 'alice' }], ['행성 개설 신청', 'planetApplications/application-1', { applicantUid: 'alice' }], ['번역 검수 원천', 'translationEntries/entry-1', { body: 'translation' }], // 브리핑은 예전에 ops 직접 편집을 허용했다. 그 길은 `fn-briefing-publish` 를 // 우회해 게시 상태를 바꾸면서 `adminActionLogs` 에 아무것도 남기지 않았고, // 누가 무엇을 승인했는지 사라지면 B-47 승인 게이트가 없는 것과 같다. ['AI 브리핑', 'briefings/briefing-1', { body: 'Briefing' }], ['관리자 계정', 'admins/admin-1', { role: 'master' }], ['관리자 작업 로그', 'adminActionLogs/log-1', { action: 'forged' }], ])('Functions 전용 경계: master도 %s 직접 쓰기가 거부된다', async (_name, path, data) => { await assertFails(setDoc(doc(dbFor('master-user', { role: 'master' }), path), data)); }); }); describe('캠페인 후원·환불과 기본 거부', () => { test('일반 사용자의 캠페인 후원 문서 생성·수정·삭제를 모두 거부한다', async () => { await seedDocuments(testEnvironment, { 'campaigns/campaign-1/donations/donation-1': { donorUid: 'alice', amount: 10 }, }); const donation = doc(dbFor('alice'), 'campaigns/campaign-1/donations/donation-1'); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/campaign-1/donations/new'), { amount: 10 })); await assertFails(updateDoc(donation, { refundState: 'complete' })); await assertFails(deleteDoc(donation)); }); test.each([ ['ordinary', {}], ['master', { role: 'master' }], ])('규칙 밖 경로는 %s 사용자에게도 읽기·쓰기를 기본 거부한다', async (uid, claims) => { await seedDocuments(testEnvironment, { 'unmatchedCollection/secret': { value: 'secret' }, }); const firestore = dbFor(uid, claims); await assertFails(getDoc(doc(firestore, 'unmatchedCollection/secret'))); await assertFails(setDoc(doc(firestore, 'unmatchedCollection/new'), { value: 'forged' })); }); test('미인증 사용자의 규칙 밖 경로 읽기·쓰기를 기본 거부한다', async () => { await seedDocuments(testEnvironment, { 'unmatchedCollection/secret': { value: 'secret' }, }); await assertFails(getDoc(doc(anonymousDb(), 'unmatchedCollection/secret'))); await assertFails(setDoc(doc(anonymousDb(), 'unmatchedCollection/new'), { value: 'forged' })); }); }); describe('MY 서버 연결 — S-M-WALLET · S-X-NOTICE · S-M-NOTICE · S-M-BLOCK · S-M-INQUIRY', () => { test('본인 POP 원장을 최신순으로 읽는다 (S-M-WALLET)', async () => { await seedDocuments(testEnvironment, { 'users/alice/popTransactions/tx-1': { type: 'grant', popType: 'general', amount: 50, reasonCode: 'QUEST_STEPS', balanceAfter: 50, createdAt: new Date('2026-09-01T00:00:00Z'), }, // 차감은 음수다. `spendPop` 이 `amount: -input.amount` 로 쓰고 설계서 §3.4 // 대조식이 `balance == Σ amount` 이므로 시드도 같은 규약을 따른다. 'users/alice/popTransactions/tx-2': { type: 'spend', popType: 'contribution', amount: -500, reasonCode: 'CAMPAIGN_FUND', balanceAfter: 0, createdAt: new Date('2026-09-02T00:00:00Z'), }, }); const rows = await assertSucceeds(getDocs(query( collection(dbFor('alice'), 'users/alice/popTransactions'), orderBy('createdAt', 'desc'), ))); expect(rows.docs.map((d) => d.id)).toEqual(['tx-2', 'tx-1']); }); test('남의 POP 원장은 읽지 못한다', async () => { await seedDocuments(testEnvironment, { 'users/alice/popTransactions/tx-1': { amount: 50 }, }); await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/popTransactions/tx-1'))); }); test('알림함은 본인이 읽고 읽음 두 필드만 고친다 (S-X-NOTICE)', async () => { await seedDocuments(testEnvironment, { 'users/alice/notifications/noti-1': { kind: 'quest', title: '만보기 퀘스트를 완료했습니다', read: false, createdAt: new Date('2026-09-10T00:00:00Z'), }, }); const mine = doc(dbFor('alice'), 'users/alice/notifications/noti-1'); await assertSucceeds(getDoc(mine)); await assertSucceeds(updateDoc(mine, { read: true, readAt: new Date() })); // 발송은 Functions(FCM) 경유라 앱이 만들거나 지울 수 없다. await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/notifications/forged'), { title: 'x' })); await assertFails(deleteDoc(mine)); // 제목을 고쳐 내용을 바꾸는 경로도 닫혀 있다. await assertFails(updateDoc(mine, { title: '바꿔치기' })); await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/notifications/noti-1'))); }); test('공지는 로그인 전에도 읽는다 (S-M-NOTICE)', async () => { await seedDocuments(testEnvironment, { 'notices/notice-1': { active: true, title: '서비스 점검 안내', body: '점검이 있습니다.', publishedAt: new Date('2026-09-01T00:00:00Z'), }, }); // 필드와 정렬은 firestore.indexes.json 의 notices 색인(active + publishedAt)과 같다. await assertSucceeds(getDocs(query( collection(anonymousDb(), 'notices'), where('active', '==', true), orderBy('publishedAt', 'desc'), ))); }); test('차단 목록은 본인이 읽고 추가하고 해제한다 (S-M-BLOCK)', async () => { const target = doc(dbFor('alice'), 'users/alice/blocks/bob'); await assertSucceeds(setDoc(target, { nickname: '스팸계정', createdAt: new Date() })); await assertSucceeds(getDocs(collection(dbFor('alice'), 'users/alice/blocks'))); await assertSucceeds(deleteDoc(target)); // 남의 차단 목록에는 손대지 못한다. await assertFails(setDoc(doc(dbFor('bob'), 'users/alice/blocks/carol'), { nickname: 'x' })); }); test('문의는 접수 상태로만 만들고 본인 것만 열거한다 (S-M-INQUIRY)', async () => { const alice = dbFor('alice'); await assertSucceeds(setDoc(doc(alice, 'inquiries/q-1'), { authorUid: 'alice', title: '결제 문의', body: '지급되지 않았습니다.', status: 'pending', createdAt: new Date('2026-09-10T00:00:00Z'), })); // 처리 상태를 올리는 것은 운영이다. await assertFails(setDoc(doc(alice, 'inquiries/q-2'), { authorUid: 'alice', status: 'answered', })); // 남을 사칭한 접수도 막힌다. await assertFails(setDoc(doc(alice, 'inquiries/q-3'), { authorUid: 'bob', status: 'pending', })); const mine = await assertSucceeds(getDocs(query( collection(alice, 'inquiries'), where('authorUid', '==', 'alice'), orderBy('createdAt', 'desc'), ))); expect(mine.docs.map((d) => d.id)).toEqual(['q-1']); }); test('조건 없는 문의 열거는 질의 자체가 거부된다', async () => { await seedDocuments(testEnvironment, { 'inquiries/q-1': { authorUid: 'alice', status: 'pending' }, 'inquiries/q-2': { authorUid: 'bob', status: 'pending' }, }); // 좁히지 않으면 남의 문의가 섞이므로 규칙이 질의를 통째로 끊는다. await assertFails(getDocs(collection(dbFor('alice'), 'inquiries'))); await assertFails(getDocs(query( collection(dbFor('alice'), 'inquiries'), where('authorUid', '==', 'bob'), ))); }); test('닉네임 길이는 2~20자다 (B-52)', async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice'), }); const mine = doc(dbFor('alice'), 'users/alice'); // 검증 기준 — 20자 성공 · 21자 거부. await assertSucceeds(updateDoc(mine, { nickname: '가'.repeat(20) })); await assertFails(updateDoc(mine, { nickname: '가'.repeat(21) })); await assertFails(updateDoc(mine, { nickname: '가' })); // 닉네임을 건드리지 않는 갱신은 길이 검사에 걸리지 않는다. await assertSucceeds(updateDoc(mine, { locale: 'ja' })); }); test('접수 상태의 본인 문의는 제목·본문만 고칠 수 있다 (S-M-INQUIRY)', async () => { await seedDocuments(testEnvironment, { 'inquiries/q-open': { authorUid: 'alice', title: '결제 문의', body: '지급되지 않았습니다.', status: 'pending', createdAt: new Date('2026-09-10T00:00:00Z'), }, }); const mine = doc(dbFor('alice'), 'inquiries/q-open'); await assertSucceeds(updateDoc(mine, { title: '결제 문의(수정)', body: '다시 적습니다.', updatedAt: new Date(), })); // 스스로 상태를 올릴 수 없다. await assertFails(updateDoc(mine, { title: '제목', status: 'answered' })); // 남의 문의는 고치지 못한다. await assertFails(updateDoc(doc(dbFor('bob'), 'inquiries/q-open'), { title: '남이 고침', })); }); test('답변이 올라간 문의는 작성자가 고치지 못한다', async () => { await seedDocuments(testEnvironment, { 'inquiries/q-done': { authorUid: 'alice', title: '결제 문의', body: '지급되지 않았습니다.', status: 'answered', answerBody: '처리했습니다.', createdAt: new Date('2026-09-10T00:00:00Z'), }, }); await assertFails(updateDoc(doc(dbFor('alice'), 'inquiries/q-done'), { title: '뒤늦게 고침', })); }); test('닉네임과 프로필 사진 URL 은 본인이 고친다 (S-M-SETTINGS)', async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice'), }); const mine = doc(dbFor('alice'), 'users/alice'); await assertSucceeds(updateDoc(mine, { nickname: '아미보이' })); await assertSucceeds(updateDoc(mine, { photoUrl: 'https://example.invalid/photo.png', })); // 서버 산출 필드는 같은 허용 목록에 없다. await assertFails(updateDoc(mine, { totalContributedPop: 999_999 })); await assertFails(updateDoc(doc(dbFor('bob'), 'users/alice'), { nickname: '남의닉' })); }); }); describe('이클립스 라이브 소셜 프루프 — fandoms/{id}/liveEvents', () => { const path = 'fandoms/fandom-a/liveEvents/evt-1'; const event = { kind: 'eclipseDefense', eclipseId: 'ecl-1', uid: 'bob', nickname: '버니즈', pop: 30, }; test('로그인 사용자는 남의 방어 참여도 읽는다', async () => { // 소셜 프루프는 **타 사용자 화면에** 떠야 한다(별첨 1 ① 시나리오 5). // 본인 것만 읽히면 기능 자체가 성립하지 않는다. await seedDocuments(testEnvironment, { [path]: event }); await assertSucceeds(getDoc(doc(dbFor('alice'), path))); }); test('클라이언트 쓰기를 거부한다', async () => { // 여기가 열리면 누구나 「내가 10만 POP 을 방어했다」를 티커에 띄울 수 있다. await assertFails(setDoc(doc(dbFor('alice'), path), event)); }); test('기록된 참여의 수정·삭제를 거부한다', async () => { await seedDocuments(testEnvironment, { [path]: event }); const firestore = dbFor('alice'); await assertFails(updateDoc(doc(firestore, path), { pop: 999_999 })); await assertFails(deleteDoc(doc(firestore, path))); }); test('미인증 사용자의 읽기를 거부한다', async () => { await seedDocuments(testEnvironment, { [path]: event }); await assertFails(getDoc(doc(anonymousDb(), path))); }); }); describe('이클립스 참여자 누적 — participants', () => { const path = 'fandoms/fandom-a/eclipseRounds/ecl-1/participants/bob'; test('로그인 사용자는 참여 누적을 읽는다', async () => { await seedDocuments(testEnvironment, { [path]: { uid: 'bob', validShakes: 30 } }); await assertSucceeds(getDoc(doc(dbFor('alice'), path))); }); test('참여량 직접 쓰기를 거부한다', async () => { // 여기가 열리면 상한·속도 판정을 우회해 방어량을 스스로 올릴 수 있다. await assertFails(setDoc(doc(dbFor('bob'), path), { uid: 'bob', validShakes: 9_999 })); }); test('본인 문서라도 수정을 거부한다', async () => { await seedDocuments(testEnvironment, { [path]: { uid: 'bob', validShakes: 30 } }); await assertFails(updateDoc(doc(dbFor('bob'), path), { validShakes: 9_999 })); }); }); describe('팬덤 라운지 쓰기 — 글 · 좋아요 · 댓글 · 신고 (B-44 · B-45)', () => { // Firestore 자동 ID 모양(20자) — Storage 파일 이름 규칙과 맞춘다. const PID = 'AbCdEfGhIjKlMnOpQrSt'; const img = (uid, id, i) => `users/${uid}/lounge/${id}-${i}`; const post = (overrides = {}) => ({ fandomId: 'fandom-a', authorUid: 'alice', authorName: '앨리스', body: 'hello', imageUrls: [], likeCount: 0, commentCount: 0, visibility: 'visible', createdAt: serverTimestamp(), updatedAt: serverTimestamp(), ...overrides, }); const comment = (uid, name, overrides = {}) => ({ authorUid: uid, authorName: name, body: '댓글', visibility: 'visible', createdAt: serverTimestamp(), updatedAt: serverTimestamp(), ...overrides, }); const report = (overrides = {}) => ({ reporterUid: 'bob', targetType: 'post', targetPath: 'posts/post-1', reasonCode: 'inappropriate', status: 'pending', createdAt: serverTimestamp(), ...overrides, }); beforeEach(async () => { await seedDocuments(testEnvironment, { // displayName 은 공급자 실명 자리 — 라운지 이름으로 쓰면 안 된다(M-7). 'users/alice': userDocument('alice', { displayName: '김실명', nickname: '앨리스' }), 'users/bob': userDocument('bob', { nickname: '밥' }), 'users/noname': userDocument('noname', { displayName: '박실명' }), 'users/outsider': userDocument('outsider', { fandomId: 'fandom-b', nickname: '외부' }), // 대표 팬덤은 다르지만 멤버십 문서가 있는 회원(복수 가입 · C-15). 'users/multi': userDocument('multi', { fandomId: 'fandom-b', nickname: '겸덕' }), 'fandoms/fandom-a/members/multi': { uid: 'multi', fandomId: 'fandom-a' }, 'posts/post-1': post({ createdAt: '2026-09-28', updatedAt: '2026-09-28' }), 'posts/hidden': post({ visibility: 'hidden', createdAt: '2026-09-28', updatedAt: '2026-09-28' }), }); }); test('작성자 이름은 공개 닉네임만 — 실명 · 사칭 거부, 닉네임 없으면 「팬」', async () => { await assertSucceeds(setDoc(doc(dbFor('alice'), 'posts/ok'), post())); await assertFails(setDoc(doc(dbFor('alice'), 'posts/real'), post({ authorName: '김실명' }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/ops'), post({ authorName: 'FANUZU 운영팀' }))); await assertSucceeds( setDoc(doc(dbFor('noname'), 'posts/fan'), post({ authorUid: 'noname', authorName: '팬' })), ); await assertFails( setDoc(doc(dbFor('noname'), 'posts/fan2'), post({ authorUid: 'noname', authorName: '박실명' })), ); await assertFails( setDoc(doc(dbFor('alice'), 'posts/post-1/comments/real'), comment('alice', '김실명')), ); }); test('사진은 자기 경로 · 이 글 · 칸 번호 그대로 5장까지만', async () => { const five = [0, 1, 2, 3, 4].map((i) => img('alice', PID, i)); await assertSucceeds(setDoc(doc(dbFor('alice'), `posts/${PID}`), post({ imageUrls: five }))); await assertFails( setDoc(doc(dbFor('alice'), 'posts/BbCdEfGhIjKlMnOpQrSt'), post({ imageUrls: [...five.map((p) => p.replace(PID, 'BbCdEfGhIjKlMnOpQrSt')), 'x'], })), ); // 외부 URL · 남의 파일 · 다른 글의 사진 · 칸 번호 어긋남 await assertFails(setDoc(doc(dbFor('alice'), 'posts/u1'), post({ imageUrls: ['https://evil/x.png'] }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/u2'), post({ imageUrls: [img('bob', 'u2', 0)] }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/u3'), post({ imageUrls: [img('alice', PID, 0)] }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/u4'), post({ imageUrls: [img('alice', 'u4', 1)] }))); // 작성 후 사진 교체는 닫는다. await assertFails( updateDoc(doc(dbFor('alice'), 'posts/post-1'), { imageUrls: ['https://evil/x.png'], updatedAt: serverTimestamp(), }), ); }); test('빈 본문 · 5,000자 초과 · 수 위조 · 시각 위조 · 다른 팬덤 글은 거부한다', async () => { await assertSucceeds(setDoc(doc(dbFor('alice'), 'posts/max'), post({ body: '가'.repeat(5000) }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/long'), post({ body: '가'.repeat(5001) }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/empty'), post({ body: '' }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/forged'), post({ likeCount: 99 }))); await assertFails(setDoc(doc(dbFor('alice'), 'posts/future'), post({ createdAt: new Date('2099-01-01') }))); await assertFails( setDoc(doc(dbFor('outsider'), 'posts/other'), post({ authorUid: 'outsider', authorName: '외부' })), ); }); test('팬덤 회원 판정 — 멤버십 문서 또는 대표 팬덤', async () => { // 멤버십 문서만 있는 회원(대표 팬덤은 fandom-b) await assertSucceeds( setDoc(doc(dbFor('multi'), 'posts/m1'), post({ authorUid: 'multi', authorName: '겸덕' })), ); await assertSucceeds(setDoc(doc(dbFor('multi'), 'posts/post-1/likes/multi'), { createdAt: serverTimestamp() })); await assertSucceeds( setDoc(doc(dbFor('multi'), 'posts/post-1/comments/m'), comment('multi', '겸덕')), ); // 대표 팬덤만 같은 회원(멤버십 문서 없음) — 이전 판정도 그대로 통과 await assertSucceeds(setDoc(doc(dbFor('bob'), 'posts/post-1/likes/bob'), { createdAt: serverTimestamp() })); // 둘 다 아닌 사용자 await assertFails(setDoc(doc(dbFor('outsider'), 'posts/post-1/likes/outsider'), { createdAt: serverTimestamp() })); }); test('글 수정 · 삭제는 작성자만, 수는 작성자도 못 고친다', async () => { const mine = doc(dbFor('alice'), 'posts/post-1'); await assertSucceeds(updateDoc(mine, { body: 'edited', updatedAt: serverTimestamp() })); await assertFails(updateDoc(mine, { body: 'edited2', updatedAt: '2026-01-01' })); await assertFails(updateDoc(mine, { body: '가'.repeat(5001), updatedAt: serverTimestamp() })); await assertFails(updateDoc(mine, { likeCount: 50 })); await assertFails(updateDoc(mine, { commentCount: 50 })); await assertFails(updateDoc(doc(dbFor('bob'), 'posts/post-1'), { body: 'hijack', updatedAt: serverTimestamp() })); await assertFails(deleteDoc(doc(dbFor('bob'), 'posts/post-1'))); await assertSucceeds(deleteDoc(mine)); }); test('좋아요는 팬덤 회원이 본인 문서로만, 보이는 글에만 누른다', async () => { const mine = doc(dbFor('bob'), 'posts/post-1/likes/bob'); await assertSucceeds(setDoc(mine, { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('bob'), 'posts/post-1/likes/alice'), { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('bob'), 'posts/hidden/likes/bob'), { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('alice'), 'posts/post-1/likes/alice'), { createdAt: new Date() })); await assertFails(setDoc(doc(dbFor('alice'), 'posts/post-1/likes/alice'), { createdAt: serverTimestamp(), n: 1 })); await assertSucceeds(deleteDoc(mine)); }); test('댓글은 팬덤 회원이 보이는 글에만 1,000자까지 쓰고 본인 것만 지운다', async () => { await assertSucceeds(setDoc(doc(dbFor('bob'), 'posts/post-1/comments/c1'), comment('bob', '밥'))); await assertSucceeds( setDoc(doc(dbFor('bob'), 'posts/post-1/comments/cmax'), comment('bob', '밥', { body: '가'.repeat(1000) })), ); await assertFails( setDoc(doc(dbFor('bob'), 'posts/post-1/comments/clong'), comment('bob', '밥', { body: '가'.repeat(1001) })), ); await assertFails(setDoc(doc(dbFor('outsider'), 'posts/post-1/comments/c2'), comment('outsider', '외부'))); await assertFails(setDoc(doc(dbFor('bob'), 'posts/hidden/comments/c3'), comment('bob', '밥'))); await assertFails(setDoc(doc(dbFor('bob'), 'posts/post-1/comments/c4'), comment('bob', '밥', { body: '' }))); await assertFails(deleteDoc(doc(dbFor('alice'), 'posts/post-1/comments/c1'))); await assertSucceeds(deleteDoc(doc(dbFor('bob'), 'posts/post-1/comments/c1'))); }); test('신고는 post-{글}-{신고자} ID 로만, 스키마대로 한 번만 접수된다', async () => { const ref = doc(dbFor('bob'), 'reports/post-post-1-bob'); await assertSucceeds(setDoc(ref, report())); // 같은 신고자의 재접수는 덮어쓰기라 거부 — 신고자 수가 부풀지 않는다. await assertFails(setDoc(ref, report())); // 임의 ID · 남의 UID 를 붙인 ID · 다른 글 ID await assertFails(setDoc(doc(dbFor('bob'), 'reports/anything'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-post-1-bob2'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-post-1-alice'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-other-bob'), report())); // 대상 경로 모양 · 사유 코드 · 상태 · 필드 · 시각 await assertFails( setDoc(doc(dbFor('bob'), 'reports/post-post-1-bob'), report({ targetPath: 'posts/post-1/comments/x' })), ); await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', reasonCode: '아무거나' }))); await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', status: 'handled' }))); await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', handledBy: 'carol' }))); await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', createdAt: new Date() }))); await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'bob' }))); await assertSucceeds(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol' }))); // 접수 목록은 운영만 본다. await assertFails(getDocs(collection(dbFor('bob'), 'reports'))); }); test('운영 사칭 닉네임은 대소문자와 무관하게 거부한다', async () => { const mine = doc(dbFor('bob'), 'users/bob'); for (const n of ['FANUZU팬', 'fanuzu', '운영팀장', '관리자', 'Admin99', 'xOFFICIALx']) { await assertFails(updateDoc(mine, { nickname: n })); } await assertSucceeds(updateDoc(mine, { nickname: '평범한팬' })); }); test('초대 코드는 서버만 쓴다 — inviteCodes 생성 · users.inviteCode 변경 거부, 코드 직접 조회는 허용', async () => { await assertFails(setDoc(doc(dbFor('bob'), 'inviteCodes/ABCDEFGH23'), { uid: 'bob' })); await assertFails(updateDoc(doc(dbFor('bob'), 'users/bob'), { inviteCode: 'ABCDEFGH23' })); await assertSucceeds(getDoc(doc(dbFor('bob'), 'inviteCodes/ABCDEFGH23'))); // 코드 주인(UID)은 서버 전용. await assertFails(getDoc(doc(dbFor('bob'), 'inviteCodeOwners/ABCDEFGH23'))); await assertFails(setDoc(doc(dbFor('bob'), 'inviteCodeOwners/ABCDEFGH23'), { uid: 'bob' })); await assertFails(getDoc(doc(dbFor('bob'), 'referralsByUser/bob'))); }); test('닉네임 「팬」(앞뒤 공백 무시)은 이름 없는 작성자 표기라 거부한다', async () => { const mine = doc(dbFor('bob'), 'users/bob'); for (const n of ['팬', ' 팬 ', ' 팬', '\u3000팬', '팬\u200B', 'Fan', ' FAN', 'ファン', '粉丝', 'ad\u200Dmin', 'FANU\uFEFFZU']) { await assertFails(updateDoc(mine, { nickname: n })); } await assertSucceeds(updateDoc(mine, { nickname: '팬팬' })); await assertSucceeds(updateDoc(mine, { nickname: 'Fans' })); }); }); describe('캠페인 응원 게시판 댓글 — campaigns/{id}/comments', () => { const comment = (uid, name, overrides = {}) => ({ authorUid: uid, authorName: name, body: '응원해요', visibility: 'visible', createdAt: serverTimestamp(), updatedAt: serverTimestamp(), ...overrides, }); const report = (overrides = {}) => ({ reporterUid: 'bob', targetType: 'campaignComment', targetPath: 'campaigns/open/comments/c1', reasonCode: 'spam', status: 'pending', createdAt: serverTimestamp(), ...overrides, }); beforeEach(async () => { await seedDocuments(testEnvironment, { 'users/alice': userDocument('alice', { displayName: '김실명', nickname: '앨리스' }), 'users/bob': userDocument('bob', { fandomId: 'fandom-b', nickname: '밥' }), 'users/noname': userDocument('noname', { displayName: '박실명' }), 'campaigns/open': { creatorUid: 'alice', status: 'funding', fandomId: 'fandom-a', commentCount: 0 }, 'campaigns/draft': { creatorUid: 'alice', status: 'draft', commentCount: 0 }, 'campaigns/review': { creatorUid: 'alice', status: 'review' }, 'campaigns/open/comments/c1': comment('alice', '앨리스', { createdAt: '2026-09-28', updatedAt: '2026-09-28' }), 'campaigns/open/comments/hid': comment('alice', '앨리스', { visibility: 'hidden', createdAt: '2026-09-28', updatedAt: '2026-09-28' }), 'campaigns/draft/comments/d0': comment('alice', '앨리스', { createdAt: '2026-09-28', updatedAt: '2026-09-28' }), }); }); test('작성 자격 config/policy.campaignCommentScope — all · members · backers', async () => { const scope = (v) => seedDocuments(testEnvironment, { 'config/policy': v === undefined ? {} : { campaignCommentScope: v } }); const write = (uid, name, id) => setDoc(doc(dbFor(uid), `campaigns/open/comments/${id}`), comment(uid, name)); // 필드가 없으면 'all' — 다른 팬덤(bob · fandom-b)도 쓴다. await scope(undefined); await assertSucceeds(write('bob', '밥', 's0')); await scope('all'); await assertSucceeds(write('bob', '밥', 's1')); // members — 캠페인 팬덤(fandom-a) 회원만. 대표 팬덤 또는 멤버십 문서. await scope('members'); await assertFails(write('bob', '밥', 's2')); await assertSucceeds(write('alice', '앨리스', 's3')); await seedDocuments(testEnvironment, { 'fandoms/fandom-a/members/bob': { uid: 'bob', fandomId: 'fandom-a' } }); await assertSucceeds(write('bob', '밥', 's4')); // backers — 후원자 표식이 있는 사람만. 팬덤 회원이어도 후원 전이면 거부. await scope('backers'); await assertFails(write('alice', '앨리스', 's5')); await seedDocuments(testEnvironment, { 'campaigns/open/backers/alice': { uid: 'alice' } }); await assertSucceeds(write('alice', '앨리스', 's6')); // 모르는 값은 닫는다. await scope('everyone'); await assertFails(write('alice', '앨리스', 's7')); }); test('댓글 좋아요는 본인 문서로 한 번, 보이는 댓글 · 공개 캠페인에만', async () => { const mine = doc(dbFor('bob'), 'campaigns/open/comments/c1/likes/bob'); await assertSucceeds(setDoc(mine, { createdAt: serverTimestamp() })); await assertFails(setDoc(mine, { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/c1/likes/alice'), { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/hid/likes/bob'), { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/draft/comments/d0/likes/bob'), { createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1/likes/alice'), { createdAt: new Date() })); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1/likes/alice'), { createdAt: serverTimestamp(), n: 1 })); // 댓글의 좋아요 수는 작성자도 못 고친다. await assertFails(updateDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1'), { likeCount: 9 })); await assertSucceeds(deleteDoc(mine)); }); test('캠페인 신고는 campaign-{캠페인}-{신고자} ID 로만 한 번 접수된다', async () => { const campaignReport = (o = {}) => report({ targetType: 'campaign', targetPath: 'campaigns/open', ...o }); const ref = doc(dbFor('bob'), 'reports/campaign-open-bob'); await assertSucceeds(setDoc(ref, campaignReport())); await assertFails(setDoc(ref, campaignReport())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaign-open-alice'), campaignReport())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaign-other-bob'), campaignReport())); await assertFails( setDoc(doc(dbFor('bob'), 'reports/campaign-c1-bob'), campaignReport({ targetPath: 'campaigns/open/comments/c1' })), ); }); test('공개 닉네임이 없으면 「팬」으로도 쓸 수 없다 — 빈 닉네임 · 이메일 형태', async () => { await assertFails(setDoc(doc(dbFor('noname'), 'campaigns/open/comments/n1'), comment('noname', '팬'))); await seedDocuments(testEnvironment, { 'users/mail': userDocument('mail', { nickname: 'a@b.com' }), 'users/blank': userDocument('blank', { nickname: ' ' }), }); await assertFails(setDoc(doc(dbFor('mail'), 'campaigns/open/comments/n2'), comment('mail', '팬'))); await assertFails(setDoc(doc(dbFor('blank'), 'campaigns/open/comments/n3'), comment('blank', '팬'))); // 라운지와 달리 대체 이름 경로가 없다는 것만 다르다 — 닉네임이 생기면 바로 쓴다. await seedDocuments(testEnvironment, { 'users/noname': userDocument('noname', { nickname: '새이름' }) }); await assertSucceeds(setDoc(doc(dbFor('noname'), 'campaigns/open/comments/n4'), comment('noname', '새이름'))); }); test('로그인 회원은 공개 캠페인에 공개 닉네임으로 1,000자까지 쓴다(팬덤 무관)', async () => { await assertSucceeds(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/b1'), comment('bob', '밥'))); await assertSucceeds( setDoc(doc(dbFor('bob'), 'campaigns/open/comments/bmax'), comment('bob', '밥', { body: '가'.repeat(1000) })), ); await assertFails( setDoc(doc(dbFor('bob'), 'campaigns/open/comments/blong'), comment('bob', '밥', { body: '가'.repeat(1001) })), ); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/b2'), comment('bob', '밥', { body: '' }))); await assertFails(setDoc(doc(anonymousDb(), 'campaigns/open/comments/a1'), comment('anon', '팬'))); }); test('실명 · 사칭 · 남의 UID · 시각 위조 · 여분 필드 · 숨김 상태는 거부한다', async () => { await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/open/comments/x1'), comment('alice', '김실명'))); await assertFails(setDoc(doc(dbFor('noname'), 'campaigns/open/comments/x2'), comment('noname', '박실명'))); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x3'), comment('bob', 'FANUZU 운영팀'))); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x4'), comment('alice', '앨리스'))); await assertFails( setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x5'), comment('bob', '밥', { createdAt: new Date('2099-01-01') })), ); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x6'), comment('bob', '밥', { likeCount: 9 }))); await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x7'), comment('bob', '밥', { visibility: 'hidden' }))); }); test('공개 전 캠페인에는 달 수 없다', async () => { await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/draft/comments/d1'), comment('bob', '밥'))); await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/review/comments/r1'), comment('alice', '앨리스'))); }); test('본인 댓글만 지우고, 본문 수정 · 댓글 수 위조는 막는다', async () => { await assertFails(deleteDoc(doc(dbFor('bob'), 'campaigns/open/comments/c1'))); await assertFails( updateDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1'), { body: 'x', updatedAt: serverTimestamp() }), ); await assertFails(updateDoc(doc(dbFor('alice'), 'campaigns/draft'), { commentCount: 50 })); await assertFails(updateDoc(doc(dbFor('alice'), 'campaigns/open'), { commentCount: 50 })); await assertSucceeds(deleteDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1'))); }); test('신고는 campaignComment-{캠페인}-{댓글}-{신고자} ID 로만 한 번 접수된다', async () => { const ref = doc(dbFor('bob'), 'reports/campaignComment-open-c1-bob'); await assertSucceeds(setDoc(ref, report())); await assertFails(setDoc(ref, report())); // 캠페인이 빠진 옛 모양 · 남의 UID · 다른 댓글 · 다른 캠페인 await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-c1-bob'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-open-c1-alice'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-open-c2-bob'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-other-c1-bob'), report())); await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-c1-bob'), report())); // 대상 종류와 경로 모양이 어긋나면 거부 await assertFails( setDoc(doc(dbFor('carol'), 'reports/campaignComment-open-carol'), report({ reporterUid: 'carol', targetPath: 'posts/open' })), ); await assertFails( setDoc(doc(dbFor('carol'), 'reports/post-open-carol'), report({ reporterUid: 'carol', targetPath: 'campaigns/open' })), ); await assertFails( setDoc(doc(dbFor('carol'), 'reports/campaignComment-open-c1-carol'), report({ reporterUid: 'carol', reasonCode: '아무거나' })), ); await assertSucceeds( setDoc(doc(dbFor('carol'), 'reports/campaignComment-open-c1-carol'), report({ reporterUid: 'carol' })), ); }); test('숨김 댓글은 운영만 읽고, 작성자도 지우지 못한다', async () => { await assertFails(getDoc(doc(dbFor('bob'), 'campaigns/open/comments/hid'))); await assertFails(getDoc(doc(dbFor('alice'), 'campaigns/open/comments/hid'))); await assertSucceeds(getDoc(doc(dbFor('ops', { role: 'ops' }), 'campaigns/open/comments/hid'))); await assertSucceeds(getDoc(doc(dbFor('bob'), 'campaigns/open/comments/c1'))); // 목록은 보이는 댓글로 좁혀야 통과한다. await assertFails(getDocs(collection(dbFor('bob'), 'campaigns/open/comments'))); await assertSucceeds( getDocs(query(collection(dbFor('bob'), 'campaigns/open/comments'), where('visibility', '==', 'visible'))), ); await assertFails(deleteDoc(doc(dbFor('alice'), 'campaigns/open/comments/hid'))); }); test('좋아요도 댓글과 같은 작성 자격(campaignCommentScope)을 따른다', async () => { const like = (uid) => setDoc(doc(dbFor(uid), `campaigns/open/comments/c1/likes/${uid}`), { createdAt: serverTimestamp() }); await seedDocuments(testEnvironment, { 'config/policy': { campaignCommentScope: 'members' } }); await assertFails(like('bob')); // fandom-b — 캠페인 팬덤 아님 await assertSucceeds(like('alice')); // fandom-a await seedDocuments(testEnvironment, { 'config/policy': { campaignCommentScope: 'backers' } }); await assertFails(like('noname')); await seedDocuments(testEnvironment, { 'campaigns/open/backers/noname': { uid: 'noname' } }); await assertSucceeds(like('noname')); await seedDocuments(testEnvironment, { 'config/policy': { campaignCommentScope: 'everyone' } }); await assertFails(like('bob')); }); }); describe('참여형 번역 — translationEntries · markings · translationDaily (FR-I18N-02)', () => { const entry = 'translationEntries/entry-1'; test('번역은 로그인 사용자가, 마킹은 번역 작성자 · 마킹한 본인만 읽는다', async () => { await seedDocuments(testEnvironment, { [entry]: { authorUid: 'alice', status: 'pending', body: 'Hello' }, [`${entry}/markings/bob`]: { valid: true }, }); await assertSucceeds(getDoc(doc(dbFor('carol'), entry))); await assertFails(getDoc(doc(anonymousDb(), entry))); await assertSucceeds(getDoc(doc(dbFor('bob'), `${entry}/markings/bob`))); await assertSucceeds(getDoc(doc(dbFor('alice'), `${entry}/markings/bob`))); await assertSucceeds(getDocs(collection(dbFor('alice'), `${entry}/markings`))); await assertFails(getDoc(doc(dbFor('carol'), `${entry}/markings/bob`))); await assertFails(getDocs(collection(dbFor('carol'), `${entry}/markings`))); }); test('마킹 직접 쓰기는 누구도 못 한다(fn-translation-mark 경유)', async () => { await seedDocuments(testEnvironment, { [entry]: { authorUid: 'alice', status: 'pending' } }); await assertFails(setDoc(doc(dbFor('bob'), `${entry}/markings/bob`), { valid: true, createdAt: serverTimestamp() })); await assertFails(setDoc(doc(dbFor('alice'), `${entry}/markings/alice`), { valid: true, createdAt: serverTimestamp() })); }); test('마킹은 번복할 수 없다', async () => { await seedDocuments(testEnvironment, { [entry]: { authorUid: 'alice', status: 'pending' }, [`${entry}/markings/bob`]: { valid: true }, }); await assertFails(updateDoc(doc(dbFor('bob'), `${entry}/markings/bob`), { valid: false })); await assertFails(deleteDoc(doc(dbFor('bob'), `${entry}/markings/bob`))); }); test('번역 등록 · 일일 카운터는 클라이언트가 읽거나 쓸 수 없다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'translationEntries/forged'), { authorUid: 'alice', status: 'pending', rewardPop: 5000, }), ); await seedDocuments(testEnvironment, { 'translationDaily/alice_2026-09-28': { uid: 'alice', count: 1 } }); await assertFails(getDoc(doc(dbFor('alice'), 'translationDaily/alice_2026-09-28'))); await assertFails(setDoc(doc(dbFor('alice'), 'translationDaily/alice_2026-09-28'), { count: 0 })); }); }); describe('결제 기록 — MY/설정/서비스 결제 내역 · 환불 요청 (B-02 · B-72)', () => { const path = 'users/alice/purchases/android_gpa.1'; const refund = (extra = {}) => ({ state: 'refund-requested', refundReason: '실수로 결제했습니다', refundRequestedAt: serverTimestamp(), ...extra, }); beforeEach(async () => { await seedDocuments(testEnvironment, { [path]: { uid: 'alice', state: 'granted', amountPop: 100, productId: 'pop.support.1000' }, 'heroGiftProducts/gift-1': { name: '베이직 드랍', buyerPop: 100, dropPop: 500, active: true }, }); }); test('본인 결제 기록을 읽고 타인 기록은 읽지 못한다', async () => { await assertSucceeds(getDoc(doc(dbFor('alice'), path))); await assertFails(getDoc(doc(dbFor('bob'), path))); }); test('본인은 지급 완료 건을 환불 요청으로만 바꿀 수 있다', async () => { await assertSucceeds(updateDoc(doc(dbFor('alice'), path), refund())); }); test('환불 요청과 함께 금액·다른 상태를 바꾸지 못한다', async () => { await assertFails(updateDoc(doc(dbFor('alice'), path), refund({ amountPop: 999 }))); await assertFails(updateDoc(doc(dbFor('alice'), path), refund({ state: 'refunded' }))); await assertFails(updateDoc(doc(dbFor('bob'), path), refund())); }); test('이미 요청한 건은 다시 바꾸지 못하고 결제 기록을 만들 수 없다', async () => { await assertSucceeds(updateDoc(doc(dbFor('alice'), path), refund())); await assertFails(updateDoc(doc(dbFor('alice'), path), refund({ refundReason: '기타' }))); await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/purchases/forged'), { state: 'granted' })); }); test('Hero Gift Catalog 는 로그인 사용자가 읽고 누구도 앱에서 쓰지 못한다', async () => { await assertSucceeds(getDoc(doc(dbFor('alice'), 'heroGiftProducts/gift-1'))); await assertFails(getDoc(doc(anonymousDb(), 'heroGiftProducts/gift-1'))); await assertFails(updateDoc(doc(dbFor('alice'), 'heroGiftProducts/gift-1'), { buyerPop: 99999 })); }); }); describe('사용자 생성 Quest — 제출 · 검토 · 수정 이력 (C-07 · C-08 · G-01)', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { 'quests/q-ugc': { authorUid: 'creator', title: '투표', rewardPop: 5, status: 'published' }, 'quests/q-ugc/edits/e1': { editorUid: 'creator', field: 'title', before: 'a', after: 'b' }, 'questSubmissions/q-ugc_alice': { questId: 'q-ugc', uid: 'alice', questAuthorUid: 'creator', status: 'submitted', rewardPop: 5, }, }); }); test('제출자 · 생성자 · 운영자만 제출 건을 읽는다', async () => { const path = 'questSubmissions/q-ugc_alice'; await assertSucceeds(getDoc(doc(dbFor('alice'), path))); await assertSucceeds(getDoc(doc(dbFor('creator'), path))); await assertSucceeds(getDoc(doc(dbFor('op', { role: 'ops' }), path))); await assertFails(getDoc(doc(dbFor('bob'), path))); await assertSucceeds(getDocs(query(collection(dbFor('creator'), 'questSubmissions'), where('questId', '==', 'q-ugc'), where('questAuthorUid', '==', 'creator')))); }); test('아직 없는 자기 제출 건도 문서 ID 로 조회한다 — 목록이 UGC 퀘스트 하나로 막히지 않게', async () => { // 2026-10-04 실기기 — 없는 문서 get 이 permission-denied 로 떨어져 퀘스트 목록 전체가 실패했다. await assertSucceeds(getDoc(doc(dbFor('bob'), 'questSubmissions/q-ugc_bob'))); // 남의 제출 건 ID 로는 여전히 못 읽는다(있든 없든). await assertFails(getDoc(doc(dbFor('bob'), 'questSubmissions/q-ugc_alice'))); await assertFails(getDoc(doc(dbFor('bob'), 'questSubmissions/q-new_alice'))); // 접미만 같은 ID(`_bob` 없이 `bob`) 는 본인 것이 아니다. await assertFails(getDoc(doc(dbFor('bob'), 'questSubmissions/q-ugcbob'))); await assertFails(getDoc(doc(anonymousDb(), 'questSubmissions/q-ugc_bob'))); }); test('제출 건 상태 · 예약은 앱이 쓰지 못한다 — 자기 승인 · 위조 제출 거부', async () => { await assertFails(updateDoc(doc(dbFor('alice'), 'questSubmissions/q-ugc_alice'), { status: 'paid' })); await assertFails(updateDoc(doc(dbFor('creator'), 'questSubmissions/q-ugc_alice'), { status: 'approved' })); await assertFails(setDoc(doc(dbFor('bob'), 'questSubmissions/q-ugc_bob'), { questId: 'q-ugc', uid: 'bob', questAuthorUid: 'creator', status: 'paid', })); }); test('글 사용 기록 · 미리보기 호출 상한 문서는 서버 전용', async () => { await assertFails(setDoc(doc(dbFor('alice'), 'questPostUses/p1'), { questId: 'q-ugc' })); await assertFails(getDoc(doc(dbFor('alice'), 'questPostUses/p1'))); await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/rateLimits/linkPreview'), { count: 0 })); }); test('공개 후 잠금 필드 · 수정 이력은 생성자도 직접 못 쓴다', async () => { await assertFails(updateDoc(doc(dbFor('creator'), 'quests/q-ugc'), { rewardPop: 500 })); await assertSucceeds(getDoc(doc(dbFor('bob'), 'quests/q-ugc/edits/e1'))); await assertFails(setDoc(doc(dbFor('creator'), 'quests/q-ugc/edits/e2'), { field: 'rewardPop' })); await assertFails(deleteDoc(doc(dbFor('creator'), 'quests/q-ugc/edits/e1'))); }); }); describe('테스트 자가 점검', () => { test('규칙이 적재된 프로젝트 ID가 demo- 접두사를 사용한다', () => { expect(testEnvironment.projectId).toMatch(/^demo-/); }); });