1753 lines
76 KiB
JavaScript
1753 lines
76 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}/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-/);
|
|
});
|
|
});
|