Closed21

FirestoreのSecurity RuleをLocalでテストする Step1

sakutarosakutaro

これに沿って進める
https://www.youtube.com/watch?v=VDulvfBpzZE

Firebaseの初期化は完了済み、firestore.rulesがすでにある状態で始める。
ここから -> https://youtu.be/VDulvfBpzZE?t=391

テスト用のフォルダ作成

$ mkdir test
$ cd test
$ npm init

こんな感じで入力

package name: (test) 
version: (1.0.0) 
description: 
entry point: (index.js) test.js
test command: mocha --exit
git repository: 
keywords: 
author: 
license: (ISC) 

mochaのインストール

$ npm install mocha --save-dev
sakutarosakutaro

動画では、このライブラリを使うが、mogaさんに教えてもらって
新しいライブラリを使うように修正

旧:
@firebase/testingのインストール
$ npm install @firebase/testing --save-dev

新:

$ npm i -D @firebase/rules-unit-testing
$ npm i -D firebase-admin

簡単なテストを書く

test.js
const assert = require('assert');

describe("Our test app", ()=> {

  it("Understands basic addition", () => {
    assert.strictEqual(2+2, 4); // 動画ではequalで書かれてましたが、deprecatedになった模様。
  })
})

テスト実行

$ npm test

yay!

  Our test app
    ✓ Understands basic addition

  1 passing (4ms)
sakutarosakutaro

おお、新しいのが出てたのですね。
ありがとうございます!!

sakutarosakutaro

Firebaseを絡めたテストに修正

test.js
const assert = require('assert');
const firebase = require('@firebase/rules-unit-testing');

const MY_PROJECT_ID = "my-project-name" // 適宜変更

describe("Our test app", ()=> {

  it("Understands basic addition", () => {
    assert.strictEqual(2+2, 4);
  })

  it("Can read items in the read-only collection", async() => {
    const db =firebase.initializeTestApp({projectId: MY_PROJECT_ID}).firestore();
    const testDoc = db.collection("readonly").doc("testDoc");
    await firebase.assertSucceeds(testDoc.get());
  })
})

動画の通り、下記エラーとなる。

FirebaseError: Failed to get document because the client is offline.
sakutarosakutaro

エミュレーターの起動

firebase emulators:start

再度テストしてもセキュリティルールによって、Read/Write共に全拒否しているのでエラーになる。

ここで全解放かと思いきや、それはダメと怒られる。(動画の中の話)
最小限のアクセス権を与えるようにしないといけないよと。

sakutarosakutaro

ルールを修正

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // lock down the db
    match /{document=**} {
      allow read: if false;
      allow write: if false;
    }
    
+    match /readonly/{docId} {
+      allow read: if true;
+      allow write: if false;
+    }
  }
}

test実行

$ npm test

yay!

  Our test app
    ✓ Understands basic addition
    ✓ Can read items in the read-only collection (220ms)

  2 passing (225ms)
sakutarosakutaro

書き込みに失敗するテストも書く

test.js
  it("Can't write to items in the read-only collection", async() => {
    const db =firebase.initializeTestApp({projectId: MY_PROJECT_ID}).firestore();
    const testDoc = db.collection("readonly").doc("testDoc2");
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  })

yay!

  Our test app
    ✓ Understands basic addition
    ✓ Can read items in the read-only collection (116ms)
    ✓ Can't write to items in the read-only collection (52ms)

  3 passing (173ms)
sakutarosakutaro

次は、書き込みのテスト

requestにあるuserIdと、DocumentのIdが一致しなければ書き込めないようにする。

ルールはこんな感じ

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // lock down the db
    match /{document=**} {
      allow read: if false;
      allow write: if false;
    }
    
    match /readonly/{docId} {
      allow read: if true;
      allow write: if false;
    }


+   match /users/{userId} {
+     allow write: if (request.auth.uid === userId);
+   }
  }
}

テスト追加。
認証情報を追加している

test.js
  it("Can write to a user document with the same ID as our user", async() => {
    const myAuth = { uid: 'user_abc', email: 'user_abc@gmail.com'}; // <-認証情報の追加
    const db =firebase.initializeTestApp({projectId: MY_PROJECT_ID, auth: myAuth}).firestore();
    const testDoc = db.collection("users").doc("user_abc");
    await firebase.assertSucceeds(testDoc.set({foo: "bar"}));
  })

異なるIDの時に失敗するテストを追加

test.js
  it("Can't write to a user document with the different ID as our user", async() => {
    const myAuth = { uid: 'user_abc', email: 'user_abc@gmail.com'};
    const db =firebase.initializeTestApp({projectId: MY_PROJECT_ID, auth: myAuth}).firestore();
    const testDoc = db.collection("users").doc("user_xyz");  // <- user_abc != user_xyz
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  })

test実行

$ npm test

yay!

    ✓ Understands basic addition
    ✓ Can read items in the read-only collection (110ms)
    ✓ Can't write to items in the read-only collection (50ms)
    ✓ Can write to a user document with the same ID as our user (48ms)
    ✓ Can't write to a user document with the different ID as our user

  5 passing (246ms)
