Firestoreのセキュリティルールのテストを書いてみよう！〜list編〜(TypeScript)

## はじめに

私は個人開発でFirestoreを使っているのですが、恥ずかしながらセキュリティルールをあまり理解しておらず、テストも書いたことがありませんでした。

そんなとき、たまたま [YouTubeにFirestore設計入門の動画を投稿したというツイート](https://twitter.com/_mogaming/status/1256575690654093315?s=20) を見て、軽い気持ちで視聴しました（おい）。
[動画](https://www.youtube.com/watch?v=fHFoqJpkbJg) ではTypeScript + Jestを使ってサクサクテストを書いていてわかりやすく、これなら私でも書けると思い、実際に個人開発で使っているセキュリティルールのテストを書いてみました。

本記事では私がどのような観点でテストを書いたかを中心に、Firestoreのオペレーションのひとつである `list` のテストについて解説します。

## 対象読者

本記事は、以下のような方に向けて執筆しています。

- Firestoreを使ったことがあり、セキュリティルールについて理解している
- セキュリティルールのテストを書いたことがなく、書きたいと思っている
- どのような観点で `list` のテストを書いていいかわからない

## Firestoreのオペレーション

Firestoreの知識がある前提で執筆していますが、オペレーションだけ説明します。

Firestoreには5種類（+2）のオペレーションがあります。

- `write`  
以下の3つをまとめたオペレーション
  - `create`  
  ドキュメントを作成する
  - `update`  
  ドキュメントを更新する
  - `delete`  
  ドキュメントを削除する
- `read`  
以下の2つをまとめたオペレーション
  - `get`  
  ドキュメントを単体で取得する
  - `list`  
  ドキュメントをまとめて取得する

当たり前ですが、CRUD操作を網羅しています。

セキュリティルールは、あるコレクションやドキュメントに対し、どのような条件でオペレーションを許可するかを記述します。
そのため、すべてのオペレーションを理解していないと、セキュリティルールを適切に表現できません。

不用意に `write` や `read` を許可するのでなく、必要なオペレーションのみ許可するのが望ましいです。
__セキュリティルールはできる限り厳しくする__ ことを意識すると、どのように書けばいいかわかってくると思います。

## 私が個人開発しているアプリ

~~宣伝のため~~ Firestoreをどのように使っているかわからないと解説が伝わりにくいため、私が個人開発しているアプリについて、 __少しだけ__ 説明します。

### 概要

「ウホーイ図鑑」という、私が描いたキャラクターを閲覧するだけのアプリですｗ

OSSで開発しており、かんたんに開発環境を構築できるので、よかったらデバッグしてみてください。
まだ `master` ブランチにマージしていないので、 `develop` ブランチをチェックアウトしてください。

- iOS  
https://github.com/uhooi/UhooiPicBook/tree/develop
- Android  
https://github.com/uhooi/UhooiPicBook-Android/tree/develop

PRやIssue、ソースレビューは特にルールを設けていないので、お気軽に送ってください！

### 画面一覧

主な画面は以下の2つです（スクリーンショットはiOS）。

|キャラクター一覧|キャラクター詳細|
|:--|:--|
|![MonsterList.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/0b68c19f-c58d-001c-3fb3-311750d1fad3.png)|![MonsterDetail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/b73e0011-c23a-f82a-ac8c-4736c735daee.png)|

__私が描いたキュートなキャラクターを図鑑として見ることができる、非常に素晴らしいアプリ__ ということがわかります。

### Firestoreの使われ方

ウホーイ図鑑では、キャラクターデータの配信にFirestoreを使っています（やっと本題）。
画像はCloud Storage for Firebaseに配置し、画像のURLをFirestoreに格納することで、Firebaseとのやりとりをキャラクターの全件取得（ `list` ）の1回のみに抑えています。

Firestoreのセキュリティルールは以下の通りです。
本記事ではこのセキュリティルールのテストを書きます。

```:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /monsters/{monsterId} {
      allow list;
    }
  }
}
```

見てわかるように、 `/monsters/{monsterId}` の `list` のみ許可しています。

参考までに、実際のデータも載せます。
<img width="1282" alt="スクリーンショット_2020-05-06_10_31_44.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/abed429a-7470-cae3-66c9-680a0bdfa8be.jpeg">

データはすべて私がコンソール上から手動で登録しています（フィールド名を間違えたりと、けっこう大変でミスが多い、、）。

Firestoreの設計については体系的に学んでいないため、さらにいい設計があるかもしれません。
例えば、キャラクターの表示順を `order` で表していますが、手動で採番するのが大変なので、もっといい方法があれば知りたいです。

## セキュリティルールのテスト観点を考える

先ほど紹介した動画ではTODOアプリの開発にFirestoreを使っており、 __「ユーザーにおかしなデータを登録させない」という観点__ で `create` と `update` のバリデーションのテストを書いていました。
例えば、「TODOのタイトルが文字列かつ20文字以下だと登録でき、21文字以上だと登録できない」のようなテストです。
Firestoreのセキュリティルールは、RDBでいう「スキーマ定義」に近いと思うので、このようなテストは仕様を把握する上でも有用です。

ただ、ウホーイ図鑑はユーザーがデータを操作できないため、TODOアプリと同様の観点ではテストできません。
__「ユーザーがデータを追加・更新・削除できず、一覧取得のみ行える」という観点__ でテストを書きました。

## `list` のテストケースを洗い出す

（「オペレーション」という言葉が長いので、ここからは単に「操作」と呼びます）

まず、 `list` に必要なテストケースを洗い出します。

「 `/monsters` コレクションに対する `list` のみ成功し、他の操作および他のコレクションに対するすべての操作が失敗する」ことが確認できればいいはずであり、「他の操作」と「他のコレクション」を洗い出せば必要なテストケースがわかります。

「他の操作」は、先述した通り `create` ・ `update` ・ `delete` ・ `get` の4つです。

「他のコレクション」は、「まったく別のコレクション」と「 `/monsters` コレクション内のドキュメントのサブコレクション」の2つに分けて考えます。
理由として、セキュリティルールの書き方によっては操作を再帰的に許可するためです。

当初、セキュリティルールで `match /monsters/{document=**} { allow list; }` のように書いていました。しかし、このようにワイルドカードを使うと、 `/monsters` コレクションで `list` を再帰的に許可します。
今回は `/monsters` コレクション直下のみ `list` を許可したいので、 `match /monsters/{monsterId} { allow list; }` と書き、「サブコレクションに対するすべての操作が失敗する」ことをテストで確認します。

「他の操作」と「他のコレクション」を洗い出せたので、テストケースに漏れとダブりがないか（MECEになっているか）確認するため、洗い出したことを表に起こします。
サブコレクションを `/monsters/document/subcollection` 、まったく別のコレクションを `/ohters` で表しています。

|コレクション|`create`|`update`|`delete`|`get`|`list`|
|:--|:--|:--|:--|:--|:--|
|`/monsters`|×|×|×|×|○|
|`/monsters/document/subcollection`|×|×|×|×|×|
|`/ohters`|×|×|×|×|×|

これで網羅できているかわかりやすくなりました。
私は網羅できていると思ったので（できていなかったら教えてください）、3×5=15つのテストケースを書いていきます。

## セキュリティルールのテストを書く

[動画を投稿された方がGitHubでテンプレートを公開している](https://github.com/mogaming217/youtube-simple-todo-sample) ので、こちらと公式ドキュメントなどを参考にテストを書きました。
GitHubで公開し、READMEに沿えばローカルでテストを実行できるので、ぜひ試してみてください。
https://github.com/uhooi/UhooiPicBook-Firebase/blob/develop/tests/monsters-tests.ts

GitHubで公開しているテストコードをそのまま載せます。
長いし、本記事ではコードを解説しない（しようと思ったのですが、 ~~疲れてきた~~ 記事が長くなり過ぎたのと、私がGitHubのREADMEで上げている参考資料を見た方がわかりやすいので、しないことにします）ので、折りたたんでおきます。

<details><summary>セキュリティルールのテストコード</summary><div>

TypeScript + Jestで書いています。
BDDのテストフレームワークはあまり使ったことがないのですが、仕様書代わりになるのがいいと思いました。
Firestoreのセキュリティルールだけだと仕様が見えづらいので、特に有用です。

```ts:monsters-tests.ts
import * as firebase from '@firebase/testing'
import * as fs from 'fs'

//#region Types

type Auth = {
  uid?: string,
  [key: string]: any
}

type Monster = {
  name: string,
  description: string,
  base_color: string,
  icon_url: string,
  dancing_url: string,
  order: number
}

//#endregion

//#region Consts

const projectId = 'uhooipicbook'
const databaseName = 'uhooipicbook'
const rules = fs.readFileSync('./firestore.rules', 'utf8')
const authedApp = (auth?: Auth) => firebase.initializeTestApp({ projectId: projectId, databaseName, auth }).firestore()
const adminApp =firebase.initializeAdminApp({ projectId: projectId, databaseName }).firestore()
const coverageUrl = `http://localhost:8080/emulator/v1/projects/${projectId}:ruleCoverage.html`

//#endregion

//#region TestCase Life-Cycle Methods

beforeAll(async () => {
  await firebase.loadFirestoreRules({ projectId: projectId, rules })
})

beforeEach(async () => {
  await firebase.clearFirestoreData({ projectId: projectId })
})

afterAll(async () => {
  await Promise.all(firebase.apps().map(app => app.delete()))
  console.log(`View rule coverage information at ${coverageUrl}\n`)
})

//#endregion

//#region Test Methods

describe('/monsters', () => {
  describe('create', () => {
    it('can not create', async () => {
      const db = authedApp(null)
      const monster = createTestMonster()

      await firebase.assertFails(db.collection('monsters').doc('uhooi').set(monster))
    })
  })

  describe('update', () => {
    it('can not update', async () => {
      await configureTestData('monsters', 'uhooi')
      const db = authedApp(null)
      const monster = createTestMonster()

      await firebase.assertFails(db.collection('monsters').doc('uhooi').set(monster))
    })
  })

  describe('delete', () => {
    it('can not delete', async () => {
      await configureTestData('monsters', 'uhooi')
      const db = authedApp(null)

      await firebase.assertFails(db.collection('monsters').doc('uhooi').delete())
    })
  })

  describe('get', () => {
    it('can not get', async () => {
      await configureTestData('monsters', 'uhooi')
      const db = authedApp(null)

      await firebase.assertFails(db.collection('monsters').doc('uhooi').get())
    })
  })
  
  describe('list', () => {
    it('can get list', async () => {
      await configureTestData('monsters', 'uhooi')
      const db = authedApp(null)

      await firebase.assertSucceeds(db.collection('monsters').get())
    })
  })

  describe('/document/subcollection', () => {
    describe('create', () => {
      it('can not create', async () => {
        await configureTestData('monsters', 'document')
        const db = authedApp(null)
        const monster = createTestMonster()
  
        await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').set(monster))
      })
    })
  
    describe('update', () => {
      it('can not update', async () => {
        await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
        const db = authedApp(null)
        const monster = createTestMonster()
  
        await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').set(monster))
      })
    })
  
    describe('delete', () => {
      it('can not delete', async () => {
        await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
        const db = authedApp(null)
  
        await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').delete())
      })
    })
  
    describe('get', () => {
      it('can not get', async () => {
        await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
        const db = authedApp(null)
  
        await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').doc('uhooi').get())
      })
    })
    
    describe('list', () => {
      it('can not get list', async () => {
        await configureSubCollectionTestData('monsters', 'document', 'subcollection', 'uhooi')
        const db = authedApp(null)
  
        await firebase.assertFails(db.collection('monsters').doc('document').collection('subcollection').get())
      })
    })
  })
})

describe('/others', () => {
  describe('create', () => {
    it('can not create', async () => {
      const db = authedApp(null)
      const monster = createTestMonster()

      await firebase.assertFails(db.collection('others').doc('uhooi').set(monster))
    })
  })

  describe('update', () => {
    it('can not update', async () => {
      await configureTestData('others', 'uhooi')
      const db = authedApp(null)
      const monster = createTestMonster()

      await firebase.assertFails(db.collection('others').doc('uhooi').set(monster))
    })
  })

  describe('delete', () => {
    it('can not delete', async () => {
      await configureTestData('others', 'uhooi')
      const db = authedApp(null)

      await firebase.assertFails(db.collection('others').doc('uhooi').delete())
    })
  })

  describe('get', () => {
    it('can not get', async () => {
      await configureTestData('others', 'uhooi')
      const db = authedApp(null)

      await firebase.assertFails(db.collection('others').doc('uhooi').get())
    })
  })
  
  describe('list', () => {
    it('can not get list', async () => {
      await configureTestData('others', 'uhooi')
      const db = authedApp(null)

      await firebase.assertFails(db.collection('others').get())
    })
  })
})

//#endregion

//#region Other Methods

function configureTestData(collectionId: string, documentId: string) {
  const db = adminApp

  return db.collection(collectionId).doc(documentId).set(createTestMonster())
}

function configureSubCollectionTestData(collectionId: string, documentId: string, subCollectionId: string, subDocumentId: string) {
  const db = adminApp

  return db.collection(collectionId).doc(documentId).collection(subCollectionId).doc(subDocumentId).set(createTestMonster())
}

function createTestMonster(): Monster {
  return {
    name: 'uhooi',
    description: 'ゆかいな　みどりの　せいぶつ。\nわるそうに　みえるが　むがい。',
    base_color: '#FFFFFF',
    icon_url: 'https://example.com/example.png',
    dancing_url: 'https://example.com/example.gif',
    order: 1
  }
}

//#endregion
```

私が実装で苦戦したポイントを箇条書きにします。

- `projectId` と `databaseName` に何を指定すればいいかわからない  
私は本番環境に繋がずローカルでテストを実行しているので、何を指定しても問題ないようです  
注意として、 __`projectId` に大文字を含むとエラーになります__  
（エラーログから原因がわからないので、だいぶ苦戦しました…）
- セキュリティルールで `create` を許可していないので、テストデータを作成できない  
`adminApp` を使うことでテストデータを作成できました
- テストデータの作成を待たずにテストが実行され、 `update` のテストが `create` 扱いになる  
私がasync/awaitを正しく理解していないからでした、、上記のコードは修正済みです
- テストが正しく書けているかわからない  
セキュリティルールを変更してテストを実行し、テストが失敗するかも確認しました  
例えば `allow create, list;` に変更して `create` のテストが失敗するなどです  
オペレーションの理解も深まったので、おすすめです
- ルールのカバレッジが取得できない  
テスト終了時にHTMLで出力されるのですが、すぐにエミュレータがシャットダウンするため、確認する前に消えてしまいます  
解決方法を知っている方がいたら教えてほしいです

</div></details>

テストの実行結果です。
BDDでは、 `description` や `it` を使ってテストを階層化することで、テスト結果の可読性が上がります。
<img width="920" alt="スクリーンショット 2020-05-06 12.36.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138245/99c3ff4c-f55c-6373-3996-486b7e4423c2.png">

私はBDDに慣れていないので、自分では読みやすいと思って書きましたが、このような階層の分け方が適切なのかはわかりません。
もし他に適切な分け方があれば教えていただけると嬉しいです。

## おわりに

セキュリティルールのテストの書き方について、少しでも参考になれば幸いです。
慣れてくるとここまでテストを書く必要がなくなるかもしれませんが、「ユーザーはデータをまとめて取得することのみでき、追加・更新・削除・単体での取得はできない」ことが保証できたので、私にとっては必要でした。

みなさんはどのような観点でセキュリティルールのテストを書いているでしょうか？
もし今回紹介した観点以外で書いていたら、ぜひ教えていただきたいです。

## 参考リンク

- [Cloud Firestore セキュリティ ルールをカスタマイズする  |  Firebase](https://firebase.google.com/docs/firestore/security/secure-data?hl=ja)