Using TDD to Test and Deploy Firestore Security Rules Locally

22 min read

Originally published August 20, 2021

When we use a Firestore database in our projects, we gladly and freely reveal our projects API key to the world in our client-side code. This is fine, because we should have a set of Firestore Security Rules defined that control what operations can be performed against the database based on who is making the request, and what they are trying to create, read, update, or delete.

Since Firebase adds an auth object to each request to the database, we can easily identify data related to the user making the request (e.g. their uid, email, or role) and use that data in our security rules to define what can and can not be done. We know that this data can't be spoofed/tampered with as it is verified by Firebase's authentication server. As well as the auth object, we will also have access to resource objects that will tell us what resource the user is requesting/modifying and what they want to update it with.

This article is not about the basics of security rules. If you're interested in learning about how to create security rules in general, or if you need a bit of a refresher, I published a video on the basic concepts a while ago (it's a couple of years old now so it's using the previous version of security rules, but all of the information is still accurate):

That isn't what this article is about. I wanted to get the above out of the way to highlight that...

Firestore rules are like... super duper important

Our security rules are the only thing that prevent things like:

  • A user deleting all of our data
  • A hacker finding a way to steal all of your users data
  • People being able to see posts they aren't supposed to
  • People being able to update other users data
  • ...and a whole lot more

Your client side code might not provide explicit functionality to allow these things, but since anyone can find your API key, anyone can do anything that they are authorised to do as determined by the security rules. What your application allows is irrelevant.

Despite how important these rules are, it is not uncommon to just edit/update the rules directly through the Firebase console:

Cursor hovering over publish button for firestore security rules

Let's just hope we didn't make a typo, right?

If we are building serious applications that we want to maintain and build long term, it is generally a good idea to incorporate an automated testing strategy like the one we implement in the Test Driven Development with Cypress/Jest module.

But... how do we incorporate our Firestore Security Rules into our automated test strategy? Well, my friend, that is what we are about to find out! In this tutorial, we are going to walk through using Jest to unit test our Firestore security rules.

We are going to use a TDD (Test Driven Development) approach to build our tests in this article. That means that we will write our tests before writing the security rules. However, you will also be able to follow the advice in this tutorial if you want to write tests after having already written your security rules.

Outline

Source code

1. Setting up a Firebase Project

We are going to set up an example project to work with, but you can use an existing project if you like. If you are unfamiliar with what we are discussing in this tutorial, I would recommend not proceeding with your production project. Create a test project so that you can mess around and learn, without potentially negatively impacting your actual application.

Go to console.firebase.google.com and create a new project

Go to Firestore Database > Create database > Start in production mode

Go to Authentication > Get started > Google > Enable > Save

This will give us a project with Firestore and Authentication enabled.

Since we have used production mode for the database, the security rules will deny all reads and writes by default. We always want to start from this point. It is best to follow the principle of least privilege - we block everything by default, and only allow the minimum set of operations required for our application to function properly.

2. Install the Dependencies

In order to test and develop our rules locally there are a few main ingredients:

  1. Using the Firebase Local Emulator Suite to emulate a Firestore database locally
  2. Storing our security rules in .rules files locally, which will be published to our real project when ready
  3. Installing the @firebase/rules-unit-testing package to help create our tests

Let's get everything set up. If you haven't already, you will need to install firebase-tools globally. Even if you already have it, you might need to update it:

npm install -g firebase-tools

Inside of your project you will need to install the following package:

npm install @firebase/rules-unit-testing

Now we can initialise Firebase inside of our project, which will create a .rules file for us along with other configurations required to deploy to the Firebase project we created.

Inside of your project run the following commands:

firebase login
firebase init

Select both Firestore and Emulators using <space> and then hit <enter>

Choose Use an existing project

Choose the project you just created

Keep all the default files by just hitting <enter>

Enable the Authentication Emulator and Firestore Emulator then hit <enter>

Keep the default ports for both emulators

Select Y to enable the Emulator UI

Use default (any available) port for Emulator UI

Select y to download emulators now

We should now have the emulators downloaded and four new files in our project:

  • .firebaserc
  • firebase.json
  • firestore.indexes.json
  • firestore.rules

To publish firestore.rules so that they are active on your production database you can run:

Do NOT do this now - only when you are ready to publish your rules to Firebase

firebase deploy

and if you specifically want to just deploy the rules and nothing else you can use:

firebase deploy --only firestore:rules

However, we will still be able to test our rules with the local Emulator without needing to push them live all the time.

3. Setting up Jest

I will assume that you already have Jest set up in your project, you can follow this guide if you are using Angular:

If you are not using Angular, you will be able to find instructions on the Jest website. You also don't have to use Jest for this. Any unit testing framework you prefer will be fine (you just might need to modify the tests we create).

