I

Challenges and problems I faced testing Supabase RLS with Vitest

Jan 12023 HE

Testing with persistent user session

I wanted to write some unit tests that check that my RLS rules are working correctly.

To do so, I would have to write some tests for anonymous users and authenticated users.

describe.todo("anonymous user", () => {
  test.todo("can not see anything", () => {
    // …
  })
})

describe.todo("authentificated user", () => {
  test.todo("can only see own stuff", () => {
    // …
  })
})

For the logged in ones, I need to actually log in a test user before all tests in this group to operate as such a user.

export async function signInUser() {
  const {
    error,
    data: { session },
  } = await supabase.auth.signInWithPassword({
    email: process.env.TEST_USER_EMAIL as string,
    password: process.env.TEST_USER_PASSWORD as string,
  })
  expect(error).toBeNull()
  expect(session).not.toBeNull()
  expect(await supabase.auth.getSession()).not.toBeNull()
}

However, when signing the user in this always failed. The session I got from remote did not persist and therefore any further calls with the - actually not - signed-in user failed, because the client was not signed in anymore.

I figured out that supabase persists its auth stuff in the browsers local storage. And since in vitest we are running on Node rather than in the browser context, this kind of won’t work.

Luckily, we can change the test environment with vitest thanks to jsdom.

Inside vitest.config.ts:

import { defineConfig } from "vitest/config"

export default defineConfig({
  test: {
    environment: "jsdom",
  },
})

On the next test run, this will prompt to install the jsdom package.

Running the tests this way allows supabase to persist its session, and I was able to keep the test user signed in over multiple test runs.


Service role client along with normal user client

To be able to create fixtures in the database before all tests and clean them up after all tests, I need to have a service role client that can overcome RLS.

Because my normal users should not be able to do anything else than just reading/selecting data.

So I went ahead and created the clients, one normal client with the public anonymous key to test with and one service role client with the service role secret to manipulate the database.

I run into very strange behaviors which I was later able to point back to the different clients.

I had a test structure looking something like:

describe("users", () => {
  beforeAll(async () => {
    await createFixtures()
  })

  afterAll(async () => {
    await deleteFixtures()
  })

  describe("anonymous user", () => {
    describe.each(TABLES)("$name", (TABLE) => {
      // …
    })
  })

  describe("authenticated user", () => {
    beforeAll(async () => {
      await signInUser()
    })

    describe("deactivated user", () => {
      beforeAll(async () => {
        await deactivateUser()
      })

      describe.each(TABLES)("$name", (TABLE) => {
        // …
      })
    })

    describe("activated user", () => {
      beforeAll(async () => {
        await activateUser()
      })

      describe.each(TABLES)("$name", (TABLE) => {
        // …
      })
    })
  })
})

And I run into the following problems:

I was able to step by step nail the problems down to the point I noticed that my service role client works when I don’t sign my test user in.

So when I activate/deactivate/delete with my service client before signing the test user in with the normal client, things work as expected.

However, doing thing the opposite order and sing in and then use the service role client and noting worked.

So I thought, well, let’s have a look into the current sessions of both clients, because it feels like my service client kind of lost its privileges after the normal client signed in.

console.log(await supabase.auth.getSession())

console.log(await supabaseServiceRole.auth.getSession())

And it turned out both clients actually share the same session. Whaat? Why did my service role client picked up the normal client’s session, it was constructed using supabase’s createClient() with the service role secret and not the anonymous key, so it should not do this, right?

Well, I then was able to reproduce the issue:

async function logCurrentSession(sbClient) {
  const { data, error } = await sbClient.auth.getSession()
  console.log(data, error)
}

test.only("sb-clients", async () => {
  await logCurrentSession(supabase) // null as expected
  await signInUser()
  await logCurrentSession(supabase) // user session as expected
  await logCurrentSession(supabaseServiceRole) // also user session - not expected at all
})

And this made it clear that the service client was not caring about its constructed service role at all, and was instead picking up any auth event and reading from the same storage the normal client does.

This led to the problem that I would have to do all my service role actions with the test user logged out.

So instead of doing:

describe("users", () => {
  describe("authenticated user", () => {
    beforeAll(async () => {
      await signInUser() // kills service role client session
    })

    describe("deactivated user", () => {
      beforeAll(async () => {
        await deactivateUser() // make update call with service role client
      })
    })

    describe("activated user", () => {
      beforeAll(async () => {
        await activateUser() // make update call with service role client
      })
    })
  })
})

I would have to:

describe("users", () => {
  describe("authenticated user", () => {
    describe("deactivated user", () => {
      beforeAll(async () => {
        await deactivateUser() // make update call with service role client
        await signInUser() // kills service role client session
      })
    })

    describe("activated user", () => {
      beforeAll(async () => {
        await activateUser() // make update call with service role client
        await signInUser() // kills service role client session
      })
    })
  })
})

And things work.

But this is not that nice. An alternative would probably be to do sign-outs and sign-ins all the time - not that nice, either.

So I thought I could create new service role clients all the time I need one instead of using the same one that gets reset to the user session all the time, but actually this did not work as well, the new client also picked up the user session right away.

That made my think if I could entirely prevent the supabase service role client from accessing the storage with the user auth details written by the other client.

My first thoughts were to somehow limit the scope of the test environment with jsdom to only those tests involving user sign up logic.

But it turned out there is a way better way to do this. When creating the supabase client we can initialise it with an persistSession option, which we can turn to false, and the client won’t pick up any storage data at all.

This is exactly what I needed and it worked out:

export const supabaseServiceRole = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY as string,
  {
    auth: {
      persistSession: false,
    },
  }
)

test.only("sb-clients", async () => {
  await logCurrentSession(supabase) // null as expected
  await signInUser()
  await logCurrentSession(supabase) // user session as expected
  await logCurrentSession(supabaseServiceRole) // null/no user session as expected and needed
})

With this setup in place I was finally able to test my supabase RLS rules using vitest.

legal privacy