🐈

Firebase Cloud Storageのセキュリティルールのテストを書いてみる

9 min read

firebase-toolsのバージョン9.11.0でStorage Emulatorがサポートされ、firebase/rule-unit-testingのバージョン1.3.0から、Storageのセキュリティルールのテストを書くための各種インターフェースがサポートされ、実際のプロジェクトのStorageを操作しなくてもセキュリティルールをテストすることができるようになりました。

また、firebase/rule-unit-testingバージョン2からは、よりテストが書きやすくなるようにインターフェースが一新されました

今回は現状あまり情報の少ない、Storageのルールのテストの書き方と簡単な例をご紹介します。

packageのインストール

今回最低限必要なものが以下になります。

  "dependencies": {
    "firebase": "^9.1.2"
  },
  "devDependencies": {
    "@firebase/rules-unit-testing": "^2.0.1",
    "firebase-tools": "^9.19.0", // 任意
    "jest": "^27.2.4" // 任意、お好きなものを
  },

"@firebase/rules-unit-testing"バージョン2.0.0以上と"firebase"バージョン9以上をインストールできると良いでしょう。
また、テストフレームワークに関してはjestでもmochaでもお好きなものを採用して構いません。
以降はjestを前提に進めていきます。

  • "@firebase/rules-unit-testing"バージョン2.0.0未満だとこの記事で紹介している書き方とは異なりますが、1.3.0以上であればstorageのルールのテストを書くことはできます。 詳細

  • "firebase" (Firebase JS SDK)がなかったり、バージョン9未満でも動作させることは一応できますが、特にTypeScriptで書く場合に型の定義を@firebase/rules-unit-testingからうまいこと取得できず書きづらくなります。両方指定のバージョンを入れることをおすすめします。

storage.rulesの準備

適切な場所にstorage.rulesを配置し、次のようにルールを記述します。

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /users/{userId}/{allPaths=**} {
      allow get: if request.auth.uid == userId;
      allow create: if request.auth.uid == userId
        && isValidContentType(request.resource)
        && isValidFileExtension(request.resource.name)
        && isValidFileSize(request.resource);
    }
  }
  function isValidContentType(data) {
    return data.contentType in ['image/png'];
  }

  function isValidFileExtension(fileName) {
    return fileName.matches('.*[.]png');
  }

  function isValidFileSize(data) {
    return data.size < 50 * 1024
  }
}

今回は、ユーザーが自身のディレクトリ users/{user_id}/ 以下にpng画像をアップロードでき、自分自身の画像のみ取得できることを想定したルールにしています。

また、png画像であることを保証するために、Content-Type,拡張子の確認を行い、追加で50KiB未満の画像であることを合わせて確認します。

ディレクトリ内の画像の一覧を取得、metadataの更新、ファイルの削除は簡略化するために今回許可していません。

Storageのセキュリティルールの基本については以下のドキュメントから学ぶことができます。

https://firebase.google.com/docs/storage/security

firebase.jsonの準備

Storage Emulatorを動かすための設定を、firebase.jsonに記述します。

{
  "storage": {
    "rules": "storage.rules"
  },
  "emulators": {
    "storage": {
      "port": 9199
    }
  }
}

Storage Emulatorのポートはデフォルトで9199のようなのでそれを指定します。
もしくは firebase init でセットアップするときにEmulatorのセットアップも合わせて行うか、 firebase emulator:setup でセットアップするとこの設定が追記されると思います。手動で書き込んでも問題ありません。

https://firebase.google.com/docs/rules/emulator-setup

テストをセットアップする

Storageのルールのテストに必要なセットアップ処理(と終了処理)を記述します。

import * as ftest from '@firebase/rules-unit-testing'
import * as fs from 'fs'
import 'jest'

