Unit test your Firestore security rules

When developing a Firebase app with a Firestore database layer a lot of tutorials suggest allowing all traffic while developing and then setting the rules. I always do the other way around, I keep the default deny all rule and then open up for each use case I have.

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

When writing security rules it’s very important to think about what malicious users might try to get around them. How they would try to access or manipulate other user data. When writing unit tests for the security rules we need to test that users can access data as intended but also focus on what should fail. All the different scenarios that we can think of that shouldn’t be possible to be sure that the user can’t access or create any data that they shouldn’t.

Setup test environment

In the root of my Firebase project folder, I create a test folder where all my tests can live. This will keep them out of any build chains for unctions and frontend. Under test, I then create a firestore folder and run npm init to initialize a separate package control for this specific test. This will help when running automated tests in your CI/CD pipeline. The packages.json looks something like this when init is completed:

{
  "name": "firestore-tests",
  "version": "1.0.0",
  "description": "Unit testing for firestore rules",
  "main": "index.js",
  "scripts": {
    "test": "mocha index.js --exit"
  },
  "author": "",
  "license": "ISC"
}

Then we need to install the testing packages as dev dependencies with:
npm install @firebase/testing mocha –save-dev

Writing a basic test

There are a few ways of writing tests, you can chain them together and re-use the data from the previous test. Personally, I prefer to go with clean data for each test to make sure that all tests run in a controlled environment.

First, import the Firebase testing library and then set a const with a mocked project id.

const firebase = require('@firebase/testing');
const PROJECT_ID = "firestore-emulator-test";

Since the number of tests will grow over time a function for getting the Firestore with a specific auth will clean up your code.

// Get firestore as user
function getFirestore(auth) {
    return firebase.initializeTestApp({projectId: PROJECT_ID, auth: auth}).firestore();
}

Then we add a mocha specific function called beforeEach, this function will run before each individual test, and have that clear the database.

// Clear database between each iteration
beforeEach("Clearing database", async function() {
    this.timeout(10000);
    await firebase.clearFirestoreData({projectId: PROJECT_ID});
});

Then we write the actual tests, you can nest the describe functions as deep as you want to get a more human-readable output grouping your test results together. The timeout is needed since there can be some latency from the firestore emulator at times.

describe("Firestore access rules", function() {
    this.timeout(10000);

    it("Anon user - can't create a new account", async() => {
        const db = getFirestore(null);
        const docRef = db.collection("accounts").doc("new-account");
        await firebase.assertFails(docRef.set({ test: "just a test field" }));
    });
});

So here we have our basic test, an anonymous call trying to create a document in the collection accounts. Calling the getFirestore function returns an instance of the firestore, since we called it with null as a parameter the call will be made to the firestore as an anonymous user. Then we create the document reference by setting the collection to accounts and document to new-account. Since we don’t want an annon user to be able to do this we use assertFails which means that if the command fails the test passes.

Then we want to cleanup after us, so we create a after function.

// Clear the database when done
after(async() => {
    await firebase.clearFirestoreData({projectId: PROJECT_ID});
});

Let’s run this first test and see the outcome. More information on running tests is at the end of the post.

Firestore access rules
✔ Anon user – can’t create a new account (225ms)
1 passing (284ms)

Adding more tests

After the first test let’s try adding a test that should pass or assertSucceds. Let’s write an actual user creating a document in the accounts collection. We place this after the last it within the describe.

it("Authenticated user - can create a new account", async() => {
    const db = getFirestore({ uid: "user-id" }); 
    const docRef = db.collection("accounts").doc("new-account");
    await firebase.assertSucceeds(docRef.set({ test: "just a test field" }));
});

This test will pass, which it shouldn’t. This means that a user can create an account document without any restraints, for example they can create accounts with another user as owner but give them self access. If we look at the actual security rule for reading account documents.

match /accounts/{accountID} {
      // Check if user have access to the account
      function userInAccount(accountDoc) {
        return accountDoc.data.access[request.auth.uid] in ['owner', 'admin'];
      }
      allow read, update: if userInAccount(resource);
      allow create: if request.auth != null;
}

