Firebase functions + Realtime databaseをユーザ権限で運用したいときのTips
Firebase functionsはデフォルトではsecurity-ruleを無視してRealtime databaseへ読み書きできてしまう。これだと、誤ってデータ全削除しちゃうなんてことにもなりかねないので、クライアント同様にsecurity-rule制限下で稼働させたいことがあると思います。
ただ、これを行うとエミュレータや、テスト上では動いたのに、いざデプロイするとセキュリティルールに弾かれて失敗するといったことが頻発*1したので、functionsのコードをセキュリティルール適用した状態で単体テストするUtilityを作成しました。
※1 firebase-tools 11.19.0では直っているようですが、以前はエミュレート環境でセキュリティルールが機能していなかったので特に
何はともあれ、以下のdatabaseAuthVariableOverrideを設定。
これでIDがmy-service-workerのユーザとして、functions上でDBを操作できます。
運用時には、Realtime databaseのセキュリティルールを以下のように設定。
こうすることで、特定のドキュメントは管理者権限のユーザとしてDBを操作、ただしそれ以外の操作はできないといった制限が可能。
"admin": {
".read": "root.child('admin/'+auth.uid).exists()",
".write": false
},
"hoge": {
".read": "auth != null",
".write": "root.child('admin/'+auth.uid).exists()"
},
運用環境ではFirebaseコンソール、エミュレータではFirebase Emulator SuiteからDBに対して直接以下を設定することでルール制限下のユーザでfunctionsを稼働できる。
admin: {
"my-service-worker": true
}
ただ、上記の通りIDを追加するためにはスーパーユーザ権限で書き込める手段が必要なため、functionsの単体テストを行いたいときに、上記の設定ができずに詰む。
このため、単体テストでは@firebase/rules-unit-testingを使用して上記の問題を回避する。
functionsの単体テストで行いたい要件は以下
- security-rules制限下でDBを操作
- 各テスト実行前にDBを任意のデータで初期化できる
- 「モジュール実行後のDBのスナップショットが期待値と一致するか」といったテストを行える
まず、単体テスト実行時に使用する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;
}
単体テスト実行時に先ほどのdb()メソッドがテスト用のDBを返すようにモックします。
import * as appModule from '../firebase/FirebaseApp';
jest.spyOn(appModule, 'db').mockImplementation(() => {
return this._instance.adminDB;
});
テスト用のDB生成は公式参照
最終的に単体テスト用に作成した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;
}
});
}
}
使用方法としては以下のようにbeforeEachで毎回初期化を行い、テスト対象実行後にDBが期待値通りになっているかといったテストを行っています。
beforeEach(async () => {
await SecurityRuleTestUtil.beforeEachAction();
});
describe('hoge', () => {
it('hoge', async () => {
await testMethod();
await SecurityRuleTestUtil.snapshotDBMatchToExpect(expectData);
});
});
private constructor(env: RulesTestEnvironment) {
this.testEnv = env;
this.adminDB = this.testEnv.authenticatedContext(ADMIN_AUTHENTICATE_UID).database();
}
ここでは、authenticatedContextを使用して指定したIDのユーザでDBを操作するように設定します。
- 以下と同じことをテスト環境で実施しているようなもの
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のモック
- 現在時刻を正しく返されると期待値とスナップショットで時刻の不一致が起こるためモック
- 単純にモックするとテストが止まるので以下を実施
private async initialize() {
await this.testEnv.clearDatabase();
await this.testEnv.withSecurityRulesDisabled(async (context) => {
const noRuleDB = context.database();
await noRuleDB.ref().update(testData);
});
}
ここでは各テスト実施前の初期化を行っています。
withSecurityRulesDisabledを使用することでルールを無視して読み書きができるため、初期化の際に任意のデータをDBに書き込みます。
この際に
を挿入データに入れておくことで、セキュリティルール上のadminユーザとして実行できます。
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を使用してルールを無視してルート直下からデータを取得しています。そして期待値と一致しているかをチェック、一致しなければそのスナップショットを出力しています。
以上で終わりです。
Firebase functions + Realtime databaseをユーザ権限で運用した際にエミュレータ上では動いたけど、いざデプロイするとセキュリティルールに弾かれて失敗するといったことが頻発したので、単体テストでも実行できるようなUtilityを実装してみました。
少しでも参考になれば幸いです。また、他に良いやり方があれば教えてください。