Firestore セキュリティルール ローカルエミュレーターでテストして困ったこと【その1】


FirestoreのセキュリティルールをFirebase コンソールにあるルールシュミレーターで行なっていたんですが、テストをするたびにテストデータをセットし直すのが大変(面倒)なので、ちらほらみる「ローカルエミュレーター」を使ってみました!

ただ、詳しい情報が少なくって開発しているチームメンバーで四苦八苦しながらなんとかテスト完了したので備忘録として残します。

環境と参考文献

以下のサンプル構成を元にして書いていきます。

blog(コレクション)
|---userCode(ドキュメント)

users(コレクション)
|---authUuid(ドキュメント)

firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    match /blog/{userCode} {
      allow get: if isAuthenticated();
      allow create: if isAuthenticated()
        && isUserCode(userCode)
        && incomingData().size() == 3
        && incomingData().keys().hasAll(['text', 'created_timestamp', 'updated_timestamp'])
        && incomingData().text is string
        && incomingData().created_timestamp is timestamp
        && incomingData().updated_timestamp is timestamp;
      allow update: if isAuthenticated()
        && isUserCode(userCode)
        && incomingData().size() == 3
        && incomingData().keys().hasAll(['text', 'created_timestamp', 'updated_timestamp'])
        && incomingData().text is string
        && incomingData().created_timestamp == existingData().created_timestamp
        && incomingData().created_timestamp is timestamp
        && incomingData().updated_timestamp is timestamp;
      allow delete: if isAuthenticated()
        && isUserCode(userCode);
  }

match /users/{authUuid} {
      allow get: if isAuthenticated();
      allow create: if isAuthenticated()
        && matchFirebaseUid(authUuid)
        && incomingData().size() == 4
        && incomingData().keys().hasAll(['text', 'gender', 'created_timestamp', 'updated_timestamp'])
        && incomingData().name is string
        && incomingData().gender is string
        && incomingData().created_timestamp is timestamp
        && incomingData().updated_timestamp is timestamp;
      allow update: if isAuthenticated()
        && matchFirebaseUid(authUuid)
        && incomingData().size() == 4
        && incomingData().keys().hasAll(['text', 'gender', 'created_timestamp', 'updated_timestamp'])
        && incomingData().name is string
        && incomingData().gender is string
        && incomingData().created_timestamp == existingData().created_timestamp
        && incomingData().created_timestamp is timestamp
        && incomingData().updated_timestamp is timestamp;
      allow delete: if isAuthenticated();
}
    function isAuthenticated() {
      return request.auth != null
    }
    function incomingData() {
      return request.resource.data;
    }
    function existingData() {
      return resource.data;
    }
    function documentPath(paths) {
      return path([['databases', database, 'documents'].join('/'), paths.join('/')].join('/'));
    }
    function isUserCode(userCode) {
      return exists(documentPath(['users', authUuid])).data.user_code]))
    }
    function matchFirebaseUid(authUuid) {
      return authUuid.matches(request.auth.uid)
    }
  }
}

1: そもそもローカルシミレーション上にデータを作成しないと何もテストできない

当たり前と言えば当たり前なんですが、ローカルシミレーションを起動しただけではデータが入っていません。データがないのにテストを書いてもエラーが出るだけなので、テストを書く前にデータを作ってからテストを書いてください。
テストを走らせたあと、firebase-debug.logファイルを見てちゃんとデータが作成されているか確認すれば何が原因でエラーが出ているかわかりやすいです。

例えば

    function isUserCode(userCode) {
      return exists(documentPath(['users', authUuid])).data.user_code))
    }

上記の関数を使うにはまずusersコレクションを先に作成しないとエラーが出ます。

const userDocument: string = '1f7b40dde12f';
const createTime: Date = new Date('2019-12-12 12:00:00');
cost updateTime: Date = new Date('2019-12-15 12:00:00');

// blogコレクション作成
function getBlogRef(db: firebase.firestore.Firestore) {
  return db.collection('blog')
}
// usersコレクション作成
function getUsersRef(db: firebase.firestore.Firestore) {
  return db.collection('users')
}
// usersデータ作成
async function createUsers(db: firebase.firestore.Firestore, userDocument:string) {
  const userInfo = getUsersRef(db).doc(userDocument)
  await userInfo.set({
    user_code: 'test1234',
    name: '田中太郎',
    gender: 'mele',
    created_timestamp: createTime,
    updated_timestamp: createTime
  })
}
// blogデータ作成
async function createBlog(db: firebase.firestore.Firestore, userCode:string) {
  const blogInfo = getBlogRef(db).doc(userCode)
  await blogInfo.set({
    text: 'blog本文',
    created_timestamp: createTime,
    updated_timestamp: createTime
  })
}
 // blogコレクションルール
  describe('blog collection test', () => {
    // get()認証あり
    test('blogの読みが可能', async () => {
      const db = provider.getFirestoreWithAuth({ uid: uid })
      await createUsers(db, userDocument) ← 先にusersコレクションを作成
      await createblog(db, userCode)

      const blogInfo = getBlogRef(db).doc(userCode)
      await firetest.assertSucceeds(blogInfo.get())
    })
  })