On each account document in the accounts collection we have an access field. This is a map of fields where the auth.uid is the name and the value is a string, either owner or admin in this example. With the current rule the user can write a document that can contain anything in the access field or not even contain it at all. if this isn’t written correctly the read rule will prevent the user from reading the document again.

Firestore access rules
✔ Anon user – can’t create a new account (225ms)
✔ Authenticated user – can create a new account (133ms)

2 passing (478ms)

Changing the rules

We update the firestore.rules file with a new function called checkFields. That will verify that the request data contains both name and access fields.

// Check for required fields when creating
function checkFields() {
    return request.resource.data.keys().hasAll(['name', 'access']);
}
...
allow create: if request.auth != null && checkFields();

When we run the test we now get a different result. Since we are not passing the access field when creating the document the firestore rule prevents us from writing the document.

Firestore access rules
✔ Anon user – can’t create a new account (207ms)
1) Authenticated user – can create a new account

1 passing (348ms)
1 failing

So let’s update the test with an access field and also a name field, since we added that additional constraint in the checkFields function. Then we run the test again.

it("Authenticated user - can create a new account", async() => {
     const db = getFirestore({ uid: "user-id" }); // Get firestore instance with auth
     const docRef = db.collection("accounts").doc("new-account");
     await firebase.assertSucceeds(docRef.set({ name: "test account", access: { "another-user-id": "owner" }}));
 });

Firestore access rules
✔ Anon user – can’t create a new account (226ms)
✔ Authenticated user – can create a new account (117ms)

2 passing (454ms)

So this passed, even though we created a document in the accounts collection with another-user-id as owner when we are user-id. This means that the user user-id will not be able to access the account document they created. Adding another account for another user or creating an orphan in the firestore. So by adding unit tests to our security rules we found a flaw in our code. So let’s correct our mistake and run the test again.

function checkFields() {
    return request.resource.data.keys().hasAll(['name', 'access']) && 
                 request.resource.data.access[request.auth.uid] === 'owner';
}

Firestore access rules
✔ Anon user – can’t create a new account (212ms)
1) Authenticated user – can create a new account

1 passing (367ms)
1 failing

So let’s copy our test and change the name to Authenticated user – can’t create a new account without ownership. Then change assertSucceeds to assertFails and we have a test confirming that users can’t create new accounts they don’t claim ownership over.

Firestore access rules
✔ Anon user – can’t create a new account (259ms)
1) Authenticated user – can create a new account
✔ Authenticated user – can’t create a new account without ownership (76ms)

2 passing (536ms)
1 failing

Then we change our original test to pass access: {“user-id”: “owner”} instead and it should also pass.

Firestore access rules
✔ Anon user – can’t create a new account (222ms)
✔ Authenticated user – can create a new account (121ms)
✔ Authenticated user – can’t create a new account without ownership (89ms)

3 passing (527ms)

Running the tests

When running the tests you need to match the project id parameter with the one used in the firestore emulator. During development I usually run the emulators with persistent data, I export on exit and import it again when the emulators start. This helps a lot with the development and testing of new features in the frontend since I don’t have to spend time creating test data all the time. When running the tests however I usually run the firestore only like this:

firebase emulators:start –only firestore

Then you can run your tests with:

mocha index.js –exit

Or just use the npm test that was created in the package.json. You can do all this in a oneliner that also will exit the firestore emulator when done:

firebase emulators:exec –only firestore “mocha index.js –exit”

You can update this in the packages.json but you have to escape the quatation marks like this:

"scripts": {
    "test": "firebase emulators:exec --only firestore \"mocha index.js --exit\""
},

Conclusion and next steps

As you see from this example unit testing of security rules is a must. You will find scenarios you haven’t thought of and if you don’t someone else will. For the example in this post the next logical step is to write a test trying to update an account document removing the access field.

Firestore unit tests are a fast and simple way to test different scenarios without writing too much code. Once you have this in place, write the tests before the rules for new features. See the test fail and then write your new security rule and see the test succed.

When your application goes into production it’s a good thing to implement this test step into your deployment pipeline to make sure that you can’t push any changes to production that aren’t properly tested and secure.

I also recommend this article and video: https://firebase.google.com/docs/firestore/security/test-rules-emulator

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: