Unit Testing your Cloud Firestore Security Rules - Firebase

Introduction

I recently got on a project to build a mobile application using Cloud Firestore as the NoSQL database for the application.

Using Cloud Firestore allows you to secure access to the data using Rules. While I've worked with Rules in the past, it was easier to check that the rules were working by testing the application.

But this time, the friction to check if the rules were working was high - I would have to login as different users multiple times).

And so, I looked into if it was possible to unit test the Firestore rules to make sure that access to the collections was secure. And voila, it was possible to do that using the Firebase emulator.

So, in this article, I'll be going through how to setup a testing environment for the Firestore rules. Let's begin

Getting Started

To get started, we'd want to install the Firebase CLI on our local machine. Firebase has an awesome guide on that.

After installing the Firebase CLI on your machine, we'd want to copy the existing rules from the Firebase project to our local machine.

Copy
mkdir firebase_rules && cd firebase_rules

firebase init firestore

Then, we'll want to setup the testing environment by installing the packages we'll need for testing and also install the Firestore Emulators (Java must be installed on your machine for the emulator to work).

Copy
npm init -y

npm i @firebase/testing --dev
npm i jest --dev


firebase setup:emulators:firestore

To confirm that everything is configured appropriately, let's run the emulator,

Copy
firebase emulators:start --only firestore

emulators-setup-local-machine

Testing the Firestore Rules

So far, we have achieved two things:

  • Copying the firestore rules from our project to our local machine
  • Installing and Running the Firebase emulator suite.

Setup Mock Data

To run the tests, we'll need to initialize the Firestore database, and seed it with mock data. We'll create a setup method to do that.

Copy
const firebase = require("@firebase/testing");
const { assertFails, assertSucceeds } = require("@firebase/testing");
const fs = require("fs");

const PROJECT_ID = "project-id"; //Your Project ID
const FIRESTORE_RULES = fs.readFileSync("firestore.rules", "utf8");

const mockData = {
  "users/edwards": {
    name: "edwards",
  },
  "users/michael": {
    name: "michael",
  },
  "posts/unitTesting": {
    name: "Unit Testing Firestore Rules",
    content: "How to unit testing Firestore Rules",
    written_by: "edwards",
  },
};

const setup = async (auth) => {
  const app = await firebase.initializeTestApp({
    projectId: PROJECT_ID,
    auth,
  });

  const db = app.firestore();

  // Initialize admin app
  const adminApp = firebase.initializeAdminApp({
    projectId: PROJECT_ID,
  });

  const adminDB = adminApp.firestore();

  // Write mock documents before rules using adminApp
  if (mockData) {
    for (const key in mockData) {
      const ref = adminDB.doc(key);
      await ref.set(mockData[key]);
    }
  }

  // Apply rules
  await firebase.loadFirestoreRules({
    projectId: PROJECT_ID,
    rules: FIRESTORE_RULES,
  });

  return db;
};

expect.extend({
  async toAllow(x) {
    let pass = false;
    try {
      await firebase.assertSucceeds(x);
      pass = true;
    } catch (err) {}

    return {
      pass,
      message: () => "Expected Firebase operation to be allowed, but it was denied",
    };
  },
});

expect.extend({
  async toDeny(x) {
    let pass = false;
    try {
      await firebase.assertFails(x);
      pass = true;
    } catch (err) {}
    return {
      pass,
      message: () => "Expected Firebase operation to be denied, but it was allowed",
    };
  },
});

What is happening above?

First, we have the firestore rules read into the FIRESTORE_RULES variable

Then, we initialize the Firebase project and the Admin SDK. We're using the Admin SDK to bypass the rules so we can create the mock documents.

We apply the rules using loadFirestoreRules

We also make sure that before every test suite runs, we're clearing the Firestore data to clean-up.

And, lastly, we implement custom Jest matchers to improve the readability of our test suites. Thanks to Fireship for this

Testing Firestore Rules

Now that we have all the configurations we need setup, we'll want to move on to actually testing the Firestore rules.

Writing the tests

We want to write all of our tests under:

Copy
describe("Database Rules", () => {
  afterAll(async () => {
    Promise.all(firebase.apps().map((app) => app.delete())); //teardown the testing environment
  });

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

  test("should allow a user to update their document", async () => {});
});