let testEnv: ftest.RulesTestEnvironment
beforeAll(async () => {
  testEnv = await ftest.initializeTestEnvironment({
    projectId: 'demo-users-storage-rules-test',
    storage: {
      rules: fs.readFileSync('./storage.rules', 'utf8'),
    },
  })
})
beforeEach(async () => await testEnv.clearStorage())
afterAll(async () => await testEnv.cleanup())
  • テスト開始時に、テストファイルごとに一意のprojectIDを指定して、RuleTestEnvironmentを生成する
  • それぞれのテストケース開始前(もしくはそれぞれのテストケース終了時)にデータを削除する
  • 全てのテストケース終了時に、RulesTestEnvironmentのcleanupをする

といった内容になります。このあたりの記述はFirestoreのセキュリティルールのテストととても似ています。

  • 複数のテストファイルを用意するときはテストファイル毎に一意になるように設定しましょう。特にテストを並列で実行する場合は。
  • projectIdについては、最新のドキュメントを読むとdemo-プレフィクスをつけておくのが推奨されています。 詳細: Connect your app to the Cloud Storage Emulator

テストファイルを作成する

(特に)get, list, update, deleteオペレーションのテストを書く場合に、事前にテストファイルをアップロードしておく必要があります。
予めファイルをStorageにアップロードしてテストファイルを準備する場合は、RuleTestEnvironmentwithSecurityRulesDisabled関数を利用して、セキュリティルールをバイパス(無効にした状態)してファイルを準備するのが良いでしょう。

例えば、users/user/icon.pngというパスで画像を、セキュリティルールを無視して上げる場合は次のようになります。

const userImageRef = (storage: Storage, userId: string, imageName: string) =>
  userImagesRef(storage, userId).child(imageName)
const loadIconImage = () => fs.readFileSync('./icon.png')

beforeEach(async () => {
  await testEnv.withSecurityRulesDisabled(async context => {
    await userImageRef(context.storage(), userId, 'icon.png').put(loadIconImage()).then()
  })
})

withSecurityRulesDisabled内で、context.storage()を2回書いてしまうとエラーが発生することがあります。もしこの関数内で複数のファイルを準備する場合は次のように一度変数に代入してから使うと良いでしょう。

await testEnv.withSecurityRulesDisabled(async context => {
  const storage = context.storage()
  const refA = storage.ref(...)
  const refB = storage.ref(...)
})

Firestoreと違う点
Firestoreと違う点に関しては、readのオペレーション実行時に参照先のファイルがStorageにないとルールが正しくても取得が失敗してしまうので、単純にreadのルールを試す場合でもテストデータを作成しておくと良いでしょう。

非認証、認証済ユーザーのStorageを生成する

  • 非認証状態のStorageインスタンスを生成する
testEnv.unauthenticatedContext().storage()
  • 認証状態のStorageインスタンスを生成する
testEnv.authenticatedContext('user_id').storage()

指定のユーザーID(auth.uid)を渡して生成します。また第2引数により細かいauthの情報を渡すこともできます。

const storage = testEnv.authenticatedContext(
  userId,
  {
    email 'foobar@baz.com', 
    email_verified: true
  }
).storage()

オペレーション毎のルールの検証

get/listのルールを検証する

getに関しては getDownloadUrl もしくは getMetadata で検証が行えます。

const userId = ...
const path = `users/${userId}/icon.png`
const ref = testEnv.authenticatedContext(userId).storage().ref(path)
await assertSucceeds(ref.getDownloadURL())

listに関しては list もしくは listAll で検証ができます。

const userId = ...
const ref = testEnv.authenticatedContext(userId).storage().ref(`users/${userId}`)
await assertSucceeds(ref.listAll())

createのルールを検証する

テストファイルを作成するときと同様に、putを利用します。

const userId = ...
const ref = testEnv.authenticatedContext(userId).storage().ref(
  `users/${userId}/icon.png`
)
const image = fs.readFileSync('./icon.png')
await assertSucceeds(
  ref.put(image, { contentType: 'image/png' }).then()
)

