Cloud Functions 서버 기반과 앱 서버 연동 계층 — 계정 생성 트리거 · 약관 동의 기록 · 운영자 권한 · Admin 쓰기 관문 · Firestore·Functions 클라이언트 · 오류 코드 전달

This commit is contained in:
Sn-Kinos
2026-09-01 00:20:45 +09:00
parent 2037b6d573
commit 1d462ebe31
21 changed files with 21578 additions and 12 deletions
+5
View File
@@ -43,3 +43,8 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Firebase 도구·에뮬레이터 산출물
firebase/**/node_modules/
firebase/**/*-debug.log
firebase/functions/lib/
+4
View File
@@ -0,0 +1,4 @@
analyzer:
exclude:
# Firebase 도구가 내려받는 서드파티 소스. 우리 분석 대상이 아니다.
- '**/node_modules/**'
+20 -3
View File
@@ -4,8 +4,25 @@
"indexes": "firestore.indexes.json" "indexes": "firestore.indexes.json"
}, },
"emulators": { "emulators": {
"firestore": { "port": 8080 }, "firestore": {
"ui": { "enabled": false }, "port": 8080
"singleProjectMode": true },
"ui": {
"enabled": false
},
"singleProjectMode": true,
"auth": {
"port": 9099
},
"functions": {
"port": 5001
} }
},
"functions": [
{
"source": "functions",
"codebase": "default",
"runtime": "nodejs22"
}
]
} }
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "fanuzu-functions",
"version": "1.0.0",
"private": true,
"description": "FANUZU Cloud Functions — 2주차 Firestore 설계서 §8 인터페이스 목록의 구현",
"type": "module",
"main": "lib/index.js",
"engines": {
"node": "22"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\""
},
"dependencies": {
"firebase-admin": "13.6.0",
"firebase-functions": "6.5.0"
},
"devDependencies": {
"@types/node": "22.20.0",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,102 @@
import * as functionsV1 from 'firebase-functions/v1';
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
import { callerUid, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
/**
* `fn-auth-oncreate` — 계정 문서의 유일한 생성 주체.
*
* 보안 규칙이 `users` 의 클라이언트 `create` 를 닫아 두었으므로(교차검증 F-06)
* 이 트리거가 만들지 않으면 계정 문서가 존재하지 않는다. 생성 주체를 하나로
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
* 사용자 문서가 남는다.
*
* v2 에는 사용자 생성 배경 트리거가 없어 v1 Auth 트리거를 쓴다.
*/
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
const db = getFirestore();
const batch = db.batch();
batch.set(db.doc(`users/${user.uid}`), {
uid: user.uid,
displayName: user.displayName ?? '팬우주 여행자',
photoUrl: user.photoURL ?? null,
countryCode: 'KR',
locale: 'ko',
fandomId: '',
authProviders: user.providerData.map((p) => p.providerId),
totalContributedPop: 0,
representativeBadgeId: null,
status: 'active',
notificationPrefs: {},
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
activityBalance: 0,
supportBalance: 0,
pendingActivity: 0,
reconcileStatus: 'ok',
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
});
/**
* `fn-consent-record` — 약관 동의 이력(교차검증 F-04).
*
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
*/
export const fnConsentRecord = onCall(async (req) => {
const uid = callerUid(req);
const docType = str(req.data, 'docType');
const docVersion = str(req.data, 'docVersion');
const granted = (req.data as Record<string, unknown>)?.granted === true;
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}`;
await getFirestore().doc(`users/${uid}/consents/${consentId}`).set({
docType,
docVersion,
granted,
ip: req.rawRequest?.ip ?? null,
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
createdAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { consentId };
});
/**
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
*
* 클레임은 다음 토큰 갱신부터 적용되므로 `admins` 문서도 함께 갱신해 Admin 화면이
* 즉시 목록을 보여줄 수 있게 한다.
*/
export const fnAdminSetRole = onCall(async (req) => {
const actorUid = callerUid(req);
if (req.auth?.token?.role !== 'master') {
fail(ERR.permissionDenied, '마스터 권한이 필요합니다.');
}
const targetUid = str(req.data, 'uid');
const role = str(req.data, 'role');
if (!['master', 'ops', 'viewer'].includes(role)) {
fail(ERR.permissionDenied, '알 수 없는 권한 등급입니다.');
}
await getAuth().setCustomUserClaims(targetUid, { role });
await getFirestore().doc(`admins/${targetUid}`).set({
uid: targetUid,
role,
grantedBy: actorUid,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { uid: targetUid, role };
});
+60
View File
@@ -0,0 +1,60 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { callerUid, requireRole, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
/**
* `fn-admin-write` — 모든 Admin 쓰기의 단일 관문(§7.5).
*
* Admin 콘솔이 Firestore 를 직접 쓰지 않고 여기를 지나는 이유는 로그 때문이다.
* 보안 규칙은 거부만 할 수 있고 로그를 남기지 못한다. 누가 무엇을 왜 바꿨는지는
* 여기서만 남길 수 있고, rbac-summary 가 요구하는 작업 로그가 그것이다.
*/
/** 원장·지갑은 이 경로로 못 쓴다. 재화는 반드시 원장 함수를 지나야 한다. */
const FORBIDDEN = [
/popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/,
];
export const fnAdminWrite = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const targetPath = str(req.data, 'targetPath');
const reason = str(req.data, 'reason');
const patch = (req.data as Record<string, unknown>)?.patch;
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
fail(ERR.permissionDenied, '변경 내용이 올바르지 않습니다.');
}
if (FORBIDDEN.some((re) => re.test(targetPath))) {
// 관리자라도 잔액을 직접 고칠 수 없다. 별첨1 ② 「원장 정합성」의 마지막 방벽이다.
fail(ERR.permissionDenied, '재화 관련 문서는 원장 함수로만 변경할 수 있습니다.');
}
// 경로 깊이가 짝수여야 문서다. 홀수면 컬렉션이라 통째로 덮어쓸 위험이 있다.
const segments = targetPath.split('/').filter(Boolean);
if (segments.length === 0 || segments.length % 2 !== 0) {
fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
}
const db = getFirestore();
const ref = db.doc(targetPath);
const before = await ref.get();
await ref.set({
...(patch as Record<string, unknown>),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
const log = await db.collection('adminActionLogs').add({
actorUid,
role,
targetPath,
reason,
patch,
existedBefore: before.exists,
createdAt: FieldValue.serverTimestamp(),
});
return { logId: log.id, targetPath };
});
@@ -0,0 +1,49 @@
import { HttpsError } from 'firebase-functions/v2/https';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { ERR, CatalogError } from './errors.js';
/** 호출자 UID. 익명 호출은 여기서 끊는다 — 설계서 §8 「전부 인증 필요」. */
export function callerUid(req: CallableRequest): string {
const uid = req.auth?.uid;
if (!uid) throw new CatalogError(ERR.authFailed, '로그인이 필요합니다.');
return uid;
}
export type AdminRole = 'master' | 'ops' | 'viewer';
/**
* 운영자 판정 — 커스텀 클레임만 본다.
*
* Firestore 문서 조회로 판정하지 않는 이유는 보안 규칙과 같다: 판정 1회당 읽기
* 비용이 붙고, 우회 표면이 늘어난다.
*/
export function requireRole(req: CallableRequest, allowed: AdminRole[]): AdminRole {
const role = req.auth?.token?.role as AdminRole | undefined;
if (!role || !allowed.includes(role)) {
throw new CatalogError(ERR.permissionDenied, '권한이 없습니다.');
}
return role;
}
/** 문자열 필드를 안전하게 꺼낸다. */
export function str(data: unknown, key: string): string {
const value = (data as Record<string, unknown> | null)?.[key];
if (typeof value !== 'string' || value.length === 0) {
throw new HttpsError('invalid-argument', `${key} 이(가) 필요합니다.`);
}
return value;
}
export function optionalStr(data: unknown, key: string): string | undefined {
const value = (data as Record<string, unknown> | null)?.[key];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function num(data: unknown, key: string): number {
const value = (data as Record<string, unknown> | null)?.[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new HttpsError('invalid-argument', `${key} 이(가) 필요합니다.`);
}
return value;
}
+55
View File
@@ -0,0 +1,55 @@
import { HttpsError } from 'firebase-functions/v2/https';
/**
* 오류 코드 — 2주차 Firestore 설계서 §8 「실패 시 에러 카탈로그의 ID 를 그대로 응답 코드로 쓴다」.
*
* 클라이언트는 이 문자열로 분기하므로 값을 바꾸면 앱이 깨진다. 새 코드가 필요하면
* 카탈로그에 먼저 등재한 뒤 여기에 더한다.
*/
export const ERR = {
permissionDenied: 'E-PERM-01',
authFailed: 'E-AUTH-03',
} as const;
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
/** gRPC 상태로 옮긴다. 앱은 상태가 아니라 `details.code` 로 분기한다. */
type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists'
| 'permission-denied' | 'not-found' | 'resource-exhausted' | 'internal';
const STATUS: Record<ErrorCode, Status> = {
'E-PERM-01': 'permission-denied',
'E-AUTH-03': 'permission-denied',
};
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
export class CatalogError extends HttpsError {
constructor(readonly catalogCode: ErrorCode, message: string) {
super(STATUS[catalogCode], message, { code: catalogCode });
}
}
export const fail = (code: ErrorCode, message: string): never => {
throw new CatalogError(code, message);
};
/**
* 입력이 없으면 그 자리에서 끊는다.
*
* 카탈로그에 없는 실패라 `invalid-argument` 로 둔다 — 사용자가 고칠 수 있는
* 상황이 아니라 클라이언트 구현 오류다.
*/
export function required<T>(value: T | null | undefined, name: string): T {
if (value === null || value === undefined || value === '') {
throw new HttpsError('invalid-argument', `${name} 이(가) 필요합니다.`);
}
return value;
}
/** POP 수량은 양의 정수만 받는다. 소수·음수·비정수는 원장을 오염시킨다. */
export function positiveInt(value: unknown, name: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new HttpsError('invalid-argument', `${name} 은(는) 양의 정수여야 합니다.`);
}
return value;
}
+14
View File
@@ -0,0 +1,14 @@
import { initializeApp } from 'firebase-admin/app';
import { setGlobalOptions } from 'firebase-functions/v2';
// Admin SDK 는 보안 규칙을 우회한다. 그래서 재화를 건드리는 경로가 전부
// 이 패키지 안에만 있어야 하고, 규칙 층은 클라이언트 쓰기를 전면 차단한다.
initializeApp();
// 리전을 서울로 고정한다. 기본값(us-central1)이면 왕복 지연이 그대로 체감된다.
setGlobalOptions({ region: 'asia-northeast3', maxInstances: 20 });
export {
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
} from './account/account.js';
export { fnAdminWrite } from './admin/admin.js';
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { callerUid, requireRole } from '../src/core/callable.js';
import { fnAdminSetRole, fnConsentRecord } from '../src/account/account.js';
import { fnAdminWrite } from '../src/admin/admin.js';
/** 에뮬레이터 없이 도는 인증 · 권한 관문 검증. 데이터에 닿기 전에 끊겨야 한다. */
const req = (auth?: { uid: string; token: Record<string, unknown> }) =>
({ data: {}, auth } as unknown as CallableRequest);
describe('인증 관문', () => {
it('익명 호출은 E-AUTH-03 으로 끊는다', () => {
expect(() => callerUid(req())).toThrow(expect.objectContaining({ details: { code: 'E-AUTH-03' } }));
});
it('로그인한 호출은 UID 를 돌려준다', () => {
expect(callerUid(req({ uid: 'u1', token: {} }))).toBe('u1');
});
});
describe('운영자 관문', () => {
it('허용 목록에 없는 역할은 E-PERM-01 로 끊는다', () => {
expect(() => requireRole(req({ uid: 'v', token: { role: 'viewer' } }), ['master', 'ops']))
.toThrow(expect.objectContaining({ details: { code: 'E-PERM-01' } }));
});
it('허용된 역할은 그대로 돌려준다', () => {
expect(requireRole(req({ uid: 'o', token: { role: 'ops' } }), ['master', 'ops'])).toBe('ops');
});
it('Admin 쓰기 · 권한 부여는 일반 사용자와 조회 운영자를 데이터 접근 전에 거부한다', async () => {
for (const fn of [fnAdminWrite, fnAdminSetRole]) {
for (const auth of [{ uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fn.run(req(auth) as Parameters<typeof fn.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
}
});
it('약관 동의 기록은 익명 호출을 거부한다', async () => {
await expect(fnConsentRecord.run(req() as Parameters<typeof fnConsentRecord.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2023",
"lib": ["ES2023"],
"outDir": "lib",
"rootDir": ".",
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["lib"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
/**
* NodeNext 규약대로 소스가 `./ledger.js` 로 임포트하지만 실제 파일은 `.ts` 다.
* Vite 는 이 치환을 하지 않으므로 여기서 직접 이어 준다.
*/
const tsExtension = {
name: 'resolve-js-to-ts',
resolveId(source: string, importer?: string) {
if (!importer || !source.startsWith('.') || !source.endsWith('.js')) return null;
const candidate = resolve(dirname(importer), source.replace(/\.js$/, '.ts'));
return existsSync(candidate) ? candidate : null;
},
};
export default defineConfig({
plugins: [tsExtension],
test: {
include: ['test/**/*.test.ts'],
// 에뮬레이터 왕복이 있어 기본 5초로는 모자란다.
testTimeout: 30_000,
hookTimeout: 30_000,
// 원장 테스트는 같은 사용자 문서를 건드리므로 순차로 돌린다.
fileParallelism: false,
},
});
+8659
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "fanuzu-firebase",
"version": "1.0.0",
"private": true,
"description": "FANUZU Firebase 산출물 — 보안 규칙 · 인덱스 · Cloud Functions. 에뮬레이터 도구를 여기 한 벌만 둔다.",
"scripts": {
"test": "npm run test:rules && npm run test:functions",
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
"test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\""
},
"devDependencies": {
"firebase-tools": "15.28.2"
}
}
+8056
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"firebase": "12.17.1" "firebase": "12.17.1"
}, },
"devDependencies": { "devDependencies": {
"firebase-tools": "^15.28.2",
"vitest": "4.1.10" "vitest": "4.1.10"
} }
} }
+137
View File
@@ -0,0 +1,137 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
/// 서버 경계 — 앱에서 Firestore·Cloud Functions 로 나가는 유일한 통로.
///
/// **읽기와 쓰기를 다르게 다룬다.**
/// - 읽기는 Firestore 를 직접 구독한다. 목록·상세는 실시간이어야 하고, 함수를
/// 한 번 더 거치면 지연과 비용만 는다.
/// - **재화가 오가는 쓰기는 전부 Cloud Functions 를 지난다.** 보안 규칙이
/// `popTransactions`·`wallet` 의 클라이언트 쓰기를 전면 차단해 두었으므로
/// (별첨 1 ② 「클라이언트 단독 지급 0건」) 앱에서 직접 쓰는 경로 자체가 없다.
///
/// 리전을 서울로 고정한 것은 Functions 배포 리전과 맞추기 위함이다. 어긋나면
/// 호출이 `not-found` 로 떨어지는데, 원인이 함수 부재처럼 보여 찾기 어렵다.
class Backend {
Backend({
FirebaseFirestore? firestore,
FirebaseFunctions? functions,
FirebaseAuth? auth,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_functions = functions,
_auth = auth ?? FirebaseAuth.instance;
static const String region = 'asia-northeast3';
final FirebaseFirestore _firestore;
final FirebaseAuth _auth;
/// 실제로 함수를 부르기 전까지 만들지 않는다. 생성자에서 만들면 Firestore 만
/// 쓰는 화면·테스트도 Firebase 초기화를 강제당한다.
FirebaseFunctions? _functions;
FirebaseFunctions get functions =>
_functions ??= FirebaseFunctions.instanceFor(region: region);
FirebaseFirestore get db => _firestore;
String? get uid => _auth.currentUser?.uid;
/// 로그인 필수 경로에서 UID 를 강제로 얻는다.
String get requireUid {
final value = uid;
if (value == null) {
throw const BackendException('E-AUTH-03', '로그인이 필요합니다.');
}
return value;
}
/// Callable 호출 한 번. 서버가 실은 카탈로그 코드를 그대로 꺼내 던진다.
///
/// `FirebaseFunctionsException.details` 에 `{code: 'E-POP-01'}` 가 실려 오는데,
/// 화면은 gRPC 상태가 아니라 **이 코드**로 분기해야 한다. 상태는 여러 사유가
/// 겹쳐 들어오지만 카탈로그 코드는 사유와 1:1 이다.
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
try {
final result = await functions.httpsCallable(name).call<Object?>(payload);
final data = result.data;
if (data is Map) return Map<String, dynamic>.from(data);
return const {};
} on FirebaseFunctionsException catch (error) {
throw BackendException(
_catalogCode(error),
error.message ?? '요청을 처리하지 못했습니다.',
);
}
}
static String _catalogCode(FirebaseFunctionsException error) {
final details = error.details;
if (details is Map && details['code'] is String) {
return details['code'] as String;
}
// 서버가 카탈로그 코드를 싣지 못한 경우. 화면이 최소한 분기는 할 수 있게
// 상태를 옮겨 준다.
return switch (error.code) {
'unauthenticated' => 'E-AUTH-03',
'permission-denied' => 'E-PERM-01',
_ => 'E-SYS-01',
};
}
}
/// 화면이 잡는 실패. [code] 는 에러 카탈로그 ID 다.
@immutable
class BackendException implements Exception {
const BackendException(this.code, this.message);
final String code;
final String message;
@override
String toString() => 'BackendException($code): $message';
}
/// Firestore 문서에서 값을 꺼내는 도우미.
///
/// 서버 스키마가 앞서 나가거나 필드가 아직 채워지지 않은 문서가 섞일 수 있어,
/// 없는 필드를 기본값으로 받는다. 여기서 던지면 화면 전체가 빈 상태가 된다.
extension SnapshotReader on Map<String, dynamic> {
int intOr(String key, [int fallback = 0]) {
final value = this[key];
if (value is int) return value;
if (value is num) return value.round();
return fallback;
}
String strOr(String key, [String fallback = '']) {
final value = this[key];
return value is String && value.isNotEmpty ? value : fallback;
}
bool boolOr(String key, [bool fallback = false]) {
final value = this[key];
return value is bool ? value : fallback;
}
DateTime dateOr(String key, DateTime fallback) {
final value = this[key];
if (value is Timestamp) return value.toDate();
if (value is DateTime) return value;
return fallback;
}
List<Map<String, dynamic>> mapsOr(String key) {
final value = this[key];
if (value is! List) return const [];
return value
.whereType<Map>()
.map(Map<String, dynamic>.from)
.toList(growable: false);
}
}
+184 -8
View File
@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.76" version: "1.3.76"
antlr4:
dependency: transitive
description:
name: antlr4
sha256: "752b4a6e4ad97953652a2b2bbf5377f46c94b579d3372b50080c7e5858234a05"
url: "https://pub.dev"
source: hosted
version: "4.13.2"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
cel:
dependency: transitive
description:
name: cel
sha256: "72776d4cacd526f9846522e7a3ce8584c2071974d747e2ea695645faa2dcea1b"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +89,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
sha256: a9667b44b995eb9c642e6460275af9c478f4e72befc61146f2e0f88e6f174705
url: "https://pub.dev"
source: hosted
version: "6.8.0"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
sha256: "700d043dfe95fac8a4eea79d21dd41f6e4c8f2360e43d7b669374dedd465f4ba"
url: "https://pub.dev"
source: hosted
version: "8.0.6"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
sha256: ee86386bb41f6583cc6fc8a75bffe830a6f0128bbd545176c86c18dc5e2c2a7e
url: "https://pub.dev"
source: hosted
version: "5.7.2"
cloud_functions:
dependency: "direct main"
description:
name: cloud_functions
sha256: "82fa5811d184c4135d2177174507cc03744f032a085cd3be95246c0f1b006e53"
url: "https://pub.dev"
source: hosted
version: "6.3.6"
cloud_functions_platform_interface:
dependency: transitive
description:
name: cloud_functions_platform_interface
sha256: "86842d91cd4e83b71aa2dff874fd3554e237aa6e222d8e6e54bb7692fb1bd779"
url: "https://pub.dev"
source: hosted
version: "6.0.6"
cloud_functions_web:
dependency: transitive
description:
name: cloud_functions_web
sha256: aeca9c8f24838630f28bd75622668725a85044211be83de9f0a2fe07c4c8b643
url: "https://pub.dev"
source: hosted
version: "5.1.12"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -81,6 +145,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto: crypto:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -89,6 +161,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
url: "https://pub.dev"
source: hosted
version: "3.4.1"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -97,6 +185,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
fake_cloud_firestore:
dependency: "direct dev"
description:
name: fake_cloud_firestore
sha256: "4276eadc5e2c36663fe393e98d14d4cc413ebe8ce0d02edd8cda57ef7a5a8369"
url: "https://pub.dev"
source: hosted
version: "4.3.0"
fake_firebase_security_rules:
dependency: transitive
description:
name: fake_firebase_security_rules
sha256: "983a1b3aec751af3122c1cfa79ebac0336a1589226a9683548b30457a5268720"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -113,6 +217,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.7" version: "6.5.7"
firebase_auth_mocks:
dependency: "direct dev"
description:
name: firebase_auth_mocks
sha256: "98628921966dab6ae097d447799b2f8c6e252b8adc0f4a9fe4b67ce5c165e5fb"
url: "https://pub.dev"
source: hosted
version: "0.15.2"
firebase_auth_platform_interface: firebase_auth_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -153,6 +265,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.10.0" version: "3.10.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -304,6 +424,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logger:
dependency: transitive
description:
name: logger
sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7"
url: "https://pub.dev"
source: hosted
version: "2.8.0"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -316,10 +444,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.20"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -332,10 +460,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.19.0"
mock_exceptions:
dependency: transitive
description:
name: mock_exceptions
sha256: "6e3e623712d2c6106ffe9e14732912522b565ddaa82a8dcee6cd4441b5984056"
url: "https://pub.dev"
source: hosted
version: "0.8.2"
more:
dependency: transitive
description:
name: more
sha256: e252628d2183cc09539b686abfbd9d8302675959b89a2a8146f5f4baca6ac5ba
url: "https://pub.dev"
source: hosted
version: "4.7.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -360,6 +504,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix: posix:
dependency: transitive dependency: transitive
description: description:
@@ -368,6 +520,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.2" version: "6.5.2"
rx:
dependency: transitive
description:
name: rx
sha256: "3c819c80915138089c517e0d78f462792c5a2de05189466ab38bee7b6a8a330f"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sign_in_with_apple: sign_in_with_apple:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -441,10 +609,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.12"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -453,14 +621,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.4.3"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
+9
View File
@@ -21,6 +21,11 @@ dependencies:
firebase_core: ^4.13.0 firebase_core: ^4.13.0
firebase_auth: ^6.5.7 firebase_auth: ^6.5.7
# 서버 연동 — 읽기는 Firestore 직결, 재화가 오가는 쓰기는 전부 Cloud Functions 를 지난다.
# 이 구분이 별첨1 ② 「클라이언트 단독 지급 0건」을 앱 쪽에서 지키는 방법이다.
cloud_firestore: ^6.3.0
cloud_functions: ^6.1.0
# Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다. # Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다.
crypto: ^3.0.7 crypto: ^3.0.7
@@ -32,6 +37,10 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
# Firestore 연동 코드를 에뮬레이터 없이 검증한다. 쿼리·정렬·커서까지 흉내 내므로
# 「무엇을 읽어 무엇으로 옮기는가」를 실제로 돌려 볼 수 있다.
fake_cloud_firestore: ^4.0.0
firebase_auth_mocks: ^0.15.0
# 런처 아이콘 생성기. 개발 의존성이라 앱에 포함되지 않는다. # 런처 아이콘 생성기. 개발 의존성이라 앱에 포함되지 않는다.
# 재생성: dart run flutter_launcher_icons # 재생성: dart run flutter_launcher_icons
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
+68
View File
@@ -0,0 +1,68 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';
/// 서버 연동 계층 — 로그인 강제와 문서 값 읽기 규칙.
///
/// 문서 값 읽기가 틀리면 화면은 조용히 0 이나 빈 칸을 그리므로 눈으로는 잡히지 않는다.
void main() {
group('로그인 강제', () {
test('로그인하지 않았으면 카탈로그 코드 E-AUTH-03 으로 끊는다', () {
final backend = Backend(
firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(),
);
expect(backend.uid, isNull);
expect(
() => backend.requireUid,
throwsA(isA<BackendException>().having((e) => e.code, 'code', 'E-AUTH-03')),
);
});
test('로그인했으면 UID 를 돌려준다', () {
final backend = Backend(
firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: 'u-test')),
);
expect(backend.requireUid, 'u-test');
});
});
group('문서 값 읽기', () {
test('없는 필드는 기본값으로 받는다', () {
final doc = <String, dynamic>{};
expect(doc.intOr('pop'), 0);
expect(doc.strOr('name', '이름 없음'), '이름 없음');
expect(doc.boolOr('active'), isFalse);
expect(doc.mapsOr('items'), isEmpty);
});
test('실수는 반올림해 정수로 받는다', () {
expect(<String, dynamic>{'pop': 12.6}.intOr('pop'), 13);
});
test('빈 문자열은 없는 값으로 본다', () {
expect(<String, dynamic>{'name': ''}.strOr('name', '팬'), '팬');
});
test('서버 시각은 DateTime 으로 옮긴다', () {
final at = DateTime(2026, 9, 1, 9);
final doc = <String, dynamic>{'createdAt': Timestamp.fromDate(at)};
expect(doc.dateOr('createdAt', DateTime(2000)), at);
expect(doc.dateOr('missing', DateTime(2000)), DateTime(2000));
});
test('목록 안의 맵만 골라 받는다', () {
final doc = <String, dynamic>{
'items': [
{'id': 1},
'x',
{'id': 2},
],
};
expect(doc.mapsOr('items').map((m) => m['id']), [1, 2]);
});
});
}