Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"emulators": {
|
||||
"firestore": { "port": 8080 },
|
||||
"ui": { "enabled": false },
|
||||
"singleProjectMode": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
{
|
||||
"indexes": [
|
||||
{
|
||||
"collectionGroup": "posts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "visibility", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "posts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "visibility", "order": "ASCENDING" },
|
||||
{ "fieldPath": "likeCount", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "posts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "authorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "posts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "visibility", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "comments",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "visibility", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "popTransactions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "popType", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "popTransactions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "reasonCode", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "popTransactions",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "reasonCode", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "popTransactions",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "popType", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "popLots",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "popType", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "grantedAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "popLots",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "expiresAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "activityLogs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "type", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "activityLogs",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "type", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "questProgress",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "questId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "dayKey", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "endsAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "achievedRate", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "campaignType", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "creatorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "donations",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "donorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "donations",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "refundStatus", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "questions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "questions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "bountyPop", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "questions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "category", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "questions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "bountyExpiresAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "answers",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{ "fieldPath": "authorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "fandoms",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "countryCode", "order": "ASCENDING" },
|
||||
{ "fieldPath": "fpiScore", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "fandoms",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "artistId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "fpiScore", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "fpiEvents",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "axis", "order": "ASCENDING" },
|
||||
{ "fieldPath": "occurredAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "heroDrops",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "endsAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "users",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "endsAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "translationEntries",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "preRegistrations",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "preRegistrations",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "adRewardLogs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "uid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "adRewardLogs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "network", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "adRewardLogs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "adminActionLogs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "actorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "adminActionLogs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "targetType", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "translationEntries",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "locale", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "translationEntries",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "authorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "reports",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "reports",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "targetType", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "inquiries",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "inquiries",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "authorUid", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "notifications",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "read", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "notices",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "active", "order": "ASCENDING" },
|
||||
{ "fieldPath": "publishedAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "briefings",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "publishedAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "stationLinks",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "rank", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "stationSuggestions",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "inviteCodes",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "fandomId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "type", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "planetApplications",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "ASCENDING" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
{
|
||||
"collectionGroup": "popTransactions",
|
||||
"fieldPath": "createdAt",
|
||||
"indexes": [
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION" },
|
||||
{ "order": "DESCENDING", "queryScope": "COLLECTION" },
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" },
|
||||
{ "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "donations",
|
||||
"fieldPath": "createdAt",
|
||||
"indexes": [
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION" },
|
||||
{ "order": "DESCENDING", "queryScope": "COLLECTION" },
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" },
|
||||
{ "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "activityLogs",
|
||||
"fieldPath": "createdAt",
|
||||
"indexes": [
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION" },
|
||||
{ "order": "DESCENDING", "queryScope": "COLLECTION" },
|
||||
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" },
|
||||
{ "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "posts",
|
||||
"fieldPath": "body",
|
||||
"indexes": []
|
||||
},
|
||||
{
|
||||
"collectionGroup": "briefings",
|
||||
"fieldPath": "body",
|
||||
"indexes": []
|
||||
},
|
||||
{
|
||||
"collectionGroup": "campaigns",
|
||||
"fieldPath": "body",
|
||||
"indexes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
rules_version = '2';
|
||||
|
||||
// Fancake — Firestore 보안 규칙 (2주차 설계본 v0.1)
|
||||
//
|
||||
// 근거
|
||||
// - wireframe/handoff/rbac-summary.md 역할 3종 · 운영자 등급 3단계(D-18)
|
||||
// - Docs/별첨1_기능별_완료기준표.html 「전 기능 공통 완료 기준 — 서버검증 원칙」
|
||||
// - 주간보고/1주차/src/1주차_정책설계서_초안.md P-02 원장 무결성 · P-06 어뷰징 방어
|
||||
//
|
||||
// 3대 원칙
|
||||
// 1) POP · FPI · 등급 · 원장 필드는 클라이언트가 쓸 수 없다.
|
||||
// 전부 Cloud Functions(Admin SDK)가 쓰며, Admin SDK는 본 규칙을 우회한다.
|
||||
// → 별첨1 ② 판정 기준 「클라이언트 단독 지급 0건」을 규칙 층에서 물리적으로 보장.
|
||||
// 2) 개인 리소스는 본인만 읽고, 쓰기는 화이트리스트 필드로만 허용한다.
|
||||
// 3) 운영자 권한은 커스텀 클레임 role(master|ops|viewer)로만 판정한다.
|
||||
// Firestore 문서 조회로 판정하지 않는다 — 판정 1회당 읽기 1회 비용이 붙고,
|
||||
// 규칙 평가 중 문서 읽기는 우회 표면을 늘린다.
|
||||
//
|
||||
// 미구현 표기
|
||||
// - 규칙은 거부만 할 수 있고 로그를 남기지 못한다. rbac-summary 「관리자 권한 없는 접근은
|
||||
// E-PERM-01로 차단하고 시도 자체를 작업 로그에 남긴다」의 로그 적재는 Admin 콘솔이
|
||||
// Cloud Functions를 경유하도록 설계해 Functions 층에서 처리한다(설계서 §8 fn-admin-*).
|
||||
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// ───────────────────────── 공통 헬퍼 ─────────────────────────
|
||||
function signedIn() { return request.auth != null; }
|
||||
function isSelf(u) { return signedIn() && request.auth.uid == u; }
|
||||
function role() { return signedIn() ? request.auth.token.get('role', '') : ''; }
|
||||
function isViewer() { return role() == 'master' || role() == 'ops' || role() == 'viewer'; }
|
||||
function isOps() { return role() == 'master' || role() == 'ops'; }
|
||||
function isMaster() { return role() == 'master'; }
|
||||
|
||||
// 업데이트가 지정한 필드만 건드렸는지 검사 — 그 외 필드 위조를 차단한다.
|
||||
function touchesOnly(fields) {
|
||||
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
|
||||
}
|
||||
// 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단.
|
||||
function authorIsSelf() {
|
||||
return signedIn() && request.resource.data.authorUid == request.auth.uid;
|
||||
}
|
||||
|
||||
// 요청자의 users 문서. 비정규화 복제 필드(표시명·팬덤)의 원본 대조에 쓴다.
|
||||
// §7.1 원칙 3은 "역할 판정"에 get()을 쓰지 않는다는 뜻이고, 여기는 역할 판정이 아니다.
|
||||
// 호출 지점은 전부 생성(create) 1건 단위라 목록 조회처럼 읽기가 곱해지지 않는다.
|
||||
// 같은 평가 안에서 같은 경로의 get()은 1회로 합산된다.
|
||||
function myUser() {
|
||||
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
||||
}
|
||||
// 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단 (교차검증 F-05)
|
||||
function authorNameMatchesProfile() {
|
||||
return request.resource.data.authorName == myUser().displayName;
|
||||
}
|
||||
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단 (교차검증 F-05)
|
||||
function fandomIsMine() {
|
||||
return request.resource.data.fandomId == myUser().fandomId;
|
||||
}
|
||||
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08)
|
||||
function campaignIsPublic() {
|
||||
return resource.data.status in ['voting', 'funding', 'success', 'fail', 'report'];
|
||||
}
|
||||
|
||||
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
|
||||
|
||||
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
|
||||
// 근거: 결정 D-07·D-13·D-14·D-21·D-22·D-23 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
|
||||
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
|
||||
match /config/{configId} {
|
||||
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
|
||||
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
|
||||
}
|
||||
|
||||
// 어뷰징 탐지 임계치·판정 로직. 노출되면 우회가 가능해 클라이언트 접근을 전면 차단한다.
|
||||
// 근거: 정책설계서 P-06 「규칙은 공개하지 않는 것을 제안」
|
||||
match /configPrivate/{configId} {
|
||||
allow read, write: if false; // 서버(Functions) 전용
|
||||
}
|
||||
|
||||
// 앱 버전 정책 · 점검 모드 · 최소 지원 버전. 미로그인 스플래시가 읽는다.
|
||||
// 화면: S-A-SPLASH · S-X-MAINT · S-AD-SYSTEM
|
||||
match /appConfig/{docId} {
|
||||
allow read: if true;
|
||||
allow write: if isMaster(); // rbac-summary 「점검 모드·강제 업데이트」 = 마스터 전용
|
||||
}
|
||||
|
||||
// 다국어 문자열 원격 배포 리소스. 화면: S-M-LANG · S-AD-I18N
|
||||
match /i18n/{locale} {
|
||||
allow read: if true;
|
||||
allow write: if isMaster(); // rbac-summary 「다국어 문자열」 = 마스터 전용
|
||||
}
|
||||
|
||||
// ───────────────────────── 2. 사용자 ─────────────────────────
|
||||
|
||||
// 계정·프로필. 등급(grade)·누적 기여(totalContributedPop)는 서버 산출값이라 쓰기 목록에서 뺀다.
|
||||
// 화면: S-A-PROFILE · S-M-HOME · S-M-SETTINGS · S-AD-USER
|
||||
match /users/{uid} {
|
||||
allow get: if isSelf(uid) || isViewer();
|
||||
allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER)
|
||||
// 교차검증 F-06 — 클라이언트 생성 경로를 닫는다.
|
||||
// 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고
|
||||
// ② 클라이언트가 먼저 만들면 grade·totalContributedPop 이 허용 목록에 없어 필드가 빈
|
||||
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
|
||||
// 생성 주체를 Auth 트리거 하나로 고정한다.
|
||||
allow create: if false;
|
||||
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
|
||||
allow update: if isSelf(uid)
|
||||
&& touchesOnly(['displayName','photoUrl','countryCode','locale',
|
||||
'representativeBadgeId','notificationPrefs','updatedAt']);
|
||||
// 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상
|
||||
allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']);
|
||||
allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13)
|
||||
}
|
||||
|
||||
// POP 지갑 요약(집계 필드). 읽기만 허용하고 쓰기는 전부 Functions.
|
||||
// 화면: S-M-WALLET · S-H-HOME · S-C-FUND
|
||||
match /users/{uid}/wallet/{docId} {
|
||||
allow read: if isSelf(uid) || isViewer(); // S-AD-LEDGER 조회 등급 이상
|
||||
allow write: if false; // 별첨1 ② 「클라이언트 단독 지급 0건」
|
||||
}
|
||||
|
||||
// POP Lot — 획득 단위별 잔량·만료일. FIFO 차감의 계산 단위.
|
||||
// 화면: S-M-WALLET(소멸 예정 POP)
|
||||
match /users/{uid}/popLots/{lotId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false; // 잔량·만료 조작 차단
|
||||
}
|
||||
|
||||
// POP 원장. 지급·차감·환불·소멸 전건. 수정·삭제 불가(append-only)는 Functions에서도 지킨다.
|
||||
// 화면: S-M-WALLET(거래 내역) · S-AD-LEDGER
|
||||
match /users/{uid}/popTransactions/{txId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false; // 별첨1 ② 원장 정합성 · 멱등키는 Functions 관리
|
||||
}
|
||||
|
||||
// 활동 로그(걸음·스밍·위치·출석). 어뷰징 판정 입력이라 클라이언트 쓰기 금지.
|
||||
// 화면: S-H-WALK · S-H-STREAM · S-H-PLACE · S-H-ATTEND · S-AD-ABUSE
|
||||
match /users/{uid}/activityLogs/{logId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 퀘스트 진행·일일 한도 카운터. 한도 우회를 막기 위해 서버 전용.
|
||||
// 화면: S-H-HOME · S-X-AD(일일 한도)
|
||||
match /users/{uid}/questProgress/{questId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 보유 배지·등급 이력. 부여는 서버 판정.
|
||||
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
|
||||
//
|
||||
// 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
|
||||
// **1차 범위에 타인 프로필 화면이 없다**(화면 95개 전수 확인). 주석이 사실과 달랐다.
|
||||
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
|
||||
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
|
||||
// 랭킹 하나 보여주려고 타인 문서를 통째로 여는 경로를 닫는다.
|
||||
match /users/{uid}/badges/{badgeId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 구독(Focus) 상태. 영수증 검증 결과라 서버 전용.
|
||||
// 화면: S-M-FOCUS
|
||||
match /users/{uid}/subscription/{docId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 알림함. 읽음 처리만 본인이 할 수 있다.
|
||||
// 화면: S-X-NOTICE
|
||||
match /users/{uid}/notifications/{notiId} {
|
||||
allow read: if isSelf(uid);
|
||||
allow update: if isSelf(uid) && touchesOnly(['read','readAt']);
|
||||
allow create, delete: if false; // 발송은 Functions(FCM) 경유
|
||||
}
|
||||
|
||||
// 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다.
|
||||
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
|
||||
match /users/{uid}/consents/{consentId} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false; // 기록은 fn-consent-record 경유 (교차검증 F-04)
|
||||
}
|
||||
|
||||
// 차단 목록. 본인만 읽고 쓴다.
|
||||
// 화면: S-M-BLOCK · S-I-REPORT
|
||||
match /users/{uid}/blocks/{targetUid} {
|
||||
allow read, write: if isSelf(uid);
|
||||
}
|
||||
|
||||
// ───────────────────────── 3. 아티스트 · 팬덤 · 행성 ─────────────────────────
|
||||
|
||||
// 아티스트 마스터. 온보딩에서 미인증 상태로도 목록을 본다.
|
||||
// 화면: S-A-ARTIST · S-W-PREREG-NEW
|
||||
match /artists/{artistId} {
|
||||
allow read: if true;
|
||||
allow write: if isOps();
|
||||
}
|
||||
|
||||
// 팬덤. fpiScore·orbit 등 집계 필드가 붙어 있어 클라이언트 쓰기를 전면 금지한다.
|
||||
// 화면: S-G-MAP · S-G-FPI · S-A-ARTIST
|
||||
match /fandoms/{fandomId} {
|
||||
allow read: if true; // 웹 브릿지(미로그인) 요약 노출 — D-16
|
||||
allow write: if false; // 생성·집계 모두 Functions
|
||||
}
|
||||
|
||||
// 팬덤 멤버십 + 개인 기여 집계(히어로 랭킹 원천).
|
||||
// 화면: S-M-HERO · S-I-LOUNGE(내 기여도)
|
||||
match /fandoms/{fandomId}/members/{uid} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false; // 기여 POP 집계 = 서버 산출값
|
||||
}
|
||||
|
||||
// 사용자 행성. 성장 단계·누적 기여 POP은 서버 산출이고, 외형 설정만 본인이 바꾼다.
|
||||
// 화면: S-G-PLANET · S-G-THEME · S-A-PLANET-NEW
|
||||
match /fandoms/{fandomId}/planets/{planetId} {
|
||||
allow read: if signedIn(); // 타인 행성 열람 허용(갤럭시 맵 진입)
|
||||
allow update: if isSelf(planetId)
|
||||
&& touchesOnly(['planetType','colorKey','themeId','effectLevel','updatedAt']);
|
||||
allow create, delete: if false; // 생성은 온보딩 Function(fn-planet-create)
|
||||
}
|
||||
|
||||
// 성장 단계 정의(7단계). 마스터 데이터.
|
||||
// 화면: S-G-STAGE
|
||||
match /planetStages/{stageNo} {
|
||||
allow read: if true;
|
||||
allow write: if isMaster();
|
||||
}
|
||||
|
||||
// FPI 이벤트(집계 입력). 클라이언트는 읽지도 쓰지도 않는다 — 가중치 역산 방지.
|
||||
// 화면: S-AD-LEDGER(FPI 집계 로그)
|
||||
match /fandoms/{fandomId}/fpiEvents/{eventId} {
|
||||
allow read: if isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// FPI 사전 집계 스냅샷(축별 순위·등락). 랭킹 화면의 실제 읽기 대상.
|
||||
// 화면: S-G-FPI · S-G-CONTRIB
|
||||
match /fandoms/{fandomId}/fpiSnapshots/{periodKey} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 이클립스 발동 이벤트.
|
||||
// 화면: S-G-ECLIPSE · S-AD-ECLIPSE
|
||||
match /fandoms/{fandomId}/eclipses/{eclipseId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준)
|
||||
}
|
||||
|
||||
// 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다.
|
||||
// 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다.
|
||||
// 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」
|
||||
match /fandoms/{fandomId}/eclipses/{eclipseId}/shards/{shardId} {
|
||||
allow read: if signedIn(); // 합산은 클라이언트가 해도 무방
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 방어 로그(라이브 소셜 프루프 원천).
|
||||
// 화면: S-G-ECLIPSE(실시간 방어 로그)
|
||||
match /fandoms/{fandomId}/eclipses/{eclipseId}/defenseLogs/{logId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 영웅의 선물 발동 이벤트.
|
||||
// 화면: S-X-HERODROP
|
||||
match /fandoms/{fandomId}/heroDrops/{dropId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 수령 내역. 문서 ID = uid 로 중복 수령을 구조적으로 차단한다(E-POP-03).
|
||||
// 화면: S-X-HERODROP
|
||||
match /fandoms/{fandomId}/heroDrops/{dropId}/claims/{uid} {
|
||||
allow read: if isSelf(uid) || isViewer();
|
||||
allow write: if false; // 수령은 fn-herodrop-claim 경유
|
||||
}
|
||||
|
||||
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
|
||||
|
||||
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
|
||||
match /quests/{questId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
|
||||
}
|
||||
|
||||
// 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL
|
||||
match /places/{placeId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if isOps();
|
||||
}
|
||||
|
||||
// AI Daily Briefing 카드(Functions 생성 → 캐시). 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
|
||||
match /briefings/{briefingId} {
|
||||
allow read: if true; // 웹 브릿지 열람 — D-16
|
||||
allow write: if isOps(); // 생성은 Functions, 수동 편집은 운영
|
||||
}
|
||||
|
||||
// 배지 도감 마스터. 화면: S-M-BADGE
|
||||
match /badges/{badgeId} {
|
||||
allow read: if true;
|
||||
allow write: if isMaster();
|
||||
}
|
||||
|
||||
// ───────────────────────── 5. 캠페인 ─────────────────────────
|
||||
|
||||
// 캠페인. 목표 달성률·후원 합계는 집계 필드라 클라이언트가 못 쓴다.
|
||||
// 화면: S-C-LIST · S-C-DETAIL · S-C-NEW · S-AD-CAMPAIGN
|
||||
//
|
||||
// ⚠️ 목록 쿼리에는 status 필터가 필수다 (교차검증 G-03).
|
||||
// 아래 read 규칙이 공개 상태만 허용하므로, status 필터가 없는 목록 쿼리는
|
||||
// draft·review 문서까지 매칭돼 **쿼리 전체가 거부**된다(Firestore 는 list 규칙을
|
||||
// 반환 후보 전체에 적용한다). 인덱스 문제로 오인하기 쉬운 지점이다.
|
||||
// 허용되는 목록 쿼리 형태는 설계서 §6.1 IDX-15·16·17·19 참조.
|
||||
match /campaigns/{campaignId} {
|
||||
// 교차검증 F-08 — 예전에는 read: if true 라 심사 중(review)·초안(draft) 캠페인 본문이
|
||||
// URL만 알면 미로그인 상태로 읽혔다. FR-CMP-01「승인 대기 캠페인은 목록에 노출하지 않는다」(E-CMP-03)를
|
||||
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
|
||||
allow read: if campaignIsPublic()
|
||||
|| isOps()
|
||||
|| (signedIn() && resource.data.creatorUid == request.auth.uid);
|
||||
// 교차검증 F-02 — 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로
|
||||
// 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다.
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'fandomId','creatorUid','creatorName','title','summary','body',
|
||||
'coverImageUrl','campaignType','targetPop','endsAt','status',
|
||||
'raisedPop','achievedRate','donorCount','votedPop','isAiDraft',
|
||||
'refundState','createdAt','updatedAt'
|
||||
])
|
||||
&& request.resource.data.creatorUid == request.auth.uid
|
||||
&& request.resource.data.creatorName == myUser().displayName
|
||||
&& fandomIsMine()
|
||||
&& request.resource.data.status == 'draft'
|
||||
&& request.resource.data.raisedPop == 0 // 개설 시 모금액 0 고정
|
||||
&& request.resource.data.achievedRate == 0 // 달성률 위조 차단(웹 브릿지 노출 대상)
|
||||
&& request.resource.data.donorCount == 0 // 후원자 수 위조 차단
|
||||
&& request.resource.data.votedPop == 0 // D-22 승격 임계치 판정 입력
|
||||
&& request.resource.data.isAiDraft == false // 「AI 초안」 배지 사칭 차단
|
||||
&& request.resource.data.refundState == 'none';
|
||||
// 개설자는 심사 전 초안만 고칠 수 있다.
|
||||
allow update: if signedIn()
|
||||
&& resource.data.creatorUid == request.auth.uid
|
||||
&& resource.data.status == 'draft'
|
||||
&& touchesOnly(['title','summary','body','coverImageUrl',
|
||||
'targetPop','campaignType','endsAt','updatedAt']);
|
||||
// 승인·반려·상태 전이. rbac-summary 「캠페인 승인·반려」 = 운영 이상
|
||||
allow update: if isOps()
|
||||
&& touchesOnly(['status','reviewNote','rejectReason','reviewedBy','updatedAt']);
|
||||
allow delete: if false; // 원장 연결 문서라 삭제 대신 상태 전이
|
||||
}
|
||||
|
||||
// 후원 내역. POP 차감과 1:1이라 전량 서버 경유.
|
||||
// 화면: S-C-FUND · S-C-MINE · S-C-REFUND
|
||||
match /campaigns/{campaignId}/donations/{donationId} {
|
||||
allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL)
|
||||
allow write: if false; // 별첨1 ⑤ 환불 무결성
|
||||
}
|
||||
|
||||
// 결과 보고. 성공 캠페인 개설자만 1회 제출하고 이후 운영이 관리한다.
|
||||
// 화면: S-C-RESULT
|
||||
match /campaigns/{campaignId}/reports/{reportId} {
|
||||
allow read: if true;
|
||||
// 교차검증 F-07 — §7.3 표는 「성공 캠페인 개설자」라고 적었는데 규칙은 creatorUid만 보고
|
||||
// 상태를 확인하지 않아 실패·초안 캠페인에도 결과보고를 올릴 수 있었다.
|
||||
// 별첨1 ⑤ 판정 기준 「결과보고 게시 확인」의 대상은 성공한 캠페인이다.
|
||||
allow create: if signedIn()
|
||||
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
|
||||
.data.creatorUid == request.auth.uid
|
||||
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
|
||||
.data.status == 'success';
|
||||
allow update: if isOps();
|
||||
allow delete: if false;
|
||||
}
|
||||
|
||||
// ───────────────────────── 6. INSIDE 커뮤니티 ─────────────────────────
|
||||
|
||||
// 라운지 게시물. 좋아요 수는 집계 필드라 본인 수정 목록에서 제외한다.
|
||||
// 화면: S-I-LOUNGE · S-I-POST · S-I-POST-NEW
|
||||
match /posts/{postId} {
|
||||
allow read: if signedIn();
|
||||
// 교차검증 F-05 — 예전에는 authorUid·likeCount·visibility 3개만 봐서
|
||||
// ① authorName 을 "Fancake 운영팀"으로 적어 사칭 ② commentCount 위조
|
||||
// ③ 소속하지 않은 팬덤 피드에 글 삽입이 가능했다. 화이트리스트 + 원본 대조로 닫는다.
|
||||
allow create: if authorIsSelf()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'fandomId','authorUid','authorName','body','imageUrls',
|
||||
'likeCount','commentCount','visibility','createdAt','updatedAt'
|
||||
])
|
||||
&& authorNameMatchesProfile()
|
||||
&& fandomIsMine()
|
||||
&& request.resource.data.likeCount == 0
|
||||
&& request.resource.data.commentCount == 0
|
||||
&& request.resource.data.visibility == 'visible';
|
||||
// 교차검증 F-14 — 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
|
||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& touchesOnly(['body','imageUrls','updatedAt']);
|
||||
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
||||
}
|
||||
|
||||
// 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다.
|
||||
match /posts/{postId}/likes/{uid} {
|
||||
allow read: if signedIn();
|
||||
allow create, delete: if isSelf(uid); // 집계 반영은 Functions 트리거
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
// 댓글. 화면: S-I-POST
|
||||
match /posts/{postId}/comments/{commentId} {
|
||||
allow read: if signedIn();
|
||||
// 교차검증 F-05 — 댓글에도 같은 표시명 사칭 경로가 있었다.
|
||||
allow create: if authorIsSelf()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
||||
])
|
||||
&& authorNameMatchesProfile()
|
||||
&& request.resource.data.visibility == 'visible';
|
||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& touchesOnly(['body','updatedAt']);
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
||||
}
|
||||
|
||||
// 현상금 Q&A 질문. 현상금 예치가 POP 차감이라 생성도 Functions 경유다.
|
||||
// 화면: S-I-KNOW · S-I-QUESTION · S-I-QUESTION-NEW
|
||||
match /questions/{questionId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false; // fn-question-create(예치) · fn-answer-accept(전송)
|
||||
}
|
||||
|
||||
// 답변. 등록은 재화 이동이 없어 클라이언트 쓰기를 허용하되, 채택 필드는 잠근다.
|
||||
// 화면: S-I-QUESTION · S-I-ANSWER-NEW
|
||||
match /questions/{questionId}/answers/{answerId} {
|
||||
allow read: if signedIn();
|
||||
// 교차검증 F-05 — 답변에도 같은 표시명 사칭 경로가 있었다.
|
||||
allow create: if authorIsSelf()
|
||||
&& request.resource.data.keys().hasOnly([
|
||||
'authorUid','authorName','body','sourceUrls','accepted',
|
||||
'visibility','createdAt','updatedAt'
|
||||
])
|
||||
&& authorNameMatchesProfile()
|
||||
&& request.resource.data.accepted == false;
|
||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||
&& touchesOnly(['body','sourceUrls','updatedAt']);
|
||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||
allow delete: if false; // 채택 대상이라 삭제 대신 숨김
|
||||
}
|
||||
|
||||
// 신고. 접수는 본인 명의로만, 처리 상태는 운영만 바꾼다.
|
||||
// 화면: S-I-REPORT · S-AD-REPORT
|
||||
match /reports/{reportId} {
|
||||
allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid);
|
||||
allow list: if isOps();
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.reporterUid == request.auth.uid
|
||||
&& request.resource.data.status == 'pending';
|
||||
allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']);
|
||||
allow delete: if false;
|
||||
}
|
||||
|
||||
// 문의. 화면: S-M-INQUIRY · S-AD-REPORT
|
||||
match /inquiries/{inquiryId} {
|
||||
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
||||
allow list: if isOps();
|
||||
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
|
||||
allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']);
|
||||
allow delete: if false;
|
||||
}
|
||||
|
||||
// ───────────────────────── 7. STATION · 광고 · 공지 ─────────────────────────
|
||||
|
||||
// 큐레이션 링크. 화면: S-S-HUB
|
||||
match /stationLinks/{linkId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if isOps();
|
||||
}
|
||||
|
||||
// 사용자 제안 링크. 심사 상태는 운영만 바꾼다. 화면: S-S-SUGGEST
|
||||
match /stationSuggestions/{suggestionId} {
|
||||
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
||||
allow list: if isOps();
|
||||
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
|
||||
allow update: if isOps() && touchesOnly(['status','reviewNote','updatedAt']);
|
||||
allow delete: if false;
|
||||
}
|
||||
|
||||
// 광고 보상 로그. 보상 지급 근거라 서버 전용. 화면: S-X-AD · S-AD-ADREV
|
||||
match /adRewardLogs/{logId} {
|
||||
allow read: if isViewer(); // rbac-summary 「광고 보상 내역」 = 조회 이상
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 공지·배너. 화면: S-X-NOTICE · S-AD-NOTICE
|
||||
match /notices/{noticeId} {
|
||||
allow read: if true;
|
||||
allow write: if isOps();
|
||||
}
|
||||
|
||||
// 푸시 발송 이력·도달률. 운영자만 보고, 발송 집계는 Functions(FCM)가 쓴다.
|
||||
// 화면: S-AD-PUSH · rbac-summary 「푸시 발송 관리」 = 운영 이상 (교차검증 F-04 #2)
|
||||
match /pushCampaigns/{pushId} {
|
||||
allow read: if isViewer();
|
||||
allow write: if false; // 발송·집계는 fn-push-send 경유
|
||||
}
|
||||
|
||||
// ───────────────────────── 8. 사전예약 · 초대 코드 ─────────────────────────
|
||||
|
||||
// 사전예약 신청. 미로그인 웹앱에서 접수되고 중복·봇 판정이 붙어 서버 전용으로 둔다.
|
||||
// 화면: S-W-PREREG · S-W-PREREG-DONE · S-AD-PREREG
|
||||
// 근거: 정책설계서 P-06 reCAPTCHA v3 · E-PRE-02 중복 차단 · E-PRE-03 보류
|
||||
match /preRegistrations/{preRegId} {
|
||||
allow read: if isOps(); // 개인정보(연락처) 포함 — 운영 이상만
|
||||
allow write: if false; // 접수는 fn-prereg-submit 경유
|
||||
}
|
||||
|
||||
// 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다.
|
||||
// 남용 방지를 위해 문서에는 사용 횟수 등 최소 필드만 두고 개인정보를 담지 않는다.
|
||||
// 화면: S-W-PREREG(코드 입력) · E-PRE-01
|
||||
match /inviteCodes/{code} {
|
||||
allow get: if true; // 코드 직접 조회만 허용
|
||||
allow list: if isOps(); // 전수 열거는 운영 이상(코드 수집 방지)
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 신규 행성 개설 신청. 화면: S-W-PREREG-NEW · S-AD-PREREG
|
||||
match /planetApplications/{applicationId} {
|
||||
allow read: if isOps();
|
||||
allow write: if false; // 접수·심사 모두 Functions
|
||||
}
|
||||
|
||||
// ───────────────────────── 9. 참여형 번역 ─────────────────────────
|
||||
|
||||
// 번역 제출. 보상이 걸려 있어 제출은 Functions 경유(도배 제한·중복 검사).
|
||||
// 화면: S-M-TRANSLATE · S-AD-TRANSLATE
|
||||
match /translationEntries/{entryId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// 마킹. 문서 ID = uid 로 중복 마킹을 차단하고, 본인 번역 자기 마킹은 서버가 막는다(E-TRN-01).
|
||||
match /translationEntries/{entryId}/markings/{uid} {
|
||||
allow read: if signedIn();
|
||||
allow create: if isSelf(uid)
|
||||
&& get(/databases/$(database)/documents/translationEntries/$(entryId))
|
||||
.data.authorUid != request.auth.uid;
|
||||
allow update, delete: if false; // 번복 불가 — 어뷰징 가드
|
||||
}
|
||||
|
||||
// ───────────────────────── 10. 랭킹 사전 집계 ─────────────────────────
|
||||
|
||||
// 글로벌·국가·아티스트 랭킹 사전 집계 문서. 랭킹 화면은 이 문서 1건만 읽는다.
|
||||
// 화면: S-G-FPI · S-I-RANK · S-M-HERO
|
||||
match /rankings/{rankingId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// ───────────────────────── 11. 관리자 ─────────────────────────
|
||||
|
||||
// 관리자 계정 목록. 실제 권한 판정은 커스텀 클레임이고 이 문서는 목록·감사용이다.
|
||||
// 화면: S-AD-ACCOUNT
|
||||
match /admins/{uid} {
|
||||
allow read: if isViewer();
|
||||
allow write: if false; // 클레임 설정과 함께 fn-admin-set-role 경유
|
||||
}
|
||||
|
||||
// 관리자 작업 로그. 전건 기록이 판정 기준이라 수정·삭제를 규칙 층에서 봉인한다.
|
||||
// 화면: S-AD-LOG · 근거: 별첨1 ⑨ 판정 기준 「관리자 조작 전건이 작업 로그로 추적 가능」
|
||||
match /adminActionLogs/{logId} {
|
||||
allow read: if isViewer();
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// ─────────────── 12. 컬렉션 그룹 쿼리 (교차검증 F-01) ───────────────
|
||||
//
|
||||
// Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은
|
||||
// **단일 컬렉션 쿼리에만** 적용되고 collectionGroup('popTransactions') 에는 적용되지 않는다.
|
||||
// 재귀 와일드카드 블록이 없으면 §1.4가 지정한 컬렉션 그룹 쿼리가 전부 아래 기본 거부에 걸린다.
|
||||
// 실측으로 S-AD-LEDGER·S-C-MINE 등 7건이 403이었다.
|
||||
//
|
||||
// 원칙: **권한을 넓히지 않는다.** 각 블록의 조건은 위 단일 컬렉션 블록과 동일하게 유지한다.
|
||||
// 쓰기는 선언하지 않으므로 그대로 거부된다(원장 append-only 유지).
|
||||
//
|
||||
// 주의: 규칙은 OR로 평가되므로 이 블록들이 기존 단일 컬렉션 읽기를 좁히지 않는다.
|
||||
|
||||
// S-AD-LEDGER 전체 원장 (IDX-08·09). 조회 등급 이상만.
|
||||
// 본인 내역은 /users/{uid}/popTransactions 단일 컬렉션 경로를 쓰므로 여기서 열지 않는다.
|
||||
match /{path=**}/popTransactions/{txId} {
|
||||
allow read: if isViewer();
|
||||
}
|
||||
|
||||
// S-AD-ABUSE 어뷰징 모니터링 (IDX-13). 운영자 전용.
|
||||
match /{path=**}/activityLogs/{logId} {
|
||||
allow read: if isViewer();
|
||||
}
|
||||
|
||||
// S-AD-ABUSE 일일 한도 감사 (IDX-14). 운영자 전용.
|
||||
match /{path=**}/questProgress/{questId} {
|
||||
allow read: if isViewer();
|
||||
}
|
||||
|
||||
// S-AD-CONTENT 댓글 전수 조회 (IDX-05). 운영자 전용.
|
||||
match /{path=**}/comments/{commentId} {
|
||||
allow read: if isViewer();
|
||||
}
|
||||
|
||||
// S-C-MINE·S-C-REFUND 내 후원 내역 (IDX-20).
|
||||
// 단일 컬렉션 규칙과 동일하게 로그인 사용자 읽기(후원자 리더보드가 이미 공개 범위).
|
||||
match /{path=**}/donations/{donationId} {
|
||||
allow read: if signedIn();
|
||||
}
|
||||
|
||||
// S-M-HOME 내 답변 (IDX-26). 단일 컬렉션 규칙과 동일.
|
||||
match /{path=**}/answers/{answerId} {
|
||||
allow read: if signedIn();
|
||||
}
|
||||
|
||||
// popLots(IDX-11)·환불 배치 donations(IDX-21)는 스케줄 Function 전용이다.
|
||||
// Admin SDK는 규칙을 우회하므로 컬렉션 그룹 블록을 두지 않는다 — 열면 권한만 넓어진다.
|
||||
|
||||
// ───────────────────────── 13. 기본 거부 ─────────────────────────
|
||||
// 위에서 명시하지 않은 모든 경로는 거부한다.
|
||||
match /{document=**} {
|
||||
allow read, write: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.firebase/
|
||||
|
||||
@@ -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` 복합 인덱스를 먼저 확인한 뒤, 그 인덱스가 필요한 유효 쿼리를 에뮬레이터에 보냅니다. 쿼리가 허용되면 에뮬레이터가 복합 인덱스를 강제하지 않는다는 뜻이므로, 인덱스 배포 유효성 검증에는 사용할 수 없습니다.
|
||||
Generated
+2294
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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-/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user