Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스

This commit is contained in:
Sn-Kinos
2026-08-10 01:02:03 +09:00
parent fbba4405c3
commit 233ef99837
113 changed files with 8549 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
coverage/
.firebase/
+28
View File
@@ -0,0 +1,28 @@
# Firestore 보안 규칙 회귀 테스트
Firebase 실제 프로젝트 없이 로컬 Firestore 에뮬레이터에서 `../firestore.rules`의 allow/deny 동작을 검증합니다.
## 실행
```bash
cd app/firebase/test
npm ci
npm run test:emulator
```
`test:emulator`는 다음 순서로 실행됩니다.
1. Firebase CLI가 `demo-fancake` 프로젝트 ID로 Firestore 에뮬레이터를 기동합니다.
2. `initializeTestEnvironment()`가 `../firestore.rules` 원문을 에뮬레이터 규칙 REST API로 적재합니다.
3. 음성 대조군이 로그인 사용자의 지갑 쓰기가 `permission-denied`인지 먼저 확인합니다.
4. 소유자 지갑 읽기 양성 대조군을 확인한 뒤 Vitest 회귀 테스트 전체를 실행합니다.
Firebase CLI의 `emulators:exec` 기동 성공만으로 규칙 적재·컴파일 성공을 판단하지 않습니다. 규칙 적재 실패 시 2단계에서 실행이 중단되고, 음성 대조군이 허용되면 3단계에서 실행이 중단됩니다.
## 복합 인덱스 검증 한계 실측
```bash
npm run probe:indexes:emulator
```
이 프로브는 `../firestore.indexes.json`에 없는 4필드 `posts` 복합 인덱스를 먼저 확인한 뒤, 그 인덱스가 필요한 유효 쿼리를 에뮬레이터에 보냅니다. 쿼리가 허용되면 에뮬레이터가 복합 인덱스를 강제하지 않는다는 뜻이므로, 인덱스 배포 유효성 검증에는 사용할 수 없습니다.
+2294
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "fancake-firestore-rules-tests",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"test": "npm run test:control && npm run test:regression",
"test:control": "node scripts/negative-control.mjs",
"test:regression": "vitest run",
"test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm test\"",
"probe:indexes": "node scripts/index-emulator-probe.mjs",
"probe:indexes:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm run probe:indexes\""
},
"dependencies": {
"@firebase/rules-unit-testing": "5.0.1",
"firebase": "12.17.1"
},
"devDependencies": {
"vitest": "4.1.10"
}
}
@@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs';
import {
collection,
getDocs,
orderBy,
query,
where,
} from 'firebase/firestore';
import {
createTestEnvironment,
seedDocuments,
} from '../support/environment.mjs';
const manifest = JSON.parse(
readFileSync(new URL('../../firestore.indexes.json', import.meta.url), 'utf8'),
);
const deliberatelyMissingFields = [
['fandomId', 'ASCENDING'],
['visibility', 'ASCENDING'],
['createdAt', 'DESCENDING'],
['likeCount', 'DESCENDING'],
];
const hasDeliberatelyMissingIndex = manifest.indexes.some((index) => {
if (index.collectionGroup !== 'posts' || index.fields.length !== deliberatelyMissingFields.length) {
return false;
}
return deliberatelyMissingFields.every(
([fieldPath, order], position) =>
index.fields[position].fieldPath === fieldPath && index.fields[position].order === order,
);
});
if (hasDeliberatelyMissingIndex) {
throw new Error('Probe precondition failed: deliberately missing composite index exists');
}
console.log('INDEX MANIFEST CONTROL PASS: required 4-field posts composite index -> absent');
const testEnvironment = await createTestEnvironment();
try {
await testEnvironment.clearFirestore();
await seedDocuments(testEnvironment, {
'posts/post-1': {
fandomId: 'fandom-a',
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
likeCount: 1,
},
'posts/post-2': {
fandomId: 'fandom-a',
visibility: 'visible',
createdAt: '2026-08-08T01:00:00Z',
likeCount: 2,
},
});
const firestore = testEnvironment.authenticatedContext('alice').firestore();
const missingIndexQuery = query(
collection(firestore, 'posts'),
where('fandomId', '==', 'fandom-a'),
where('visibility', '==', 'visible'),
orderBy('createdAt', 'desc'),
orderBy('likeCount', 'desc'),
);
const result = await getDocs(missingIndexQuery);
console.log(`EMULATOR MISSING-INDEX QUERY RESULT: allowed (${result.size} documents)`);
console.log('INDEX VALIDATION VERDICT: unsupported — emulator does not enforce composite indexes');
} finally {
await testEnvironment.cleanup();
}
@@ -0,0 +1,41 @@
import { doc, getDoc, setDoc } from 'firebase/firestore';
import { assertSucceeds } from '@firebase/rules-unit-testing';
import {
createTestEnvironment,
seedDocuments,
userDocument,
} from '../support/environment.mjs';
const testEnvironment = await createTestEnvironment();
try {
await testEnvironment.clearFirestore();
const uid = 'negative-control-user';
const userDb = testEnvironment.authenticatedContext(uid).firestore();
let denial;
try {
await setDoc(doc(userDb, `users/${uid}/wallet/main`), { balance: 999_999 });
} catch (error) {
denial = error;
}
if (denial?.code !== 'permission-denied') {
throw new Error(
`Negative control failed: expected permission-denied, received ${denial?.code ?? 'success'}`,
);
}
console.log('NEGATIVE CONTROL PASS: authenticated wallet write -> permission-denied');
await seedDocuments(testEnvironment, {
[`users/${uid}`]: userDocument(uid),
[`users/${uid}/wallet/main`]: { balance: 100 },
});
await assertSucceeds(getDoc(doc(userDb, `users/${uid}/wallet/main`)));
console.log('REACHABILITY CONTROL PASS: owner wallet read -> allowed');
} finally {
await testEnvironment.cleanup();
}
+59
View File
@@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs';
import { initializeTestEnvironment } from '@firebase/rules-unit-testing';
import { doc, setDoc } from 'firebase/firestore';
export const projectId = process.env.GCLOUD_PROJECT ?? 'demo-fancake';
function emulatorAddress() {
const value = process.env.FIRESTORE_EMULATOR_HOST ?? '127.0.0.1:8080';
const separator = value.lastIndexOf(':');
if (separator < 1) {
throw new Error(`Invalid FIRESTORE_EMULATOR_HOST: ${value}`);
}
const host = value.slice(0, separator);
const port = Number(value.slice(separator + 1));
if (!Number.isInteger(port)) {
throw new Error(`Invalid Firestore emulator port: ${value}`);
}
return { host, port };
}
export async function createTestEnvironment() {
const rules = readFileSync(new URL('../../firestore.rules', import.meta.url), 'utf8');
return initializeTestEnvironment({
projectId,
firestore: {
...emulatorAddress(),
rules,
},
});
}
export async function seedDocuments(testEnvironment, documents) {
await testEnvironment.withSecurityRulesDisabled(async (context) => {
const firestore = context.firestore();
await Promise.all(
Object.entries(documents).map(([path, data]) => setDoc(doc(firestore, path), data)),
);
});
}
export function userDocument(uid, overrides = {}) {
return {
uid,
displayName: uid,
fandomId: 'fandom-a',
grade: 'SILVER',
totalContributedPop: 0,
status: 'active',
updatedAt: '2026-08-08T00:00:00Z',
...overrides,
};
}
@@ -0,0 +1,700 @@
import {
assertFails,
assertSucceeds,
} from '@firebase/rules-unit-testing';
import {
collectionGroup,
deleteDoc,
doc,
getDoc,
getDocs,
setDoc,
updateDoc,
} 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, expiresAt: '2026-08-09T00:00:00Z' },
});
await assertFails(
updateDoc(doc(dbFor('alice'), 'users/alice/popLots/lot-1'), {
expiresAt: '2099-12-31T00:00:00Z',
}),
);
});
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'), { grade: 'DIAMOND' }));
});
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('원칙 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',
grade: 'DIAMOND',
}),
);
});
test('미인증 프로필 접근을 거부한다', async () => {
await assertFails(getDoc(doc(anonymousDb(), 'users/alice')));
});
test('허용된 본인 프로필 필드만 바꾸면 허용한다', async () => {
await assertSucceeds(
updateDoc(doc(dbFor('alice'), 'users/alice'), {
displayName: 'New Alice',
locale: 'ko',
updatedAt: '2026-08-08T01:00:00Z',
}),
);
});
});
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: 'support',
targetPop: 1000,
endsAt: '2026-09-01T00:00:00Z',
status: 'draft',
raisedPop: 0,
achievedRate: 0,
donorCount: 0,
votedPop: 0,
isAiDraft: false,
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' }),
});
});
test('정상 초기값의 캠페인 생성을 허용한다', async () => {
await assertSucceeds(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign));
});
test('집계·투표·AI 초안 필드 위조를 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'campaigns/forged'), {
...validCampaign,
achievedRate: 1,
donorCount: 99_999,
votedPop: 999_999,
isAiDraft: true,
}),
);
});
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' }),
});
});
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: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
test('게시물 authorName 사칭을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'posts/forged'), {
fandomId: 'fandom-a',
authorUid: 'alice',
authorName: 'Fancake 운영팀',
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: 'Fancake 운영팀',
body: 'forged',
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
test('답변 authorName 사칭을 거부한다', async () => {
await assertFails(
setDoc(doc(dbFor('alice'), 'questions/question-1/answers/forged'), {
authorUid: 'alice',
authorName: 'Fancake 운영팀',
body: 'forged',
sourceUrls: [],
accepted: false,
visibility: 'visible',
createdAt: '2026-08-08T00:00:00Z',
updatedAt: '2026-08-08T00:00:00Z',
}),
);
});
});
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', { grade: 'DIAMOND' })),
);
});
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' }),
);
});
test('성공 캠페인 개설자의 결과보고 생성을 허용한다', async () => {
await seedDocuments(testEnvironment, {
'campaigns/success': { creatorUid: 'alice', status: 'success' },
});
await assertSucceeds(
setDoc(doc(dbFor('alice'), 'campaigns/success/reports/report-1'), { body: 'report' }),
);
});
});
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('원칙 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',
run: (db) => setDoc(doc(db, 'briefings/briefing-1'), { body: 'Briefing' }),
},
{
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' }],
['관리자 계정', '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('테스트 자가 점검', () => {
test('규칙이 적재된 프로젝트 ID가 demo- 접두사를 사용한다', () => {
expect(testEnvironment.projectId).toMatch(/^demo-/);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.mjs'],
fileParallelism: false,
hookTimeout: 20_000,
testTimeout: 20_000,
},
});