Firestore · Storage 보안 규칙 · 인덱스 · 보안 규칙 테스트 · 개발 서버 구성 스크립트
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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\""
|
||||
},
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user