Open15

Firebase functions + Realtime databaseをユーザ権限で運用したいときのTips

sabo10sabo10

Firebase functionsはデフォルトではsecurity-ruleを無視してRealtime databaseへ読み書きできてしまう。これだと、誤ってデータ全削除しちゃうなんてことにもなりかねないので、クライアント同様にsecurity-rule制限下で稼働させたいことがあると思います。

ただ、これを行うとエミュレータや、テスト上では動いたのに、いざデプロイするとセキュリティルールに弾かれて失敗するといったことが頻発*1したので、functionsのコードをセキュリティルール適用した状態で単体テストするUtilityを作成しました。
※1 firebase-tools 11.19.0では直っているようですが、以前はエミュレート環境でセキュリティルールが機能していなかったので特に

sabo10sabo10

運用時には、Realtime databaseのセキュリティルールを以下のように設定。
こうすることで、特定のドキュメントは管理者権限のユーザとしてDBを操作、ただしそれ以外の操作はできないといった制限が可能。

    "admin": {
      ".read": "root.child('admin/'+auth.uid).exists()",
      ".write": false
    },
    "hoge": {
      ".read": "auth != null",
      ".write": "root.child('admin/'+auth.uid).exists()"
    },
sabo10sabo10

運用環境ではFirebaseコンソール、エミュレータではFirebase Emulator SuiteからDBに対して直接以下を設定することでルール制限下のユーザでfunctionsを稼働できる。

admin: {
   "my-service-worker": true
}
sabo10sabo10

ただ、上記の通りIDを追加するためにはスーパーユーザ権限で書き込める手段が必要なため、functionsの単体テストを行いたいときに、上記の設定ができずに詰む。
このため、単体テストでは@firebase/rules-unit-testingを使用して上記の問題を回避する。
https://www.npmjs.com/package/@firebase/rules-unit-testing

sabo10sabo10

functionsの単体テストで行いたい要件は以下

  • security-rules制限下でDBを操作
  • 各テスト実行前にDBを任意のデータで初期化できる
  • 「モジュール実行後のDBのスナップショットが期待値と一致するか」といったテストを行える
sabo10sabo10

まず、単体テスト実行時に使用するDBをrules-unit-testingで初期化したものに差し替えることができるようにするために、firebaseのインスタンスをメソッド形式で提供します。

import { initializeApp, applicationDefault } from 'firebase-admin/app';
import { Database } from '@firebase/database-types';

const config = {
  apiKey: '*******',
  authDomain: '*******',
  databaseURL:'*******',
  projectId:'*******',
  storageBucket: '*******',
  messagingSenderId: ''*******',
  appId: '*******',
  measurementId: '*******',
  databaseAuthVariableOverride: {
    uid: 'my-service-worker',
  },
  credential: applicationDefault(),
};

//Database以外の初期化は省略
export const initializedFirebase = initializeApp(config);
const database: Database = getDatabase(initializedFirebase);

export function db(): Database {
  return database;
}

sabo10sabo10

単体テスト実行時に先ほどのdb()メソッドがテスト用のDBを返すようにモックします。

import * as appModule from '../firebase/FirebaseApp';

jest.spyOn(appModule, 'db').mockImplementation(() => {
      return this._instance.adminDB;
    });

テスト用のDB生成は公式参照
https://firebase.google.com/docs/rules/unit-tests#database

sabo10sabo10

最終的に単体テスト用に作成したUtilityは以下
*top-level-awaitを使用したくなかったので、rules-unit-testingの初期化をクラスでラップしています。

import { initializeTestEnvironment, RulesTestEnvironment } from '@firebase/rules-unit-testing';
import * as fs from 'fs';
import { Database } from '@firebase/database-types';
import testData = require('./TestDB.test.json');
import * as appModule from '../firebase/FirebaseApp';

export const ADMIN_AUTHENTICATE_UID = 'my-service-worker';
const DATABASE_RULES_PATH = '../database.rules.json';
const projectId = `rules-test-${Date.now()}`;

export default class SecurityRuleTestUtil {
  private static _instance: SecurityRuleTestUtil;
  private testEnv: RulesTestEnvironment;
  private adminDB: Database;

  private constructor(env: RulesTestEnvironment) {
    this.testEnv = env;
    this.adminDB = this.testEnv.authenticatedContext(ADMIN_AUTHENTICATE_UID).database();
  }

  private static async instance(): Promise<SecurityRuleTestUtil> {
    if (this._instance) return this._instance;
    const _testEnv = await initializeTestEnvironment({
      projectId,
      database: {
        rules: fs.readFileSync(DATABASE_RULES_PATH, 'utf8'),
      },
    });
    this._instance = new SecurityRuleTestUtil(_testEnv);
    jest.setTimeout(10000);
    jest.spyOn(appModule, 'db').mockImplementation(() => {
      return this._instance.adminDB;
    });
    jest
      .useFakeTimers({
        doNotFake: [
          'nextTick',
          'setImmediate',
          'clearImmediate',
          'setInterval',
          'clearInterval',
          'setTimeout',
          'clearTimeout',
        ],
      })
      .setSystemTime(new Date(2222, 0, 1, 1, 1, 1));
    return this._instance;
  }

