Files
fanuzu/app/firebase/test/tests/firestore.rules.test.mjs
T

1808 lines
79 KiB
JavaScript

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('위치 Quest QR 코드 — questSecrets/{questId} (C-03)', () => {
test('생성자만 읽고 참여자는 읽지 못하며 누구도 쓰지 못한다', async () => {
await seedDocuments(testEnvironment, {
'questSecrets/quest-1': { authorUid: 'alice', qrCode: 'secret' },
});
await assertSucceeds(getDoc(doc(dbFor('alice'), 'questSecrets/quest-1')));
await assertFails(getDoc(doc(dbFor('bob'), 'questSecrets/quest-1')));
await assertFails(setDoc(doc(dbFor('alice'), 'questSecrets/quest-2'), { authorUid: 'alice', qrCode: 'x' }));
});
});
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-/);
});
});
describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540:19592)', () => {
test('본인 기록은 읽고, 남의 기록은 못 읽고, 아무도 쓰지 못한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/nebulaCreates/2026-10-06': { fandomId: 'f1' },
});
await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-06')));
await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-07')));
await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/nebulaCreates/2026-10-06')));
await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-08'), { fandomId: 'f2' }));
});
});
describe('Quest 진행 세션 — 일시 정지 · 진행완료(원장 C-83 · fn-quest-progress 전용)', () => {
test('본인도 진행 세션을 직접 쓰거나 지울 수 없다 — 함수로만', async () => {
await seedDocuments(testEnvironment, {
'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } },
});
const db = dbFor('alice');
await assertFails(setDoc(doc(db, 'users/alice/questProgress/shake'), { session: { state: 'completed' } }, { merge: true }));
await assertFails(setDoc(doc(db, 'users/alice/questProgress/new'), { session: { state: 'completed', judgement: { perfect: 60 } } }));
await assertFails(deleteDoc(doc(db, 'users/alice/questProgress/shake')));
});
test('본인은 일시 정지 Quest 를 조회할 수 있고(탭 위 캡슐) 다른 사용자는 못 본다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/questProgress/shake': { session: { state: 'paused', progress: 43, total: 60 } },
});
const paused = (db) => getDocs(query(collection(db, 'users/alice/questProgress'), where('session.state', '==', 'paused')));
await assertSucceeds(paused(dbFor('alice')));
await assertFails(paused(dbFor('bob')));
});
});