Lesson 4

[Sprint One] Creating Security Rules with TDD

Writing our first security rules tests

PRO

Lesson Outline

Creating Security Rules with TDD

We've done some basic configuration now to install Firebase, the emulators for developing with Firebase locally, and we even have a unit testing solution for our security rules set up. So far, we have just got the basic structure set up, in this lesson we are going to start working towards satisfying our first user story:

Feature: Log in to the application

User story: As a massage therapist, I want to be able to log in to the application using my Google account from any device, so that only I can access private information in the application and I can do it from any device


Project Management

  • Move the card for Issue #21 to the Test column in the Kanban board
  • Make sure you have the latest code in your main branch: git checkout main git pull
  • Create a new branch for this task and switch to it: git checkout -b jm-21 (always use your own initials instead of mine) a In the beginning, I will give you explicit instructions like this for the project management process. However, as we progress, I will just give simpler prompts so that you can try to implement the process yourself.

We are going to get started by working on our security rules to define how data in our database can be accessed in relation to this feature - e.g. only the authenticated admin user should be able to access data in the database. We will make sure to write tests for these security rules before creating the rules themselves.

Technically, we could start with our Cypress tests first which is where we would usually start: write a failing E2E test that captures the essence of the user story, and use that to develop further tests. If we took this approach we would eventually hit a point where the E2E test fails because the database is denying all read/write requests. At that point it would then prompt us to work on our security rules. If you're not sure where to start, then it is always a safe bet to start with the E2E tests and see where it takes you.

However, our security rules are something that we are just going to need to work on pretty much once in the beginning and then barely ever have to touch them again. So, I would prefer to just get them out of the way now.

Writing unit tests for Firestore Security Rules

The basic idea is that if the massage therapist (admin) logs in with their Google account they will be able to access/modify any of the information in the database, but nobody else can (technically later we will be adding a feature that allows clients to modify the client collection under specific circumstances, but we will address that when we get there).

Let's start by expanding out our tests to ensure that people can not access/modify the data by default, e.g. an unauthenticated user can not:

can not read from collections
can not write to collections
can not read from notes
can not write to notes

Modify firestore.rules.spec.ts to reflect the following:

import {
  assertFails,
  assertSucceeds,
  initializeTestEnvironment,
  RulesTestEnvironment,
  TokenOptions,
} from '@firebase/rules-unit-testing';

import { doc, getDoc, setDoc, deleteDoc } from 'firebase/firestore';

let testEnv: RulesTestEnvironment;

const getFirestore = (authUser?: { uid: string; token: TokenOptions }) =>
  authUser
    ? testEnv.authenticatedContext(authUser.uid, authUser.token).firestore()
    : testEnv.unauthenticatedContext().firestore();

describe('Firestore security rules', () => {
  beforeAll(async () => {
    testEnv = await initializeTestEnvironment({
      projectId: 'refresh-module',
    });
  });

  beforeEach(async () => {
    await testEnv.clearFirestore();

    await testEnv.withSecurityRulesDisabled(async (context) => {
      const existingClientDocRef = doc(
        context.firestore(),
        'clients',
        'existingDoc'
      );
      await setDoc(existingClientDocRef, { foo: 'bar' });
    });
  });

  it('can not read/write from the clients collection', async () => {
    const db = getFirestore();
    const newDoc = doc(db, 'clients', 'testDoc');
    const existingDoc = doc(db, 'clients', 'existingDoc');

    await Promise.all([
      assertFails(getDoc(existingDoc)), //read
      assertFails(setDoc(newDoc, { foo: 'bar' })), // create
      assertFails(setDoc(existingDoc, { foo: 'bar' })), // update
      assertFails(deleteDoc(existingDoc)), // delete
    ]);
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });
});

Aside from the new test which we will talk about in a moment, we have made a minor modification here in our beforeEach hook. We are utilising the withSecurityRulesDisabled method to set up some seed data in Firestore (and we do this right after clearing the data for each test, so that each test will have a nice clean slate to start from). This basically just allows us to ignore our rules to set up whatever data we want - in this case, I just want an existing document so that I can test accessing that document.

Since we will be using the more granular definitions of read/write, I have added an assertion for each of the 4 types. Now, this actually goes against a testing principle that I usually like to adhere to which is basically one assertion per test. Asserting multiple things can make your tests muddy and if something fails it isn't as clear exactly what failed. However, setting up a separate test for each of these 4 methods is a bit much (in my opinion), so I think it's a good tradeoff in this instance.

Let's do the same for the notes collection. We will modify the beforeEach:

  beforeEach(async () => {
    await testEnv.clearFirestore();

    await testEnv.withSecurityRulesDisabled(async (context) => {
      const db = context.firestore();

      const existingClientDocRef = doc(db, 'clients', 'existingDoc');
      const existingNoteDocRef = doc(db, 'notes', 'existingDoc');

      await Promise.all([
        setDoc(existingClientDocRef, { foo: 'bar' }),
        setDoc(existingNoteDocRef, { foo: 'bar' }),
      ]);
    });
  });
PRO

Thanks for checking out the preview of this lesson!

You do not have the appropriate membership to view the full lesson. If you would like full access to this module you can view membership options (or log in if you are already have an appropriate membership).