When testing your security rules, it is best to use the node environment rather than jsdom (which is the default). You can either set your testEnvironment in your jest.config.js file to node (this might cause problems for you) or just put the following at the top of any of your Firestore security rules test files:

/**
 * @jest-environment node
 */

If you do not do this, you will get errors like the following any time you try to await one of the Firestore operators like get():

@firebase/firestore: Firestore (8.8.1): FIRESTORE (8.8.1) INTERNAL ASSERTION FAILED: Unexpected state

Additional Setup for Angular Developers

If you are using Angular and have used something like the @briebug Jest schematic to set up Jest in your application, then you will have a setup-jest.ts file that tries to set up some mocks on the window object:

Object.defineProperty(window, 'localStorage', { value: mock() });
Object.defineProperty(window, 'sessionStorage', { value: mock() });
Object.defineProperty(window, 'getComputedStyle', {
  value: () => ['-webkit-appearance'],
});

The problem with this is that if you set your test environment to node for your Firestore security rules, this file is still going to be called due to the Jest configuration:

module.exports = {
  moduleNameMapper: {
    "@core/(.*)": "<rootDir>/src/app/core/$1",
  },
  preset: "jest-preset-angular",
  setupFilesAfterEnv: ["<rootDir>/setup-jest.ts"],
};

So we are in a situation where we are using a node environment trying to access a window object that does not exist in that environment, and the CLI will tell you that you are using the wrong environment and to use jsdom instead (which we can't do).

To solve this situation, we need a different Jest configuration for our Firestore security rules tests (but we want to keep the existing configuration for the rest of the tests). I'm going to show you a solution specifically where jest-preset-angular is used, but if you have a bit of a different project configuration you might need to make a few changes.

Create a file at the root of your project called firebase.jest.config.js and add the following:

module.exports = {
  preset: "jest-preset-angular",
  testPathIgnorePatterns: ["<rootDir>/cypress/", "<rootDir>/src/"],
  testEnvironment: "node",
};

Notice that we have set the testEnvironment to node - this way, we won't need to set the test environment at the top of every one of your security rules test files. We also want to ignore the directory where all of the rest of the unit tests are, which for an Angular application will be inside of the src folder. Since I am using Cypress as well for end-to-end testing, I am also excluding that.

Modify your original jest.config.js file to exclude the folder that will hold your Firestore tests:

module.exports = {
  moduleNameMapper: {
    "@core/(.*)": "<rootDir>/src/app/core/$1",
  },
  preset: "jest-preset-angular",
  setupFilesAfterEnv: ["<rootDir>/setup-jest.ts"],
  testPathIgnorePatterns: ["<rootDir>/cypress/", "<rootDir>/firestore-test/"],
};

We want to keep everything the same in this file (yours might look a bit different to mine). The important part is that we ignore the folder we will be using to store our Firestore security rules test files (e.g. firestore-test but you can call it something different if you want).

The final step is to supply the different Jest configurations depending on which tests we want to run.

Add an extra test command to your scripts in package.json:

    "test": "ng test",
    "test:rules": "jest test --config=firebase.jest.config.js --detectOpenHandles --forceExit",

We have just kept the default test command, but we also add a command specifically for the security rules. We supply Jest with our custom configuration file using the --config option. There is also another issue with @firebase/rules-unit-testing that results in unfinished async operations stopping the tests from exiting cleanly:

Jest did not exit one second after the test run has completed.

To solve that, we also add the --detectOpenHandles and --forceExit options. Now when we want to run our normal application tests we can run:

npm test

and when we want to run our security rules tests we can run:

npm run test:rules

and everything will work!

4. Create a Basic Firestore Security Rule Unit Test

Now let's take a look at the basic test structure. The focus of this tutorial is going to be specifically on the general process of using the emulators and @firebase/rules-unit-testing for unit testing. If you need more information on how Jest or unit testing works in general, I would recommend starting here: Testing Concepts.

Create a file/folder at the root of your project called firestore-test/firestore.rules.spec.ts and add the following:

import {
  apps,
  initializeTestApp,
  initializeAdminApp,
  assertFails,
  assertSucceeds,
  clearFirestoreData,
} from '@firebase/rules-unit-testing';

const PROJECT_ID = 'YOUR-PROJECT-ID';

const getFirestore = () =>
  initializeTestApp({ projectId: PROJECT_ID }).firestore();

const getAdminFirestore = () =>
  initializeAdminApp({ projectId: PROJECT_ID }).firestore();

describe('Firestore security rules', () => {
  beforeEach(async () => {
    await clearFirestoreData({ projectId: PROJECT_ID });
  });

  it('can not read from the messages collection', async () => {
    const db = getFirestore();

    const testDoc = db.collection('messages').doc('testDoc');
    await assertFails(testDoc.get());
  });

  afterAll(async () => {
    const cleanUpApps = apps().map((app) => app.delete());
    await Promise.all(cleanUpApps);
  });
});

IMPORTANT: If you do not have a specific Jest configuration set up for the security rules that sets the testEnvironment to node (as we discussed above) you will need to add the following to the top of your tests:

/**
 * @jest-environment node
 */

NOTE: For more complex rules and a larger test suite, you might want to break out your tests into multiple .rules.spec.ts files.

This is the basic structure for our tests. We set up two Firestore app instances:

const getFirestore = () =>
  initializeTestApp({ projectId: PROJECT_ID }).firestore();

const getAdminFirestore = () =>
  initializeAdminApp({ projectId: PROJECT_ID }).firestore();

The TestApp is what we will use to test our rules, as this will enforce our security rules against the requests being made. The AdminApp is what we can use to get admin access to the database (as if we were editing things directly through the Firebase console). This is great to be able to do things like set up some test data and remove it after - always making sure that we are working with a consistent set of data and not "cross contaminating" our tests.

Before each of our tests we clear the data in the database:

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

and after all of our tests have run we make sure to delete the app instances:

  afterAll(async () => {
    const cleanUpApps = apps().map((app) => app.delete());
    await Promise.all(cleanUpApps);
  });

Then we just have the actual tests:

  it('can not read from the messages collection', async () => {
    const db = getFirestore();

    const testDoc = db.collection('messages').doc('testDoc');
    await assertFails(testDoc.get());
  });

We get a reference to our TestApp from getFirestore() which we assign to db. We can then perform whatever operation we want to test. Firebase provides us with some methods that we can use to check the result of the operation like assertFails and assertSucceeds. If we expect a particular operation to succeed or fail, we can check that is actually what happens with these methods.

In this case, we are testing if a user can perform a get() on a document in the messages collection. We don't want the user to be able to get a document from the messages collection so we use assertFails.

5. Start the Emulator and Run the Test

In order to run the unit tests we create, we will need to run an instance of the Firestore emulator locally before we run the tests. To do this, you should first open up another terminal window and change the directory to your project (we will need one terminal to run the tests, and one to run the emulator).

Run the following command to start the emulator:

firebase emulators:start

Run your unit tests:

npm test

NOTE: You will need to set the PROJECT_ID from the Firebase console for the tests to work properly.

If we run our unit tests now we should see that our Firestore security rules test passes:

PASS  ./firestore.rules.spec.ts (7.555 s)

6. Using TDD to Create Security Rules

We have a working test now, but since we were testing that a user can not read from a specific collection it passes by default because our security rules already block everything. Technically, this is a no-no for Test Driven Development as we are supposed to see a failing test first, then implement the security rule to satisfy the test, and then the test should pass.

Let's take a look at a more interesting example that follows the TDD process (again, you don't have to use TDD if you don't want to).

What if we don't want the user to be able to access all messages... but, we do want them to be able to access a message document if the uid on the message document matches the uid of the authenticated user.

We will follow a strict TDD process to implement this rule.

Write a test

Our test is going to get a little bit more complex now as we are dealing with auth. To mock an authenticated user, we can just pass that user in when calling initializeTestApp:

const userAuth = { uid: 'user123', email: '[email protected]' };

const getFirestore = (authUser) =>
  initializeTestApp({ projectId: PROJECT_ID, auth: authUser }).firestore();

We have created a userAuth object that we can use by default in our tests, but now we can pass whatever kind of user data we want to getFirestore. Since we have updated the function signature, we will need to update our existing test:

  it('can not read from the messages collection', async () => {
    const db = getFirestore(userAuth);

    const testDoc = db.collection('messages').doc('testDoc');
    await assertFails(testDoc.get());
  });

Let's check that the existing test still works:

PASS  ./firestore.rules.spec.ts (6.219 s)

Great! Now let's add our new test:

  it('can read own messages from the messages collection', async () => {
    const db = getFirestore(userAuth);
    const admin = getAdminFirestore();

    const doc = admin.collection('messages').doc('abc123');
    await doc.set({ uid: userAuth.uid });

    const testDoc = db.collection('messages').doc('abc123');

    await assertSucceeds(testDoc.get());
  });

In this test we are setting up an actual test document, so we need to make use of our AdminApp. We use that admin access to create a doc with an id of abc123 and then we set a uid field on that equal to whatever the example authenticated user's uid is. We then test if we can get() that document when authenticated as that user.

Watch it fail

If we run the test now, we should see this result:

 FAIL  firestore-test/firestore.rules.spec.ts
  Firestore security rules
    ✓ can not read from the messages collection (338 ms)
    ✕ can read own messages from the messages collection (391 ms)

  ● Firestore security rules › can read own messages from the messages collection

    FirebaseError:
    false for 'get' @ L5

We can see that it is failing, and we can also get some hints as to why it's failing. Specifically, it is saying that the security rules are denying the request @ L5 or at line 5 of our security rules. If we look at our current security rules in firestore.rules we can inspect what line 5 is:

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

This is the line that blocks any attempts to read or write.

Implement the security rule

Now that we have a failing test, and have a general idea of why it is failing, we can modify our security rules to satisfy the test.

Modify firestore.rules to reflect the following:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /{document=**} {
      allow read, write: if false;
    }

    // Security rules for messages
    match /messages/{message} {

      function userIsAuthor(){
        return (request.auth.uid == resource.data.uid)
      }

      allow read: if userIsAuthor();

    }

  }
}

