6
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-05-06

はじめに

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

そんなとき、たまたま YouTubeにFirestore設計入門の動画を投稿したというツイート を見て、軽い気持ちで視聴しました(おい)。
動画 ではTypeScript + Jestを使ってサクサクテストを書いていてわかりやすく、これなら私でも書けると思い、実際に個人開発で使っているセキュリティルールのテストを書いてみました。

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

対象読者

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

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

Firestoreのオペレーション

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

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

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

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

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

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

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

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

概要

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

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

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

画面一覧

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

キャラクター一覧 キャラクター詳細
MonsterList.png MonsterDetail.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 のみ許可しています。

参考までに、実際のデータも載せます。
スクリーンショット_2020-05-06_10_31_44.jpg

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

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

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

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

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

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

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

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

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

「他の操作」は、先述した通り createupdatedeleteget の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でテンプレートを公開している ので、こちらと公式ドキュメントなどを参考にテストを書きました。
GitHubで公開し、READMEに沿えばローカルでテストを実行できるので、ぜひ試してみてください。
https://github.com/uhooi/UhooiPicBook-Firebase/blob/develop/tests/monsters-tests.ts

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

セキュリティルールのテストコード

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

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

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

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

テストの実行結果です。
BDDでは、 descriptionit を使ってテストを階層化することで、テスト結果の可読性が上がります。
スクリーンショット 2020-05-06 12.36.56.png

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

おわりに

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

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

参考リンク

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4