sakutarosakutaro

リファクタ

test.js
onst assert = require('assert');
const firebase = require('@firebase/rules-unit-testing');

const MY_PROJECT_ID = "project-id";
const myId = "user_abc"; //追加
const theirId ="user_xyz"; //追加
const myAuth = { uid: myId, email: 'user_abc@gmail.com'}; //追加

function getFirestore(auth) {  //追加
  return firebase.initializeTestApp({projectId: MY_PROJECT_ID, auth: auth}).firestore();
}

describe("Our test app", ()=> {

  it("Understands basic addition", () => {
    assert.strictEqual(2+2, 4);
  });

  it("Can read items in the read-only collection", async() => {
    const db = getFirestore(null);
    const testDoc = db.collection("readonly").doc("testDoc");
    await firebase.assertSucceeds(testDoc.get());
  });

  it("Can't write to items in the read-only collection", async() => {
    const db = getFirestore(null);
    const testDoc = db.collection("readonly").doc("testDoc2");
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  });

  it("Can write to a user document with the same ID as our user", async() => {
    const db = getFirestore(myAuth);
    const testDoc = db.collection("users").doc(myId);
    await firebase.assertSucceeds(testDoc.set({foo: "bar"}));
  });

  it("Can't write to a user document with the different ID as our user", async() => {
    const db = getFirestore(myAuth);
    const testDoc = db.collection("users").doc(theirId);
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  });

})

問題なし

  Our test app
    ✓ Understands basic addition
    ✓ Can read items in the read-only collection (217ms)
    ✓ Can't write to items in the read-only collection (55ms)
    ✓ Can write to a user document with the same ID as our user (57ms)
    ✓ Can't write to a user document with the different ID as our user
  5 passing (368ms)
sakutarosakutaro

投稿に関するルールを追加。
visibilityがpublicもしくは、自分の投稿であればデータ取得できるように。

firestore.rules
    match /posts/{postId} {
      allow read: if (resource.data.visibility =="public") ||
        (resource.data.authorId == request.auth.uid);
    }

visibilityがpublicなものは取得できるテスト

test.js
  it("Can read posts marked public", async() => {
    const db = getFirestore(null);
    const testQuery = db.collection("posts").where("visibility", "==", "public");
    await firebase.assertSucceeds(testQuery.get());
  });
✓ Can read posts marked public

自分の投稿を見れるかテスト

test.js
  it("Can query personal posts", async() => {
    const db = getFirestore(myAuth);
    const testQuery = db.collection("posts").where("authorId", "==", myId);
    await firebase.assertSucceeds(testQuery.get());
  });
✓ Can query personal posts (129ms)
sakutarosakutaro

わざと失敗するテストを書く
認証済ユーザーが全ての投稿を取得できる

test.js
  it("Can query all posts", async() => {
    const db = getFirestore(myAuth);
    const testQuery = db.collection("posts");
    await firebase.assertSucceeds(testQuery.get());
  });

無事失敗

       Can query all posts:
     FirebaseError: 
false for 'list' @ L6, Property visibility is undefined on object. for 'list' @ L20

テスト修正

test.js
  it("Can't query all posts", async() => {
    const db = getFirestore(myAuth);
    const testQuery = db.collection("posts");
    await firebase.assertFails(testQuery.get());
  });
    ✓ Can't query all posts
sakutarosakutaro

単一のpublicなポストを取得できるかのテスト

test.js
  it("Can query a single public post", async() => {
    const db = getFirestore();
    const testQuery = db.collection("posts").doc("public_post");
    await firebase.assertSucceeds(testQuery.get());
  });

これは、もちろんデータがないので失敗する。