  private async initialize() {
    await this.testEnv.clearDatabase();
    await this.testEnv.withSecurityRulesDisabled(async (context) => {
      const noRuleDB = context.database();
      await noRuleDB.ref().update(testData);
    });
  }

  public getTestEnv(): RulesTestEnvironment {
    return this.testEnv;
  }

  public static async beforeEachAction() {
    const instance = await SecurityRuleTestUtil.instance();
    await instance.initialize();
  }

  public static async snapshotDBMatchToExpect(expectDB: any, exportSnapshotPath?: string) {
    const instance = await SecurityRuleTestUtil.instance();
    await instance.getTestEnv().withSecurityRulesDisabled(async (context) => {
      const noRuleDB = context.database();
      const snapshot = await noRuleDB.ref().once('value');
      try {
        expect(snapshot.val()).toEqual(expectDB);
      } catch (e) {
        if (exportSnapshotPath) {
          const toJSON = JSON.stringify(snapshot.val());
          fs.writeFileSync(exportSnapshotPath, toJSON);
        }
        throw e;
      }
    });
  }
}

sabo10sabo10

使用方法としては以下のようにbeforeEachで毎回初期化を行い、テスト対象実行後にDBが期待値通りになっているかといったテストを行っています。

beforeEach(async () => {
  await SecurityRuleTestUtil.beforeEachAction();
});

describe('hoge', () => {
  it('hoge', async () => {
    await testMethod();
    await SecurityRuleTestUtil.snapshotDBMatchToExpect(expectData);
  });
});
sabo10sabo10
private static async instance(): Promise<SecurityRuleTestUtil> {
  if (this._instance) return this._instance;
  const _testEnv = await initializeTestEnvironment({
    projectId,
    database: {
      rules: fs.readFileSync(DATABASE_RULES_PATH, 'utf8'),
    },
  });
  this._instance = new SecurityRuleTestUtil(_testEnv);
  jest.setTimeout(10000);
  jest.spyOn(appModule, 'db').mockImplementation(() => {
    return this._instance.adminDB;
  });
  jest
    .useFakeTimers({
      doNotFake: [
        'nextTick',
        'setImmediate',
        'clearImmediate',
        'setInterval',
        'clearInterval',
        'setTimeout',
        'clearTimeout',
      ],
    })
    .setSystemTime(new Date(2222, 0, 1, 1, 1, 1));
  return this._instance;
}

ここではセキュリティルールの適用とテストのセットアップを行っています。

  • initializeTestEnvironmentでセキュリティルールを適用。
  • jestを使用してdb()の差し替え
  • Dateのモック
sabo10sabo10
private async initialize() {
    await this.testEnv.clearDatabase();
    await this.testEnv.withSecurityRulesDisabled(async (context) => {
      const noRuleDB = context.database();
      await noRuleDB.ref().update(testData);
    });
  }

ここでは各テスト実施前の初期化を行っています。
withSecurityRulesDisabledを使用することでルールを無視して読み書きができるため、初期化の際に任意のデータをDBに書き込みます。
この際に
https://zenn.dev/link/comments/cb99d621d5be21
を挿入データに入れておくことで、セキュリティルール上のadminユーザとして実行できます。

sabo10sabo10
public static async snapshotDBMatchToExpect(expectDB: any, exportSnapshotPath?: string) {
    const instance = await SecurityRuleTestUtil.instance();
    await instance.getTestEnv().withSecurityRulesDisabled(async (context) => {
      const noRuleDB = context.database();
      const snapshot = await noRuleDB.ref().once('value');
      try {
        expect(snapshot.val()).toEqual(expectDB);
      } catch (e) {
        if (exportSnapshotPath) {
          const toJSON = JSON.stringify(snapshot.val());
          fs.writeFileSync(exportSnapshotPath, toJSON);
        }
        throw e;
      }
    });
  }

ここではテスト用に現在のスナップショットが期待値と一致するかをテストするためのメソッドを提供しています。
ここでもinitialize同様、withSecurityRulesDisabledを使用してルールを無視してルート直下からデータを取得しています。そして期待値と一致しているかをチェック、一致しなければそのスナップショットを出力しています。

sabo10sabo10

以上で終わりです。
Firebase functions + Realtime databaseをユーザ権限で運用した際にエミュレータ上では動いたけど、いざデプロイするとセキュリティルールに弾かれて失敗するといったことが頻発したので、単体テストでも実行できるようなUtilityを実装してみました。
少しでも参考になれば幸いです。また、他に良いやり方があれば教えてください。