{"id":717,"date":"2026-09-23T13:01:05","date_gmt":"2026-09-23T13:01:05","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/postgres-row-level-security-testing-the-policy-my-mocks-said-was-fine\/"},"modified":"2026-09-23T13:01:05","modified_gmt":"2026-09-23T13:01:05","slug":"postgres-row-level-security-testing-the-policy-my-mocks-said-was-fine","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/postgres-row-level-security-testing-the-policy-my-mocks-said-was-fine\/","title":{"rendered":"Postgres Row Level Security Testing: The Policy My Mocks Said Was Fine"},"content":{"rendered":"<p>Short version for the impatient: if your Postgres row level security policies are only covered by tests that mock the database, they are not covered. I learned this the embarrassing way on a multi-tenant Node app back in June, and a harness that landed on the Postgres news feed this week (pgsql-test) is the first thing I&rsquo;ve seen that fixes the workflow problem rather than just the tooling problem.<\/p>\n<p>Here&rsquo;s the June story. I had a <code>documents<\/code> table with an RLS policy that read the tenant id from a session setting. The repository layer had a nice green test suite. Every test mocked <code>pg<\/code> and asserted that the right SQL string went out. The policy itself referenced <code>app.tenant_id<\/code>. The middleware that set the session variable wrote <code>app.tenant<\/code>. No test could have caught that, because no test ever ran the policy. A colleague noticed in staging when she saw a document from a customer she&rsquo;d never heard of. Nothing shipped to production, but only because she happened to be looking.<\/p>\n<h2 id=\"why-a-mock-cant-see-a-policy\">Why a mock can&rsquo;t see a policy<\/h2>\n<p>The <a href=\"https:\/\/www.postgresql.org\/about\/news\/pgsql-test-real-postgres-testing-for-faster-development-loops-3380\/\" rel=\"nofollow noopener\" target=\"_blank\">pgsql-test announcement<\/a> makes a point I&rsquo;d have argued with two years ago: a mock tests how your code handles a result, not whether Postgres will produce it. A mock doesn&rsquo;t fire a trigger. It doesn&rsquo;t evaluate a foreign key. It doesn&rsquo;t check which database role your query actually runs under. And it definitely doesn&rsquo;t run a row level security policy, because the policy lives in the database and the mock replaced the database.<\/p>\n<p>I used to counter that with &ldquo;that&rsquo;s what integration tests are for.&rdquo; True, but look at what my integration tests looked like. A shared dev database that everybody wrote to. A <code>beforeAll<\/code> that truncated six tables. Fixtures that drifted from the migrations. Tests that passed alone and failed together. Nobody ran them locally, so they only ran in CI, so a red build meant twenty minutes of guessing. The fast loop lived in the unit tests, and the unit tests couldn&rsquo;t see the database. So the rules that mattered most got the least testing.<\/p>\n<p>The announcement puts it better than I did: when rules are easier to test in application code than in Postgres, they end up in application code, even when Postgres is the better place to enforce them. That&rsquo;s exactly how my tenant check ended up duplicated in a middleware and a policy, with two different spellings.<\/p>\n<h2 id=\"what-pgsql-test-actually-does\">What pgsql-test actually does<\/h2>\n<p>The harness spins up an ephemeral Postgres, deploys your schema, seeds it once, and then wraps every test in a transaction that gets rolled back. Your assertions run in Jest or whatever runner you already have. There&rsquo;s an admin client (<code>pg<\/code>) for setup and an app client (<code>db<\/code>) for the queries you&rsquo;re actually testing. It&rsquo;s MIT licensed and on <a href=\"https:\/\/www.npmjs.com\/package\/pgsql-test\" rel=\"nofollow noopener\" target=\"_blank\">npm<\/a> and PyPI.<\/p>\n<p>The part that matters for RLS is <code>setContext()<\/code>. It applies a role and any session settings your policies read, scoped to the current transaction via <code>SET LOCAL<\/code> and <code>set_config(..., true)<\/code>. No JWT is validated. You&rsquo;re telling the database &ldquo;pretend the request came from this identity&rdquo; and then checking what it returns.<\/p>\n<p>Here&rsquo;s roughly what my old test looked like. I&rsquo;ve trimmed the noise:<\/p>\n<pre><code class=\"language-ts\">\/\/ documents.repo.test.ts (before)\nimport { listDocuments } from '.\/documents.repo';\n\nconst query = jest.fn();\njest.mock('pg', () =&gt; ({ Pool: jest.fn(() =&gt; ({ query })) }));\n\ntest('lists documents for the tenant', async () =&gt; {\n  query.mockResolvedValue({ rows: [{ id: 101 }] });\n  const rows = await listDocuments({ tenantId: 'acme' });\n  expect(query).toHaveBeenCalledWith(\n    expect.stringContaining('FROM app.documents'),\n    expect.anything()\n  );\n  expect(rows).toEqual([{ id: 101 }]);\n});\n<\/code><\/pre>\n<p>That test asserts that I asked for documents and that I got back what I told the mock to return. It would pass with no policy on the table at all.<\/p>\n<p>And here&rsquo;s the same idea with a real database underneath:<\/p>\n<pre><code class=\"language-ts\">\/\/ documents.rls.test.ts (after)\nimport { getConnections } from 'pgsql-test';\n\nlet db, teardown;\n\nbeforeAll(async () =&gt; {\n  ({ db, teardown } = await getConnections());\n});\nafterAll(() =&gt; teardown());\nbeforeEach(() =&gt; db.beforeEach());\nafterEach(() =&gt; db.afterEach());\n\ntest('acme sees only its own documents', async () =&gt; {\n  db.setContext({ role: 'authenticated', 'app.tenant_id': 'acme' });\n  const result = await db.query('SELECT id FROM app.documents ORDER BY id');\n  expect(result.rows).toEqual([{ id: 101 }]);\n});\n\ntest('a tenant with no documents sees nothing', async () =&gt; {\n  db.setContext({ role: 'authenticated', 'app.tenant_id': 'nobody' });\n  const result = await db.query('SELECT id FROM app.documents');\n  expect(result.rows).toEqual([]);\n});\n<\/code><\/pre>\n<p>Notice the query has no <code>WHERE tenant_id = ...<\/code>. That&rsquo;s the whole point. If the policy is wrong, or the session variable is misspelled, the first test returns both seeded rows and fails. My June bug would have died in a pull request instead of in staging.<\/p>\n<p>The second test is the one I now consider mandatory. Testing what a user can see is the easy half. Testing what they cannot see is where leaks hide, and it&rsquo;s the test almost nobody writes because the happy path already went green.<\/p>\n<h2 id=\"the-superuser-trap\">The superuser trap<\/h2>\n<p>This one bit me within an hour of trying the harness, so I&rsquo;ll save you the hour. Superusers bypass row level security. So does the table owner, unless you&rsquo;ve run <code>ALTER TABLE ... FORCE ROW LEVEL SECURITY<\/code>. The <a href=\"https:\/\/www.postgresql.org\/docs\/current\/ddl-rowsecurity.html\" rel=\"nofollow noopener\" target=\"_blank\">Postgres docs on row security policies<\/a> spell this out, and I&rsquo;d read them, and I still wrote my first RLS test using the admin connection and wondered why the &ldquo;nobody&rdquo; tenant could see everything.<\/p>\n<p>The fix is boring: your tests have to run under the same role your application uses. In pgsql-test that means using the <code>db<\/code> client with <code>setContext({ role: 'authenticated', ... })<\/code>, not the <code>pg<\/code> admin client. If your app connects as the schema owner (a lot of small Laravel and Node apps do, because it&rsquo;s the path of least resistance), then RLS isn&rsquo;t protecting you in production either, and the test will tell you that too. Which is uncomfortable, but better to hear it from Jest.<\/p>\n<p>While you&rsquo;re there, check the role has <code>NOBYPASSRLS<\/code>. It&rsquo;s the default, but someone on the team will have granted <code>BYPASSRLS<\/code> to &ldquo;fix&rdquo; a migration at some point. I found one of those in a project I inherited last year, and the commit message was &ldquo;temp&rdquo;.<\/p>\n<h2 id=\"pgtap-is-still-fine-this-is-just-a-different-seat\">pgTAP is still fine, this is just a different seat<\/h2>\n<p>I want to be fair to pgTAP, because I&rsquo;ve used it for years and it&rsquo;s not going anywhere. <a href=\"https:\/\/pgtap.org\/\" rel=\"nofollow noopener\" target=\"_blank\">pgTAP<\/a> writes the tests in SQL and runs them inside the database. If your team is database-first, or you have a DBA who owns the schema, that&rsquo;s the right home for the tests.<\/p>\n<p>The difference with pgsql-test is where you sit while you write them. It targets the application layer, so a JavaScript or Python developer writes a policy test in the same file and the same runner as everything else, watch mode included. The announcement says the harness is also wrapped for Supabase (<code>supabase-test<\/code>), Drizzle (<code>drizzle-orm-test<\/code>), PostGraphile (<code>graphile-test<\/code>) and an in-process PGlite variant (<code>pglite-test<\/code>) for when your schema doesn&rsquo;t need extensions PGlite can&rsquo;t load. I&rsquo;ve only tried the plain one so far.<\/p>\n<p>There&rsquo;s a claim in there I haven&rsquo;t reproduced: a <code>supabase-test<\/code> run of 246 tests across 44 databases finishing in four seconds, quoted from Supabase&rsquo;s CEO. My own numbers are less flashy. A schema with eleven tables and about forty tests took nine seconds on my laptop, and most of that was the first <code>getConnections()<\/code> deploy. Per-test rollback was fast enough that I stopped thinking about it. If you have a heavier seed, that first deploy is going to be your bottleneck, not the tests.<\/p>\n<h2 id=\"what-i-got-wrong-the-first-time\">What I got wrong the first time<\/h2>\n<p>Three things, in the order I hit them.<\/p>\n<p>I seeded inside <code>beforeEach<\/code>. That threw away the whole point of the harness, because seeding ran forty times. The seed goes in the initial deploy, and every test rolls back to it.<\/p>\n<p>I used the same UUID for two tenants in different fixtures, because I copy-pasted. Both tests passed. Both tests were lying. Pick visibly different identifiers in fixtures (<code>acme<\/code>, <code>globex<\/code>) so a leak shows up as an obviously wrong row instead of a plausible one.<\/p>\n<p>I forgot that <code>SET LOCAL<\/code> only lasts for the transaction. That&rsquo;s a feature here, since <code>db.afterEach()<\/code> rolls it back and the next test starts clean. But if you open a second connection inside a test to &ldquo;check something&rdquo;, it won&rsquo;t have the context, and you&rsquo;ll spend a while confused. Ask me how I know.<\/p>\n<h2 id=\"where-this-fits-with-the-rest-of-the-database-checks\">Where this fits with the rest of the database checks<\/h2>\n<p>I&rsquo;ve written before about <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-postgres-unindexed-foreign-keys-vacuum-lint-in-ci\/\" rel=\"noopener\">linting Postgres schemas in CI for unindexed foreign keys<\/a>, and this is the natural next step. That lint answers &ldquo;is the schema shaped well?&rdquo;. A policy test answers &ldquo;does the schema behave the way I think it does under the role the app uses?&rdquo;. They&rsquo;re different questions and I now want both in the same pipeline.<\/p>\n<p>The announcement mentions a companion tool called <code>safegres<\/code> that grades a deployed schema for security and performance regressions and fails CI if the grade drops. I haven&rsquo;t run it yet, so I&rsquo;m not going to pretend I have an opinion. It&rsquo;s on the list.<\/p>\n<p>For the client work I do through <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>, multi-tenant Postgres with RLS is the default I reach for on anything with more than one customer in a database. Until now the honest answer to &ldquo;how do you test the policies?&rdquo; was &ldquo;carefully, in staging&rdquo;. That answer was never good enough and I knew it.<\/p>\n<h2 id=\"the-one-test-to-write-this-week\">The one test to write this week<\/h2>\n<p>Don&rsquo;t migrate your suite. Write one test. Pick the single table where a leak would hurt most, seed two rows owned by two different tenants, and assert that tenant A sees exactly one row and tenant B sees exactly the other one. Run it under the application role, not the admin one.<\/p>\n<p>If it passes, you&rsquo;ve confirmed something your mocks never could. If it fails, you&rsquo;ve found the bug I found in June, except in a pull request instead of a customer&rsquo;s account. Either way it took an afternoon, and the second test is a copy of the first.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>My mocked tests said the row level security policy was fine. It wasn&#8217;t. How pgsql-test runs RLS policies under the real app role, and the superuser trap I hit first.<\/p>\n","protected":false},"author":2,"featured_media":716,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"My mocked tests said the row level security policy was fine. It wasn't. How pgsql-test runs RLS policies under the real app role, and the superuser trap I hit first.","rank_math_focus_keyword":"postgres row level security","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302,777],"tags":[806,804,805,177,803,30],"class_list":["post-717","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-postgresql","tag-multi-tenant","tag-pgsql-test","tag-pgtap","tag-postgres","tag-row-level-security","tag-testing"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/717","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=717"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/717\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/716"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=717"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=717"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=717"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}