前提
・firestore構築済み
・jestインストール済み
https://jestjs.io/ja/
・rules-unit-testingインストール済み
https://www.npmjs.com/package/@firebase/rules-unit-testing
・Emulator設定済み
https://firebase.google.com/docs/emulator-suite/install_and_configure?authuser=0
対象コレクションのセキュリティールール
userPrivateコレクション
email : stirng,
uid : string
get(読み取り時)
・認証済み
・ドキュメントIDがログインユーザーのUID同じ
create(作成時)
・認証済み
・ドキュメントIDがログインユーザーのUID同じ
・emailフィールドはstringで30文字以内
・uidフィールドはログインユーザーのUIDと同じ値で作成
・作成できるフィールドはemail, uidのみ
update(更新時)
・認証済み
・ログインユーザーのUIDと同じドキュメントIDのドキュメントのみ更新可能
・編集できるフィールドはemailのみ(※UIDは更新できない。)
・emailフィールドはstringで30文字以内
##topicコレクション
title : string,
content : string,
authorizedUIDs : Array,
uid : string
list(読み取り時)
・認証済み
・authorizedUIDsにログインユーザー中のUIDが含まれている場合
create(作成時)
・認証済み
・uidフィールドはログインユーザーのUIDと同じ値で作成
・createの際にauthorizedUIDsには必ずログインユーザーのUIDが含まれる必要がある。
・title, content, uid, authorizedUIDs フィールドのみ作成可能
update(更新時)
・認証済み
・uidフィールドがログインユーザーのUIDと同じ値のドキュメントのみ
・title, content, authorizedUIDsのみ更新可能
delete(削除時)
・認証済み
・uidフィールドがログインユーザーのUIDと同じ値のドキュメントのみ
※作成、更新可能フィールドの検証については記載省略
※各フィールドのバリデーションについては記載省略
##historyコレクション
historyコレクションは、topicコレクションのサブコレクション
topic____
|----history
|----history
|----history
url : string,
content : string,
uid : string
list(読み取り時)
・認証済み
・親topicコレクションのauthorizedUIDsにログインユーザーのUIDが含まれている
create(作成時)
・認証済み
・親topicコレクションのauthorizedUIDsにログインユーザーのUIDが含まれている場合可能
・uidフィールドはログインユーザーのUIDと異なる値とすることはできない。
update(更新時)
・認証済み
・親topicコレクションのauthorizedUIDsにログインユーザーのUIDが含まれている場合可能
・uidフィールドについては更新できない。
delete(削除時)
・認証済み
・ログインユーザーのUIDが、「親topicドキュメントのuidフィールド」, もしくは「historyドキュメントのuidフィールド」と同じである。
(※親topicドキュメントの作成者は,紐づくhiststoryコレクションのドキュメントを全て削除可能。hisitoryコレクションのドキュメント作成者はそのhistoryドキュメントは削除可能)
※各フィールドのバリデーションについては記載省略。
実装
プロジェクトのルートフォルダに下記のファイルを作成。
__tests__/firebase/firestore.test.js
package.jsonを修正
"scripts": {
"test": "jest", //追記
"test-fs": "jest ./__tests__/firebase/firestore.test.js", //追記
}
動作チェック
下記のように仮のテストを作成する。
const assert = require('assert');
describe("init test", () => {
it("Understands basic addition", () => {
assert.strictEqual(2 + 2, 4);
})
})
//エミュレーター立ち上げ
firebase emulators:start --only firestore
//テスト開始(ターミナル 別タブ)
npm run test-fs
下記のようになれば問題ありません。
テスト環境を作成するコードを実装
import * as fs from 'fs'
import { v4 } from "uuid"
import * as testing from '@firebase/rules-unit-testing'
const projectID = v4()
let testEnv
const uid = v4()
const otherUid = v4()
beforeAll(async () => {
// テストプロジェクト環境の作成
testEnv = await firebase.initializeTestEnvironment({
projectId: projectID,
firestore: {
rules: fs.readFileSync('./firestore.rules', 'utf8'),
port: 8080,
host: "localhost"
}
})
})
// グローバルで定義されたbeforeAllはテストの開始前に一回実行されます。
beforeEach(async () => {
// Firestore エミュレータ用に構成された projectId に属する Firestore データベースのデータをクリアします。
await testEnv.clearFirestore()
})
// グローバルで定義されたbeforeEachは各テストの開始前に一回実行されます。
afterAll(async () => {
//テスト終了後テスト環境で作成されたすべての RulesTestContexts を破棄します。
await testEnv.cleanup()
})
// グローバルで定義されたafterAllはテストの終了後に一回実行されます。
userPrivateコレクションのルール作成とテストを実装していきます。
条件1:
認証していなければ読み取りできない。
rules_version = '2';
service cloud.firestore {
function isAuth(){
return request.auth != null
//reequest.authで認証情報を取得。認証していなければnullとなる。
}
match /databases/{database}/documents {
match /userPrivate/{userID} {
allow get: if isAuth()
}
}
}
//略
const getDB = () => {
// ログイン情報つきのContextを作成し、そこから Firestore インスタンスを得る。
// authenticatedContextは引数をUIDにもつ認証済みContextを返す。
const authenticatedContext = testEnv.authenticatedContext(uid)
const clientDB = authenticatedContext.firestore()
// ゲストContextを作成し、そこから Firestore インスタンスを得る。
// unauthenticatedContextは未認証Contextを返す。
const unauthenticatedContext = testEnv.unauthenticatedContext()
const guestClientDB = unauthenticatedContext.firestore()
return { clientDB, guestClientDB }
}
describe('users collection', () => {
describe('get', () => {
it('get: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
getDoc(doc(guestClientDB, "userPrivate", uid))
)
})
})
})
//assertFailは引数内の処理が失敗した時、テスト結果を正常と判断する。
npm run test-fs
条件2:
・ドキュメントIDがログインユーザーのUID同じ
service cloud.firestore {
function myUID(){
return request.auth.uid
} //追記
match /databases/{database}/documents {
match /userPrivate/{userID} {
allow get: if isAuth() &&
userID == myUID() //追記
}
}
}
//略
describe('users collection', () => {
// 略
it('get: 認証済みでもログインユーザーのUIDと異なるドキュメントIDのドキュメントは不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
getDoc(doc(clientDB, "userPrivate", otherUid))
)
}) //追記
it('get: ログインユーザーのUIDと同じドキュメントIDのドキュメントは可能', async () => {
const { clientDB } = getDB();
await firebase.assertSucceeds(
getDoc(doc(clientDB, "userPrivate", uid))
)
})
}) //追記
//assertSucceedsは引数内の処理が成功した時、テスト結果を正常と判断する。
条件3:
下記の条件を満たす場合、createできる。
・認証済み
・ドキュメントIDがログインユーザーのUID同じ
・emailフィールドはstringで30文字以内
・uidフィールドはログインユーザーのUIDと同じ値
・作成できるフィールドはemail, uidのみ
rules_version = '2';
service cloud.firestore {
function isAuth(){
return request.auth != null
}
function myUID(){
return request.auth.uid
}
function incomingData(){
return request.resource.data
}
//追記
//request.resource.dataで変更しようとしている値をもつドキュメントデータを取得
function isAvailableCreateFields(fieldList){
return incomingData().keys().hasOnly(fieldList)
}
//追記
//keys() : ドキュメントのキーを取得
//hasOnly() : 引数(配列)で与えられている要素が含まれていたらtrue
//結果、作成しようとするデータに許可されていないフィールドが含まれていたらNGとなる。
match /databases/{database}/documents {
match /userPrivate/{userID} {
allow get:if isAuth() &&
userID == myUID()
allow create:if isAuth() &&
userID == myUID() &&
incomingData().email is string &&
incomingData().email.size() < 31 &&
incomingData().uid == myUID() &&
isAvailableCreateFields(["email","uid"])
//追記
}
}
}
describe('userPrivate collection', () => {
describe('get', () => {
// 略
})
describe('create', () => {
it('create: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
setDoc(doc(clientDB, "userPrivate", uid), { email: "123456789012345678901234567890", uid })
)
})
it('create: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
setDoc(doc(guestClientDB, "userPrivate", uid), { email: "otherEmail", uid })
)
})
it('create: 認証済み。ドキュメントIDがUIDと異なる値では不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", otherUid), { email: "otherEmail", uid })
)
})
it('create: 認証済み。uidフィールドがUIDと異なる値では不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", uid), { email: "otherEmail", uid: otherUid })
)
})
it('create: 認証済み。emailが31文字以上不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", uid), { email: "1234567890123456789012345678901", uid: uid })
)
})
it('create: 認証済み。許可されたフィールド以外は不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", uid), { email: "1234567890123456789012345678901", uid: uid, age: 20 })
)
})
})
条件4:
下記の条件を満たす場合、編集できる。
・認証済み
・ログインユーザーのUIDと同じドキュメントIDのドキュメントのみ更新可能
・編集できるフィールドはemailのみ(※uidは更新できない。)
・emailフィールドはstringで30文字以内
rules_version = '2';
service cloud.firestore {
function isAuth(){
return request.auth != null
}
function myUID(){
return request.auth.uid
}
function incomingData(){
return request.resource.data
}
function existingData(){
return resource.data
}
//追記
//resource.dataで変更前のドキュメントデータを取得
function isAvailableCreateFields(fieldList){
return incomingData().keys().hasOnly(fieldList)
}
function isAvailableUpdateFields(FieldList){
return incomingData().diff(existingData()).affectedKeys().hasOnly(FieldList)
}
//追記
//incomingData().diff(existingData()) : 変更前との差分を取得
//affectedKeys() : 変更があった値を持つキーを取得。
//結果、作成しようとするデータに許可されていないフィールドが含まれていたらNGとなる。
match /databases/{database}/documents {
match /userPrivate/{userID} {
allow get:if isAuth() &&
userID == myUID()
allow create:if isAuth() &&
userID == myUID() &&
incomingData().email is string &&
incomingData().email.size() < 31 &&
incomingData().uid == myUID() &&
isAvailableCreateFields(["email","uid"])
allow update:if isAuth() &&
userID == myUID() &&
incomingData().email is string &&
incomingData().email.size() < 31 &&
isAvailableUpdateFields(['email']);
}
}
}
//略
describe('userPrivate collection', () => {
// get
describe('get', () => {
//略
})
// create
describe('create', () => {
// 略
})
// update
describe('update', () => {
//各テスト前に保存済みモックデータを作成
//なお、discribeでスコープがきられるため,下記のbeforeEachは「describe('update',() =>{})」内の各テストのみ対象となる。
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "userPrivate", uid), { email: "authEmail", uid })
})
})
//withSecurityRulesDisabledメソッドでセキュリティールールを回避してデータベースを操作できる。
it('update: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await firebase.assertSucceeds(
updateDoc(doc(clientDB, "userPrivate", uid), { email: "changeEmail" })
)
})
it('update: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(guestClientDB, "userPrivate", uid), { email: "changeEmail" })
)
})
it('update: 認証済み。ドキュメントIDがUIDと異なる値では不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(clientDB, "userPrivate", otherUid), { email: "changeEmail" })
)
})
it('update: 認証済み。emailが文字列でない場合不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(clientDB, "userPrivate", uid), { email: 0 })
)
})
it('update: 認証済み。emailが31文字以上不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(clientDB, "userPrivate", uid), { email: "1234567890123456789012345678901" })
)
})
it('update: 認証済み。許可されたフィールド以外は不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(clientDB, "userPrivate", uid), { email: "changeEmail", uid: "changeUID" })
)
})
})
})
topicコレクションのテストを実装する。
条件1
下記の条件を満たす場合、listできる。
・認証済み(テスト記載割愛)
・authorizedUIDsにログインユーザー中のUIDが含まれている場合
service cloud.firestore {
match /databases/{database}/documents {
match /userPrivate/{userID} {
//略
}
match /topic/{documentID} {
allow list:if isAuth() &&
myUID() in existingData().authorizedUIDs
// in : 含まれていた場合trueを返す
}
}
}
※getとlistの違い
getについては、単体のドキュメントを取得の場合。
また、取得したドキュメントに対し権限確認を行う。
listについては、クエリによる複数ドキュメントを取得の場合。
listについてはクエリに対して権限確認を行う。
該当クエリを実行することにより、必ず条件にクリアするデータを取得するかをチェックする。(取得したデータに対して権限確認を行うわけではない。)
//略
describe('topic collection', () => {
// list
describe('list', () => {
it('list:authorizedUIDsに自分のUIDが含まれれば可能 ', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic"), where("authorizedUIDs", "array-contains", uid));
await firebase.assertSucceeds(
getDocs(q)
)
})
//このクエリを実行することにより、取得してきたデータは必ずログイン中ユーザーのUIDを
//authorizedUIDsに含んでいることが担保されるのでルールを通過する。
//クエリに対して権限チェックを行うので、事前にモックデータを作る必要はない。
it('list:authorizedUIDsに自分のUIDが含まれていない可能性があるクエリは不可', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic"));
await firebase.assertFails(
getDocs(q)
)
})
//このクエリでは、ログイン中ユーザーのUIDをauthorizedUIDsに含まないデータも
//取得してくる可能性があるためルール通過しない。
条件2:
create(作成時)
・認証済み
・uidフィールドはログインユーザーのUIDと同じ値で作成
・createの際にauthorizedUIDsには必ずログインユーザーのUIDが含まれる必要がある。
・title, content, uid, authorizedUIDs フィールドのみ作成可能
update(更新時)
・認証済み
・uidフィールドがログインユーザーのUIDと同じ値のドキュメントのみ
・title, content, authorizedUIDsのみ更新可能(uidは更新できない。)
delete(削除時)
・認証済み
・uidフィールドがログインユーザーのUIDと同じ値のドキュメントのみ
※各フィールドのバリデーションについては記載省略。
rules_version = '2';
service cloud.firestore {
// 略
match /databases/{database}/documents {
match /userPrivate/{userID} {
// 略
}
match /topic/{documentID} {
allow list:if isAuth() &&
myUID() in existingData().authorizedUIDs
allow create:if isAuth() &&
incomingData().uid == myUID() &&
myUID() in incomingData().authorizedUIDs &&
isAvailableCreateFields(["title","content","uid","authorizedUIDs"])
allow update:if isAuth() &&
existingData().uid == myUID() &&
myUID() in incomingData().authorizedUIDs &&
isAvailableUpdateFields(["title","content","authorizedUIDs"])
allow delete:if isAuth() &&
existingData().uid == myUID()
}
}
}
// topic
describe('topic collection', () => {
// list
// 略
// create
describe('create', () => {
it('create: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await firebase.assertSucceeds(
setDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
)
})
it('create: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await firebase.assertFails(
setDoc(doc(guestClientDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
)
})
it('create: uidにログインユーザーのUIDと異なる値を与えるのは不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
setDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [uid] })
)
})
it('create: authorizedUIDsにログインユーザーのUIDが含まれていない場合は不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
setDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
)
})
})
// update
describe('update', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
//ログイン中ユーザーが作成したとするモック
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
//他人(ログイン中ユーザー以外のユーザー)が作成したとするモック
await setDoc(doc(noRuleDB, "topic", "otherUserTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('update: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await firebase.assertSucceeds(
updateDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", authorizedUIDs: [uid] })
)
})
it('update: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(guestClientDB, "topic", "topicID"), { title: "title", content: "content", authorizedUIDs: [uid] })
)
})
it('update: uidの変更は不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [uid] })
)
})
it('update: uidフィールドの値がログイン中のユーザーと異なるキュメントは不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
updateDoc(doc(clientDB, "topic", "otherUserTopicID"), { title: "title", content: "content", authorizedUIDs: [uid] })
)
})
})
// // delete
describe('delete', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "otherUserTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('delete: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await firebase.assertSucceeds(
deleteDoc(doc(clientDB, "topic", "topicID"))
)
})
it('delete: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await firebase.assertFails(
deleteDoc(doc(guestClientDB, "topic", "topicID"))
)
})
it('delete: uidフィールドの値がログイン中のユーザーと異なるキュメントは不可', async () => {
const { clientDB } = getDB();
await firebase.assertFails(
deleteDoc(doc(clientDB, "topic", "otherUserTopicID"))
)
})
})
historyコレクションのテストを実装する。
historyコレクションはtopicコレクションのサブコレクション。
条件1
下記の条件を満たす場合、listできる。
・認証済み
・親topicコレクションのauthorizedUIDsにログインユーザーのUIDが含まれている
rules_version = '2';
service cloud.firestore {
//略
function getParentTopic(database,documentID){
return get(/databases/$(database)/documents/topic/$(documentID))
//get関数で対象のドキュメントを取得する。
}
match /databases/{database}/documents {
// 略
match /topic/{documentID} {
//略
match /history/{historyDocumentID}{
allow list:if isAuth() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs
//getParentTopic関数でtopicコレクションの親ドキュメントを取得する。
//取得した親ドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれているかチェック
//サブコレクションはネスト構造で記述することができる。
}
}
}
}
// history
describe('history collection', () => {
// list
describe('list', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
//ログインユーザーがauthorizedUIDsに含まれている親topicドキュメント
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
//ログインユーザーがauthorizedUIDsに含まれていない親topicドキュメント
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('list:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていたら可能', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic", "topicID", "history"));
await firebase.assertSucceeds(
getDocs(q)
)
})
//モックからtopicドキュメントを取得して、そのドキュメントの内容を含むクエリで評価する。
//モックのtopicドキュメント(ドキュメントIDはtopicID)は、 authorizedUIDsにログインユーザーのUIDを含んでいるためこのクエリは許可される。
it('list:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていなかったら不可', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic", "outOfAuthTopicID", "history",));
await firebase.assertFails(
getDocs(q)
)
})
})
})
条件2
下記の条件を満たす場合、createできる。
・認証済み(記載省略)
・親topicコレクションのauthorizedUIDsにログインユーザーのUIDが含まれている場合可能
・uidフィールドはログインユーザーのUIDと異なる値とすることはできない。
rules_version = '2';
service cloud.firestore {
//略
match /databases/{database}/documents {
// 略
match /topic/{documentID} {
//略
match /history/{historyDocumentID}{
allow list:if isAuth() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs
allow create:if isAuth() &&
incomingData().uid == myUID() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs &&
isAvailableCreateFields(["url","content","uid"])
}
}
}
}
// history
describe('history collection', () => {
// 略
// create
describe('create', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('create:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていたら、サブコレクション (history)ドキュメント作成可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "topicID", "history", "mmhistoryID");
await testing.assertSucceeds(
setDoc(docRef, { url: "url", content: "content", uid })
)
})
it('create:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていなければ、サブコレクション (history)ドキュメント作成不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "outOfAuthTopicID", "history", "mmhistoryID");
await testing.assertFails(
setDoc(docRef, { url: "url", content: "content", uid })
)
})
it('create:uidにログインユーザーのUIDと異なる値を与えるのは不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "topicID", "history", "mmhistoryID");
await testing.assertFails(
setDoc(docRef, { url: "url", content: "content", uid: otherUid })
)
})
})
})
条件3
下記の条件を満たす場合、updateできる。
・認証済み(記載省略)
・親topicコレクションのauthorizedUIDsにログインユーザーのUIDが含まれている場合可能
・uidフィールドについては更新できない。(記載省略)
rules_version = '2';
service cloud.firestore {
//略
match /databases/{database}/documents {
// 略
match /topic/{documentID} {
//略
match /history/{historyDocumentID}{
// 略
allow update:if isAuth() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs &&
isAvailableUpdateFields(["url","content"])
}
}
}
}
// history
describe('history collection', () => {
// 略
// update
describe('update', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
// 親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれている場合
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "topicID", "history", "historyID"), { url: "url", content: "content", uid: otherUid })
// 親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていない場合
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID"), { title: "title", content: "content", uid, authorizedUIDs: [otherUid] })
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID", "history", "historyID"), { url: "url", content: "content", uid: otherUid })
})
})
it('update:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていたら、サブコレクション (history)ドキュメントの更新可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "topicID", "history", "historyID");
await testing.assertSucceeds(
updateDoc(docRef, { url: "url", content: "content" })
)
})
it('update:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていなければ、サブコレクション (history)ドキュメントの更新不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "outOfAuthTopicID", "history", "historyID");
await testing.assertFails(
updateDoc(docRef, { url: "url", content: "content" })
)
})
})
})
条件4
下記の条件を満たす場合、deleteできる。
・認証済み(記載省略)
・ログインユーザーのUIDが、「親topicドキュメントのuidフィールド」, もしくは「historyドキュメントのuidフィールド」と同じである。
(※親topicドキュメントの作成者は,紐づくhiststoryコレクションのドキュメントを全て削除可能。hisitoryコレクションのドキュメント作成者はそのhistoryドキュメントは削除可能)
rules_version = '2';
service cloud.firestore {
//略
match /databases/{database}/documents {
// 略
match /topic/{documentID} {
//略
match /history/{historyDocumentID}{
// 略
allow delete:if isAuth() &&
myUID() == getParentTopic(database,documentID).data.uid ||
myUID() == existingData().uid
||演算子でどちらか一方がtureならtrueを返す。
}
}
}
}
// history
describe('history collection', () => {
// 略
//delete
describe('delete', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
// ログインユーザーが作ったtopic/他人が作ったhistory
await setDoc(doc(noRuleDB, "topic", "moTopicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "moTopicID", "history", "moHistoryID"), { url: "url", content: "content", uid: otherUid })
//他人が作ったtopic/ログインユーザーが作ったhistory
await setDoc(doc(noRuleDB, "topic", "omTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
await setDoc(doc(noRuleDB, "topic", "omTopicID", "history", "omHistoryID"), { url: "url", content: "content", uid })
//他人が作ったtopic/他人が作ったhistory
await setDoc(doc(noRuleDB, "topic", "ooTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
await setDoc(doc(noRuleDB, "topic", "ooTopicID", "history", "ooHistoryID"), { url: "url", content: "content", uid: otherUid })
})
})
it('delete:親topicドキュメントのuidがログインユーザーのUIDであれば可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "moTopicID", "history", "moHistoryID");
await testing.assertSucceeds(
deleteDoc(docRef)
)
})
it('delete:historyドキュメントのuidがログイン中のユーザーであれば可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "omTopicID", "history", "omHistoryID");
await testing.assertSucceeds(
deleteDoc(docRef)
)
})
it('delete:親topic、history共に他人が作成したものであれば不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "ooTopicID", "history", "omHistoryID");
await testing.assertFails(
deleteDoc(docRef)
)
})
})
})
以上です。
全文
rules_version = '2';
service cloud.firestore {
function isAuth(){
return request.auth != null
}
function myUID(){
return request.auth.uid
}
function incomingData(){
return request.resource.data
}
function existingData(){
return resource.data
}
function isAvailableCreateFields(fieldList){
return incomingData().keys().hasOnly(fieldList)
}
function isAvailableUpdateFields(FieldList){
return incomingData().diff(existingData()).affectedKeys().hasOnly(FieldList)
}
function getParentTopic(database,documentID){
return get(/databases/$(database)/documents/topic/$(documentID))
}
match /databases/{database}/documents {
// userPrivate
match /userPrivate/{userID} {
allow get:if isAuth() &&
userID == myUID()
allow create:if isAuth() &&
userID == myUID() &&
incomingData().email is string &&
incomingData().email.size() < 31 &&
incomingData().uid == myUID() &&
isAvailableCreateFields(["email","uid"])
allow update:if isAuth() &&
userID == myUID() &&
incomingData().email is string &&
incomingData().email.size() < 31 &&
isAvailableUpdateFields(['email'])
}
// topic
match /topic/{documentID} {
allow get:if isAuth() &&
myUID() in existingData().authorizedUIDs
allow list:if isAuth() &&
myUID() in existingData().authorizedUIDs
allow create:if isAuth() &&
incomingData().uid == myUID() &&
myUID() in incomingData().authorizedUIDs &&
isAvailableCreateFields(["title","content","authorizedUIDs","uid"])
allow update:if isAuth() &&
existingData().uid == myUID() &&
myUID() in incomingData().authorizedUIDs &&
isAvailableUpdateFields(["title","content","authorizedUIDs"])
allow delete:if isAuth() &&
existingData().uid == myUID()
// topic/history
match /history/{historyDocumentID}{
allow list:if isAuth() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs
allow create:if isAuth() &&
incomingData().uid == myUID() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs &&
isAvailableCreateFields(["url","content","uid"])
allow update:if isAuth() &&
myUID() in getParentTopic(database,documentID).data.authorizedUIDs &&
isAvailableUpdateFields(["url","content"])
allow delete:if isAuth() &&
myUID() == getParentTopic(database,documentID).data.uid ||
myUID() == existingData().uid
}
}
}
}
import * as fs from 'fs'
import { v4 } from "uuid"
import * as testing from '@firebase/rules-unit-testing'
import { doc, collection, setDoc, getDoc, updateDoc, query, where, getDocs, deleteDoc } from 'firebase/firestore'
const projectID = v4()
let testEnv
const uid = v4()
const otherUid = v4()
beforeAll(async () => {
// テストプロジェクト環境の作成
testEnv = await testing.initializeTestEnvironment({
projectId: projectID,
firestore: {
rules: fs.readFileSync('./firestore.rules', 'utf8'),
port: 8080,
host: "localhost"
}
})
})
beforeEach(async () => {
await testEnv.clearFirestore()
})
afterAll(async () => {
await testEnv.cleanup()
})
const getDB = () => {
// ログイン情報つきのContextを作成し、そこから Firestore インスタンスを得る
const authenticatedContext = testEnv.authenticatedContext(uid)
const clientDB = authenticatedContext.firestore()
// ゲストContextを作成し、そこから Firestore インスタンスを得る
const unauthenticatedContext = testEnv.unauthenticatedContext()
const guestClientDB = unauthenticatedContext.firestore()
return { clientDB, guestClientDB }
}
describe('userPrivate collection', () => {
// get
describe('get', () => {
it('get: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
getDoc(doc(guestClientDB, "userPrivate", uid))
)
})
it('get: ログインユーザーのUIDと異なるドキュメントIDのドキュメントは不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
getDoc(doc(clientDB, "userPrivate", otherUid))
)
})
it('get: ログインのUIDと同じドキュメントIDのドキュメントは可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
getDoc(doc(clientDB, "userPrivate", uid))
)
})
})
// create
describe('create', () => {
it('create: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
setDoc(doc(clientDB, "userPrivate", uid), { email: "123456789012345678901234567890", uid })
)
})
it('create: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
setDoc(doc(guestClientDB, "userPrivate", uid), { email: "otherEmail", uid })
)
})
it('create: 認証済み。ドキュメントIDがUIDと異なる値では不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", otherUid), { email: "otherEmail", uid })
)
})
it('create: 認証済み。uidフィールドがUIDと異なる値では不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", uid), { email: "otherEmail", uid: otherUid })
)
})
it('create: 認証済み。emailが31文字以上不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", uid), { email: "1234567890123456789012345678901", uid: uid })
)
})
it('create: 認証済み。許可されたフィールド以外は不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "userPrivate", uid), { email: "1234567890123456789012345678901", uid: uid, age: 20 })
)
})
})
// update
describe('update', () => {
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "userPrivate", uid), { email: "authEmail", uid })
})
})
it('update: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
updateDoc(doc(clientDB, "userPrivate", uid), { email: "changeEmail" })
)
})
it('update: 未認証では不可', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
updateDoc(doc(guestClientDB, "userPrivate", uid), { email: "changeEmail" })
)
})
it('update: 認証済み。ドキュメントIDがUIDと異なる値では不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
updateDoc(doc(clientDB, "userPrivate", otherUid), { email: "changeEmail" })
)
})
it('update: 認証済み。emailが文字列でない場合不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
updateDoc(doc(clientDB, "userPrivate", uid), { email: 0 })
)
})
it('update: 認証済み。emailが31文字以上不可。', async () => {
const { clientDB } = getDB();
await testing.assertFails(
updateDoc(doc(clientDB, "userPrivate", uid), { email: "1234567890123456789012345678901" })
)
})
it('update: 認証済み。許可されたフィールド以外は不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
updateDoc(doc(clientDB, "userPrivate", uid), { email: "changeEmail", uid: "changeUID" })
)
})
})
})
// topic
describe('topic collection', () => {
// list
describe('list', () => {
it('list:authorizedUIDsにログインユーザーのUIDが含まれれば可能 ', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic"), where("authorizedUIDs", "array-contains", uid));
await testing.assertSucceeds(
getDocs(q)
)
})
it('list:authorizedUIDsにログインユーザーのUIDが含まれていない可能性があるクエリは不可', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic"));
await testing.assertFails(
getDocs(q)
)
})
})
// create
describe('create', () => {
it('create: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
setDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
)
})
it('create: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
setDoc(doc(guestClientDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
)
})
it('create: uidにログインユーザーのUIDと異なる値を与えるのは不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [uid] })
)
})
it('create: authorizedUIDsにログインユーザーのUIDが含まれていない場合は不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
setDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
)
})
})
// update
describe('update', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "otherUserTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('update: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
updateDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", authorizedUIDs: [uid] })
)
})
it('update: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
updateDoc(doc(guestClientDB, "topic", "topicID"), { title: "title", content: "content", authorizedUIDs: [uid] })
)
})
it('update: uidの変更は不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
updateDoc(doc(clientDB, "topic", "topicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [uid] })
)
})
it('update: uidフィールドの値がログイン中のユーザーと異なるキュメントは不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
updateDoc(doc(clientDB, "topic", "otherUserTopicID"), { title: "title", content: "content", authorizedUIDs: [uid] })
)
})
})
// // delete
describe('delete', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "otherUserTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('delete: 認証済みで条件を満たす場合は可能', async () => {
const { clientDB } = getDB();
await testing.assertSucceeds(
deleteDoc(doc(clientDB, "topic", "topicID"))
)
})
it('delete: 未認証では不可。', async () => {
const { guestClientDB } = getDB();
await testing.assertFails(
deleteDoc(doc(guestClientDB, "topic", "topicID"))
)
})
it('delete: uidフィールドの値がログイン中のユーザーと異なるキュメントは不可', async () => {
const { clientDB } = getDB();
await testing.assertFails(
deleteDoc(doc(clientDB, "topic", "otherUserTopicID"))
)
})
})
})
// history
describe('history collection', () => {
// list
describe('list', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('list:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていたら可能', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic", "topicID", "history"));
await testing.assertSucceeds(
getDocs(q)
)
})
it('list:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていなかったら不可', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic", "outOfAuthTopicID", "history",));
await testing.assertFails(
getDocs(q)
)
})
it('list:authorizedUIDsにログインユーザーのUIDが含まれていない可能性があるクエリは不可', async () => {
const { clientDB } = getDB();
const q = query(collection(clientDB, "topic"));
await testing.assertFails(
getDocs(q)
)
})
})
// create
describe('create', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
})
})
it('create:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていたら、サブコレクション (history)ドキュメント作成可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "topicID", "history", "mmhistoryID");
await testing.assertSucceeds(
setDoc(docRef, { url: "url", content: "content", uid })
)
})
it('create:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていなければ、サブコレクション (history)ドキュメント作成不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "outOfAuthTopicID", "history", "mmhistoryID");
await testing.assertFails(
setDoc(docRef, { url: "url", content: "content", uid })
)
})
it('create:uidにログインユーザーのUIDと異なる値を与えるのは不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "topicID", "history", "mmhistoryID");
await testing.assertFails(
setDoc(docRef, { url: "url", content: "content", uid: otherUid })
)
})
})
// update
describe('update', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
// 親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれている場合
await setDoc(doc(noRuleDB, "topic", "topicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "topicID", "history", "historyID"), { url: "url", content: "content", uid: otherUid })
// 親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていない場合
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID"), { title: "title", content: "content", uid, authorizedUIDs: [otherUid] })
await setDoc(doc(noRuleDB, "topic", "outOfAuthTopicID", "history", "historyID"), { url: "url", content: "content", uid: otherUid })
})
})
it('update:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていたら、サブコレクション (history)ドキュメントの更新可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "topicID", "history", "historyID");
await testing.assertSucceeds(
updateDoc(docRef, { url: "url", content: "content" })
)
})
it('update:親topicドキュメントのauthorizedUIDsにログインユーザーのUIDが含まれていなければ、サブコレクション (history)ドキュメントの更新不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "outOfAuthTopicID", "history", "historyID");
await testing.assertFails(
updateDoc(docRef, { url: "url", content: "content" })
)
})
})
//delete
describe('delete', () => {
// モック作成
beforeEach(async () => {
await testEnv.withSecurityRulesDisabled(async context => {
const noRuleDB = context.firestore()
// ログインユーザーが作ったtopic/他人が作ったhistory
await setDoc(doc(noRuleDB, "topic", "moTopicID"), { title: "title", content: "content", uid, authorizedUIDs: [uid] })
await setDoc(doc(noRuleDB, "topic", "moTopicID", "history", "moHistoryID"), { url: "url", content: "content", uid: otherUid })
//他人が作ったtopic/ログインユーザーが作ったhistory
await setDoc(doc(noRuleDB, "topic", "omTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
await setDoc(doc(noRuleDB, "topic", "omTopicID", "history", "omHistoryID"), { url: "url", content: "content", uid })
//他人が作ったtopic/他人が作ったhistory
await setDoc(doc(noRuleDB, "topic", "ooTopicID"), { title: "title", content: "content", uid: otherUid, authorizedUIDs: [otherUid] })
await setDoc(doc(noRuleDB, "topic", "ooTopicID", "history", "ooHistoryID"), { url: "url", content: "content", uid: otherUid })
})
})
it('delete:親topicドキュメントのuidがログインユーザーのUIDであれば可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "moTopicID", "history", "moHistoryID");
await testing.assertSucceeds(
deleteDoc(docRef)
)
})
it('delete:historyドキュメントのuidがログイン中のユーザーであれば可能', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "omTopicID", "history", "omHistoryID");
await testing.assertSucceeds(
deleteDoc(docRef)
)
})
it('delete:親topic、history共に他人が作成したものであれば不可', async () => {
const { clientDB } = getDB();
const docRef = doc(clientDB, "topic", "ooTopicID", "history", "ooHistoryID");
await testing.assertFails(
deleteDoc(docRef)
)
})
})
})