diff --git a/app/firebase/firebase.json b/app/firebase/firebase.json index 79ec08b..59ccead 100644 --- a/app/firebase/firebase.json +++ b/app/firebase/firebase.json @@ -3,10 +3,16 @@ "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, + "storage": { + "rules": "storage.rules" + }, "emulators": { "firestore": { "port": 8080 }, + "storage": { + "port": 9199 + }, "ui": { "enabled": false }, diff --git a/app/firebase/firestore.indexes.json b/app/firebase/firestore.indexes.json index 8e2456c..303f5a9 100644 --- a/app/firebase/firestore.indexes.json +++ b/app/firebase/firestore.indexes.json @@ -4,420 +4,1182 @@ "collectionGroup": "posts", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "fandomId", "order": "ASCENDING" }, - { "fieldPath": "visibility", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "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" } + { + "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" } + { + "fieldPath": "authorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "posts", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "visibility", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "comments", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "visibility", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "popTransactions", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "popType", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "popType", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "popTransactions", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "reasonCode", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "reasonCode", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "popTransactions", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "reasonCode", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "reasonCode", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "popTransactions", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "popType", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "popType", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "activityLogs", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "type", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "activityLogs", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "type", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "questProgress", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "questId", "order": "ASCENDING" }, - { "fieldPath": "dayKey", "order": "ASCENDING" } + { + "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" } + { + "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" } + { + "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" } + { + "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" } + { + "fieldPath": "creatorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "campaigns", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "ASCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "donations", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "donorUid", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "donorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "donations", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "refundStatus", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "ASCENDING" } + { + "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" } + { + "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" } + { + "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" } + { + "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" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "bountyExpiresAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "questions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "expiresAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "answers", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "authorUid", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "authorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "fandoms", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "countryCode", "order": "ASCENDING" }, - { "fieldPath": "fpiScore", "order": "DESCENDING" } + { + "fieldPath": "countryCode", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiScore", + "order": "DESCENDING" + } ] }, { "collectionGroup": "fandoms", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "artistId", "order": "ASCENDING" }, - { "fieldPath": "fpiScore", "order": "DESCENDING" } + { + "fieldPath": "artistId", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiScore", + "order": "DESCENDING" + } ] }, { "collectionGroup": "fpiEvents", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "axis", "order": "ASCENDING" }, - { "fieldPath": "occurredAt", "order": "DESCENDING" } + { + "fieldPath": "axis", + "order": "ASCENDING" + }, + { + "fieldPath": "occurredAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "heroDrops", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "endsAt", "order": "ASCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "endsAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "users", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "campaigns", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "endsAt", "order": "ASCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "endsAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "translationEntries", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "ASCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "preRegistrations", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "fandomId", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "ASCENDING" } + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "preRegistrations", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "adRewardLogs", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "uid", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "uid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "adRewardLogs", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "network", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "network", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "adRewardLogs", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "fandomId", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "adminActionLogs", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "actorUid", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "actorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "adminActionLogs", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "targetType", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "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" } + { + "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" } + { + "fieldPath": "authorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "translationEntries", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "rootPath", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "translationEntries", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "targetPath", + "order": "ASCENDING" + }, + { + "fieldPath": "locale", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "reports", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "ASCENDING" } + { + "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" } + { + "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" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } ] }, { "collectionGroup": "inquiries", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "authorUid", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "authorUid", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "notifications", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "read", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "DESCENDING" } + { + "fieldPath": "read", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "notices", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "active", "order": "ASCENDING" }, - { "fieldPath": "publishedAt", "order": "DESCENDING" } + { + "fieldPath": "active", + "order": "ASCENDING" + }, + { + "fieldPath": "publishedAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "briefings", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "fandomId", "order": "ASCENDING" }, - { "fieldPath": "publishedAt", "order": "DESCENDING" } + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "publishedAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "stationLinks", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "fandomId", "order": "ASCENDING" }, - { "fieldPath": "rank", "order": "ASCENDING" } + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "rank", + "order": "ASCENDING" + } ] }, { "collectionGroup": "stationSuggestions", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "status", "order": "ASCENDING" }, - { "fieldPath": "createdAt", "order": "ASCENDING" } + { + "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" } + { + "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" } + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "briefings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "rank", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "campaigns", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "raisedPop", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "questions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "questions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "answerCount", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "questions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "bountyPop", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "fandoms", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "rankingEligible", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiAxes.bpm", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "fandoms", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "rankingEligible", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiAxes.health", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "fandoms", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "rankingEligible", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiAxes.referral", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "fandoms", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "rankingEligible", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiAxes.community", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "fandoms", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "rankingEligible", + "order": "ASCENDING" + }, + { + "fieldPath": "fpiAxes.contribution", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "briefings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "dayKey", + "order": "ASCENDING" + }, + { + "fieldPath": "section", + "order": "ASCENDING" + }, + { + "fieldPath": "rank", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "eclipseRounds", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "startAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "eclipseRounds", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "result", + "order": "ASCENDING" + }, + { + "fieldPath": "startAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "fpiEvents", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "verified", + "order": "ASCENDING" + }, + { + "fieldPath": "occurredAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "popLots", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "popType", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "grantedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "growthEvents", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomIdAtPayout", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "growthEvents", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomIdAtPayout", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "quests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "fandomId", + "order": "ASCENDING" + }, + { + "fieldPath": "activeTo", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "quests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "endsAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "questSubmissions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "submittedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "questSubmissions", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fandomIdAtSubmit", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "submittedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "quests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "referralsByUser", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "reward", + "order": "ASCENDING" + }, + { + "fieldPath": "settleAfter", + "order": "ASCENDING" + } ] } ], @@ -426,30 +1188,66 @@ "collectionGroup": "popTransactions", "fieldPath": "createdAt", "indexes": [ - { "order": "ASCENDING", "queryScope": "COLLECTION" }, - { "order": "DESCENDING", "queryScope": "COLLECTION" }, - { "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" }, - { "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" } + { + "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" } + { + "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" } + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } ] }, { @@ -466,6 +1264,28 @@ "collectionGroup": "campaigns", "fieldPath": "body", "indexes": [] + }, + { + "collectionGroup": "eclipseRounds", + "fieldPath": "status", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] } ] } diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index a3f37c9..09f49fd 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -36,6 +36,28 @@ service cloud.firestore { function touchesOnly(fields) { return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields); } + // 닉네임 길이 — 정책 원장 B-52 (2~20자 · 중복 허용). 검증 기준이 + // 「20자 성공 · 21자 거부」이므로 길이를 규칙에서 본다. 앱도 같은 두 수를 + // `nicknameMinLength`·`nicknameMaxLength` 로 들고 있다. + // 닉네임을 건드리지 않는 갱신(사진·언어 등)은 이 검사를 통과시킨다. + // 예약어 판정용 정규화 — 폭 없는 문자(U+200B~200D · U+FEFF)를 지우고 전각 공백(U+3000)을 + // 공백으로 본 뒤 앞뒤 공백을 떼고 소문자로. 앱 `normalizeNickname` 과 같은 계산. + function nicknameKey(n) { + return n.replace('[\\x{200B}-\\x{200D}\\x{FEFF}]', '').replace('\\x{3000}', ' ').trim().lower(); + } + function nicknameOk() { + return !('nickname' in request.resource.data.diff(resource.data).affectedKeys()) + || (request.resource.data.nickname is string + && request.resource.data.nickname.size() >= 2 + && request.resource.data.nickname.size() <= 20 + // 운영 사칭 이름 차단 — 대소문자 무시 부분 일치. 앱 `reservedNicknameWords` 와 같은 목록. + && !nicknameKey(request.resource.data.nickname) + .matches('.*(fanuzu|운영팀|관리자|admin|official).*') + // 닉네임 없는 작성자 표기(`publicNameOf` 「팬」)와 그 번역(Fan · ファン · 粉丝)은 + // 정확히 일치하면 막는다. 앱 `reservedFanNames` 와 같은 목록. + && !(nicknameKey(request.resource.data.nickname) in ['팬', 'fan', 'ファン', '粉丝'])); + } + // 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단. function authorIsSelf() { return signedIn() && request.resource.data.authorUid == request.auth.uid; @@ -48,24 +70,107 @@ service cloud.firestore { function myUser() { return get(/databases/$(database)/documents/users/$(request.auth.uid)).data; } - // 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단 - function authorNameMatchesProfile() { - return request.resource.data.authorName == myUser().displayName; + // 다른 사용자에게 보이는 작성자 이름 — 앱 `publicName(nickname)` 과 같은 계산. + // 닉네임이 없거나 이메일 형태면 「팬」. 공급자 실명(displayName)은 쓰지 않는다(M-7). + function publicNameOf(u) { + let n = u.get('nickname', ''); + return (n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*')) ? n.trim() : '팬'; + } + // 공개 닉네임이 있는가 — publicNameOf 가 「팬」으로 대체하지 않는 경우와 같다. + function hasPublicNickname() { + let n = myUser().get('nickname', ''); + return n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*'); + } + // 라운지 글 · 댓글 · 인사이드 답변의 작성자 이름이 공개 닉네임과 같은지 — 사칭 차단. + function authorNameIsPublic() { + return request.resource.data.authorName == publicNameOf(myUser()); + } + // 팬덤 회원인가. 멤버십 문서(`fandoms/{id}/members/{uid}`)가 있거나, 없던 시절의 + // 원천인 대표 팬덤(`users.fandomId`)이 같으면 회원이다. 두 원천을 모두 받아 + // 멤버십 도입 전후 어느 쪽에서도 같은 판정이 난다. + function isFandomMember(fandomId) { + return signedIn() + && (exists(/databases/$(database)/documents/fandoms/$(fandomId)/members/$(request.auth.uid)) + || myUser().fandomId == fandomId); } // 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단 function fandomIsMine() { - return request.resource.data.fandomId == myUser().fandomId; + return isFandomMember(request.resource.data.fandomId); + } + // 라운지 반응(좋아요 · 댓글)을 남길 수 있는 글인가 — 보이는 글이고, 요청자가 + // 그 글의 팬덤 회원이다. 글 작성과 같은 회원 판정을 쓴다. + function canReactTo(postId) { + let post = get(/databases/$(database)/documents/posts/$(postId)).data; + return post.visibility == 'visible' && isFandomMember(post.fandomId); + } + // 라운지 본문 — 빈 글 차단 · 길이 상한(글 5,000 · 댓글 1,000). + function bodyOk(max) { + return request.resource.data.body is string + && request.resource.data.body.size() > 0 + && request.resource.data.body.size() <= max; + } + // 서버 시각 강제 — 앱은 serverTimestamp 를 보낸다. 시각 위조로 정렬 맨 위를 차지하지 못한다. + function createdNow() { + return request.resource.data.createdAt == request.time; + } + function updatedNow() { + return request.resource.data.updatedAt == request.time; + } + // 라운지 글 사진 — Storage 경로를 담는다(`users/{uid}/lounge/{postId}-{i}`). + // URL 이 아니라 경로라 버킷 이름에 묶이지 않고, 칸마다 정확한 경로만 받으므로 + // 남의 파일 · 외부 주소 · 다른 글의 사진을 끼워 넣지 못한다. B-44 「이미지 5 장」. + function loungeImagesOk(postId) { + let urls = request.resource.data.imageUrls; + let base = 'users/' + request.auth.uid + '/lounge/' + postId + '-'; + return urls is list && urls.size() <= 5 + && (urls.size() < 1 || urls[0] == base + '0') + && (urls.size() < 2 || urls[1] == base + '1') + && (urls.size() < 3 || urls[2] == base + '2') + && (urls.size() < 4 || urls[3] == base + '3') + && (urls.size() < 5 || urls[4] == base + '4'); } // 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 function campaignIsPublic() { return resource.data.status in ['funding', 'success', 'fail', 'report']; } + // 하위 문서(댓글 · 좋아요) 쓰기에서 부모 캠페인이 공개 상태인가. + function campaignOpenFor(campaignId) { + return get(/databases/$(database)/documents/campaigns/$(campaignId)).data.status + in ['funding', 'success', 'fail', 'report']; + } + // 응원 게시판 댓글 작성 자격 — config/policy.campaignCommentScope(1. 운영 파라미터 참고). + function campaignCommentScope() { + let policy = /databases/$(database)/documents/config/policy; + return exists(policy) ? get(policy).data.get('campaignCommentScope', 'all') : 'all'; + } + function campaignCommenter(campaignId) { + let scope = campaignCommentScope(); + return scope == 'all' + || (scope == 'members' + && isFandomMember(get(/databases/$(database)/documents/campaigns/$(campaignId)).data.fandomId)) + || (scope == 'backers' + && exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid))); + } // ───────────────────────── 1. 운영 파라미터 ───────────────────────── // 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다. // 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91) // 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE + // + // config/policy.campaignCommentScope — 응원 게시판 댓글 작성 자격(발주사 확인 중). + // 'all'(없으면 이 값) 로그인 회원 누구나 · 'members' 캠페인 팬덤 회원 · 'backers' 후원자. + // 명세에 작성 자격이 없어 잠정 'all'. 그 밖의 값은 아무도 쓰지 못한다(오타는 닫힌 쪽으로). + // + // config/features — 앱 기능 스위치. 지금 있는 키는 하나다. + // stationEnabled (bool, 기본 false) STATION 하단 탭 노출. + // 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고 + // Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.* + // 앱은 로그인 전에도 이 문서를 읽으므로 아래 공개 읽기가 그대로 필요하다. + // 쓰기가 마스터 전용인 것이 이 스위치의 유일한 보호막이다. + // + // 이메일/비밀번호 provider 에 의존하는 규칙은 없다(별첨 B v1.4 로 이메일 가입 제외). + // 판정은 전부 request.auth.uid 와 커스텀 클레임 role 로만 한다. match /config/{configId} { allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기 allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용 @@ -104,9 +209,27 @@ service cloud.firestore { // 생성 주체를 Auth 트리거 하나로 고정한다. allow create: if false; // 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다. + // `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(원장 B-52 — + // 2~20자 · 중복 허용 · 유니크 인덱스 제거). 목록에 없으면 MY 의 닉네임 변경이 + // permission-denied 로 떨어지는데, 원인이 화면처럼 보여 찾기 어렵다. + // `displayName` 은 Auth 트리거가 처음 채우는 필드라(§8.5 fn-auth-oncreate) 함께 남긴다. + // + // `fcmTokens` — 단말 푸시 토큰. 재화가 아니고 본인 문서에만 붙으므로 본인이 + // 직접 쓴다. 토큰 등록을 Functions 로 돌리면 로그인마다 호출이 한 번 늘고, + // 토큰은 앱이 재발급받을 때마다 바뀌어 그 호출이 계속 반복된다. + // + // `lastSeenAt` — HUD 「LIVE」 접속자 집계 입력(해석 확인 요청 Q-GM-03). 서버 시각만 받고, + // 직전 기록에서 60초가 지나야 다시 쓸 수 있다(앱 주기 2분 · 쓰기 폭주 차단). + // `fandomIds`(가입 팬덤 비정규화)는 목록에 없으므로 서버만 쓴다. allow update: if isSelf(uid) - && touchesOnly(['displayName','photoUrl','countryCode','locale', - 'representativeBadgeId','notificationPrefs','updatedAt']); + && touchesOnly(['nickname','displayName','photoUrl','countryCode','locale', + 'representativeBadgeId','notificationPrefs', + 'fcmTokens','lastSeenAt','updatedAt']) + && nicknameOk() + && (!('lastSeenAt' in request.resource.data.diff(resource.data).affectedKeys()) + || (request.resource.data.lastSeenAt == request.time + && (!('lastSeenAt' in resource.data) + || request.time > resource.data.lastSeenAt + duration.value(60, 's')))); // 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상 allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']); allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13) @@ -167,6 +290,23 @@ service cloud.firestore { allow write: if false; } + // 결제 기록. `fn-iap-verify` 가 쓰고 환불 회수는 `fn-iap-refund-webhook` 가 바꾼다. + // 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (원장 B-02 · B-72) + // 본인이 할 수 있는 것은 **지급 완료 → 환불 요청** 한 가지 전이뿐이다. 금액·상태의 + // 다른 전이(승인·반려·회수)는 Admin 과 서버가 한다. + match /users/{uid}/purchases/{purchaseId} { + allow read: if isSelf(uid) || isViewer(); + allow update: if isSelf(uid) + && resource.data.state == 'granted' + && request.resource.data.state == 'refund-requested' + && request.resource.data.refundReason is string + && request.resource.data.refundReason.size() > 0 + && request.resource.data.refundReason.size() <= 100 + && request.resource.data.refundRequestedAt == request.time + && touchesOnly(['state', 'refundReason', 'refundRequestedAt']); + allow create, delete: if false; + } + // 알림함. 읽음 처리만 본인이 할 수 있다. // 화면: S-X-NOTICE match /users/{uid}/notifications/{notiId} { @@ -175,6 +315,13 @@ service cloud.firestore { allow create, delete: if false; // 발송은 Functions(FCM) 경유 } + // 가입한 행성/성운 목록(명세 §2.58 「가입 또는 생성한 행성/성운」). 가입 · 탈퇴 Function 만 쓴다 — + // 클라이언트가 쓰면 가입하지 않은 팬덤이 목록에 끼고 `memberCount` 와 어긋난다. + match /users/{uid}/memberships/{fandomId} { + allow read: if isSelf(uid) || isViewer(); + allow write: if false; + } + // 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다. // 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS match /users/{uid}/consents/{consentId} { @@ -215,9 +362,19 @@ service cloud.firestore { // 화면: 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) + // 원장 v1.2 C-50 — 행성 외형은 **생성 시 형태(planetType) + 컬러(colorKey)만** 고르고 + // 이후 사용자 커스텀은 없다. 생성은 온보딩 Function(fn-planet-create)이 하며, + // themeId · effectLevel 은 서버 산출값이다. 클라이언트 쓰기는 전부 닫는다. + allow create, update, delete: if false; + } + + // 갤럭시 맵 상단에 흐르는 최근 사건(이클립스 · 아이템 사용). + // 로그인한 사용자에게만 보인다 — 맵 자체가 로그인 뒤 화면이다. + // 쓰기는 서버(Functions)만 한다. 클라이언트가 사건을 만들면 맵이 조작된다. + // 화면: S-G-GALAXY + match /galaxyEvents/{eventId} { + allow read: if signedIn(); + allow write: if false; } // 성장 단계 정의(7단계). 마스터 데이터. @@ -241,24 +398,43 @@ service cloud.firestore { allow write: if false; } - // 이클립스 발동 이벤트. + // 이클립스 회차(설계서 v1.2 §2.11 `eclipseRounds` — v0.3.0 `eclipses` 에서 전환). // 화면: S-G-ECLIPSE · S-AD-ECLIPSE - match /fandoms/{fandomId}/eclipses/{eclipseId} { + match /fandoms/{fandomId}/eclipseRounds/{eclipseId} { allow read: if signedIn(); allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준) } + // 참여자별 유효 Shake 누적(`participants.validShakes` — v0.3.0 `defenders`). 사용자당 + // 상한과 속도 제한의 근거이며, 문서 ID 가 uid 라 중복이 구조적으로 없다(G-04). + // 판정은 서버가 하므로 클라이언트 쓰기를 막는다. 화면: S-G-ECLIPSE(내 참여량) + match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/participants/{uid} { + allow read: if signedIn(); // 참여자 목록은 공동 이벤트의 일부다 + allow write: if false; // 상한·속도 판정은 fn-eclipse-defend + } + // 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다. // 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다. // 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」 - match /fandoms/{fandomId}/eclipses/{eclipseId}/shards/{shardId} { + match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/shards/{shardId} { allow read: if signedIn(); // 합산은 클라이언트가 해도 무방 allow write: if false; } - // 방어 로그(라이브 소셜 프루프 원천). + // 방어 로그(이클립스 단위 원장). 원천은 여기고, 화면이 구독하는 것은 아래 + // 팬덤 단위 liveEvents 다 — 티커는 활성 이클립스 ID 를 모른 채 떠야 한다. // 화면: S-G-ECLIPSE(실시간 방어 로그) - match /fandoms/{fandomId}/eclipses/{eclipseId}/defenseLogs/{logId} { + match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/defenseLogs/{logId} { + allow read: if signedIn(); + allow write: if false; + } + + // 라이브 소셜 프루프(방어 참여 · 이후 아이템 사용). 홈 라이브 띠와 갤럭시 + // 섬광 배너가 이 컬렉션을 구독한다. 닉네임·POP·시각만 담는다. + // 클라이언트가 쓰면 「누가 방어했다」를 위조할 수 있어 Functions 전용이다. + // 화면: S-H-HOME(라이브 띠) · S-G-GALAXY(섬광 배너) + // 근거: 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프 실시간 노출」 + match /fandoms/{fandomId}/liveEvents/{eventId} { allow read: if signedIn(); allow write: if false; } @@ -277,6 +453,14 @@ service cloud.firestore { allow write: if false; // 수령은 fn-herodrop-claim 경유 } + // Hero Gift 상품 Catalog — 원장 B-85. 문서 ID = 스토어 상품 ID. + // 화면: 홈 「아이템 구입」 · Admin `관리 마스터_아이템 관리`(fnAdminWrite 경유) + // 지급량(buyerPop · dropPop)은 `fn-iap-verify` 가 여기서 읽으므로 클라이언트 쓰기를 막는다. + match /heroGiftProducts/{productId} { + allow read: if signedIn(); + allow write: if false; + } + // 단결형 아이템 발동 상태. // 화면: S-M-SHOP · 근거: 별첨 1 ⑥ 「지급 대상·배수·시간창 정확」 match /fandoms/{fandomId}/solidarityBoosts/{boostId} { @@ -290,6 +474,22 @@ service cloud.firestore { match /quests/{questId} { allow read: if signedIn(); allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」 + + // 공개 후 수정 이력(C-07). 쓰기는 fn-quest-edit 전용 — append-only. + match /edits/{editId} { + allow read: if signedIn(); + allow write: if false; + } + } + + // 사용자 생성 Quest 제출 · 검토 · 지급(C-08 · C-17 · G-01 · G-03). + // 제출자 · 그 Quest 의 생성자 · 운영자만 읽는다. 상태 전이와 예약은 fn-quest-submit / + // fn-quest-review 가 원장과 한 트랜잭션에서 하므로 클라이언트 쓰기는 없다. + match /questSubmissions/{submissionId} { + allow read: if signedIn() && (resource.data.uid == request.auth.uid + || resource.data.questAuthorUid == request.auth.uid + || isOps()); + allow write: if false; } // 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL @@ -298,10 +498,23 @@ service cloud.firestore { allow write: if isOps(); } - // AI Daily Briefing 카드(Functions 생성 → 캐시). 화면: S-H-BRIEF · S-H-BRIEF-DETAIL + // AI Daily Briefing 카드. 화면: S-H-BRIEF · S-H-BRIEF-DETAIL + // + // **게시분만 공개한다.** 예전 규칙은 `if true` 였는데, 그러면 승인 전 Draft 가 + // 누구에게나 읽혔다. 별첨 B B-47 의 판정 기준이 「미승인 Draft 노출 0건」이므로 + // 규칙 층에서 막는다 — 앱이 걸러 주는 것과 읽을 수 없는 것은 다르다. + // 로그인 없는 웹 브릿지 열람(D-16)은 게시분만 필요하므로 그대로 열려 있다. + // + // 앱 질의가 `status == 'published'` 를 포함하므로 목록 조회도 통과한다. + // 운영자는 검수를 위해 Draft 를 봐야 해서 `isViewer()` 를 함께 둔다. + // + // **쓰기는 함수만 한다.** 예전 규칙의 `isOps()` 는 `fn-briefing-publish` 를 + // 우회해 상태를 바꿀 수 있는 길이었고, 그 경로로 바뀐 게시는 `adminActionLogs` + // 에 남지 않는다. 누가 무엇을 승인했는지가 사라지면 B-47 의 승인 게이트가 + // 사실상 없는 것과 같다. match /briefings/{briefingId} { - allow read: if true; // 웹 브릿지 열람 — D-16 - allow write: if isOps(); // 생성은 Functions, 수동 편집은 운영 + allow read: if resource.data.status == 'published' || isViewer(); + allow write: if false; // 수신·승인은 Functions 전용 } // 배지 도감 마스터. 화면: S-M-BADGE @@ -326,24 +539,18 @@ service cloud.firestore { // 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다. allow read: if campaignIsPublic() || isOps() - || (signedIn() && resource.data.creatorUid == request.auth.uid); - // 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로 - // 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다. - allow create: if signedIn() - && request.resource.data.keys().hasOnly([ - 'fandomId','creatorUid','creatorName','title','summary','body', - 'coverImageUrl','campaignType','targetPop','endsAt','status', - 'raisedPop','achievedRate','donorCount', - '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.refundState == 'none'; + || (signedIn() && resource.data.creatorUid == request.auth.uid) + // 회사 귀책 취소(B-58)는 후원자에게 환불 알림이 가고 그 링크가 이 문서를 연다. + // 후원 문서 ID 가 거래 ID 라 규칙이 찾을 수 없어, fn-campaign-fund 가 같은 + // 트랜잭션에 쓰는 `backers/{uid}` 표식으로 후원 여부를 본다. 철회(withdrawn)는 + // 공개 전·첫 후원 전에만 가능해 후원자가 없으므로 계속 비공개다. + || (signedIn() && resource.data.status == 'cancelled' + && exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid))); + // 개설은 `fn-campaign-create` 전용이다. 등록비 차감(B-57)과 자격 판정(B-56), + // 심사 대기(review) 진입이 한 트랜잭션이어야 하고, 개설자는 status 를 쓸 수 없어 직접 + // 만든 draft 는 심사로 넘어갈 길이 없었다. 화이트리스트 · 집계 초기값 + // 판정은 이제 함수가 필드를 직접 정해 쓰는 것으로 대신한다. + allow create: if false; // 개설자는 심사 전 초안만 고칠 수 있다. allow update: if signedIn() && resource.data.creatorUid == request.auth.uid @@ -358,23 +565,81 @@ service cloud.firestore { // 후원 내역. POP 차감과 1:1이라 전량 서버 경유. // 화면: S-C-FUND · S-C-MINE · S-C-REFUND + // 캠페인 좋아요. 문서 ID = uid 로 중복을 구조적으로 막고, 개수는 앱이 + // 집계 쿼리로 센다 — 캠페인 문서의 화이트리스트를 넓히지 않는다. 공개 캠페인에만 누른다. + match /campaigns/{campaignId}/likes/{uid} { + allow read: if signedIn(); + allow create: if isSelf(uid) + && request.resource.data.keys().hasOnly(['createdAt']) + && get(/databases/$(database)/documents/campaigns/$(campaignId)) + .data.status in ['funding','success','fail','report']; + allow delete: if isSelf(uid); + allow update: if false; + } + + // 응원 게시판 댓글. 화면: S-C-DETAIL 응원 게시판 탭 · 시안 `527:21190`. + // 라운지 댓글과 같은 틀 — 표시명은 공개 닉네임만(M-7), 서버 시각, 1,000자. + // 누가 쓰는가: 운영값 `config/policy.campaignCommentScope`(all · members · backers). + // 명세에 제한이 없어 잠정 'all' = 로그인 회원 누구나. + // 공개 캠페인에만 단다. `commentCount` 는 fnCampaignCommentCount 가 다시 센다. + match /campaigns/{campaignId}/comments/{commentId} { + // 숨김 댓글은 운영만 본다. 목록 질의는 `visibility == 'visible'` 로 좁혀야 통과한다. + allow read: if isOps() || (signedIn() && resource.data.visibility == 'visible'); + allow create: if authorIsSelf() + && request.resource.data.keys().hasOnly([ + 'authorUid','authorName','body','visibility','createdAt','updatedAt' + ]) + // 응원 게시판은 「팬」 대체 이름을 받지 않는다 — 공개 닉네임이 있어야 쓴다. + && hasPublicNickname() + && authorNameIsPublic() + && request.resource.data.visibility == 'visible' + && bodyOk(1000) + && createdNow() && updatedNow() + && campaignOpenFor(campaignId) + && campaignCommenter(campaignId); + allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); + // 운영이 숨긴 댓글은 작성자가 지워 증거를 없애지 못한다. + allow delete: if signedIn() && resource.data.authorUid == request.auth.uid + && resource.data.visibility == 'visible'; + } + + // 응원 게시판 댓글 좋아요(시안 `527:21192`). 문서 ID = uid 로 1인 1회. + // 댓글의 `likeCount` 는 fnCampaignCommentLikeCount 가 다시 센다 — 댓글 문서는 + // 운영의 숨김 외에 수정 경로가 없어 위조할 수 없다. + match /campaigns/{campaignId}/comments/{commentId}/likes/{uid} { + allow read: if signedIn(); + allow create: if isSelf(uid) + && request.resource.data.keys().hasOnly(['createdAt']) + && createdNow() + && get(/databases/$(database)/documents/campaigns/$(campaignId)/comments/$(commentId)) + .data.visibility == 'visible' + && campaignOpenFor(campaignId) + // 댓글을 쓸 수 없는 사람은 좋아요도 누를 수 없다(같은 작성 자격). + && campaignCommenter(campaignId); + allow delete: if isSelf(uid); + allow update: if false; + } + + // 후원자 표식. 문서 ID = uid. `fn-campaign-fund` 가 후원 문서와 같은 트랜잭션에 쓴다. + match /campaigns/{campaignId}/backers/{uid} { + allow read: if isSelf(uid) || isOps(); + allow write: if false; + } + match /campaigns/{campaignId}/donations/{donationId} { allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL) allow write: if false; // 별첨1 ⑤ 환불 무결성 } - // 결과 보고. 성공 캠페인 개설자만 1회 제출하고 이후 운영이 관리한다. + // 결과 보고. 성공 캠페인 개설자(또는 운영)가 1회 제출하고 이후 운영이 관리한다. // 화면: S-C-RESULT match /campaigns/{campaignId}/reports/{reportId} { allow read: if true; - // §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'; + // 대상은 성공한 캠페인이다(별첨1 ⑤ 「결과보고 게시 확인」). + // 제출은 `fn-campaign-report-submit` 전용이다. 보고 문서와 캠페인 상태 `report` + // 전이·후원자 알림이 한 번에 서야 하는데, 개설자는 캠페인 status 를 쓸 수 없어 + // 클라이언트 직접 생성으로는 「보고는 있는데 상태는 success」가 남는다. + allow create: if false; allow update: if isOps(); allow delete: if false; } @@ -393,23 +658,36 @@ service cloud.firestore { 'fandomId','authorUid','authorName','body','imageUrls', 'likeCount','commentCount','visibility','createdAt','updatedAt' ]) - && authorNameMatchesProfile() + && authorNameIsPublic() && fandomIsMine() && request.resource.data.likeCount == 0 && request.resource.data.commentCount == 0 - && request.resource.data.visibility == 'visible'; + && request.resource.data.visibility == 'visible' + && bodyOk(5000) + && createdNow() && updatedNow() + // 1 장 용량 · 형식 · 파일 이름은 storage.rules 가 본다. + && loungeImagesOk(postId); // 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다. + // 사진은 작성 후 바꾸지 않는다 — 본문만 고친다. allow update: if signedIn() && resource.data.authorUid == request.auth.uid - && touchesOnly(['body','imageUrls','updatedAt']); + && touchesOnly(['body','updatedAt']) + && bodyOk(5000) + && updatedNow(); // 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상 allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); allow delete: if signedIn() && resource.data.authorUid == request.auth.uid; } // 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다. + // likeCount 는 fnPostLikeCount 트리거가 하위 문서 수를 다시 세어 적는다 — + // 클라이언트는 글 문서의 수를 못 건드리므로(위 touchesOnly) 위조 경로가 없다. match /posts/{postId}/likes/{uid} { allow read: if signedIn(); - allow create, delete: if isSelf(uid); // 집계 반영은 Functions 트리거 + allow create: if isSelf(uid) + && request.resource.data.keys().hasOnly(['createdAt']) + && createdNow() + && canReactTo(postId); + allow delete: if isSelf(uid); // 취소는 회원 자격과 무관하게 연다 allow update: if false; } @@ -421,10 +699,15 @@ service cloud.firestore { && request.resource.data.keys().hasOnly([ 'authorUid','authorName','body','visibility','createdAt','updatedAt' ]) - && authorNameMatchesProfile() - && request.resource.data.visibility == 'visible'; + && authorNameIsPublic() + && request.resource.data.visibility == 'visible' + && bodyOk(1000) + && createdNow() && updatedNow() + && canReactTo(postId); // 팬덤 회원만 · 보이는 글에만 allow update: if signedIn() && resource.data.authorUid == request.auth.uid - && touchesOnly(['body','updatedAt']); + && touchesOnly(['body','updatedAt']) + && bodyOk(1000) + && updatedNow(); allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); allow delete: if signedIn() && resource.data.authorUid == request.auth.uid; } @@ -446,9 +729,15 @@ service cloud.firestore { 'authorUid','authorName','body','sourceUrls','accepted', 'visibility','createdAt','updatedAt' ]) - && authorNameMatchesProfile() - && request.resource.data.accepted == false; + && authorNameIsPublic() + && request.resource.data.accepted == false + // 서버 시각만 받는다 — 과거 시각으로 최신순 정렬을 흔들지 못하게. + && request.resource.data.createdAt == request.time + // 채택·만료된 질문에는 답변을 달지 않는다(현상금이 이미 정산됨). + && get(/databases/$(database)/documents/questions/$(questionId)).data.status == 'open'; + // 채택된 답변은 본문을 고칠 수 없다 — 채택 후 내용 바꿔치기 차단. allow update: if signedIn() && resource.data.authorUid == request.auth.uid + && resource.data.accepted == false && touchesOnly(['body','sourceUrls','updatedAt']); allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']); allow delete: if false; // 채택 대상이라 삭제 대신 숨김 @@ -459,9 +748,37 @@ service cloud.firestore { match /reports/{reportId} { allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid); allow list: if isOps(); + // 설계서 §2.6 reports 스키마로만 받는다. 문서 ID 를 「post-{글}-{신고자}」로 + // 규칙이 묶어 두므로 같은 사람의 재신고는 update 가 되어 아래에서 거부되고, + // 임의 ID 로 같은 글을 여러 번 신고할 수도 없다(B-45 「신고자 수」). + // 지금 앱이 접수하는 대상은 라운지 글 · 캠페인(`campaign-{캠페인}-{신고자}`) · + // 캠페인 응원 게시판 댓글(`campaignComment-{캠페인}-{댓글}-{신고자}`)이다. 라운지 댓글 · 답변 · 회원 + // 신고는 앱 경로가 생길 때 같은 방식으로 ID 를 묶어 연다. + // 사유 코드는 Figma 사본 신고 사유 4항목(`moderation.dart`)의 코드다. allow create: if signedIn() + && request.resource.data.keys().hasOnly([ + 'reporterUid','targetType','targetPath','reasonCode', + 'status','createdAt' + ]) && request.resource.data.reporterUid == request.auth.uid - && request.resource.data.status == 'pending'; + && request.resource.data.targetPath is string + && ((request.resource.data.targetType == 'post' + && request.resource.data.targetPath.matches('posts/[^/]+') + && reportId == 'post-' + request.resource.data.targetPath.split('/')[1] + + '-' + request.auth.uid) + || (request.resource.data.targetType == 'campaign' + && request.resource.data.targetPath.matches('campaigns/[^/]+') + && reportId == 'campaign-' + request.resource.data.targetPath.split('/')[1] + + '-' + request.auth.uid) + || (request.resource.data.targetType == 'campaignComment' + && request.resource.data.targetPath.matches('campaigns/[^/]+/comments/[^/]+') + && reportId == 'campaignComment-' + request.resource.data.targetPath.split('/')[1] + + '-' + request.resource.data.targetPath.split('/')[3] + + '-' + request.auth.uid)) + && request.resource.data.reasonCode in + ['defamation','inappropriate','spam','privacy'] + && request.resource.data.status == 'pending' + && createdNow(); allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']); allow delete: if false; } @@ -469,8 +786,19 @@ service cloud.firestore { // 문의. 화면: S-M-INQUIRY · S-AD-REPORT match /inquiries/{inquiryId} { allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid); - allow list: if isOps(); + // 내 문의 목록(S-M-INQUIRY · Figma `373:634916`)이 자기 문의를 열거한다. + // 규칙이 문서마다 `authorUid` 를 보므로, 질의가 `authorUid == uid` 로 + // 좁혀지지 않으면 **질의 전체가 거부**된다 — 남의 문의는 한 건도 새지 않는다. + allow list: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid); allow create: if authorIsSelf() && request.resource.data.status == 'pending'; + // 작성자 수정 — Figma 문의 수정 `554:24330` · 상세의 「수정」 `373:635123`. + // 접수(`pending`) 상태에서만, 제목과 본문만 고칠 수 있다. `status` 를 함께 + // 건드리면 거부되므로 스스로 「답변 완료」로 올릴 수 없고, 운영이 상태를 + // 올린 뒤에는 이 갈래가 닫힌다. + allow update: if signedIn() + && resource.data.authorUid == request.auth.uid + && resource.data.status == 'pending' + && touchesOnly(['title','body','updatedAt']); allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']); allow delete: if false; } @@ -522,7 +850,8 @@ service cloud.firestore { } // 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다. - // 남용 방지를 위해 문서에는 사용 횟수 등 최소 필드만 두고 개인정보를 담지 않는다. + // 문서에는 `{active}` 만 둔다 — 코드 주인(UID)은 서버 전용 `inviteCodeOwners/{code}` + // (기본 거부 구역)에 있어 코드로 사용자를 알아낼 수 없다. // 화면: S-W-PREREG(코드 입력) · E-PRE-01 match /inviteCodes/{code} { allow get: if true; // 코드 직접 조회만 허용 @@ -545,12 +874,19 @@ service cloud.firestore { allow write: if false; } - // 마킹. 문서 ID = uid 로 중복 마킹을 차단하고, 본인 번역 자기 마킹은 서버가 막는다(E-TRN-01). + // 번역 일일 카운터(`{uid}_{dayKey}`). 도배 판정 입력이라 서버 전용. + match /translationDaily/{docId} { + allow read, write: if false; + } + + // 마킹. 문서 ID = uid 로 중복 마킹을 차단한다. 쓰기는 fn-translation-mark 만 — + // 본인 번역(E-TRN-01) · 팬덤 멤버 · 가입 24시간(E-TRN-04) 판정이 규칙으로는 안 된다. + // 읽기는 번역 작성자 · 마킹한 본인 · 운영자만 — 수는 번역 문서(validCount · invalidCount)에 있다. 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 read: if signedIn() + && (request.auth.uid == uid || isOps() + || get(/databases/$(database)/documents/translationEntries/$(entryId)).data.authorUid == request.auth.uid); + allow create: if false; allow update, delete: if false; // 번복 불가 — 어뷰징 가드 } diff --git a/app/firebase/seed-demo.py b/app/firebase/seed-demo.py new file mode 100644 index 0000000..10ecbed --- /dev/null +++ b/app/firebase/seed-demo.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""검수용 시연 데이터를 Firestore 에 넣는다. + +발주사가 설치본으로 화면을 확인하실 수 있어야 하는데(별첨 2 2주차 「앱 설치·로그인· +탭 이동 확인」), 빈 프로젝트에서는 모든 화면이 빈 상태로 뜬다. 그래서 검수 프로젝트에만 +시연 데이터를 넣는다. **운영 데이터가 아니다.** + +인증은 firebase-tools 가 보관한 dev@sweech.io 액세스 토큰을 쓴다. 별도 서비스 계정 +키를 만들지 않으려는 것이며, 토큰은 파일에 남기지 않는다. + + python3 seed-demo.py [--uid <가입한 uid>] + +uid 를 주면 그 사용자의 지갑·행성·소속을 함께 만든다. 주지 않으면 공용 컬렉션만 넣는다. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone + +PROJECT = "fanuzu-dev-b020d" +BASE = f"https://firestore.googleapis.com/v1/projects/{PROJECT}/databases/(default)/documents" +KST = timezone(timedelta(hours=9)) + + +def access_token(email: str = "dev@sweech.io") -> str: + """firebase-tools 토큰 저장소에서 액세스 토큰을 꺼낸다.""" + path = os.path.expanduser("~/.config/configstore/firebase-tools.json") + store = json.load(open(path, encoding="utf-8")) + if store.get("user", {}).get("email") == email: + return store["tokens"]["access_token"] + for account in store.get("additionalAccounts", []): + if account.get("user", {}).get("email") == email: + return account["tokens"]["access_token"] + raise SystemExit(f"{email} 토큰을 찾지 못했습니다. firebase login:add 를 먼저 하십시오.") + + +class GeoPoint: + """좌표. Firestore 는 지도(map)가 아니라 geoPointValue 로 받아야 하며, 서버가 + `place.get('geo').latitude` 로 읽으므로 형이 다르면 반경 판정이 조용히 실패한다.""" + + def __init__(self, lat: float, lng: float) -> None: + self.lat = lat + self.lng = lng + + +def value(v): + """파이썬 값 → Firestore REST 의 Value 표현.""" + if isinstance(v, GeoPoint): + return {"geoPointValue": {"latitude": v.lat, "longitude": v.lng}} + if isinstance(v, bool): + return {"booleanValue": v} + if isinstance(v, int): + return {"integerValue": str(v)} + if isinstance(v, float): + return {"doubleValue": v} + if isinstance(v, datetime): + return {"timestampValue": v.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")} + if isinstance(v, list): + return {"arrayValue": {"values": [value(x) for x in v]}} + if isinstance(v, dict): + return {"mapValue": {"fields": {k: value(x) for k, x in v.items()}}} + return {"stringValue": str(v)} + + +def put(token: str, path: str, fields: dict) -> None: + """문서 하나를 통째로 덮어쓴다. 여러 번 돌려도 결과가 같도록 PATCH 를 쓴다.""" + url = f"{BASE}/{path}" + body = json.dumps({"fields": {k: value(v) for k, v in fields.items()}}).encode() + req = urllib.request.Request(url, data=body, method="PATCH") + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + try: + urllib.request.urlopen(req, timeout=30).read() + print(f" ✔ {path}") + except urllib.error.HTTPError as e: + print(f" ✖ {path} — {e.code} {e.read().decode()[:200]}", file=sys.stderr) + raise + + +NOW = datetime.now(KST) +FANDOM = "fanuzu-demo" + +# FPI 5 축 — 별첨 B 「FPI 5 축」 확정. 순서를 여기서 한 번 고정하고 아래 표가 따른다. +AXES = ["bpm", "health", "referral", "community", "contribution"] + +# 갤럭시에 뜨는 행성들. `fpiScore` 는 5 축 단순 합산이다(별첨 B · 설계서 §4.1, +# 가중치 초기 1.0). 앱이 TOTAL FPI 를 축의 합으로 그리므로 여기서 어긋나면 +# 지표 카드의 합과 TOTAL FPI 가 갈린다 — 발주사 검토회신서 4절의 지적 자리다. +# +# **축마다 1 위가 다르도록 짰다.** 「서로 다른 지표 화면에 동일한 Ranking 데이터가 +# 반복된다」가 지적이었으므로, 시연 데이터에서 지표를 바꾸면 순서가 바뀌는 것이 +# 눈으로 확인돼야 한다. +FANDOMS = [ + # id, 이름, 축값[5], 멤버, 접속, 티커, 직전 축별 순위(순위 동향 ▲▼ 용) + (FANDOM, "FANUZU 데모 행성", [24_800, 31_200, 18_400, 27_600, 26_400], + 12_840, 431, "오늘의 기여가 132% 늘었습니다", [3, 1, 4, 1, 2]), + ("aurora", "오로라", [31_400, 12_800, 22_600, 15_200, 14_200], + 8_310, 214, "", [1, 4, 1, 3, 2]), + ("nova-crew", "노바 크루", [9_800, 24_600, 11_200, 19_400, 9_800], + 6_002, 158, "", [3, 2, 4, 2, 3]), + ("stellar", "스텔라", [7_200, 8_400, 25_800, 6_300, 3_600], + 4_115, 96, "", [4, 5, 2, 5, 5]), + ("lumen", "루멘", [5_100, 6_200, 4_300, 11_400, 6_700], + 2_884, 61, "", [5, 5, 5, 4, 4]), +] + +# 데모 행성의 멤버. 사용자 지표 랭킹(Figma 546:14668·14674)의 원천이다. +# 국가 랭킹(546:14713)은 이 표를 국가별로 합산해 만든다 — 따로 손으로 적으면 +# 두 화면의 수치가 갈린다. +MEMBERS = [ + # uid, 닉네임, 국가, 축값[5] + ("demo-bunnies", "별빛수집가", "KR", [4_820, 2_110, 1_240, 980, 1_530]), + ("demo-carat", "달빛", "KR", [3_960, 3_480, 620, 2_140, 870]), + ("demo-moomin", "코스모스", "JP", [1_880, 4_020, 940, 3_050, 760]), + ("demo-bora", "은하수", "JP", [1_210, 870, 2_260, 1_180, 2_980]), + ("demo-dive", "네뷸라", "US", [940, 1_640, 1_720, 610, 1_120]), + ("demo-nova", "혜성", "", [520, 780, 430, 340, 610]), +] + + +def axis_map(values): + """축값 5개를 `fpiAxes` 맵으로.""" + return dict(zip(AXES, values)) + +# (문서 ID, 제목, 보상 POP, 일일 한도, 진행 라벨, Quest Type) +# +# `type` 은 앱이 **어느 검증 함수를 부를지** 고르는 값이다(설계서 §2.8). 비워 두면 앱이 +# 판정 경로를 몰라 참여 버튼을 잠그고 사유를 표시한다 — 임의로 붙이면 검증 없는 지급 +# 경로가 생기므로 비워 두는 쪽이 맞다. 라운지·응원처럼 Community 유형은 목표 수치 정의가 +# 회신 대기(Q-QC-08)라 실제로 비어 있는 상태다. +QUESTS = [ + ("daily-checkin", "출석 체크", 50, 1, "1/1", "attend"), + ("daily-steps", "8,000보 걷기", 120, 1, "6,240 / 8,000", "walk"), + ("daily-place", "홍대 팝업스토어 방문", 100, 1, "0 / 1", "location"), + ("daily-post", "라운지에 글 남기기", 100, 1, "0 / 1", ""), + ("daily-cheer", "다른 행성 응원하기", 60, 3, "1 / 3", ""), +] + +# 행성 성장 단계 — 설계서 §2.3 `planetStages/{stageNo}`. +# +# 값의 출처는 **발주사 제공 수치표 v1.4 「Stage Summary」** 시트다(구간 기본 POP·sFPI). +# 설계서의 `requiredPop` 은 「해당 Level 진입 **누적** 기여 POP」이므로 시트의 구간값을 +# 누계로 바꿔 적었다. 합계는 시트의 합계 행과 같다 — 50,000,000 POP · 7,500,000 sFPI. +# +# Stage 번호는 별첨 B **B-53** 을 따른다. 성운은 Stage0 으로 빼고 정식 행성이 1~6 이다 +# (시트의 1~7 번호와 한 칸 어긋난다). 성운 → 정식 행성 전환 조건은 이 표가 아니라 +# B-10(10 명 + 1,000 POP + 7 일)이라 Stage0 의 문턱은 0 이다. +# +# (stageNo, levelFrom, levelTo, 진입 누적 POP, 진입 누적 sFPI) +PLANET_STAGES = [ + (0, None, None, 0, 0), # 태동 — 성운(Pre-Planet) + (1, 1, 20, 0, 0), # 시작 + (2, 21, 40, 100_000, 15_000), # 성장 + (3, 41, 60, 500_000, 75_000), # 함께 + (4, 61, 80, 2_000_000, 300_000), # 동행 + (5, 81, 99, 10_000_000, 1_500_000), # 빛 + (6, 100, 100, 50_000_000, 7_500_000), # 영원 +] + +# AI Daily Briefing 표본 — 정책 원장 B-47 · B-75. +# 문안은 발주사 Agent 엔진이 만들고 우리는 수신·승인·노출만 한다(B-48). 표본도 +# 그 규격대로 5 요소(출처명·게시시각·원본 URL·원문보기·AI 요약)를 전건 채운다 — +# 하나라도 비면 앱이 그 줄을 버리므로 데모에서 카드가 사라진다. +BRIEFINGS = [ + ("News", 1, "데뷔 10주년 단독 콘서트 서울 이틀 확정", + "연합뉴스", "https://example.com/news/anniversary-concert", + "서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다."), + ("News", 2, "신곡, 주요 음원 차트 상위권 진입", + "스포티파이 차트", "https://example.com/news/chart-entry", + "발매 당일 글로벌 차트 상위권에 올랐습니다."), + ("SNS", 1, "공식 계정, 팬미팅 비하인드 공개 예고", + "공식 X 계정", "https://example.com/sns/behind-teaser", + "비하인드 영상 공개 일정이 예고되었습니다."), +] + +QUESTIONS = [ + ("데뷔 초기 무대 영상 어디서 볼 수 있나요", "공식 채널에 남아 있는 영상이 있는지 찾고 있습니다.", "별빛수집가", 500, 4), + ("응원봉 배터리 교체 방법 아시는 분", "공식 안내가 없어서 직접 해보신 분 계신지 여쭙니다.", "달빛", 300, 7), + ("팬미팅 대기 순서는 어떻게 정해지나요", "선착순인지 추첨인지 정확히 아는 분 계실까요.", "코스모스", 200, 2), +] + +POSTS = [ + ("오늘 방송 보신 분", "마지막 곡 편곡이 완전히 달라졌더라고요. 다시 듣고 왔습니다.", "별빛수집가", 42, 8), + ("행성 3단계 축하합니다", "다들 고생 많으셨어요. 다음 목표도 금방일 것 같습니다.", "달빛", 128, 23), + ("굿즈 실물 후기", "생각보다 크기가 커서 놀랐습니다. 마감도 깔끔해요.", "코스모스", 76, 14), + ("걷기 퀘스트 같이 하실 분", "혼자 하니 자꾸 까먹네요. 인증 스레드 만들어요.", "은하수", 55, 31), +] + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--uid", help="가입한 사용자 uid — 지갑·소속·행성을 함께 만든다") + args = ap.parse_args() + token = access_token() + + # 별첨 B v1.4 — STATION 은 1차 비노출. 문서를 미리 만들어 두어야 + # 나중에 "앱 재배포 없이" 값 하나만 뒤집을 수 있다. + print("▸ 기능 스위치(config/features)") + put(token, "config/features", {"stationEnabled": False}) + + # 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값). + # 서버 `DEFAULT_QUEST_CATALOG`(functions/src/quest/ugc.ts)와 같은 값이다. 운영 변경은 fn-admin-write. + print("▸ 퀘스트 분류 마스터(config/questCatalog)") + field = lambda f: [{"field": f, "required": True}] if f else [] + put(token, "config/questCatalog", { + "categories": [ + {"key": k, "label": label, "order": i + 1, "visible": True} + for i, (k, label) in enumerate([ + ("health", "건강"), ("planet", "행성 활동"), + ("community", "커뮤니티"), ("location", "특정 위치"), + ]) + ], + "activities": [ + {"key": k, "categoryKey": c, "order": o, "visible": True, "ugcAllowed": u, + "inputSchema": field(f), **({"minReactions": 1} if k == "community" else {})} + for k, c, o, u, f in [ + ("walk", "health", 1, False, "goal"), ("shake", "health", 2, False, "goal"), + ("streaming", "planet", 1, False, "link"), ("youtube", "planet", 2, False, "link"), + ("vote", "planet", 3, True, "link"), ("sns", "planet", 4, True, "link"), + ("community", "community", 1, True, None), + ("location", "location", 1, True, "place"), + # 위치 무관 QR / NFC — 검증 명세 확정 전 사용자 생성 비노출(C-03). + ("qr", "location", 2, False, "code"), ("nfc", "location", 3, False, "tag"), + ] + ], + }) + + print("▸ 행성(fandoms)") + # 축별 순위는 서버(fn-fpi-aggregate)가 매기는 값이다. 시드도 같은 방식으로 + # 계산해 넣는다 — 손으로 적으면 값과 순위가 갈린다. + ranks = { + axis: { + fid: i + 1 + for i, (fid, *_) in enumerate( + sorted(FANDOMS, key=lambda f: -f[2][a]) + ) + } + for a, axis in enumerate(AXES) + } + # 생성 팔레트 키(C-50 · 앱 planetColors) — 행성마다 돌려 쓴다. + colors = ["blue", "purple", "gold", "green", "red", "orange", "white", "black"] + for i, (fid, name, axes, members, online, ticker, prev) in enumerate(FANDOMS): + color = colors[i % len(colors)] + # 팬덤당 행성 1건 — 문서 ID 고정 `planet`(fn-planet-create 와 같은 자리 · 설계서 §2.4). + put(token, f"fandoms/{fid}/planets/planet", { + "ownerUid": "seed", "fandomId": fid, "planetType": "shape1", "colorKey": color, + "createdAt": NOW, + }) + # 이름 중복 판정 문서(fn-nebula-create) — 시드 이름으로 성운을 또 만들지 못하게. + put(token, f"fandomNames/{name.lower()}", {"fandomId": fid, "createdAt": NOW}) + put(token, f"fandoms/{fid}", { + "planetType": "shape1", "colorKey": color, + "name": name, + # TOTAL FPI = 5 축 단순 합산. + "fpiScore": sum(axes), + "fpiAxes": axis_map(axes), + "fpiRanks": {axis: ranks[axis][fid] for axis in AXES}, + "fpiRanksPrev": axis_map(prev), + # 별첨 B 「FPI 랭킹」 모집단 — 정식 행성(Stage 1 이상) 중 + # 최근 7 일 유효 활동 존재 행성. + "stage": 1, "rankingEligible": True, + "memberCount": members, "onlineCount": online, + "liveTicker": ticker, "countryCode": "KR", + "fpiUpdatedAt": NOW, + }) + + print("▸ Daily FPI Snapshot — 일 1 회(별첨 B). 지표 카드의 「최근 동향」 기준선") + # 앱의 최근 동향 = 현재 `fpiAxes` − 오늘 스냅샷. 스냅샷을 조금 낮게 두어 + # 상승·하락이 섞여 보이게 한다. + day_key = NOW.strftime("%Y-%m-%d") + for fid, _name, axes, *_rest in FANDOMS: + base = [v - d for v, d in zip(axes, [1_700, -1_150, 500, 1_800, 1_500])] + put(token, f"fandoms/{fid}/fpiSnapshots/{day_key}", { + "createdAt": NOW.replace(hour=0, minute=0, second=0, microsecond=0), + "fpiAxes": axis_map(base), "fpiScore": sum(base), + }) + + print("▸ 데모 행성 멤버 — 사용자 지표 랭킹의 원천") + for muid, nickname, country, axes in MEMBERS: + put(token, f"fandoms/{FANDOM}/members/{muid}", { + "uid": muid, "fandomId": FANDOM, "nickname": nickname, + # 국가 랭킹 집계가 읽는 비정규화 필드. 빈 값은 ZZ(미지정) 버킷이다. + "countryCode": country, + "fpiAxes": axis_map(axes), + "contributedPop": axes[4], + "joinedAt": NOW - timedelta(days=20), + }) + + print("▸ 국가별 지표 랭킹 — 축마다 문서 1건(설계서 §10)") + for a, axis in enumerate(AXES): + totals = {} + for _muid, _nick, country, axes in MEMBERS: + totals[country or "ZZ"] = totals.get(country or "ZZ", 0) + axes[a] + rows = sorted(totals.items(), key=lambda kv: -kv[1]) + put(token, f"rankings/country-{axis}", { + "axis": axis, "updatedAt": NOW, + # 표시명은 넣지 않는다 — 앱의 `countryName` 한 곳이 코드를 이름으로 + # 옮긴다. 여기에도 이름을 적으면 표기가 두 벌이 된다. + "rows": [ + { + "code": code, "value": value, "rank": i + 1, + # 시드에는 직전 집계가 없으므로 동향은 0 이다. + "change": 0, + } + for i, (code, value) in enumerate(rows) + ], + }) + + print("▸ 퀘스트") + for qid, title, reward, limit, label, qtype in QUESTS: + fields = { + "title": title, "rewardPop": reward, "dailyLimit": limit, + "progressLabel": label, "active": True, + } + if qtype: + fields["type"] = qtype + if qid == "daily-place": + # 위치 퀘스트는 장소 문서를 가리켜야 서버가 반경을 판정할 수 있다. + fields["placeId"] = "hongdae-popup" + put(token, f"quests/{qid}", fields) + + print("▸ 행성 성장 단계 — 발주사 수치표 v1.4 「Stage Summary」") + for stage_no, level_from, level_to, pop, sfpi in PLANET_STAGES: + fields = {"stageNo": stage_no, "requiredPop": pop, "requiredSfpi": sfpi} + if level_from: + fields["levelFrom"] = level_from + fields["levelTo"] = level_to + put(token, f"planetStages/{stage_no}", fields) + + print("▸ 위치 퀘스트 장소 — 반경은 Admin 운영값(별첨 B B-27 기본 100m)") + put(token, "places/hongdae-popup", { + "name": "홍대 팝업스토어", + "geo": GeoPoint(37.5563, 126.9236), + "radiusM": 100, + "rewardPop": 100, + "verifyMethods": ["gps", "qr"], + }) + + print("▸ 브리핑") + # 앱은 `status == published` 이고 `dayKey` 가 당일(KST)인 것만 읽는다(B-47). + # 시드가 그 두 필드를 채우지 않으면 데모에서 브리핑 블록이 숨는다. + day_key = NOW.strftime("%Y-%m-%d") + for section, rank, headline, source_name, url, summary in BRIEFINGS: + put(token, f"briefings/{FANDOM}_{day_key}_{section}_{rank}", { + "fandomId": FANDOM, "dayKey": day_key, "section": section, + "rank": rank, "headline": headline, "status": "published", + "sourceName": source_name, "sourceAt": NOW - timedelta(hours=rank), + "sourceUrl": url, "articleUrl": url, "aiSummary": summary, + "aiGenerated": True, + "createdAt": NOW, "publishedAt": NOW, + }) + + print("▸ 캠페인") + put(token, "campaigns/demo-campaign", { + "fandomId": FANDOM, "status": "funding", "campaignType": "support", + "title": "데뷔 5주년 지하철 광고", "authorName": "별빛수집가", + "description": "역사 스크린에 5주년 축하 영상을 올립니다. 모인 POP 만큼 노출 일수가 늘어납니다.", + "goalPop": 500_000, "raisedPop": 341_200, "donorCount": 284, + "commentCount": 47, "likeCount": 512, "achievedRate": 68, + "createdAt": NOW - timedelta(days=6), "endsAt": NOW + timedelta(days=9), + }) + + print("▸ 질문(INSIDE)") + for i, (title, body, author, bounty, answers) in enumerate(QUESTIONS): + put(token, f"questions/q-{i}", { + "fandomId": FANDOM, "status": "open", "category": "general", + "title": title, "body": body, "authorName": author, + "bountyPop": bounty, "answerCount": answers, + "createdAt": NOW - timedelta(hours=2 + i * 5), + }) + + print("▸ 라운지 글") + for i, (title, body, author, likes, comments) in enumerate(POSTS): + put(token, f"posts/post-{i}", { + # 설계서 §2.6 — visible | hidden | deleted. 규칙이 새 글을 visible 로만 만든다. + "fandomId": FANDOM, "visibility": "visible", + "authorUid": f"demo-author-{i}", + "title": title, "body": body, "authorName": author, + "likeCount": likes, "commentCount": comments, + "createdAt": NOW - timedelta(hours=1 + i * 4), + }) + + print("▸ STATION 큐레이션 링크") + for i, (title, url, category, desc, verified) in enumerate([ + ("FANUZU 공식 사이트", "https://fanuzu.co.kr", "official", "공지와 일정이 가장 먼저 올라옵니다.", True), + ("공식 스토어", "https://fanuzu.co.kr", "shop", "앨범과 응원봉을 파는 공식 판매처입니다.", True), + ("팬미팅 예매", "https://fanuzu.co.kr", "ticket", "예매 일정과 대기 순서를 확인하십시오.", False), + ("공식 미디어 채널", "https://fanuzu.co.kr", "media", "무대 영상과 비하인드가 올라옵니다.", True), + ]): + put(token, f"stationLinks/link-{i}", { + "fandomId": FANDOM, "rank": i + 1, "title": title, "url": url, + "category": category, "description": desc, "verified": verified, + }) + + print("▸ 갤럭시 이벤트") + put(token, "galaxyEvents/evt-latest", { + "kind": "itemUsed", "message": "오로라가 성장 촉진 아이템을 사용했습니다", + "createdAt": NOW - timedelta(minutes=18), + }) + + print("▸ 이클립스 운영값 — Admin 「관리 마스터_이클립스 관리」 항목") + # **`enabled` 를 끈 채 심는다.** 켜 두면 `fn-eclipse-trigger` 가 발동 시각에 + # 이클립스를 하나 더 만들어, 아래에서 손으로 심는 시연용 이클립스와 둘이 동시에 + # 활성이 된다. 발동 스케줄을 확인할 때 이 값을 켠다. + put(token, "config/eclipse", { + "enabled": False, + # 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[]. + "schedule": [{"hhmm": "07:50"}, {"hhmm": "17:50"}], + "erosionPercent": 0.25, # C-23 실패 감소율(%) + "warnSeconds": 180, # C-22 발생 3분 전 푸시 + "windowSec": 60, # C-22 공통 방어 창(초) + "targetRate": 0.05, # C-25 7일 평균 DAU 대비 목표율 + "dauWindowDays": 7, # C-21 DAU 평균 기간(일) + "qualifyShakes": 60, # C-25 개인 인정 유효 Shake + "perCallMaxShakes": 50, "perUserMaxDefense": 500, "minIntervalMs": 300, + }) + + print("▸ 활성 이클립스 — 경보 배너·침식 HUD·ZAP 방어의 대상") + # 샤드는 비워 둔다. `fn-eclipse-defend` 가 채우고 `fn-eclipse-aggregate` 가 + # 합산하는 것이 확인 항목이므로, 여기서 채워 두면 방어가 반영되는지를 못 본다. + # 시연 편의로 창을 30분 열어 둔다(운영 기본은 60초). 목표 2명 · 인정 60회(C-25·C-26). + put(token, f"fandoms/{FANDOM}/eclipseRounds/ecl-demo", { + "status": "active", + "fandomName": "BTS", + "target": 2, "qualifyShakes": 60, "windowSec": 1800, + "defense": 0, "defenseCount": 0, + "erosionRate": 100, "defended": False, + "startAt": NOW - timedelta(minutes=6), + "endsAt": NOW + timedelta(minutes=24), + }) + + print("▸ 라이브 소셜 프루프 — 홈 라이브 띠·갤럭시 섬광 배너") + # 별첨 B 「Global Ticker」 — 이 컬렉션은 이클립스 전용이 아니다. `kind` 로 갈린다. + for i, (nickname, defense, minutes) in enumerate([ + ("달빛", 30, 1), ("오로라", 10, 4), ("버니즈", 50, 9), + ]): + at = NOW - timedelta(minutes=minutes) + put(token, f"fandoms/{FANDOM}/liveEvents/live-{i}", { + "kind": "eclipse_defense", "eclipseId": "ecl-demo", + "uid": f"seed-{i}", "nickname": nickname, "defense": defense, + "priority": 20, "linkTo": f"/eclipse/{FANDOM}/ecl-demo", + "createdAt": at, "expiresAt": at + timedelta(hours=1), + }) + + if args.uid: + uid = args.uid + print(f"▸ 사용자 {uid}") + # 대표 팬덤(C-15) — primaryFandomId 가 정본, fandomId 는 옛 독자용 같은 값. + put(token, f"users/{uid}", { + "primaryFandomId": FANDOM, "fandomId": FANDOM, "status": "active", "createdAt": NOW, + }) + put(token, f"users/{uid}/wallet/main", {"activityBalance": 12_400, "supportBalance": 3_000}) + # GALAXY 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천. + put(token, f"users/{uid}/memberships/{FANDOM}", { + "fandomId": FANDOM, "joinedAt": NOW - timedelta(days=12), + }) + put(token, f"fandoms/{FANDOM}/members/{uid}", { + "uid": uid, "fandomId": FANDOM, "nickname": "나", + "countryCode": "KR", + # 지표마다 내 순위가 달라야 한다 — REFERRAL·CONTRIBUTION 은 1 위, + # BPM 은 3 위가 되도록 잡았다. + "fpiAxes": axis_map([2_740, 1_290, 3_180, 1_620, 4_210]), + # 지표 카드의 「내 기여 상위 n%」. 서버 산출값이며 멤버 순위에서 나온다. + "topPercentByAxis": axis_map([43, 72, 15, 43, 15]), + "contributedPop": 4_210, + "joinedAt": NOW - timedelta(days=12), + }) + for qid, *_ in QUESTS[:2]: + put(token, f"users/{uid}/questProgress/{qid}", {"count": 1, "dayKey": NOW.strftime("%Y-%m-%d")}) + + print("\n완료했습니다.") + + +if __name__ == "__main__": + main() diff --git a/app/firebase/setup-project.sh b/app/firebase/setup-project.sh new file mode 100755 index 0000000..c563db0 --- /dev/null +++ b/app/firebase/setup-project.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# FANUZU Firebase 프로젝트 개설 — 별첨 3 B-1 「저희 계정으로 먼저 개설」. +# +# 사전 조건: `firebase login` 을 한 번 해 두어야 합니다(브라우저가 열립니다). +# +# cd firebase && npx firebase login +# ./setup-project.sh +# +# 하는 일 +# 1. 프로젝트 개설 (이미 있으면 건너뜁니다) +# 2. Android · iOS 앱 등록 +# 3. 설정 파일 두 개를 제자리에 내려받기 +# 4. Firestore 규칙·인덱스 배포 +# +# **Cloud Functions 배포는 하지 않습니다.** Blaze 요금제 전환이 필요하고(D-10), +# 그건 결제 수단이 걸린 결정이라 발주사 회신 대상입니다. +set -euo pipefail + +cd "$(dirname "$0")" +export PATH="$PWD/node_modules/.bin:$PATH" + +PROJECT_ID="${FANUZU_PROJECT_ID:-fanuzu-dev-b020d}" +ANDROID_ID="${FANUZU_ANDROID_ID:-kr.co.fanuzu.app}" +IOS_ID="${FANUZU_IOS_ID:-kr.co.fanuzu.app}" + +if ! firebase login:list 2>&1 | grep -q '@'; then + echo "먼저 로그인하십시오: npx firebase login" >&2 + exit 1 +fi + +echo "▸ 프로젝트 $PROJECT_ID" +firebase projects:list --json 2>/dev/null | grep -q "\"$PROJECT_ID\"" \ + || firebase projects:create "$PROJECT_ID" --display-name "FANUZU Dev" + +echo "▸ Android 앱 $ANDROID_ID" +firebase apps:list ANDROID --project "$PROJECT_ID" --json 2>/dev/null | grep -q "\"packageName\": *\"$ANDROID_ID\"" \ + || firebase apps:create ANDROID "FANUZU Android" \ + --package-name "$ANDROID_ID" --project "$PROJECT_ID" + +echo "▸ iOS 앱 $IOS_ID" +firebase apps:list IOS --project "$PROJECT_ID" --json 2>/dev/null | grep -q "\"bundleId\": *\"$IOS_ID\"" \ + || firebase apps:create IOS "FANUZU iOS" \ + --bundle-id "$IOS_ID" --project "$PROJECT_ID" + +echo "▸ 설정 파일 내려받기" +# 앱이 둘 이상(레거시 kr.co.sweech.fancake 잔존)이라 첫 행이 아니라 패키지명으로 고른다. +ANDROID_APP=$(firebase apps:list ANDROID --project "$PROJECT_ID" --json 2>/dev/null \ + | python3 -c "import json,sys;print(next(a['appId'] for a in json.load(sys.stdin)['result'] if a.get('packageName')=='$ANDROID_ID'))") +IOS_APP=$(firebase apps:list IOS --project "$PROJECT_ID" --json 2>/dev/null \ + | python3 -c "import json,sys;print(next(a['appId'] for a in json.load(sys.stdin)['result'] if a.get('bundleId')=='$IOS_ID'))") + +firebase apps:sdkconfig ANDROID "$ANDROID_APP" --project "$PROJECT_ID" \ + --out ../android/app/google-services.json +firebase apps:sdkconfig IOS "$IOS_APP" --project "$PROJECT_ID" \ + --out ../ios/Runner/GoogleService-Info.plist + +echo "▸ Firestore 규칙·인덱스 배포" +firebase deploy --only firestore --project "$PROJECT_ID" + +cat <<'DONE' + +끝났습니다. 남은 것 둘. + + 1. Firebase 콘솔에서 Authentication → Sign-in method → + 「이메일/비밀번호」와 「Google」을 켜 주십시오. CLI 로는 켤 수 없습니다. + Google 을 켜야 google-services.json 에 oauth_client 가 채워집니다. + 2. Android SHA-1 지문 등록도 CLI 에 명령이 없습니다. 콘솔의 + 프로젝트 설정 → 내 앱 → Android 앱 → 「디지털 지문 추가」로 넣으십시오. + 절차와 현재 등록값은 BUILD.md 「Firebase 앱 등록」에 있습니다. + 3. Cloud Functions 배포는 Blaze 요금제 전환(D-10) 뒤에 합니다. + +설정 파일 두 개는 .gitignore 에 걸려 있어 저장소에 올라가지 않습니다. +DONE diff --git a/app/firebase/storage.rules b/app/firebase/storage.rules new file mode 100644 index 0000000..66ee68c --- /dev/null +++ b/app/firebase/storage.rules @@ -0,0 +1,78 @@ +rules_version = '2'; + +// FANUZU — Cloud Storage 보안 규칙 +// +// 근거 +// - 정책 원장 B-44 「Community 미디어」 — *업로드 → 클라이언트 1차 검증 → +// Storage 규칙 서버 검증 → 게시*. 상한은 이미지 10MB · 영상 100MB/60초. +// - firestore.rules 의 `users/{uid}` — `photoUrl` 은 본인이 고칠 수 있는 필드다. +// +// 원칙 +// 1) 클라이언트 검증은 **거를 수 있을 뿐 막지는 못한다.** 용량과 형식을 여기서 +// 한 번 더 본다. 앱을 고쳐 올리는 경로가 있어도 규칙은 우회되지 않는다. +// 2) 남의 경로에는 쓰지 못한다. 경로 첫 칸이 UID 이고 토큰의 UID 와 같아야 한다. +// 3) 열지 않은 경로는 전부 닫는다(마지막 블록). +// +// 다운로드 URL 에 대하여 +// `getDownloadURL()` 이 만드는 URL 은 토큰이 붙은 공개 링크이고 **본 규칙의 read +// 판정을 지나지 않는다.** 프로필 사진은 프로필 카드·라운지에서 다른 사용자에게 +// 보여야 하므로 그 성격이 맞다. 여기 `read` 는 SDK 로 바이트를 직접 읽는 경로만 +// 가린다. + +service firebase.storage { + match /b/{bucket}/o { + + function isSelf(uid) { + return request.auth != null && request.auth.uid == uid; + } + + // 프로필 사진 — `FirestoreMyRepository.uploadProfilePhoto` 가 쓰는 경로. + // 사용자당 한 장으로 고정되어 있어 바꿀 때 이전 파일이 남지 않는다. + match /users/{uid}/profile/{fileName} { + allow read: if request.auth != null; + allow write: if isSelf(uid) + && request.resource.size <= 10 * 1024 * 1024 + && request.resource.contentType.matches('image/(jpeg|png|webp|gif)'); + // 사진을 지우는 동선이 아직 없다. 여는 순간 「되돌릴 수 없는 삭제」의 + // 확인 동선까지 필요해지므로 필요해지는 회차에 함께 연다. + allow delete: if false; + } + + // 캠페인 결과보고 집행 사진 · 영수증 — 원장 B-35 · FR-ADM-05 「결과보고는 Admin 수동 운영」. + // 운영(master·ops)만 올린다. `fn-campaign-report-submit` 이 이 경로 접두와 파일 존재를 + // 다시 본다. 결과보고는 공개 캠페인 상세에 뜨므로 읽기는 열어 둔다. 상한은 B-44 10MB. + match /campaignReports/{campaignId}/{fileName} { + allow read: if true; + allow create: if request.auth != null + && request.auth.token.get('role', '') in ['master', 'ops'] + && request.resource.size <= 10 * 1024 * 1024 + && request.resource.contentType.matches('image/(jpeg|png|webp|gif)'); + allow update, delete: if false; + } + + // 팬덤 라운지 글 사진 — `FirestorePlanetRepository.createPost` 가 쓰는 경로. + // B-44 「이미지 5 장 10MB」의 1 장 상한 · 형식을 여기서 보고, 장수(5)는 + // firestore.rules 의 `posts.imageUrls.size()` 가 본다. 팬덤 회원 판정도 글 문서 + // 쪽 규칙이 한다 — 회원이 아니면 글이 거부되고 앱이 올린 사진을 지운다. + // 삭제는 본인만(글 삭제 · 글 저장 실패 시 되돌리기). + match /users/{uid}/lounge/{fileName} { + allow read: if request.auth != null; + // `resource == null` — 덮어쓰기 금지. 글의 URL 이 가리키는 내용이 바뀌면 안 된다. + // 파일 이름 = 「글 ID(Firestore 자동 ID 20자)-칸 번호(0~4)」. 글 문서의 규칙이 + // 칸마다 같은 경로만 받으므로 다른 이름의 파일은 어디에도 붙지 않는다. + allow create: if isSelf(uid) + && resource == null + && fileName.matches('[A-Za-z0-9]{20}-[0-4]') + && request.resource.size <= 10 * 1024 * 1024 + && request.resource.contentType.matches('image/(jpeg|png|webp|gif)'); + allow update: if false; + allow delete: if isSelf(uid); + } + + // 그 밖의 경로는 닫는다. 인사이드 미디어 · 라운지 영상(B-44 영상 100MB/60초)은 + // 업로드 동선이 들어오는 회차에 같은 방식으로 상한과 함께 연다. + match /{allPaths=**} { + allow read, write: if false; + } + } +} diff --git a/app/firebase/test/README.md b/app/firebase/test/README.md index abcec77..3f129c8 100644 --- a/app/firebase/test/README.md +++ b/app/firebase/test/README.md @@ -1,24 +1,34 @@ -# Firestore 보안 규칙 회귀 테스트 +# Firestore · Storage 보안 규칙 회귀 테스트 -Firebase 실제 프로젝트 없이 로컬 Firestore 에뮬레이터에서 `../firestore.rules`의 allow/deny 동작을 검증합니다. +Firebase 실제 프로젝트 없이 로컬 에뮬레이터에서 `../firestore.rules` 와 `../storage.rules` 의 allow/deny 동작을 검증합니다. ## 실행 ```bash -cd app/firebase/test +cd firebase/test npm ci npm run test:emulator ``` `test:emulator`는 다음 순서로 실행됩니다. -1. Firebase CLI가 `demo-fancake` 프로젝트 ID로 Firestore 에뮬레이터를 기동합니다. +1. Firebase CLI가 `demo-fancake` 프로젝트 ID로 Firestore · Storage 에뮬레이터를 기동합니다. 2. `initializeTestEnvironment()`가 `../firestore.rules` 원문을 에뮬레이터 규칙 REST API로 적재합니다. 3. 음성 대조군이 로그인 사용자의 지갑 쓰기가 `permission-denied`인지 먼저 확인합니다. 4. 소유자 지갑 읽기 양성 대조군을 확인한 뒤 Vitest 회귀 테스트 전체를 실행합니다. +`tests/storage.rules.test.mjs` 는 프로필 사진 업로드 경로(`users/{uid}/profile/{file}`)의 용량·형식·소유자 판정을 같은 방식으로 확인합니다. 정책 원장 B-44 가 *클라이언트 1차 검증 → Storage 규칙 서버 검증* 2단을 요구하므로, 앱을 고쳐 우회해도 규칙에서 막히는지를 여기서 봅니다. + Firebase CLI의 `emulators:exec` 기동 성공만으로 규칙 적재·컴파일 성공을 판단하지 않습니다. 규칙 적재 실패 시 2단계에서 실행이 중단되고, 음성 대조군이 허용되면 3단계에서 실행이 중단됩니다. +## 한국어 로케일 JVM 에서 실행할 때 + +에뮬레이터 JVM 이 한국어 로케일이면 규칙 평가 중 리소스 번들 예외로 일부 테스트가 죽는 사례가 보고되었습니다. 실행 전에 로케일을 고정하십시오. + +```bash +JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US" npm run test:emulator +``` + ## 복합 인덱스 검증 한계 실측 ```bash diff --git a/app/firebase/test/package.json b/app/firebase/test/package.json index fcbae9a..e8d5530 100644 --- a/app/firebase/test/package.json +++ b/app/firebase/test/package.json @@ -10,7 +10,7 @@ "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\"", + "test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore,storage --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\"" }, diff --git a/app/firebase/test/support/environment.mjs b/app/firebase/test/support/environment.mjs index f81d8c8..a7b7e67 100644 --- a/app/firebase/test/support/environment.mjs +++ b/app/firebase/test/support/environment.mjs @@ -34,6 +34,38 @@ export async function createTestEnvironment() { }); } +function storageAddress() { + const value = process.env.FIREBASE_STORAGE_EMULATOR_HOST ?? '127.0.0.1:9199'; + const separator = value.lastIndexOf(':'); + + if (separator < 1) { + throw new Error(`Invalid FIREBASE_STORAGE_EMULATOR_HOST: ${value}`); + } + + const host = value.slice(0, separator); + const port = Number(value.slice(separator + 1)); + + if (!Number.isInteger(port)) { + throw new Error(`Invalid Storage emulator port: ${value}`); + } + + return { host, port }; +} + +/// Storage 규칙 전용 환경. Firestore 와 나눠 둔 이유는 에뮬레이터가 서로 다른 +/// 포트에 뜨고, 한쪽만 켠 실행에서도 남은 하나가 돌아야 하기 때문이다. +export async function createStorageTestEnvironment() { + const rules = readFileSync(new URL('../../storage.rules', import.meta.url), 'utf8'); + + return initializeTestEnvironment({ + projectId, + storage: { + ...storageAddress(), + rules, + }, + }); +} + export async function seedDocuments(testEnvironment, documents) { await testEnvironment.withSecurityRulesDisabled(async (context) => { const firestore = context.firestore(); diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs index 4a71b7f..8879056 100644 --- a/app/firebase/test/tests/firestore.rules.test.mjs +++ b/app/firebase/test/tests/firestore.rules.test.mjs @@ -3,13 +3,18 @@ import { assertSucceeds, } from '@firebase/rules-unit-testing'; import { + collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, + orderBy, + query, + serverTimestamp, setDoc, updateDoc, + where, } from 'firebase/firestore'; import { afterAll, @@ -111,6 +116,22 @@ describe('원칙 1 — POP·FPI·원장은 클라이언트 쓰기 불가', () => }); }); +describe('행성 외형 — 생성 후 사용자 변경 없음(원장 v1.2 C-50)', () => { + test('소유자도 형태 · 컬러 · 테마 · 효과를 생성 후 바꿀 수 없고 직접 생성도 못 한다', async () => { + await seedDocuments(testEnvironment, { + 'fandoms/fandom-a/planets/alice': { ownerUid: 'alice', planetType: 'rocky', colorKey: 'blue' }, + }); + const mine = doc(dbFor('alice'), 'fandoms/fandom-a/planets/alice'); + for (const patch of [{ planetType: 'gas' }, { colorKey: 'red' }, { themeId: 't1' }, + { effectLevel: 3 }, { updatedAt: new Date() }]) { + await assertFails(updateDoc(mine, patch)); + } + await assertFails(setDoc(doc(dbFor('bob'), 'fandoms/fandom-a/planets/bob'), + { ownerUid: 'bob', planetType: 'rocky', colorKey: 'blue' })); + await assertSucceeds(getDoc(mine)); + }); +}); + describe('단결형 아이템 접근 경계', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { @@ -192,6 +213,34 @@ describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => { await assertFails(getDoc(doc(anonymousDb(), 'users/alice'))); }); + test('LIVE 접속 시각은 서버 시각으로만 본인이 쓴다', async () => { + await assertSucceeds( + updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: serverTimestamp() }), + ); + await assertFails( + updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: new Date('2099-01-01T00:00:00Z') }), + ); + await assertFails( + updateDoc(doc(dbFor('alice'), 'users/bob'), { lastSeenAt: serverTimestamp() }), + ); + // 60초 안의 재기록은 거부 · 60초가 지났으면 허용. + await assertFails( + updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: serverTimestamp() }), + ); + await seedDocuments(testEnvironment, { + 'users/alice': userDocument('alice', { lastSeenAt: new Date(Date.now() - 61_000) }), + }); + await assertSucceeds( + updateDoc(doc(dbFor('alice'), 'users/alice'), { lastSeenAt: serverTimestamp() }), + ); + }); + + test('가입 팬덤 목록(fandomIds)은 본인도 쓸 수 없다', async () => { + await assertFails( + updateDoc(doc(dbFor('alice'), 'users/alice'), { fandomIds: ['forged'] }), + ); + }); + test('허용된 본인 프로필 필드만 바꾸면 허용한다', async () => { await assertSucceeds( updateDoc(doc(dbFor('alice'), 'users/alice'), { @@ -203,6 +252,21 @@ describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => { }); }); +describe('가입 목록 — users/{uid}/memberships (C-15)', () => { + beforeEach(async () => { + await seedDocuments(testEnvironment, { + 'users/alice/memberships/fandom-a': { fandomId: 'fandom-a' }, + }); + }); + + test('본인만 읽고 누구도 직접 쓰지 못한다', async () => { + await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/memberships/fandom-a'))); + await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/memberships/fandom-a'))); + await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/memberships/fandom-b'), { fandomId: 'fandom-b' })); + await assertFails(deleteDoc(doc(dbFor('alice'), 'users/alice/memberships/fandom-a'))); + }); +}); + describe('G-02 — 약관 동의 이력 무결성 회귀', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { @@ -323,8 +387,12 @@ describe('F-02 — 캠페인 집계 필드 위조 회귀', () => { }); }); - test('정상 초기값의 캠페인 생성을 허용한다', async () => { - await assertSucceeds(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign)); + // 개설은 fn-campaign-create 전용이다 — 등록비 · 자격 · review 진입이 한 번에 선다. + test('정상 초기값이어도 클라이언트 직접 생성은 거부한다', async () => { + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/valid'), validCampaign)); + await assertFails( + setDoc(doc(dbFor('alice'), 'campaigns/review'), { ...validCampaign, status: 'review' }), + ); }); test('집계 필드 위조를 거부한다', async () => { @@ -350,7 +418,7 @@ describe('F-02 — 캠페인 집계 필드 위조 회귀', () => { describe('F-05 — 작성자 표시명 사칭 회귀', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { - 'users/alice': userDocument('alice', { displayName: 'Alice' }), + 'users/alice': userDocument('alice', { displayName: 'Alice', nickname: 'Alice' }), }); }); @@ -365,8 +433,8 @@ describe('F-05 — 작성자 표시명 사칭 회귀', () => { likeCount: 0, commentCount: 0, visibility: 'visible', - createdAt: '2026-08-08T00:00:00Z', - updatedAt: '2026-08-08T00:00:00Z', + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), }), ); }); @@ -401,6 +469,26 @@ describe('F-05 — 작성자 표시명 사칭 회귀', () => { ); }); + test('답변 authorName 은 공개 닉네임만 받는다 — 공급자 실명(displayName)은 거부(M-7)', async () => { + await seedDocuments(testEnvironment, { + 'users/named': userDocument('named', { displayName: '김실명', nickname: '닉네임' }), + 'users/nonick': userDocument('nonick', { displayName: '박실명' }), + // 줄바꿈 뒤의 @ 도 이메일 형태로 본다('(?s)'). + 'users/multiline': userDocument('multiline', { nickname: 'me\nx@example.com' }), + 'questions/m7-q': { authorUid: 'alice', status: 'open' }, + }); + const answer = (uid, authorName) => ({ + authorUid: uid, authorName, body: '답', sourceUrls: [], accepted: false, + visibility: 'visible', createdAt: serverTimestamp(), + }); + await assertFails(setDoc(doc(dbFor('named'), 'questions/m7-q/answers/a'), answer('named', '김실명'))); + await assertSucceeds(setDoc(doc(dbFor('named'), 'questions/m7-q/answers/b'), answer('named', '닉네임'))); + await assertFails(setDoc(doc(dbFor('nonick'), 'questions/m7-q/answers/c'), answer('nonick', '박실명'))); + await assertSucceeds(setDoc(doc(dbFor('nonick'), 'questions/m7-q/answers/d'), answer('nonick', '팬'))); + await assertFails(setDoc(doc(dbFor('multiline'), 'questions/m7-q/answers/e'), answer('multiline', 'me\nx@example.com'))); + await assertSucceeds(setDoc(doc(dbFor('multiline'), 'questions/m7-q/answers/f'), answer('multiline', '팬'))); + }); + test('답변 authorName 사칭을 거부한다', async () => { await assertFails( setDoc(doc(dbFor('alice'), 'questions/question-1/answers/forged'), { @@ -417,6 +505,68 @@ describe('F-05 — 작성자 표시명 사칭 회귀', () => { }); }); +describe('현상금 Q&A 답변 — 열린 질문 · 서버 시각 · 채택 후 잠금', () => { + const answer = (overrides = {}) => ({ + authorUid: 'bob', + authorName: 'Bob', + body: '답', + accepted: false, + visibility: 'visible', + createdAt: serverTimestamp(), + ...overrides, + }); + + beforeEach(async () => { + await seedDocuments(testEnvironment, { + 'users/alice': userDocument('alice', { displayName: 'Alice' }), + 'users/alice': userDocument('alice', { displayName: '김실명', nickname: 'Alice' }), + 'users/bob': userDocument('bob', { displayName: '박실명', nickname: 'Bob' }), + 'questions/open-q': { authorUid: 'alice', status: 'open' }, + 'questions/accepted-q': { authorUid: 'alice', status: 'accepted' }, + 'questions/expired-q': { authorUid: 'alice', status: 'expired' }, + 'questions/open-q/answers/bob-open': { + authorUid: 'bob', authorName: 'Bob', body: '원래', accepted: false, + }, + 'questions/accepted-q/answers/bob-accepted': { + authorUid: 'bob', authorName: 'Bob', body: '원래', accepted: true, + }, + }); + }); + + test('열린 질문에 서버 시각으로 답변을 단다', async () => { + await assertSucceeds(setDoc(doc(dbFor('bob'), 'questions/open-q/answers/a1'), answer())); + }); + + test('질문자도 자기 질문에 답변을 달 수 있다(채택만 서버가 막는다 · E-QNA-01)', async () => { + await assertSucceeds( + setDoc(doc(dbFor('alice'), 'questions/open-q/answers/a2'), + answer({ authorUid: 'alice', authorName: 'Alice' })), + ); + }); + + test('채택 · 만료된 질문과 없는 질문에는 답변을 달 수 없다', async () => { + for (const q of ['accepted-q', 'expired-q', 'missing-q']) { + await assertFails(setDoc(doc(dbFor('bob'), `questions/${q}/answers/a3`), answer())); + } + }); + + test('createdAt 위조를 거부한다', async () => { + await assertFails( + setDoc(doc(dbFor('bob'), 'questions/open-q/answers/a4'), + answer({ createdAt: '2020-01-01T00:00:00Z' })), + ); + }); + + test('채택 전 답변은 작성자가 고치고, 채택 후에는 못 고친다', async () => { + await assertSucceeds(updateDoc(doc(dbFor('bob'), 'questions/open-q/answers/bob-open'), { body: '수정' })); + await assertFails(updateDoc(doc(dbFor('bob'), 'questions/accepted-q/answers/bob-accepted'), { body: '바꿔치기' })); + }); + + test('작성자도 accepted 를 스스로 켤 수 없다', async () => { + await assertFails(updateDoc(doc(dbFor('bob'), 'questions/open-q/answers/bob-open'), { accepted: true })); + }); +}); + describe('F-14 — posts 허용 필드 회귀', () => { test('작성자도 스키마에 없는 title 필드를 추가할 수 없다', async () => { await seedDocuments(testEnvironment, { @@ -467,15 +617,42 @@ describe('F-07 — 결과보고 대상 상태 회귀', () => { ); }); - test('성공 캠페인 개설자의 결과보고 생성을 허용한다', async () => { + // 제출은 fn-campaign-report-submit 이 보고 문서 · 상태 `report` · 후원자 알림을 함께 쓴다. + // 개설자가 직접 만들면 상태가 success 로 남아 보고가 반쪽이 된다. + test('성공 캠페인이어도 클라이언트 직접 생성은 거부한다(서버 경유)', async () => { await seedDocuments(testEnvironment, { 'campaigns/success': { creatorUid: 'alice', status: 'success' }, }); - await assertSucceeds( + await assertFails( setDoc(doc(dbFor('alice'), 'campaigns/success/reports/report-1'), { body: 'report' }), ); }); + + test('보고가 게시된 캠페인과 보고는 로그인 없이 읽힌다', async () => { + await seedDocuments(testEnvironment, { + 'campaigns/reported': { creatorUid: 'alice', status: 'report' }, + 'campaigns/reported/reports/main': { body: 'report', photoUrls: [] }, + }); + + await assertSucceeds(getDoc(doc(anonymousDb(), 'campaigns/reported'))); + await assertSucceeds(getDoc(doc(anonymousDb(), 'campaigns/reported/reports/main'))); + }); +}); + +describe('캠페인 좋아요 — likes/{uid}', () => { + test('공개 캠페인에 본인 좋아요만 만들고 지울 수 있다', async () => { + await seedDocuments(testEnvironment, { + 'campaigns/funding': { creatorUid: 'bob', status: 'funding' }, + 'campaigns/draft': { creatorUid: 'bob', status: 'draft' }, + }); + const mine = doc(dbFor('alice'), 'campaigns/funding/likes/alice'); + await assertSucceeds(setDoc(mine, { createdAt: new Date() })); + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/funding/likes/bob'), { createdAt: new Date() })); + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/draft/likes/alice'), { createdAt: new Date() })); + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/funding/likes/alice'), { createdAt: new Date(), count: 99 })); + await assertSucceeds(deleteDoc(mine)); + }); }); describe('F-08 — 미승인 캠페인 열람 회귀', () => { @@ -504,6 +681,46 @@ describe('F-08 — 미승인 캠페인 열람 회귀', () => { }); }); +describe('B-58 — 회사 귀책 취소 캠페인은 후원자에게만 열린다', () => { + beforeEach(async () => { + await seedDocuments(testEnvironment, { + 'campaigns/cancelled': { creatorUid: 'alice', status: 'cancelled' }, + 'campaigns/cancelled/backers/bob': { uid: 'bob' }, + 'campaigns/cancelled/donations/tx-1': { donorUid: 'bob', amount: 10 }, + 'campaigns/withdrawn': { creatorUid: 'alice', status: 'withdrawn' }, + 'campaigns/withdrawn/backers/bob': { uid: 'bob' }, + }); + }); + + test('후원자(backers 표식)는 cancelled 캠페인을 읽는다', async () => { + await assertSucceeds(getDoc(doc(dbFor('bob'), 'campaigns/cancelled'))); + }); + + test('후원하지 않은 사용자와 미인증 사용자는 cancelled 캠페인을 읽지 못한다', async () => { + await assertFails(getDoc(doc(dbFor('carol'), 'campaigns/cancelled'))); + await assertFails(getDoc(doc(anonymousDb(), 'campaigns/cancelled'))); + }); + + test('개설자와 ops는 cancelled 캠페인을 계속 읽는다', async () => { + await assertSucceeds(getDoc(doc(dbFor('alice'), 'campaigns/cancelled'))); + await assertSucceeds(getDoc(doc(dbFor('ops-user', { role: 'ops' }), 'campaigns/cancelled'))); + }); + + test('withdrawn 캠페인은 표식이 있어도 비공개다', async () => { + await assertFails(getDoc(doc(dbFor('bob'), 'campaigns/withdrawn'))); + await assertFails(getDoc(doc(anonymousDb(), 'campaigns/withdrawn'))); + }); + + test('backers 표식은 본인만 읽고 누구도 직접 쓰지 못한다', async () => { + await assertSucceeds(getDoc(doc(dbFor('bob'), 'campaigns/cancelled/backers/bob'))); + await assertFails(getDoc(doc(dbFor('carol'), 'campaigns/cancelled/backers/bob'))); + await assertFails(setDoc(doc(dbFor('carol'), 'campaigns/cancelled/backers/carol'), { uid: 'carol' })); + await assertFails( + setDoc(doc(dbFor('master-user', { role: 'master' }), 'campaigns/cancelled/backers/carol'), { uid: 'carol' }), + ); + }); +}); + describe('원칙 3 — 권한은 커스텀 클레임 role로만 판정', () => { test('users 문서에 role=master를 심어도 관리자 권한을 얻지 못한다', async () => { await seedDocuments(testEnvironment, { @@ -561,11 +778,6 @@ describe('원칙 3 — 권한은 커스텀 클레임 role로만 판정', () => { 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', @@ -704,6 +916,10 @@ describe('원칙 3 — 권한은 커스텀 클레임 role로만 판정', () => { ['사전예약', 'preRegistrations/pre-1', { socialUid: 'alice' }], ['행성 개설 신청', 'planetApplications/application-1', { applicantUid: 'alice' }], ['번역 검수 원천', 'translationEntries/entry-1', { body: 'translation' }], + // 브리핑은 예전에 ops 직접 편집을 허용했다. 그 길은 `fn-briefing-publish` 를 + // 우회해 게시 상태를 바꾸면서 `adminActionLogs` 에 아무것도 남기지 않았고, + // 누가 무엇을 승인했는지 사라지면 B-47 승인 게이트가 없는 것과 같다. + ['AI 브리핑', 'briefings/briefing-1', { body: 'Briefing' }], ['관리자 계정', 'admins/admin-1', { role: 'master' }], ['관리자 작업 로그', 'adminActionLogs/log-1', { action: 'forged' }], ])('Functions 전용 경계: master도 %s 직접 쓰기가 거부된다', async (_name, path, data) => { @@ -746,6 +962,763 @@ describe('캠페인 후원·환불과 기본 거부', () => { }); }); +describe('MY 서버 연결 — S-M-WALLET · S-X-NOTICE · S-M-NOTICE · S-M-BLOCK · S-M-INQUIRY', () => { + test('본인 POP 원장을 최신순으로 읽는다 (S-M-WALLET)', async () => { + await seedDocuments(testEnvironment, { + 'users/alice/popTransactions/tx-1': { + type: 'grant', popType: 'general', amount: 50, + reasonCode: 'QUEST_STEPS', balanceAfter: 50, + createdAt: new Date('2026-09-01T00:00:00Z'), + }, + // 차감은 음수다. `spendPop` 이 `amount: -input.amount` 로 쓰고 설계서 §3.4 + // 대조식이 `balance == Σ amount` 이므로 시드도 같은 규약을 따른다. + 'users/alice/popTransactions/tx-2': { + type: 'spend', popType: 'contribution', amount: -500, + reasonCode: 'CAMPAIGN_FUND', balanceAfter: 0, + createdAt: new Date('2026-09-02T00:00:00Z'), + }, + }); + + const rows = await assertSucceeds(getDocs(query( + collection(dbFor('alice'), 'users/alice/popTransactions'), + orderBy('createdAt', 'desc'), + ))); + expect(rows.docs.map((d) => d.id)).toEqual(['tx-2', 'tx-1']); + }); + + test('남의 POP 원장은 읽지 못한다', async () => { + await seedDocuments(testEnvironment, { + 'users/alice/popTransactions/tx-1': { amount: 50 }, + }); + + await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/popTransactions/tx-1'))); + }); + + test('알림함은 본인이 읽고 읽음 두 필드만 고친다 (S-X-NOTICE)', async () => { + await seedDocuments(testEnvironment, { + 'users/alice/notifications/noti-1': { + kind: 'quest', title: '만보기 퀘스트를 완료했습니다', read: false, + createdAt: new Date('2026-09-10T00:00:00Z'), + }, + }); + const mine = doc(dbFor('alice'), 'users/alice/notifications/noti-1'); + + await assertSucceeds(getDoc(mine)); + await assertSucceeds(updateDoc(mine, { read: true, readAt: new Date() })); + // 발송은 Functions(FCM) 경유라 앱이 만들거나 지울 수 없다. + await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/notifications/forged'), { title: 'x' })); + await assertFails(deleteDoc(mine)); + // 제목을 고쳐 내용을 바꾸는 경로도 닫혀 있다. + await assertFails(updateDoc(mine, { title: '바꿔치기' })); + await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/notifications/noti-1'))); + }); + + test('공지는 로그인 전에도 읽는다 (S-M-NOTICE)', async () => { + await seedDocuments(testEnvironment, { + 'notices/notice-1': { + active: true, title: '서비스 점검 안내', body: '점검이 있습니다.', + publishedAt: new Date('2026-09-01T00:00:00Z'), + }, + }); + + // 필드와 정렬은 firestore.indexes.json 의 notices 색인(active + publishedAt)과 같다. + await assertSucceeds(getDocs(query( + collection(anonymousDb(), 'notices'), + where('active', '==', true), + orderBy('publishedAt', 'desc'), + ))); + }); + + test('차단 목록은 본인이 읽고 추가하고 해제한다 (S-M-BLOCK)', async () => { + const target = doc(dbFor('alice'), 'users/alice/blocks/bob'); + + await assertSucceeds(setDoc(target, { nickname: '스팸계정', createdAt: new Date() })); + await assertSucceeds(getDocs(collection(dbFor('alice'), 'users/alice/blocks'))); + await assertSucceeds(deleteDoc(target)); + // 남의 차단 목록에는 손대지 못한다. + await assertFails(setDoc(doc(dbFor('bob'), 'users/alice/blocks/carol'), { nickname: 'x' })); + }); + + test('문의는 접수 상태로만 만들고 본인 것만 열거한다 (S-M-INQUIRY)', async () => { + const alice = dbFor('alice'); + + await assertSucceeds(setDoc(doc(alice, 'inquiries/q-1'), { + authorUid: 'alice', title: '결제 문의', body: '지급되지 않았습니다.', + status: 'pending', createdAt: new Date('2026-09-10T00:00:00Z'), + })); + // 처리 상태를 올리는 것은 운영이다. + await assertFails(setDoc(doc(alice, 'inquiries/q-2'), { + authorUid: 'alice', status: 'answered', + })); + // 남을 사칭한 접수도 막힌다. + await assertFails(setDoc(doc(alice, 'inquiries/q-3'), { + authorUid: 'bob', status: 'pending', + })); + + const mine = await assertSucceeds(getDocs(query( + collection(alice, 'inquiries'), + where('authorUid', '==', 'alice'), + orderBy('createdAt', 'desc'), + ))); + expect(mine.docs.map((d) => d.id)).toEqual(['q-1']); + }); + + test('조건 없는 문의 열거는 질의 자체가 거부된다', async () => { + await seedDocuments(testEnvironment, { + 'inquiries/q-1': { authorUid: 'alice', status: 'pending' }, + 'inquiries/q-2': { authorUid: 'bob', status: 'pending' }, + }); + + // 좁히지 않으면 남의 문의가 섞이므로 규칙이 질의를 통째로 끊는다. + await assertFails(getDocs(collection(dbFor('alice'), 'inquiries'))); + await assertFails(getDocs(query( + collection(dbFor('alice'), 'inquiries'), + where('authorUid', '==', 'bob'), + ))); + }); + + test('닉네임 길이는 2~20자다 (B-52)', async () => { + await seedDocuments(testEnvironment, { + 'users/alice': userDocument('alice'), + }); + const mine = doc(dbFor('alice'), 'users/alice'); + + // 검증 기준 — 20자 성공 · 21자 거부. + await assertSucceeds(updateDoc(mine, { nickname: '가'.repeat(20) })); + await assertFails(updateDoc(mine, { nickname: '가'.repeat(21) })); + await assertFails(updateDoc(mine, { nickname: '가' })); + // 닉네임을 건드리지 않는 갱신은 길이 검사에 걸리지 않는다. + await assertSucceeds(updateDoc(mine, { locale: 'ja' })); + }); + + test('접수 상태의 본인 문의는 제목·본문만 고칠 수 있다 (S-M-INQUIRY)', async () => { + await seedDocuments(testEnvironment, { + 'inquiries/q-open': { + authorUid: 'alice', title: '결제 문의', body: '지급되지 않았습니다.', + status: 'pending', createdAt: new Date('2026-09-10T00:00:00Z'), + }, + }); + const mine = doc(dbFor('alice'), 'inquiries/q-open'); + + await assertSucceeds(updateDoc(mine, { + title: '결제 문의(수정)', body: '다시 적습니다.', updatedAt: new Date(), + })); + // 스스로 상태를 올릴 수 없다. + await assertFails(updateDoc(mine, { title: '제목', status: 'answered' })); + // 남의 문의는 고치지 못한다. + await assertFails(updateDoc(doc(dbFor('bob'), 'inquiries/q-open'), { + title: '남이 고침', + })); + }); + + test('답변이 올라간 문의는 작성자가 고치지 못한다', async () => { + await seedDocuments(testEnvironment, { + 'inquiries/q-done': { + authorUid: 'alice', title: '결제 문의', body: '지급되지 않았습니다.', + status: 'answered', answerBody: '처리했습니다.', + createdAt: new Date('2026-09-10T00:00:00Z'), + }, + }); + + await assertFails(updateDoc(doc(dbFor('alice'), 'inquiries/q-done'), { + title: '뒤늦게 고침', + })); + }); + + test('닉네임과 프로필 사진 URL 은 본인이 고친다 (S-M-SETTINGS)', async () => { + await seedDocuments(testEnvironment, { + 'users/alice': userDocument('alice'), + }); + const mine = doc(dbFor('alice'), 'users/alice'); + + await assertSucceeds(updateDoc(mine, { nickname: '아미보이' })); + await assertSucceeds(updateDoc(mine, { + photoUrl: 'https://example.invalid/photo.png', + })); + // 서버 산출 필드는 같은 허용 목록에 없다. + await assertFails(updateDoc(mine, { totalContributedPop: 999_999 })); + await assertFails(updateDoc(doc(dbFor('bob'), 'users/alice'), { nickname: '남의닉' })); + }); +}); + +describe('이클립스 라이브 소셜 프루프 — fandoms/{id}/liveEvents', () => { + const path = 'fandoms/fandom-a/liveEvents/evt-1'; + const event = { + kind: 'eclipseDefense', + eclipseId: 'ecl-1', + uid: 'bob', + nickname: '버니즈', + pop: 30, + }; + + test('로그인 사용자는 남의 방어 참여도 읽는다', async () => { + // 소셜 프루프는 **타 사용자 화면에** 떠야 한다(별첨 1 ① 시나리오 5). + // 본인 것만 읽히면 기능 자체가 성립하지 않는다. + await seedDocuments(testEnvironment, { [path]: event }); + + await assertSucceeds(getDoc(doc(dbFor('alice'), path))); + }); + + test('클라이언트 쓰기를 거부한다', async () => { + // 여기가 열리면 누구나 「내가 10만 POP 을 방어했다」를 티커에 띄울 수 있다. + await assertFails(setDoc(doc(dbFor('alice'), path), event)); + }); + + test('기록된 참여의 수정·삭제를 거부한다', async () => { + await seedDocuments(testEnvironment, { [path]: event }); + const firestore = dbFor('alice'); + + await assertFails(updateDoc(doc(firestore, path), { pop: 999_999 })); + await assertFails(deleteDoc(doc(firestore, path))); + }); + + test('미인증 사용자의 읽기를 거부한다', async () => { + await seedDocuments(testEnvironment, { [path]: event }); + + await assertFails(getDoc(doc(anonymousDb(), path))); + }); +}); + +describe('이클립스 참여자 누적 — participants', () => { + const path = 'fandoms/fandom-a/eclipseRounds/ecl-1/participants/bob'; + + test('로그인 사용자는 참여 누적을 읽는다', async () => { + await seedDocuments(testEnvironment, { [path]: { uid: 'bob', validShakes: 30 } }); + await assertSucceeds(getDoc(doc(dbFor('alice'), path))); + }); + + test('참여량 직접 쓰기를 거부한다', async () => { + // 여기가 열리면 상한·속도 판정을 우회해 방어량을 스스로 올릴 수 있다. + await assertFails(setDoc(doc(dbFor('bob'), path), { uid: 'bob', validShakes: 9_999 })); + }); + + test('본인 문서라도 수정을 거부한다', async () => { + await seedDocuments(testEnvironment, { [path]: { uid: 'bob', validShakes: 30 } }); + await assertFails(updateDoc(doc(dbFor('bob'), path), { validShakes: 9_999 })); + }); +}); + +describe('팬덤 라운지 쓰기 — 글 · 좋아요 · 댓글 · 신고 (B-44 · B-45)', () => { + // Firestore 자동 ID 모양(20자) — Storage 파일 이름 규칙과 맞춘다. + const PID = 'AbCdEfGhIjKlMnOpQrSt'; + const img = (uid, id, i) => `users/${uid}/lounge/${id}-${i}`; + const post = (overrides = {}) => ({ + fandomId: 'fandom-a', + authorUid: 'alice', + authorName: '앨리스', + body: 'hello', + imageUrls: [], + likeCount: 0, + commentCount: 0, + visibility: 'visible', + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + ...overrides, + }); + const comment = (uid, name, overrides = {}) => ({ + authorUid: uid, + authorName: name, + body: '댓글', + visibility: 'visible', + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + ...overrides, + }); + const report = (overrides = {}) => ({ + reporterUid: 'bob', + targetType: 'post', + targetPath: 'posts/post-1', + reasonCode: 'inappropriate', + status: 'pending', + createdAt: serverTimestamp(), + ...overrides, + }); + + beforeEach(async () => { + await seedDocuments(testEnvironment, { + // displayName 은 공급자 실명 자리 — 라운지 이름으로 쓰면 안 된다(M-7). + 'users/alice': userDocument('alice', { displayName: '김실명', nickname: '앨리스' }), + 'users/bob': userDocument('bob', { nickname: '밥' }), + 'users/noname': userDocument('noname', { displayName: '박실명' }), + 'users/outsider': userDocument('outsider', { fandomId: 'fandom-b', nickname: '외부' }), + // 대표 팬덤은 다르지만 멤버십 문서가 있는 회원(복수 가입 · C-15). + 'users/multi': userDocument('multi', { fandomId: 'fandom-b', nickname: '겸덕' }), + 'fandoms/fandom-a/members/multi': { uid: 'multi', fandomId: 'fandom-a' }, + 'posts/post-1': post({ createdAt: '2026-09-28', updatedAt: '2026-09-28' }), + 'posts/hidden': post({ visibility: 'hidden', createdAt: '2026-09-28', updatedAt: '2026-09-28' }), + }); + }); + + test('작성자 이름은 공개 닉네임만 — 실명 · 사칭 거부, 닉네임 없으면 「팬」', async () => { + await assertSucceeds(setDoc(doc(dbFor('alice'), 'posts/ok'), post())); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/real'), post({ authorName: '김실명' }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/ops'), post({ authorName: 'FANUZU 운영팀' }))); + await assertSucceeds( + setDoc(doc(dbFor('noname'), 'posts/fan'), post({ authorUid: 'noname', authorName: '팬' })), + ); + await assertFails( + setDoc(doc(dbFor('noname'), 'posts/fan2'), post({ authorUid: 'noname', authorName: '박실명' })), + ); + await assertFails( + setDoc(doc(dbFor('alice'), 'posts/post-1/comments/real'), comment('alice', '김실명')), + ); + }); + + test('사진은 자기 경로 · 이 글 · 칸 번호 그대로 5장까지만', async () => { + const five = [0, 1, 2, 3, 4].map((i) => img('alice', PID, i)); + await assertSucceeds(setDoc(doc(dbFor('alice'), `posts/${PID}`), post({ imageUrls: five }))); + await assertFails( + setDoc(doc(dbFor('alice'), 'posts/BbCdEfGhIjKlMnOpQrSt'), post({ + imageUrls: [...five.map((p) => p.replace(PID, 'BbCdEfGhIjKlMnOpQrSt')), 'x'], + })), + ); + // 외부 URL · 남의 파일 · 다른 글의 사진 · 칸 번호 어긋남 + await assertFails(setDoc(doc(dbFor('alice'), 'posts/u1'), post({ imageUrls: ['https://evil/x.png'] }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/u2'), post({ imageUrls: [img('bob', 'u2', 0)] }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/u3'), post({ imageUrls: [img('alice', PID, 0)] }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/u4'), post({ imageUrls: [img('alice', 'u4', 1)] }))); + // 작성 후 사진 교체는 닫는다. + await assertFails( + updateDoc(doc(dbFor('alice'), 'posts/post-1'), { + imageUrls: ['https://evil/x.png'], updatedAt: serverTimestamp(), + }), + ); + }); + + test('빈 본문 · 5,000자 초과 · 수 위조 · 시각 위조 · 다른 팬덤 글은 거부한다', async () => { + await assertSucceeds(setDoc(doc(dbFor('alice'), 'posts/max'), post({ body: '가'.repeat(5000) }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/long'), post({ body: '가'.repeat(5001) }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/empty'), post({ body: '' }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/forged'), post({ likeCount: 99 }))); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/future'), post({ createdAt: new Date('2099-01-01') }))); + await assertFails( + setDoc(doc(dbFor('outsider'), 'posts/other'), post({ authorUid: 'outsider', authorName: '외부' })), + ); + }); + + test('팬덤 회원 판정 — 멤버십 문서 또는 대표 팬덤', async () => { + // 멤버십 문서만 있는 회원(대표 팬덤은 fandom-b) + await assertSucceeds( + setDoc(doc(dbFor('multi'), 'posts/m1'), post({ authorUid: 'multi', authorName: '겸덕' })), + ); + await assertSucceeds(setDoc(doc(dbFor('multi'), 'posts/post-1/likes/multi'), { createdAt: serverTimestamp() })); + await assertSucceeds( + setDoc(doc(dbFor('multi'), 'posts/post-1/comments/m'), comment('multi', '겸덕')), + ); + // 대표 팬덤만 같은 회원(멤버십 문서 없음) — 이전 판정도 그대로 통과 + await assertSucceeds(setDoc(doc(dbFor('bob'), 'posts/post-1/likes/bob'), { createdAt: serverTimestamp() })); + // 둘 다 아닌 사용자 + await assertFails(setDoc(doc(dbFor('outsider'), 'posts/post-1/likes/outsider'), { createdAt: serverTimestamp() })); + }); + + test('글 수정 · 삭제는 작성자만, 수는 작성자도 못 고친다', async () => { + const mine = doc(dbFor('alice'), 'posts/post-1'); + await assertSucceeds(updateDoc(mine, { body: 'edited', updatedAt: serverTimestamp() })); + await assertFails(updateDoc(mine, { body: 'edited2', updatedAt: '2026-01-01' })); + await assertFails(updateDoc(mine, { body: '가'.repeat(5001), updatedAt: serverTimestamp() })); + await assertFails(updateDoc(mine, { likeCount: 50 })); + await assertFails(updateDoc(mine, { commentCount: 50 })); + await assertFails(updateDoc(doc(dbFor('bob'), 'posts/post-1'), { body: 'hijack', updatedAt: serverTimestamp() })); + await assertFails(deleteDoc(doc(dbFor('bob'), 'posts/post-1'))); + await assertSucceeds(deleteDoc(mine)); + }); + + test('좋아요는 팬덤 회원이 본인 문서로만, 보이는 글에만 누른다', async () => { + const mine = doc(dbFor('bob'), 'posts/post-1/likes/bob'); + await assertSucceeds(setDoc(mine, { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('bob'), 'posts/post-1/likes/alice'), { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('bob'), 'posts/hidden/likes/bob'), { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/post-1/likes/alice'), { createdAt: new Date() })); + await assertFails(setDoc(doc(dbFor('alice'), 'posts/post-1/likes/alice'), { createdAt: serverTimestamp(), n: 1 })); + await assertSucceeds(deleteDoc(mine)); + }); + + test('댓글은 팬덤 회원이 보이는 글에만 1,000자까지 쓰고 본인 것만 지운다', async () => { + await assertSucceeds(setDoc(doc(dbFor('bob'), 'posts/post-1/comments/c1'), comment('bob', '밥'))); + await assertSucceeds( + setDoc(doc(dbFor('bob'), 'posts/post-1/comments/cmax'), comment('bob', '밥', { body: '가'.repeat(1000) })), + ); + await assertFails( + setDoc(doc(dbFor('bob'), 'posts/post-1/comments/clong'), comment('bob', '밥', { body: '가'.repeat(1001) })), + ); + await assertFails(setDoc(doc(dbFor('outsider'), 'posts/post-1/comments/c2'), comment('outsider', '외부'))); + await assertFails(setDoc(doc(dbFor('bob'), 'posts/hidden/comments/c3'), comment('bob', '밥'))); + await assertFails(setDoc(doc(dbFor('bob'), 'posts/post-1/comments/c4'), comment('bob', '밥', { body: '' }))); + await assertFails(deleteDoc(doc(dbFor('alice'), 'posts/post-1/comments/c1'))); + await assertSucceeds(deleteDoc(doc(dbFor('bob'), 'posts/post-1/comments/c1'))); + }); + + test('신고는 post-{글}-{신고자} ID 로만, 스키마대로 한 번만 접수된다', async () => { + const ref = doc(dbFor('bob'), 'reports/post-post-1-bob'); + await assertSucceeds(setDoc(ref, report())); + // 같은 신고자의 재접수는 덮어쓰기라 거부 — 신고자 수가 부풀지 않는다. + await assertFails(setDoc(ref, report())); + // 임의 ID · 남의 UID 를 붙인 ID · 다른 글 ID + await assertFails(setDoc(doc(dbFor('bob'), 'reports/anything'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-post-1-bob2'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-post-1-alice'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-other-bob'), report())); + // 대상 경로 모양 · 사유 코드 · 상태 · 필드 · 시각 + await assertFails( + setDoc(doc(dbFor('bob'), 'reports/post-post-1-bob'), report({ targetPath: 'posts/post-1/comments/x' })), + ); + await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', reasonCode: '아무거나' }))); + await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', status: 'handled' }))); + await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', handledBy: 'carol' }))); + await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol', createdAt: new Date() }))); + await assertFails(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'bob' }))); + await assertSucceeds(setDoc(doc(dbFor('carol'), 'reports/post-post-1-carol'), report({ reporterUid: 'carol' }))); + // 접수 목록은 운영만 본다. + await assertFails(getDocs(collection(dbFor('bob'), 'reports'))); + }); + + test('운영 사칭 닉네임은 대소문자와 무관하게 거부한다', async () => { + const mine = doc(dbFor('bob'), 'users/bob'); + for (const n of ['FANUZU팬', 'fanuzu', '운영팀장', '관리자', 'Admin99', 'xOFFICIALx']) { + await assertFails(updateDoc(mine, { nickname: n })); + } + await assertSucceeds(updateDoc(mine, { nickname: '평범한팬' })); + }); + + test('초대 코드는 서버만 쓴다 — inviteCodes 생성 · users.inviteCode 변경 거부, 코드 직접 조회는 허용', async () => { + await assertFails(setDoc(doc(dbFor('bob'), 'inviteCodes/ABCDEFGH23'), { uid: 'bob' })); + await assertFails(updateDoc(doc(dbFor('bob'), 'users/bob'), { inviteCode: 'ABCDEFGH23' })); + await assertSucceeds(getDoc(doc(dbFor('bob'), 'inviteCodes/ABCDEFGH23'))); + // 코드 주인(UID)은 서버 전용. + await assertFails(getDoc(doc(dbFor('bob'), 'inviteCodeOwners/ABCDEFGH23'))); + await assertFails(setDoc(doc(dbFor('bob'), 'inviteCodeOwners/ABCDEFGH23'), { uid: 'bob' })); + await assertFails(getDoc(doc(dbFor('bob'), 'referralsByUser/bob'))); + }); + + test('닉네임 「팬」(앞뒤 공백 무시)은 이름 없는 작성자 표기라 거부한다', async () => { + const mine = doc(dbFor('bob'), 'users/bob'); + for (const n of ['팬', ' 팬 ', ' 팬', '\u3000팬', '팬\u200B', 'Fan', ' FAN', 'ファン', '粉丝', 'ad\u200Dmin', 'FANU\uFEFFZU']) { + await assertFails(updateDoc(mine, { nickname: n })); + } + await assertSucceeds(updateDoc(mine, { nickname: '팬팬' })); + await assertSucceeds(updateDoc(mine, { nickname: 'Fans' })); + }); +}); + +describe('캠페인 응원 게시판 댓글 — campaigns/{id}/comments', () => { + const comment = (uid, name, overrides = {}) => ({ + authorUid: uid, + authorName: name, + body: '응원해요', + visibility: 'visible', + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + ...overrides, + }); + const report = (overrides = {}) => ({ + reporterUid: 'bob', + targetType: 'campaignComment', + targetPath: 'campaigns/open/comments/c1', + reasonCode: 'spam', + status: 'pending', + createdAt: serverTimestamp(), + ...overrides, + }); + + beforeEach(async () => { + await seedDocuments(testEnvironment, { + 'users/alice': userDocument('alice', { displayName: '김실명', nickname: '앨리스' }), + 'users/bob': userDocument('bob', { fandomId: 'fandom-b', nickname: '밥' }), + 'users/noname': userDocument('noname', { displayName: '박실명' }), + 'campaigns/open': { creatorUid: 'alice', status: 'funding', fandomId: 'fandom-a', commentCount: 0 }, + 'campaigns/draft': { creatorUid: 'alice', status: 'draft', commentCount: 0 }, + 'campaigns/review': { creatorUid: 'alice', status: 'review' }, + 'campaigns/open/comments/c1': comment('alice', '앨리스', { createdAt: '2026-09-28', updatedAt: '2026-09-28' }), + 'campaigns/open/comments/hid': comment('alice', '앨리스', { visibility: 'hidden', createdAt: '2026-09-28', updatedAt: '2026-09-28' }), + 'campaigns/draft/comments/d0': comment('alice', '앨리스', { createdAt: '2026-09-28', updatedAt: '2026-09-28' }), + }); + }); + + test('작성 자격 config/policy.campaignCommentScope — all · members · backers', async () => { + const scope = (v) => seedDocuments(testEnvironment, { 'config/policy': v === undefined ? {} : { campaignCommentScope: v } }); + const write = (uid, name, id) => setDoc(doc(dbFor(uid), `campaigns/open/comments/${id}`), comment(uid, name)); + // 필드가 없으면 'all' — 다른 팬덤(bob · fandom-b)도 쓴다. + await scope(undefined); + await assertSucceeds(write('bob', '밥', 's0')); + await scope('all'); + await assertSucceeds(write('bob', '밥', 's1')); + // members — 캠페인 팬덤(fandom-a) 회원만. 대표 팬덤 또는 멤버십 문서. + await scope('members'); + await assertFails(write('bob', '밥', 's2')); + await assertSucceeds(write('alice', '앨리스', 's3')); + await seedDocuments(testEnvironment, { 'fandoms/fandom-a/members/bob': { uid: 'bob', fandomId: 'fandom-a' } }); + await assertSucceeds(write('bob', '밥', 's4')); + // backers — 후원자 표식이 있는 사람만. 팬덤 회원이어도 후원 전이면 거부. + await scope('backers'); + await assertFails(write('alice', '앨리스', 's5')); + await seedDocuments(testEnvironment, { 'campaigns/open/backers/alice': { uid: 'alice' } }); + await assertSucceeds(write('alice', '앨리스', 's6')); + // 모르는 값은 닫는다. + await scope('everyone'); + await assertFails(write('alice', '앨리스', 's7')); + }); + + test('댓글 좋아요는 본인 문서로 한 번, 보이는 댓글 · 공개 캠페인에만', async () => { + const mine = doc(dbFor('bob'), 'campaigns/open/comments/c1/likes/bob'); + await assertSucceeds(setDoc(mine, { createdAt: serverTimestamp() })); + await assertFails(setDoc(mine, { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/c1/likes/alice'), { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/hid/likes/bob'), { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/draft/comments/d0/likes/bob'), { createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1/likes/alice'), { createdAt: new Date() })); + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1/likes/alice'), { createdAt: serverTimestamp(), n: 1 })); + // 댓글의 좋아요 수는 작성자도 못 고친다. + await assertFails(updateDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1'), { likeCount: 9 })); + await assertSucceeds(deleteDoc(mine)); + }); + + test('캠페인 신고는 campaign-{캠페인}-{신고자} ID 로만 한 번 접수된다', async () => { + const campaignReport = (o = {}) => report({ targetType: 'campaign', targetPath: 'campaigns/open', ...o }); + const ref = doc(dbFor('bob'), 'reports/campaign-open-bob'); + await assertSucceeds(setDoc(ref, campaignReport())); + await assertFails(setDoc(ref, campaignReport())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaign-open-alice'), campaignReport())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaign-other-bob'), campaignReport())); + await assertFails( + setDoc(doc(dbFor('bob'), 'reports/campaign-c1-bob'), campaignReport({ targetPath: 'campaigns/open/comments/c1' })), + ); + }); + + test('공개 닉네임이 없으면 「팬」으로도 쓸 수 없다 — 빈 닉네임 · 이메일 형태', async () => { + await assertFails(setDoc(doc(dbFor('noname'), 'campaigns/open/comments/n1'), comment('noname', '팬'))); + await seedDocuments(testEnvironment, { + 'users/mail': userDocument('mail', { nickname: 'a@b.com' }), + 'users/blank': userDocument('blank', { nickname: ' ' }), + }); + await assertFails(setDoc(doc(dbFor('mail'), 'campaigns/open/comments/n2'), comment('mail', '팬'))); + await assertFails(setDoc(doc(dbFor('blank'), 'campaigns/open/comments/n3'), comment('blank', '팬'))); + // 라운지와 달리 대체 이름 경로가 없다는 것만 다르다 — 닉네임이 생기면 바로 쓴다. + await seedDocuments(testEnvironment, { 'users/noname': userDocument('noname', { nickname: '새이름' }) }); + await assertSucceeds(setDoc(doc(dbFor('noname'), 'campaigns/open/comments/n4'), comment('noname', '새이름'))); + }); + + test('로그인 회원은 공개 캠페인에 공개 닉네임으로 1,000자까지 쓴다(팬덤 무관)', async () => { + await assertSucceeds(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/b1'), comment('bob', '밥'))); + await assertSucceeds( + setDoc(doc(dbFor('bob'), 'campaigns/open/comments/bmax'), comment('bob', '밥', { body: '가'.repeat(1000) })), + ); + await assertFails( + setDoc(doc(dbFor('bob'), 'campaigns/open/comments/blong'), comment('bob', '밥', { body: '가'.repeat(1001) })), + ); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/b2'), comment('bob', '밥', { body: '' }))); + await assertFails(setDoc(doc(anonymousDb(), 'campaigns/open/comments/a1'), comment('anon', '팬'))); + }); + + test('실명 · 사칭 · 남의 UID · 시각 위조 · 여분 필드 · 숨김 상태는 거부한다', async () => { + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/open/comments/x1'), comment('alice', '김실명'))); + await assertFails(setDoc(doc(dbFor('noname'), 'campaigns/open/comments/x2'), comment('noname', '박실명'))); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x3'), comment('bob', 'FANUZU 운영팀'))); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x4'), comment('alice', '앨리스'))); + await assertFails( + setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x5'), comment('bob', '밥', { createdAt: new Date('2099-01-01') })), + ); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x6'), comment('bob', '밥', { likeCount: 9 }))); + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/open/comments/x7'), comment('bob', '밥', { visibility: 'hidden' }))); + }); + + test('공개 전 캠페인에는 달 수 없다', async () => { + await assertFails(setDoc(doc(dbFor('bob'), 'campaigns/draft/comments/d1'), comment('bob', '밥'))); + await assertFails(setDoc(doc(dbFor('alice'), 'campaigns/review/comments/r1'), comment('alice', '앨리스'))); + }); + + test('본인 댓글만 지우고, 본문 수정 · 댓글 수 위조는 막는다', async () => { + await assertFails(deleteDoc(doc(dbFor('bob'), 'campaigns/open/comments/c1'))); + await assertFails( + updateDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1'), { body: 'x', updatedAt: serverTimestamp() }), + ); + await assertFails(updateDoc(doc(dbFor('alice'), 'campaigns/draft'), { commentCount: 50 })); + await assertFails(updateDoc(doc(dbFor('alice'), 'campaigns/open'), { commentCount: 50 })); + await assertSucceeds(deleteDoc(doc(dbFor('alice'), 'campaigns/open/comments/c1'))); + }); + + test('신고는 campaignComment-{캠페인}-{댓글}-{신고자} ID 로만 한 번 접수된다', async () => { + const ref = doc(dbFor('bob'), 'reports/campaignComment-open-c1-bob'); + await assertSucceeds(setDoc(ref, report())); + await assertFails(setDoc(ref, report())); + // 캠페인이 빠진 옛 모양 · 남의 UID · 다른 댓글 · 다른 캠페인 + await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-c1-bob'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-open-c1-alice'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-open-c2-bob'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/campaignComment-other-c1-bob'), report())); + await assertFails(setDoc(doc(dbFor('bob'), 'reports/post-c1-bob'), report())); + // 대상 종류와 경로 모양이 어긋나면 거부 + await assertFails( + setDoc(doc(dbFor('carol'), 'reports/campaignComment-open-carol'), report({ reporterUid: 'carol', targetPath: 'posts/open' })), + ); + await assertFails( + setDoc(doc(dbFor('carol'), 'reports/post-open-carol'), report({ reporterUid: 'carol', targetPath: 'campaigns/open' })), + ); + await assertFails( + setDoc(doc(dbFor('carol'), 'reports/campaignComment-open-c1-carol'), report({ reporterUid: 'carol', reasonCode: '아무거나' })), + ); + await assertSucceeds( + setDoc(doc(dbFor('carol'), 'reports/campaignComment-open-c1-carol'), report({ reporterUid: 'carol' })), + ); + }); + + test('숨김 댓글은 운영만 읽고, 작성자도 지우지 못한다', async () => { + await assertFails(getDoc(doc(dbFor('bob'), 'campaigns/open/comments/hid'))); + await assertFails(getDoc(doc(dbFor('alice'), 'campaigns/open/comments/hid'))); + await assertSucceeds(getDoc(doc(dbFor('ops', { role: 'ops' }), 'campaigns/open/comments/hid'))); + await assertSucceeds(getDoc(doc(dbFor('bob'), 'campaigns/open/comments/c1'))); + // 목록은 보이는 댓글로 좁혀야 통과한다. + await assertFails(getDocs(collection(dbFor('bob'), 'campaigns/open/comments'))); + await assertSucceeds( + getDocs(query(collection(dbFor('bob'), 'campaigns/open/comments'), where('visibility', '==', 'visible'))), + ); + await assertFails(deleteDoc(doc(dbFor('alice'), 'campaigns/open/comments/hid'))); + }); + + test('좋아요도 댓글과 같은 작성 자격(campaignCommentScope)을 따른다', async () => { + const like = (uid) => setDoc(doc(dbFor(uid), `campaigns/open/comments/c1/likes/${uid}`), { createdAt: serverTimestamp() }); + await seedDocuments(testEnvironment, { 'config/policy': { campaignCommentScope: 'members' } }); + await assertFails(like('bob')); // fandom-b — 캠페인 팬덤 아님 + await assertSucceeds(like('alice')); // fandom-a + await seedDocuments(testEnvironment, { 'config/policy': { campaignCommentScope: 'backers' } }); + await assertFails(like('noname')); + await seedDocuments(testEnvironment, { 'campaigns/open/backers/noname': { uid: 'noname' } }); + await assertSucceeds(like('noname')); + await seedDocuments(testEnvironment, { 'config/policy': { campaignCommentScope: 'everyone' } }); + await assertFails(like('bob')); + }); +}); + +describe('참여형 번역 — translationEntries · markings · translationDaily (FR-I18N-02)', () => { + const entry = 'translationEntries/entry-1'; + + test('번역은 로그인 사용자가, 마킹은 번역 작성자 · 마킹한 본인만 읽는다', async () => { + await seedDocuments(testEnvironment, { + [entry]: { authorUid: 'alice', status: 'pending', body: 'Hello' }, + [`${entry}/markings/bob`]: { valid: true }, + }); + await assertSucceeds(getDoc(doc(dbFor('carol'), entry))); + await assertFails(getDoc(doc(anonymousDb(), entry))); + await assertSucceeds(getDoc(doc(dbFor('bob'), `${entry}/markings/bob`))); + await assertSucceeds(getDoc(doc(dbFor('alice'), `${entry}/markings/bob`))); + await assertSucceeds(getDocs(collection(dbFor('alice'), `${entry}/markings`))); + await assertFails(getDoc(doc(dbFor('carol'), `${entry}/markings/bob`))); + await assertFails(getDocs(collection(dbFor('carol'), `${entry}/markings`))); + }); + + test('마킹 직접 쓰기는 누구도 못 한다(fn-translation-mark 경유)', async () => { + await seedDocuments(testEnvironment, { [entry]: { authorUid: 'alice', status: 'pending' } }); + await assertFails(setDoc(doc(dbFor('bob'), `${entry}/markings/bob`), { valid: true, createdAt: serverTimestamp() })); + await assertFails(setDoc(doc(dbFor('alice'), `${entry}/markings/alice`), { valid: true, createdAt: serverTimestamp() })); + }); + + test('마킹은 번복할 수 없다', async () => { + await seedDocuments(testEnvironment, { + [entry]: { authorUid: 'alice', status: 'pending' }, + [`${entry}/markings/bob`]: { valid: true }, + }); + await assertFails(updateDoc(doc(dbFor('bob'), `${entry}/markings/bob`), { valid: false })); + await assertFails(deleteDoc(doc(dbFor('bob'), `${entry}/markings/bob`))); + }); + + test('번역 등록 · 일일 카운터는 클라이언트가 읽거나 쓸 수 없다', async () => { + await assertFails( + setDoc(doc(dbFor('alice'), 'translationEntries/forged'), { + authorUid: 'alice', status: 'pending', rewardPop: 5000, + }), + ); + await seedDocuments(testEnvironment, { 'translationDaily/alice_2026-09-28': { uid: 'alice', count: 1 } }); + await assertFails(getDoc(doc(dbFor('alice'), 'translationDaily/alice_2026-09-28'))); + await assertFails(setDoc(doc(dbFor('alice'), 'translationDaily/alice_2026-09-28'), { count: 0 })); + }); +}); + +describe('결제 기록 — MY/설정/서비스 결제 내역 · 환불 요청 (B-02 · B-72)', () => { + const path = 'users/alice/purchases/android_gpa.1'; + const refund = (extra = {}) => ({ + state: 'refund-requested', + refundReason: '실수로 결제했습니다', + refundRequestedAt: serverTimestamp(), + ...extra, + }); + + beforeEach(async () => { + await seedDocuments(testEnvironment, { + [path]: { uid: 'alice', state: 'granted', amountPop: 100, productId: 'pop.support.1000' }, + 'heroGiftProducts/gift-1': { name: '베이직 드랍', buyerPop: 100, dropPop: 500, active: true }, + }); + }); + + test('본인 결제 기록을 읽고 타인 기록은 읽지 못한다', async () => { + await assertSucceeds(getDoc(doc(dbFor('alice'), path))); + await assertFails(getDoc(doc(dbFor('bob'), path))); + }); + + test('본인은 지급 완료 건을 환불 요청으로만 바꿀 수 있다', async () => { + await assertSucceeds(updateDoc(doc(dbFor('alice'), path), refund())); + }); + + test('환불 요청과 함께 금액·다른 상태를 바꾸지 못한다', async () => { + await assertFails(updateDoc(doc(dbFor('alice'), path), refund({ amountPop: 999 }))); + await assertFails(updateDoc(doc(dbFor('alice'), path), refund({ state: 'refunded' }))); + await assertFails(updateDoc(doc(dbFor('bob'), path), refund())); + }); + + test('이미 요청한 건은 다시 바꾸지 못하고 결제 기록을 만들 수 없다', async () => { + await assertSucceeds(updateDoc(doc(dbFor('alice'), path), refund())); + await assertFails(updateDoc(doc(dbFor('alice'), path), refund({ refundReason: '기타' }))); + await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/purchases/forged'), { state: 'granted' })); + }); + + test('Hero Gift Catalog 는 로그인 사용자가 읽고 누구도 앱에서 쓰지 못한다', async () => { + await assertSucceeds(getDoc(doc(dbFor('alice'), 'heroGiftProducts/gift-1'))); + await assertFails(getDoc(doc(anonymousDb(), 'heroGiftProducts/gift-1'))); + await assertFails(updateDoc(doc(dbFor('alice'), 'heroGiftProducts/gift-1'), { buyerPop: 99999 })); + }); +}); + +describe('사용자 생성 Quest — 제출 · 검토 · 수정 이력 (C-07 · C-08 · G-01)', () => { + beforeEach(async () => { + await seedDocuments(testEnvironment, { + 'quests/q-ugc': { authorUid: 'creator', title: '투표', rewardPop: 5, status: 'published' }, + 'quests/q-ugc/edits/e1': { editorUid: 'creator', field: 'title', before: 'a', after: 'b' }, + 'questSubmissions/q-ugc_alice': { + questId: 'q-ugc', uid: 'alice', questAuthorUid: 'creator', status: 'submitted', rewardPop: 5, + }, + }); + }); + + test('제출자 · 생성자 · 운영자만 제출 건을 읽는다', async () => { + const path = 'questSubmissions/q-ugc_alice'; + await assertSucceeds(getDoc(doc(dbFor('alice'), path))); + await assertSucceeds(getDoc(doc(dbFor('creator'), path))); + await assertSucceeds(getDoc(doc(dbFor('op', { role: 'ops' }), path))); + await assertFails(getDoc(doc(dbFor('bob'), path))); + await assertSucceeds(getDocs(query(collection(dbFor('creator'), 'questSubmissions'), + where('questId', '==', 'q-ugc'), where('questAuthorUid', '==', 'creator')))); + }); + + test('제출 건 상태 · 예약은 앱이 쓰지 못한다 — 자기 승인 · 위조 제출 거부', async () => { + await assertFails(updateDoc(doc(dbFor('alice'), 'questSubmissions/q-ugc_alice'), { status: 'paid' })); + await assertFails(updateDoc(doc(dbFor('creator'), 'questSubmissions/q-ugc_alice'), { status: 'approved' })); + await assertFails(setDoc(doc(dbFor('bob'), 'questSubmissions/q-ugc_bob'), { + questId: 'q-ugc', uid: 'bob', questAuthorUid: 'creator', status: 'paid', + })); + }); + + test('글 사용 기록 · 미리보기 호출 상한 문서는 서버 전용', async () => { + await assertFails(setDoc(doc(dbFor('alice'), 'questPostUses/p1'), { questId: 'q-ugc' })); + await assertFails(getDoc(doc(dbFor('alice'), 'questPostUses/p1'))); + await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/rateLimits/linkPreview'), { count: 0 })); + }); + + test('공개 후 잠금 필드 · 수정 이력은 생성자도 직접 못 쓴다', async () => { + await assertFails(updateDoc(doc(dbFor('creator'), 'quests/q-ugc'), { rewardPop: 500 })); + await assertSucceeds(getDoc(doc(dbFor('bob'), 'quests/q-ugc/edits/e1'))); + await assertFails(setDoc(doc(dbFor('creator'), 'quests/q-ugc/edits/e2'), { field: 'rewardPop' })); + await assertFails(deleteDoc(doc(dbFor('creator'), 'quests/q-ugc/edits/e1'))); + }); +}); + describe('테스트 자가 점검', () => { test('규칙이 적재된 프로젝트 ID가 demo- 접두사를 사용한다', () => { expect(testEnvironment.projectId).toMatch(/^demo-/); diff --git a/app/firebase/test/tests/storage.rules.test.mjs b/app/firebase/test/tests/storage.rules.test.mjs new file mode 100644 index 0000000..a0d65ef --- /dev/null +++ b/app/firebase/test/tests/storage.rules.test.mjs @@ -0,0 +1,116 @@ +import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing'; +import { deleteObject, ref, uploadBytes } from 'firebase/storage'; +import { afterAll, beforeAll, describe, test } from 'vitest'; + +import { createStorageTestEnvironment } from '../support/environment.mjs'; + +/// 프로필 사진 업로드 — 정책 원장 B-44 의 *Storage 규칙 서버 검증* 이 실제로 +/// 거르는지 본다. 클라이언트 1차 검증(`checkProfilePhoto`)은 앱 쪽 테스트가 잡고, +/// 여기서는 **앱을 고쳐 우회해도 막히는가**를 확인한다. + +let testEnvironment; + +const storageFor = (uid) => + testEnvironment.authenticatedContext(uid).storage(); +const anonymousStorage = () => + testEnvironment.unauthenticatedContext().storage(); + +/// 규칙은 내용을 보지 않고 크기와 선언된 종류만 본다. +const bytesOf = (size) => new Uint8Array(size); + +const image = { contentType: 'image/png' }; + +beforeAll(async () => { + testEnvironment = await createStorageTestEnvironment(); +}); + +afterAll(async () => { + await testEnvironment.cleanup(); +}); + +describe('프로필 사진 — users/{uid}/profile', () => { + test('본인 경로에 이미지를 올린다', async () => { + await assertSucceeds( + uploadBytes(ref(storageFor('alice'), 'users/alice/profile/photo'), bytesOf(1024), image), + ); + }); + + test('10MB 를 넘으면 거부한다', async () => { + await assertFails( + uploadBytes( + ref(storageFor('alice'), 'users/alice/profile/photo'), + bytesOf(10 * 1024 * 1024 + 1), + image, + ), + ); + }); + + test('이미지가 아닌 종류는 거부한다', async () => { + await assertFails( + uploadBytes( + ref(storageFor('alice'), 'users/alice/profile/photo'), + bytesOf(1024), + { contentType: 'application/pdf' }, + ), + ); + }); + + test('남의 경로에는 올리지 못한다', async () => { + await assertFails( + uploadBytes(ref(storageFor('bob'), 'users/alice/profile/photo'), bytesOf(1024), image), + ); + }); + + test('로그인하지 않으면 올리지 못한다', async () => { + await assertFails( + uploadBytes(ref(anonymousStorage(), 'users/alice/profile/photo'), bytesOf(1024), image), + ); + }); + + test('라운지 사진은 본인 경로에 10MB 이하 이미지만, 지우기도 본인만', async () => { + const id = 'AbCdEfGhIjKlMnOpQrSt'; // Firestore 자동 ID 모양(20자) + const path = `users/alice/lounge/${id}-0`; + await assertSucceeds(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), image)); + // 이미 올린 파일 덮어쓰기는 닫는다 — 글의 URL 이 가리키는 내용이 바뀐다. + await assertFails(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), image)); + await assertFails( + uploadBytes( + ref(storageFor('alice'), `users/alice/lounge/${id}-1`), + bytesOf(10 * 1024 * 1024 + 1), + image, + ), + ); + await assertFails( + uploadBytes(ref(storageFor('alice'), `users/alice/lounge/${id}-2`), bytesOf(1024), { + contentType: 'application/pdf', + }), + ); + await assertFails(uploadBytes(ref(storageFor('bob'), `users/alice/lounge/${id}-3`), bytesOf(1024), image)); + // 파일 이름 규칙 밖 — 칸 번호 5 · 임의 이름. + await assertFails(uploadBytes(ref(storageFor('alice'), `users/alice/lounge/${id}-5`), bytesOf(1024), image)); + await assertFails(uploadBytes(ref(storageFor('alice'), 'users/alice/lounge/anything'), bytesOf(1024), image)); + await assertFails(deleteObject(ref(storageFor('bob'), path))); + await assertSucceeds(deleteObject(ref(storageFor('alice'), path))); + }); + + test('열지 않은 경로는 기본 거부다', async () => { + await assertFails( + uploadBytes(ref(storageFor('alice'), 'posts/post-1/media/1.png'), bytesOf(1024), image), + ); + }); +}); + +describe('캠페인 결과보고 사진·영수증 — campaignReports/{campaignId} (B-35 운영 전용)', () => { + const path = 'campaignReports/campaign-1/r1.png'; + const ops = () => testEnvironment.authenticatedContext('ops-user', { role: 'ops' }).storage(); + + test('운영이 이미지를 올린다', async () => { + await assertSucceeds(uploadBytes(ref(ops(), path), bytesOf(1024), image)); + }); + + test('운영이 아니면(개설자 포함) · 10MB 초과 · 이미지 아닌 종류는 거부한다', async () => { + await assertFails(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), image)); + await assertFails(uploadBytes(ref(ops(), path), bytesOf(10 * 1024 * 1024 + 1), image)); + await assertFails(uploadBytes(ref(ops(), path), bytesOf(1024), { contentType: 'text/html' })); + }); +});