Our first security rule blocks access to everything (it is always a good idea to have this), but we can add additional rules and if any of them pass it will allow the request to succeed. We have added an additional rule that checks if the authenticated users uid matches the uid in the document they are requesting. If it does match, then we allow read access We have separated this logic out into a function for readability/reusability but that isn't strictly required.

Watch the test pass

If we run our tests again with:

npm run test:rules

We should see that the test now passes:

 PASS  firestore-test/firestore.rules.spec.ts
  Firestore security rules
    ✓ can not read from the messages collection (324 ms)
    ✓ can read own messages from the messages collection (378 ms)

Add more tests

I like using the TDD approach as I think it helps create sensible tests for each bit of functionality you are adding to the application. However, you might still want to add additional tests as well. Using the TDD approach, we have implemented a security rule that will allow a user to read a document that they created.

However, let's say it is also an important requirement that a user can not update that document. They should only be allowed to read it. There shouldn't be any changes required for this, we already expect that our rules would block this attempt. But, humans aren't perfect, and we might make a mistake in our rules so it would be good to also explicitly tests for this:

  it('can not update own messages', async () => {
    const db = getFirestore(userAuth);
    const admin = getAdminFirestore();

    const doc = admin.collection('messages').doc('abc123');
    await doc.set({ uid: userAuth.uid });

    const testDoc = db.collection('messages').doc('abc123');

    await assertFails(testDoc.set({ foo: 'bar' }));
  });

If we run that test we will see that it passes:

 PASS  firestore-test/firestore.rules.spec.ts
  Firestore security rules
    ✓ can not read from the messages collection (340 ms)
    ✓ can read own messages from the messages collection (394 ms)
    ✓ can not update own messages (132 ms)

This doesn't strictly follow a TDD process, but using TDD doesn't also mean you can't add additional tests. Having this test will help add confidence that our rules are correct. Let's say that someone made a bit of a mistake and changed:

allow read: if userIsAuthor();

to

allow read, write: if userIsAuthor();

If we were to run the tests now we would get:

 FAIL  firestore-test/firestore.rules.spec.ts
  Firestore security rules
    ✓ can not read from the messages collection (325 ms)
    ✓ can read own messages from the messages collection (389 ms)
    ✕ can not update own messages (109 ms)

  ● Firestore security rules › can not update own messages

    Expected request to fail, but it succeeded.

Notice that it is just that one new test we added that fails. If we didn't add that test, we might not have caught that mistake before the rules got deployed to production.

Deploy your rules

Although you don't have to all the time during development, make sure that you do actually deploy your Firestore security rules to your production database when you are ready! They won't do you much good protecting your local emulator:

firebase deploy

or

firebase deploy --only firestore:rules
i  deploying firestore
i  firestore: reading indexes from firestore.indexes.json...
i  cloud.firestore: checking firestore.rules for compilation errors...
✔  cloud.firestore: rules file firestore.rules compiled successfully
✔  firestore: deployed indexes in firestore.indexes.json successfully
i  firestore: uploading rules firestore.rules...
✔  firestore: released rules firestore.rules to cloud.firestore

✔  Deploy complete!

Summary

Just because your security rules are tested and passing it doesn't mean that you can be 100% certain they are actually correct... but you can certainly be much more confident that they are than if you did not have a suite a tests verifying your security rules.

This also means that updating your rules in the future will be less intimidating. If you need to change existing rules or add new ones, you know that you have a suite of tests verifying that your rules still accomplish their original goals (the ones you have explicitly created tests for at least).

If you want to learn more about building real-world Firestore applications with Ionic and Angular, keep an eye out for a new pro module that I'll be releasing soon.

If you're not already a pro member, you can subscribe to the newsletter to be notified when it's released!