put 自体はPromise型を返さないため、 .then() を付けてPromise型にし、awaitで結果を待つようにしています。
もしかしたらasync/awaitではなく通常のassertion+ put だけでも十分かもしれません。

metadataの検証

アップロードする際のmetadataの情報、例えばcontentTypeやカスタムデータなどを検証したい場合は、putの第2引数を変えると良いでしょう。

await assertFails(
  ref.put(image, { contentType: 'image/jpeg' }).then()
)

ファイル拡張子の検証

StorageReferenceのpathの拡張子を任意のものに変更してテストを検証します

const userId = ...
const ref = testEnv.authenticatedContext(userId).storage().ref(
  `users/${userId}/icon.jpeg`
)
await assertFails(
  ref.put(image, { contentType: 'image/png' }).then()
)

ファイルサイズの検証

putにわたすファイルを指定のサイズのものに変えると良いでしょう。

// Load over 50KiB icon image
const image = fs.readFileSync('./big_icon.png')

updateのルールを検証する

updateのルールは、 updateMetadata を用いることで検証ができます。

await assertFails(ref.updateMetadata({})

deleteのルールを検証する

deleteのルールは、 delete を用いることで検証できます。

await assertFails(ref.delete())

💡 大きなファイルをEmulatorにアップロードするには

今回の例では簡略化するためにファイルサイズの検証を50KiBとしていましたが、実際のプロダクトでは数MiB,GiBを扱うかと思います。
その場合を検証するために、リポジトリ内に巨大なファイルを作成して配置し、テストに用いるのは良い案ではありません。

その場合には以下のようにして大きいファイルをテスト内で動的に生成し、利用すると良いでしょう。

const createTestFile = (size: int) => Buffer.alloc(size);

// Create a buffer with 500Mib size and try to upload it to Storage
await assertFails(ref.put(createLargeFile(500 * 1024 * 1024)).then())

この場合、Storage側では自動的にContentTypeの判別をしてアップロードはしてくれない(判別できない)ため、putの第2引数に正しいContentTypeを渡す必要があります。

テストの全容

今回は記事向けに記述を省いてライトに書いていますが、今回紹介したstorage.rulesのセキュリティルールに対する完全な状態のテストコードはこちらに掲載しています。

https://github.com/sgr-ksmt/firebase-storage-rule-test-example/blob/main/test/users.test.ts

テストの実行

テストの実行には、Storage Emulatorの起動が必要になるので、firebaseのコマンドでEmulatorを起動した上で、テストを実行します。
まだStorage Emulatorをセットアップしていない場合は以下のコマンドを先に実行します。

$ yarn firebase setup:emulators:storage 
$ yarn firebase emulators:exec --only storage 'jest'
$ yarn firebase emulators:exec --only storage 'jest --watch'

これらのコマンドはpackage.jsonのscriptsに書いておくと良いでしょう。

今回はStorage単体でのテストを想定していますが、もし他のEmulatorと組み合わせたテストを行う場合には各種必要なEmulatorを起動してください。

さいごに

現時点ではそこまで情報が豊富ではないStorageのセキュリティルールのテストを書く方法についてまとめてみました。

サンプルはGitHubに掲載しています。npmもしくはyarnがセットアップできていればクローンして2ステップでテストを試せるようにしてあるので気になる方は試してみてください。

https://github.com/sgr-ksmt/firebase-storage-rule-test-example

また、英語版の記事も書きましたのでよかったら見てください。

https://medium.com/@_sgr_ksmt/how-to-write-firebase-cloud-storage-rules-tests-48559806a268

併せて読みたい

https://firebase.google.com/docs/rules/unit-tests

https://zenn.dev/sgr_ksmt/books/2f83a604d636b241cf3c

https://zenn.dev/moga/articles/firebase-rules-unit-testing-v2

この記事に贈られたバッジ

Discussion

ログインするとコメントできます