エミュレーター (http://localhost:4000/firestore/)にアクセスして
データを作成し、テスト通ることを確認。

    ✓ Can query a single public post
sakutarosakutaro

毎回エミュレーターを使って手動でデータを作るのは非効率なので、
コードで実行できるようにします。
initializeAdminAppというセキュリティルールを全無視してfirestoreの操作ができるライブラリがあるので、それを使います。

functionを作成

test.js
function getAdminFirestore(auth) {
  return firebase.initializeAdminApp({projectId: MY_PROJECT_ID}).firestore();
}
sakutarosakutaro

documentをセットするコードを追加

test.js
  it("Can query a single public post", async() => {
    const admin = getAdminFirestore();
    const postId = "public_post";
    const setupDoc = admin.collection("posts").doc(postId);
    await setupDoc.set({authorId: theirId, visibility: "public"});

    const db = getFirestore();
    const readQuery = db.collection("posts").doc(postId);
    await firebase.assertSucceeds(readQuery.get());
  });
   ✓ Can query a single public post (60ms)
sakutarosakutaro

自分のプライベートの投稿は、自分は取得できるテスト

test.js
  it("Can read a private post belonging to the user", async() => {
    const admin = getAdminFirestore();
    const postId = "private_post";
    const setupDoc = admin.collection("posts").doc(postId);
    await setupDoc.set({authorId: myId, visibility: "private"});

    const db = getFirestore(myAuth);
    const readQuery = db.collection("posts").doc(postId);
    await firebase.assertSucceeds(readQuery.get());
  });
  ✓ Can read a private post belonging to the user
sakutarosakutaro

他人のプライベート投稿は、取得できないテスト

test.js
  it("Can't read a private post belonging to the another user", async() => {
    const admin = getAdminFirestore();
    const postId = "private_post";
    const setupDoc = admin.collection("posts").doc(postId);
    await setupDoc.set({authorId: theirId, visibility: "private"});

    const db = getFirestore(myAuth);
    const readQuery = db.collection("posts").doc(postId);
    await firebase.assertFails(readQuery.get());
  });
   ✓ Can't read a private post belonging to the another user
sakutarosakutaro

エミュレータの中に、テストデータが残るので終了後に自動で削除する

test.js
  after (async() => {
    await firebase.clearFirestoreData({projectId: MY_PROJECT_ID});
  });
sakutarosakutaro

テストをクリーンな状態で行うように、各テスト前にも自動で削除する

test.js
beforeEach (async() => {
  await firebase.clearFirestoreData({projectId: MY_PROJECT_ID});
});
sakutarosakutaro

最終的なテストコード

test.js
const assert = require('assert');
const firebase = require('@firebase/rules-unit-testing');

const MY_PROJECT_ID = "project-id";
const myId = "user_abc";
const theirId ="user_xyz";
const myAuth = { uid: myId, email: 'user_abc@gmail.com'};

function getFirestore(auth) {
  return firebase.initializeTestApp({projectId: MY_PROJECT_ID, auth: auth}).firestore();
}
function getAdminFirestore(auth) {
  return firebase.initializeAdminApp({projectId: MY_PROJECT_ID}).firestore();
}

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

describe("Our test app", ()=> {

  it("Understands basic addition", () => {
    assert.strictEqual(2+2, 4);
  });

  it("Can read items in the read-only collection", async() => {
    const db = getFirestore(null);
    const testDoc = db.collection("readonly").doc("testDoc");
    await firebase.assertSucceeds(testDoc.get());
  });

  it("Can't write to items in the read-only collection", async() => {
    const db = getFirestore(null);
    const testDoc = db.collection("readonly").doc("testDoc2");
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  });

  it("Can write to a user document with the same ID as our user", async() => {
    const db = getFirestore(myAuth);
    const testDoc = db.collection("users").doc(myId);
    await firebase.assertSucceeds(testDoc.set({foo: "bar"}));
  });

  it("Can't write to a user document with the different ID as our user", async() => {
    const db = getFirestore(myAuth);
    const testDoc = db.collection("users").doc(theirId);
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  });

  it("Can read posts marked public", async() => {
    const db = getFirestore(null);
    const testQuery = db.collection("posts").where("visibility", "==", "public");
    await firebase.assertSucceeds(testQuery.get());
  });

  it("Can query personal posts", async() => {
    const db = getFirestore(myAuth);
    const testQuery = db.collection("posts").where("authorId", "==", myId);
    await firebase.assertSucceeds(testQuery.get());
  });

  it("Can't query all posts", async() => {
    const db = getFirestore(myAuth);
    const testQuery = db.collection("posts");
    await firebase.assertFails(testQuery.get());
  });

  it("Can query a single public post", async() => {
    const admin = getAdminFirestore();
    const postId = "public_post";
    const setupDoc = admin.collection("posts").doc(postId);
    await setupDoc.set({authorId: theirId, visibility: "public"});

    const db = getFirestore();
    const readQuery = db.collection("posts").doc(postId);
    await firebase.assertSucceeds(readQuery.get());
  });

  it("Can read a private post belonging to the user", async() => {
    const admin = getAdminFirestore();
    const postId = "private_post";
    const setupDoc = admin.collection("posts").doc(postId);
    await setupDoc.set({authorId: myId, visibility: "private"});

    const db = getFirestore(myAuth);
    const readQuery = db.collection("posts").doc(postId);
    await firebase.assertSucceeds(readQuery.get());
  });

  it("Can't read a private post belonging to the another user", async() => {
    const admin = getAdminFirestore();
    const postId = "private_post";
    const setupDoc = admin.collection("posts").doc(postId);
    await setupDoc.set({authorId: theirId, visibility: "private"});

    const db = getFirestore(myAuth);
    const readQuery = db.collection("posts").doc(postId);
    await firebase.assertFails(readQuery.get());
  });

  after (async() => {
    await firebase.clearFirestoreData({projectId: MY_PROJECT_ID});
  });

})
sakutarosakutaro

最終的なルール

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // lock down the db
    match /{document=**} {
      allow read: if false;
      allow write: if false;
    }

    match /readonly/{docId} {
      allow read: if true;
      allow write: if false;
    }

    match /users/{userId} {
      allow write: if (request.auth.uid == userId);
    }

    match /posts/{postId} {
      allow read: if (resource.data.visibility =="public") ||
        (resource.data.authorId == request.auth.uid);
    }
}
このスクラップは2021/01/23にクローズされました