Chore: added RLS tests (#40689)

* added RLS tests

* removed index clicking for label and added unrestricted tests

* updated PR feedback
This commit is contained in:
Ali Waseem authored and GitHub committed 2025-11-21 20:10:47 +00:00
1 parent 5d3493914a
commit 2023b2632a
5 files changed
+456 -2

No files matched your search

@@ -154,7 +154,12 @@ export const Policies = ({
{tables.map((table) => {
const isVisible = visibleTableIds.has(table.id)
return (
<section key={table.id} hidden={!isVisible} aria-hidden={!isVisible}>
<section
key={table.id}
hidden={!isVisible}
aria-hidden={!isVisible}
data-testid={`policy-table-${table.name}`}
>
<PolicyTableRow
table={table}
isLocked={schema === 'realtime' ? true : isLocked}
@@ -111,7 +111,12 @@ export const PolicyRow = ({
{!isLocked && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
<Button
type="default"
className="px-1.5"
icon={<MoreVertical />}
data-testid={`policy-${policy.name}-actions-button`}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
@@ -74,6 +74,7 @@ export const PolicyTableRowHeader = ({
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
data-testid={`${table.name}-toggle-rls`}
tooltip={{
content: {
side: 'bottom',
@@ -90,6 +91,7 @@ export const PolicyTableRowHeader = ({
type="default"
disabled={!canToggleRLS || !canCreatePolicies}
onClick={() => onSelectCreatePolicy(table)}
data-testid={`${table.name}-create-policy`}
tooltip={{
content: {
side: 'bottom',
+441
View File
@@ -0,0 +1,441 @@
import { expect, Page } from '@playwright/test'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
import { createApiResponseWaiter, waitForApiResponse } from '../utils/wait-for-response.js'
const policyTableName = 'pw_rls_policy_test_table'
const policySelectName = 'pw_test_select_policy'
const policyInsertName = 'pw_test_insert_policy'
const policyUpdateName = 'pw_test_update_policy'
const policyDeleteName = 'pw_test_delete_policy'
/**
* Helper function to create a test table for RLS policies
*/
const createTestTable = async (page: Page, ref: string) => {
await page.goto(toUrl(`/project/${ref}/editor`))
await page.waitForTimeout(1000)
// Check if table already exists
const tableExists =
(await page.getByRole('button', { name: `View ${policyTableName}` }).count()) > 0
if (!tableExists) {
await page.getByRole('button', { name: 'New table', exact: true }).click()
await page.getByTestId('table-name-input').fill(policyTableName)
await page.getByRole('button', { name: 'Save' }).click()
await expect(
page.getByText(`Table ${policyTableName} is good to go!`),
'Table creation confirmation should be visible'
).toBeVisible({ timeout: 50000 })
}
}
/**
* Helper function to delete the test table
*/
const deleteTestTable = async (page: Page, ref: string) => {
await page.goto(toUrl(`/project/${ref}/editor`))
await page.waitForTimeout(1000)
const tableExists =
(await page.getByRole('button', { name: `View ${policyTableName}` }).count()) > 0
if (tableExists) {
await page.getByLabel(`View ${policyTableName}`).nth(0).click()
// Open the row actions menu (three dots) using the same selector pattern as table-editor.spec.ts.
// This avoids brittle index-based selection and ignores the Unrestricted badge button.
await page
.getByLabel(`View ${policyTableName}`)
.locator('button[aria-haspopup="menu"]')
.click({ force: true })
await page.getByText('Delete table').click()
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).click()
await page.getByRole('button', { name: 'Delete' }).click()
await expect(
page.getByText(`Successfully deleted table "${policyTableName}"`),
'Table deletion confirmation should be visible'
).toBeVisible({ timeout: 50000 })
}
}
/**
* Helper function to navigate to policies page and wait for it to load
*/
const navigateToPoliciesPage = async (page: Page, ref: string) => {
const wait = createApiResponseWaiter(page, 'pg-meta', ref, 'policies')
await page.goto(toUrl(`/project/${ref}/auth/policies`))
await wait
await page.waitForTimeout(500)
}
/**
* Helper function to delete a policy if it exists
*/
const deletePolicyIfExists = async (page: Page, ref: string, policyNameToDelete: string) => {
// Look for the policy in the table
const policyButton = page.getByRole('button', { name: policyNameToDelete })
const policyExists = (await policyButton.count()) > 0
if (policyExists) {
// Click the policy row actions button
await page.getByTestId(`policy-${policyNameToDelete}-actions-button`).click()
await page.waitForTimeout(200)
// Click delete
await page.getByText('Delete', { exact: true }).click()
await page.waitForTimeout(200)
const waitForDeletion = waitForApiResponse(page, 'pg-meta', ref, 'query?key=')
// Confirm deletion
await page.getByRole('button', { name: 'Delete' }).click()
// Wait for deletion to complete
await waitForDeletion
await expect(
page.getByText('Successfully removed policy'),
'Policy deletion confirmation should be visible'
).toBeVisible({ timeout: 50000 })
await page.waitForTimeout(500)
}
}
test.describe.serial('RLS Policies', () => {
let page: Page
test.beforeAll(async ({ browser, ref }) => {
page = await browser.newPage()
// Create test table
await createTestTable(page, ref)
// Navigate to policies page
await navigateToPoliciesPage(page, ref)
})
test.afterAll(async ({ ref }) => {
// Clean up: delete test table
await deleteTestTable(page, ref)
await page.close()
})
test.describe('Policies Page', () => {
test('should display policies page correctly', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Check page elements
await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible()
await expect(
page.getByText('Manage Row Level Security policies for your tables')
).toBeVisible()
// Check schema selector is present
await expect(page.getByRole('button', { name: 'schema public' })).toBeVisible()
// Check search/filter input is present
await expect(page.getByPlaceholder('Filter tables and policies')).toBeVisible()
// Check the test table is visible
await expect(page.getByText(policyTableName)).toBeVisible()
})
test('should filter tables and policies by search', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
const searchInput = page.getByPlaceholder('Filter tables and policies')
// Search for the test table
await searchInput.fill(policyTableName)
await page.waitForTimeout(500)
// Test table should be visible
await expect(page.getByText(policyTableName)).toBeVisible()
// Clear search
await searchInput.fill('')
await page.waitForTimeout(500)
})
test('should switch between schemas', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Click schema selector
await page.getByRole('button', { name: 'schema public' }).click()
// Select auth schema
await page.getByRole('option', { name: 'auth' }).click()
await page.waitForTimeout(1000)
// Should see auth schema tables
await expect(page.getByRole('heading', { name: 'users', exact: true })).toBeVisible()
// Switch back to public
await page.getByRole('button', { name: 'schema auth' }).click()
await page.getByRole('option', { name: 'public', exact: true }).click()
await page.waitForTimeout(1000)
})
})
test.describe('Table editor RLS badge', () => {
test('shows Unrestricted badge when RLS is disabled for public table', async ({ ref }) => {
// First, ensure RLS is disabled for the test table via the Policies page
await navigateToPoliciesPage(page, ref)
// Wait for the test table row to appear in the policies list
await expect(
page.getByRole('heading', { name: policyTableName, exact: true }),
'Test table heading should be visible on policies page'
).toBeVisible({ timeout: 50000 })
const toggleRlsButton = page.getByTestId(`${policyTableName}-toggle-rls`)
// If RLS is currently enabled, the toggle button label will be "Disable RLS"
const toggleLabel = (await toggleRlsButton.innerText()) ?? ''
if (toggleLabel.includes('Disable RLS')) {
await toggleRlsButton.click()
// A confirmation modal appears when toggling RLS from the policies page
await expect(
page.getByRole('heading', { name: 'Confirm to disable Row Level Security' }),
'RLS disable confirmation modal should appear'
).toBeVisible({ timeout: 50000 })
// Confirm disabling RLS
await page.getByRole('button', { name: 'Confirm' }).click()
// After confirming, the toggle button text should change to "Enable RLS"
await expect(
toggleRlsButton,
'Toggle should switch to "Enable RLS" after disabling RLS'
).toHaveText(/Enable RLS/, { timeout: 50000 })
}
// Navigate to the table editor for the public schema so we can see the sidebar badge
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await page.waitForTimeout(1000)
// In the table sidebar, the test table should have an "Unrestricted" badge
const tableRow = page.getByRole('button', { name: `View ${policyTableName}` })
await expect(
tableRow,
'Test table should be visible in the table editor sidebar'
).toBeVisible({ timeout: 50000 })
const unrestrictedBadge = tableRow.getByText('Unrestricted', { exact: true })
await expect(
unrestrictedBadge,
'Unrestricted badge should be visible for public tables with RLS disabled'
).toBeVisible({ timeout: 50000 })
// Hover the badge to verify the tooltip explains the risk
await unrestrictedBadge.hover()
await expect(
page.getByText(/Data is publicly accessible via API/i),
'Tooltip should describe unrestricted public access when RLS is disabled'
).toBeVisible({ timeout: 10000 })
})
})
test.describe('Create RLS Policy', () => {
test('should create a SELECT policy successfully', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Delete policy if it exists from previous run
await deletePolicyIfExists(page, ref, policySelectName)
// Find the test table and click "Create policy"
await page.getByTestId(`${policyTableName}-create-policy`).click()
// Wait for dialog to open
await expect(
page.getByRole('heading', { name: 'Create a new Row Level Security policy' }),
'Policy creation dialog should open'
).toBeVisible()
// Fill in policy name
await page.getByRole('textbox', { name: 'Policy Name' }).fill(policySelectName)
// Verify the table is already selected (should be the table we clicked from)
await expect(page.getByRole('button', { name: `public.${policyTableName}` })).toBeVisible()
// SELECT should be selected by default
await expect(page.getByRole('radio', { name: 'SELECT' })).toBeChecked()
// Fill in USING clause - allow all access
const editor = page.getByRole('textbox', { name: 'Editor content;Press Alt+F1' })
await editor.fill('true')
// Save policy
await page.getByRole('button', { name: 'Save policy' }).click()
// Wait for success message
await expect(
page.getByText('Successfully created new policy'),
'Policy creation success message should be visible'
).toBeVisible({ timeout: 50000 })
// Verify policy appears in the list
await expect(page.getByRole('button', { name: policySelectName })).toBeVisible()
// Verify policy details
const policyRow = page.locator(`tr:has-text("${policySelectName}")`)
await expect(policyRow.locator('code').filter({ hasText: /^SELECT$/ })).toBeVisible()
await expect(policyRow.locator('code').filter({ hasText: /^public$/ })).toBeVisible()
})
test('should create an INSERT policy with authenticated role', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Delete policy if it exists
await deletePolicyIfExists(page, ref, policyInsertName)
// Open create policy dialog
await page.getByTestId(`${policyTableName}-create-policy`).click()
await expect(
page.getByRole('heading', { name: 'Create a new Row Level Security policy' })
).toBeVisible()
// Fill in policy name
await page.getByRole('textbox', { name: 'Policy Name' }).fill(policyInsertName)
// Select INSERT command
await page.getByRole('radio', { name: 'INSERT' }).click()
// Select target role - authenticated
await page.getByText('Defaults to all (public) roles if none selected').click()
await page.getByRole('option', { name: 'authenticated' }).click()
// Close the dropdown
await page.keyboard.press('Escape')
// Fill in WITH CHECK clause - allow all inserts
const editor = page.getByRole('textbox', { name: 'Editor content;Press Alt+F1' })
await editor.fill('true')
// Save policy
await page.getByRole('button', { name: 'Save policy' }).click()
// Wait for success
await expect(page.getByText('Successfully created new policy')).toBeVisible({
timeout: 50000,
})
// Verify policy appears with correct details
await expect(page.getByRole('button', { name: policyInsertName })).toBeVisible()
const policyRow = page.locator(`tr:has-text("${policyInsertName}")`)
await expect(policyRow.locator('code').filter({ hasText: /^INSERT$/ })).toBeVisible()
await expect(policyRow.locator('code').filter({ hasText: /^authenticated$/ })).toBeVisible()
})
test('should create an UPDATE policy with custom condition', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Delete policy if it exists
await deletePolicyIfExists(page, ref, policyUpdateName)
// Open create policy dialog
await page.getByTestId(`${policyTableName}-create-policy`).click()
await expect(
page.getByRole('heading', { name: 'Create a new Row Level Security policy' })
).toBeVisible()
// Fill in policy name
await page.getByRole('textbox', { name: 'Policy Name' }).fill(policyUpdateName)
// Select UPDATE command
await page.getByRole('radio', { name: 'UPDATE' }).click()
// Select authenticated role
await page.getByText('Defaults to all (public) roles if none selected').click()
await page.getByRole('option', { name: 'authenticated' }).click()
await page.keyboard.press('Escape')
// Fill in USING clause (UPDATE has both USING and WITH CHECK editors, so use first)
const editor = page.getByRole('textbox', { name: 'Editor content;Press Alt+F1' }).first()
await editor.fill('true')
// Save policy
await page.getByRole('button', { name: 'Save policy' }).click()
// Wait for success
await expect(page.getByText('Successfully created new policy')).toBeVisible({
timeout: 50000,
})
// Verify policy appears
await expect(page.getByRole('button', { name: policyUpdateName })).toBeVisible()
const policyRow = page.locator(`tr:has-text("${policyUpdateName}")`)
await expect(policyRow.locator('code').filter({ hasText: /^UPDATE$/ })).toBeVisible()
})
test('should create a DELETE policy', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Delete policy if it exists
await deletePolicyIfExists(page, ref, policyDeleteName)
// Open create policy dialog
await page.getByTestId(`${policyTableName}-create-policy`).click()
await expect(
page.getByRole('heading', { name: 'Create a new Row Level Security policy' })
).toBeVisible()
// Fill in policy name
await page.getByRole('textbox', { name: 'Policy Name' }).fill(policyDeleteName)
// Select DELETE command
await page.getByRole('radio', { name: 'DELETE' }).click()
// Select authenticated role
await page.getByText('Defaults to all (public) roles if none selected').click()
await page.getByRole('option', { name: 'authenticated' }).click()
await page.keyboard.press('Escape')
// Fill in USING clause
const editor = page.getByRole('textbox', { name: 'Editor content;Press Alt+F1' })
await editor.fill('true')
// Save policy
await page.getByRole('button', { name: 'Save policy' }).click()
// Wait for success
await expect(page.getByText('Successfully created new policy')).toBeVisible({
timeout: 50000,
})
// Verify policy appears
await expect(page.getByRole('button', { name: policyDeleteName })).toBeVisible()
const policyRow = page.locator(`tr:has-text("${policyDeleteName}")`)
await expect(policyRow.locator('code').filter({ hasText: /^DELETE$/ })).toBeVisible()
})
test('should cancel policy creation', async ({ ref }) => {
await navigateToPoliciesPage(page, ref)
// Open create policy dialog
await page.getByTestId(`${policyTableName}-create-policy`).click()
await expect(
page.getByRole('heading', { name: 'Create a new Row Level Security policy' })
).toBeVisible()
// Fill in some data
await page.getByRole('textbox', { name: 'Policy Name' }).fill('policy_to_cancel')
// Click cancel
await page.getByRole('button', { name: 'Cancel' }).click()
// Dialog should close
await expect(
page.getByRole('heading', { name: 'Create a new Row Level Security policy' })
).not.toBeVisible()
})
})
})
+1
View File
@@ -58,5 +58,6 @@ export default defineConfig({
command: 'pnpm --workspace-root run e2e:setup',
port: WEB_SERVER_PORT,
timeout: WEB_SERVER_TIMEOUT,
reuseExistingServer: true,
},
})