2: データを作る順番を間違えるとエラーが出てテストが通らない

こちらも当然ですが、Firestoreに複数のコレクションを作るとそのコレクションに対してのテストを書いていきます。コレクションを作成する順番を間違うとエラーが出ます。
実際にどの順番でデータを作成しているのかを確認してください。
後、データの作成時に await しないと順番通りにデータを作ってくれませんので要注意です!

test('blogの読みが可能', async () => {
      const db = provider.getFirestoreWithAuth({ uid: uid })
      await createBlog(db, userCode) ←usersにuserCodeをチェックするのでusersコレクションを先に作らないとダメ!
      await createUsers(db, userDocument)

      const blogInfo = getBlogRef(db).doc(userCode)
      await firetest.assertSucceeds(blogInfo.get())
    })

上記の様にblogのデータを先に作ってしまうとエラーが出ます。

3: updateのテストを書くとエラーが出る

jestのテストでupdateを書くと以下のようなエラーが出ます。

FirebaseError: [code=permission-denied]: 7 PERMISSION_DENIED:
    Null value error. for 'update'

これはデータがないから。@firebase/testingモジュールにも書かれてあります。

/**
     * Updates fields in the document referred to by this `DocumentReference`.
     * The update will fail if applied to a document that does not exist.
     *
     〜省略〜

このエラー結構みんな出ているようで海外のOverStackFollowでは 「 データがないとupdate()はエラーが出るから create() を使え!」と書いています。どういう意味なのかちょっと微妙ですが、先にデータを作ってから更新をすればテストが通ります。(要するにデーターがないのに更新はできないってことです。)

// blogコレクションルール
  describe('blog collection test', () => {
    // update()認証あり
    test('blogの読みが可能', async () => {
      const db = provider.getFirestoreWithAuth({ uid: uid })
      await createUsers(db, userDocument)
      await createblog(db, userCode) ← 先にblogデータを作成

      const blogInfo = getBlogRef(db).doc(userCode)
      await blogInfo.update({
    text: 'blog本文2',
    created_timestamp: createTime,
    updated_timestamp: updateTime
  })
    })
  })

3: データの存在がないテストをする時は、対象のデータを一度作成して、その後に対象のデータを削除する

例えば、データの更新時に、ブログのユーザーが存在することをチェックしてデータを取得するようにセキュリティルールを書いた場合、ローカルシュミレーションのテストは、「ユーザーが存在しない場合は読みが失敗」するテストを書きます。

const usersDocument: string = '1f7b40dde12f';
// blogコレクション作成
function getBlogRef(db: firebase.firestore.Firestore) {
  return db.collection('blog')
}
// usersコレクション作成
function getUsersRef(db: firebase.firestore.Firestore) {
  return db.collection('users')
}
// usersデータ作成
async function createUsers(db: firebase.firestore.Firestore, usersDocument:string) {
  const userInfo = getUsersRef(db).doc(usersDocument)
  await userInfo.set({
    user_code: 'test1234',
    name: '田中太郎',
    gender: 'mele',
    created_timestamp: createTime,
    updated_timestamp: createTime
  })
}
// blogデータ作成
async function createBlog(db: firebase.firestore.Firestore, userCode:string) {
  const blogInfo = getBlogRef(db).doc(userCode)
  await blogInfo.set({
    text: 'blog本文',
    created_timestamp: createTime,
    updated_timestamp: createTime
  })
}
// usersデータ削除
async deleteUsers(db: firebase.firestore.Firestore, usersDocument:string) {
        const userInfo = getUsersRef(db).doc(usersDocument)
        await userInfo.delete()
    }
 // blogコレクションルール
  describe('blog collection test', () => {
    // get()認証あり
    test('ユーザーが存在しない場合は読みが失敗', async () => {
      const db = provider.getFirestoreWithAuth({ uid: uid })
      await createUsers(db, userDocument) 
      await createblog(db, userCode)

    await deleteUsers(db, usersDocument)← usersコレクションを削除

      const blogInfo = getBlogRef(db).doc(userCode)
      await firetest.assertFails(blogInfo.get())
    })
  })

まとめ

困ったこと【その1】ではデータの作り方を中心にまとめてみました。良く考えると当たり前の事かもしれないのですが、実際にセキュリティルールを作成してローカルシュミレーションでテストをしているとエラーがたくさん出て、苦戦したものばかりです。一度データの追加や削除の関数を作ってしまえば、後は使い回しなものばかりなのですがそこに気づくのに苦戦しました。

Firestoreは構築だけでも、何度も作り直しての構築でしたがセキュリティルールも一癖あったので今回書いた「データ作成について」以外もまとめていきたいと思います。

もし、書いていることに間違いなどあれば教えてください。よろしくお願いします。