Let's get started on the test cases.

1. Users should only be able to update their own document and not others

The related rule:

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

function isAuthenticated() {
      return request.auth != null;
}

And testing the above rule:

Copy
test("should allow a user to update their document", async () => {
  const db = await setup({ uid: "edwards" }); //using the setup method above
  userRef = db.doc("users/edwards");
  await expect(userRef.update({})).toAllow();
});

test("should deny a user from updating another user document", async () => {
  const db = await setup({ uid: "michael" }); //using the setup method above
  userRef = db.doc("users/edwards");
  await expect(userRef.update({})).toDeny();
});

Each test in the test suite initializes a fresh database instance with a different authenticated user, and uses the same mock data.

In the above, edwards would be able to update his document, but michael would fail to update edwards document.

2. Users can read all posts but can't update posts not created by them

The related rule:

Copy
match /posts/{postId} {
      allow read: if isAuthenticated();
      allow write: if isAuthenticated() && request.auth.uid == request.resource.data.written_by
    }

 function isAuthenticated() {
      return request.auth != null;
}

And testing the above test case:

Copy
test("should allow a user to read posts", async () => {
  const db = await setup({ uid: "michael" });
  postRef = db.doc("posts/unitTesting");
  await expect(postRef.get()).toAllow();
});

test("should deny a user from updating posts written by another user", async () => {
  const db = await setup({ uid: "michael" });
  postRef = db.doc("posts/unitTesting");
  await expect(postRef.update({})).toDeny();
});

Notice how every test above uses the Mock data we have in the setup method. In the above, michael can read a post created by edwards, but can't update it.

Running the Tests

To run the tests,

Update the test scripts in your package.json to:

Copy
 "scripts": {
    "test": "jest"
  },

Then run:

Copy
firebase emulators:exec --only firestore "npm run test"

Additional Tests

Debugging in Firestore Rules

While working with the Firestore rules, especially when working with complex Firestore rules, it would be helpful to have insight into what each rule statement returns, and Firestore gives us the debug function.

The debug function only works in the local Firebase Emulator suite, and plays pretty nicely with the testing environment.

If we wanted to identify what the request.auth.uid was returning in the post rules, here's how we do it.

Copy
match /posts/{postId} {
      allow read: if isAuthenticated();
      allow write: if isAuthenticated() && debug(request.auth.uid) == request.resource.data.written_by
}

And when we run our test suite, in the firestore-debug.log file, we should have the following output.

Copy
---
string_value: "michael" // for debug(request.auth.uid)

---

Here's Firestore comprehensive documentation on the debug function.

Testing Access with Custom Claims

Another useful feature that the Firestore Testing suite provides is the ability to test access to documents using custom user claims.

Let's assume that in our database structure, we wanted admins to be able to update Posts document for every user — Admins in this scenario would be users with admin set to true in their token claim.

How would we do that?

The related rule:

Copy
match /posts/{postId} {
      allow read: if isAuthenticated();
      allow write: if isAuthenticated() && request.auth.uid == request.resource.data.written_by
      allow write: if isAuthenticated() && isAdmin()
    }

 function isAdmin() {
      return request.auth.token.admin == true;
}

And testing the above rule:

Copy
test("should allow users with the admin ROLE to update posts written by other users", async () => {
  const db = await setup({ uid: "admin", admin: true });

  postRef = db.doc("posts/unitTesting");
  await expect(postRef.update({})).toAllow();
});

And what we're doing above is initializing the Firebase instance with an authenticated user, but with a custom user claim named admin

And, all tests passed

all-firestore-tests-passed

The End

Using the Firestore emulator for testing the security logic of the Firestore rules boosts the confidence we have in the rules we have created. We can be sure that there are no data breaches, and it's a delight to work with.

Here's the source code available on GitHub — https://github.com/edwardsmoses/testing-firestore-rules

Comments

Edwards Moses - Web & Mobile — React & React Native Consultant

Edwards Moses
Web & Mobile — React & React Native Consultant

I'm Edwards, based in Lagos, Nigeria.
Freelancer Software Developer — collaborating with teams to craft extraordinary products.

From conception through to completion, I find immense joy in witnessing the evolution of an idea into a fully realized product in the hands of users. Check out my projects and articles to see what I've been up to lately.

Ready to bring your ideas to life?