Firestore · Storage 보안 규칙 · 인덱스 · 보안 규칙 테스트 · 개발 서버 구성 스크립트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 316821e7d5
commit cbfb72efe0
11 changed files with 3128 additions and 204 deletions
+14 -4
View File
@@ -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
+1 -1
View File
@@ -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\""
},
+32
View File
@@ -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();
File diff suppressed because it is too large Load Diff
@@ -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' }));
});
});