import { expect, Page } from '@playwright/test' import { test } from '../utils/test.js' import { toUrl } from '../utils/to-url.js' import { createApiResponseWaiter, waitForApiResponse, waitForTableToLoad, } from '../utils/wait-for-response.js' import { dismissToastsIfAny } from '../utils/dismiss-toast.js' import { openTableContextMenu, deleteTable } from '../utils/table-helpers.js' const TABLE_NAME_PREFIX = 'pw_api_access' /** * The API privilege types we care about for Data API access. * Filters out other PostgreSQL privileges like REFERENCES, TRIGGER, TRUNCATE. */ const API_PRIVILEGE_TYPES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] /** * Executes a SQL query via the SQL Editor and returns the result. * This is used to verify database state directly. */ async function executeSql( page: Page, ref: string, sql: string ): Promise>> { // Create API response waiter for content/count which indicates project is loaded const contentCountWaiter = createApiResponseWaiter( page, 'platform/projects', ref, 'content/count' ) // Navigate to SQL editor await page.goto(toUrl(`/project/${ref}/sql/new?skip=true`)) // Wait for content/count API response to ensure project is fully loaded await contentCountWaiter await expect(page.getByText('Loading...')).not.toBeVisible({ timeout: 10000 }) // Clear and type the SQL await page.locator('.view-lines').click() await page.keyboard.press('ControlOrMeta+KeyA') await page.keyboard.type(sql) // Run the query const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { method: 'POST', }) await page.getByTestId('sql-run-button').click() await sqlMutationPromise // Wait for either results grid or "Success. No rows returned" message const grid = page.getByRole('grid') const noRowsMessage = page.getByText('Success. No rows returned') // Wait for either element to appear await expect(grid.or(noRowsMessage)).toBeVisible({ timeout: 10000 }) // If no rows returned, return empty array if (await noRowsMessage.isVisible().catch(() => false)) { return [] } // If grid is not visible (shouldn't happen if we get here, but just in case) if ((await grid.count()) === 0) { return [] } // Extract column headers const headers: Array = [] const headerCells = grid.getByRole('columnheader') const headerCount = await headerCells.count() for (let i = 0; i < headerCount; i++) { const text = await headerCells.nth(i).textContent() if (text) headers.push(text.trim()) } // Extract row data const results: Array> = [] const rows = grid.getByRole('row') const rowCount = await rows.count() // Skip first row (header row) for (let i = 1; i < rowCount; i++) { const cells = rows.nth(i).getByRole('gridcell') const cellCount = await cells.count() const row: Record = {} for (let j = 0; j < Math.min(cellCount, headers.length); j++) { const cellText = await cells.nth(j).textContent() row[headers[j]] = cellText?.trim() ?? null } if (Object.keys(row).length > 0) { results.push(row) } } return results } async function verifyTablePrivileges( page: Page, ref: string, schemaName: string, tableName: string, expectedPrivileges: { anon: Array authenticated: Array } ) { const sql = ` SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_schema = '${schemaName}' AND table_name = '${tableName}' AND grantee IN ('anon', 'authenticated') AND privilege_type IN ('SELECT', 'INSERT', 'UPDATE', 'DELETE') ORDER BY grantee, privilege_type; ` const results = await executeSql(page, ref, sql) // Group privileges by grantee const actualPrivileges: Record> = { anon: [], authenticated: [], } for (const row of results) { const grantee = row['grantee'] as string const privilegeType = row['privilege_type'] as string if ( grantee && privilegeType && (grantee === 'anon' || grantee === 'authenticated') && API_PRIVILEGE_TYPES.includes(privilegeType) ) { actualPrivileges[grantee].push(privilegeType) } } // Sort for comparison actualPrivileges.anon.sort() actualPrivileges.authenticated.sort() const sortedExpected = { anon: [...expectedPrivileges.anon].sort(), authenticated: [...expectedPrivileges.authenticated].sort(), } expect(actualPrivileges.anon).toEqual(sortedExpected.anon) expect(actualPrivileges.authenticated).toEqual(sortedExpected.authenticated) } /** * Locates the API access toggle switch for Data API Access. * The switch is labeled by the nearby "Data API Access" text. */ function getApiAccessToggle(page: Page) { const sidePanel = page.getByTestId('table-editor-side-panel') // The switch is near the "Data API Access" label - get the section first, then find the switch const dataApiSection = sidePanel .locator('div') .filter({ hasText: 'Data API Access' }) .filter({ has: page.getByRole('switch') }) return dataApiSection.getByRole('switch') } /** * Locates the settings button for granular privilege settings. */ function getPrivilegeSettingsButton(page: Page) { const sidePanel = page.getByTestId('table-editor-side-panel') return sidePanel.getByRole('button', { name: 'Configure API privileges' }) } /** * Gets the privilege selector combobox for a specific role in the privileges popover. * The popover must already be open. */ function getRolePrivilegeSelector(page: Page, roleLabel: 'Anonymous (anon)' | 'Authenticated') { // The popover is a dialog with structure: paragraph (role label) followed by combobox // We find the paragraph with the role text, then get the adjacent combobox const popoverContent = page.locator('[data-radix-popper-content-wrapper]') // Get the paragraph containing the role label, then navigate to the sibling combobox return popoverContent.getByText(roleLabel, { exact: true }).locator('..').getByRole('combobox') } test.describe('API Access Toggle', () => { test.beforeEach(async ({ page, ref }) => { const loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise }) test('API access is default on for a new table', async ({ page, ref }) => { const tableName = `${TABLE_NAME_PREFIX}_default_on` // Open new table dialog await page.getByRole('button', { name: 'New table', exact: true }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() // Fill in table name await page.getByTestId('table-name-input').fill(tableName) // Find and click the API access toggle to turn it off const toggle = getApiAccessToggle(page) await expect(toggle).toBeChecked() // Create the table const createTablePromise = createApiResponseWaiter( page, 'pg-meta', ref, 'query?key=table-create' ) await page.getByRole('button', { name: 'Save' }).click() await createTablePromise // Wait for success toast which indicates all operations are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) // Dismiss toast to prevent it from blocking subsequent interactions await dismissToastsIfAny(page) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) // Verify table was created await expect( page.getByRole('button', { name: `View ${tableName}`, exact: true }), 'Table should be visible after creation' ).toBeVisible() // Verify all API access privileges were granted await verifyTablePrivileges(page, ref, 'public', tableName, { anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], }) }) test('can toggle API access off for a new table', async ({ page, ref }) => { const tableName = `${TABLE_NAME_PREFIX}_toggle_off` // Open new table dialog await page.getByRole('button', { name: 'New table', exact: true }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() // Fill in table name await page.getByTestId('table-name-input').fill(tableName) // Find and click the API access toggle to turn it off const toggle = getApiAccessToggle(page) await expect(toggle).toBeChecked() await toggle.click() await expect(toggle, 'Toggle should be unchecked after clicking').not.toBeChecked() // Create the table const createTablePromise = createApiResponseWaiter( page, 'pg-meta', ref, 'query?key=table-create' ) await page.getByRole('button', { name: 'Save' }).click() await createTablePromise // Wait for success toast which indicates all operations are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) // Dismiss toast to prevent it from blocking subsequent interactions await dismissToastsIfAny(page) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) // Verify table was created await expect( page.getByRole('button', { name: `View ${tableName}`, exact: true }), 'Table should be visible after creation' ).toBeVisible() // Verify no API access privileges were granted await verifyTablePrivileges(page, ref, 'public', tableName, { anon: [], authenticated: [], }) }) test('shows API access toggle when editing an existing table', async ({ page, ref }) => { const tableName = `${TABLE_NAME_PREFIX}_edit` // Create a table first await page.getByRole('button', { name: 'New table', exact: true }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() await page.getByTestId('table-name-input').fill(tableName) const createPromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-create') await page.getByRole('button', { name: 'Save' }).click() await createPromise // Wait for success toast which indicates all operations are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) // Dismiss toast to prevent it from blocking subsequent interactions await dismissToastsIfAny(page) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) // Verify table was created await expect( page.getByRole('button', { name: `View ${tableName}`, exact: true }), 'Table should be visible after creation' ).toBeVisible() // Verify default full privileges were granted await verifyTablePrivileges(page, ref, 'public', tableName, { anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], }) // Navigate back to table editor let loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise // Click on the table to view it const navigationPromise = page.waitForURL(/\/editor\/\d+\?schema=public$/) await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() await navigationPromise // Open edit table dialog via context menu await openTableContextMenu(page, tableName) await page.getByRole('menuitem', { name: 'Edit table' }).click() // Verify the side panel is open await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() // Verify Data API Access section is visible await expect( page.getByText('Data API Access'), 'Data API Access label should be visible in edit mode' ).toBeVisible() // Verify the toggle is present const toggle = getApiAccessToggle(page) await expect(toggle, 'API Access toggle should be visible in edit mode').toBeVisible() }) test('creates table with partial privileges and verifies correct grants', async ({ page, ref, }) => { const tableName = `${TABLE_NAME_PREFIX}_partial_grants` // Open new table dialog await page.getByRole('button', { name: 'New table', exact: true }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() // Fill in table name await page.getByTestId('table-name-input').fill(tableName) // Open the privilege settings popover const settingsButton = getPrivilegeSettingsButton(page) await settingsButton.click() await expect(page.getByText('Adjust API privileges per role')).toBeVisible() // Modify anon privileges - leave only SELECT const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)') await anonSelector.click() // Click DELETE to toggle it off await page.getByRole('option', { name: 'DELETE' }).click() // Click UPDATE to toggle it off await page.getByRole('option', { name: 'UPDATE' }).click() await page.getByRole('option', { name: 'INSERT' }).click() // Close the dropdown by clicking the combobox again await anonSelector.click() // Wait for dropdown to close await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 }) // Modify authenticated privileges - remove DELETE and UPDATE (leave SELECT + INSERT) const authSelector = getRolePrivilegeSelector(page, 'Authenticated') await authSelector.click() // Remove all except SELECT await page.getByRole('option', { name: 'DELETE' }).click() await page.getByRole('option', { name: 'UPDATE' }).click() // Close the dropdown by clicking the combobox again await authSelector.click() // Wait for dropdown to close await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 }) // Close the popover by pressing Escape await page.keyboard.press('Escape') // Create the table const createTablePromise = createApiResponseWaiter( page, 'pg-meta', ref, 'query?key=table-create' ) await page.getByRole('button', { name: 'Save' }).click() await createTablePromise // Wait for success toast which indicates all operations (including privilege updates) are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) // Verify table was created await expect( page.getByRole('button', { name: `View ${tableName}`, exact: true }), 'Table should be visible after creation' ).toBeVisible() // Verify partial grants - anon: SELECT; authenticated: SELECT, INSERT await verifyTablePrivileges(page, ref, 'public', tableName, { anon: ['SELECT'], authenticated: ['SELECT', 'INSERT'], }) }) test('preserves API grants when editing non-privilege table properties', async ({ page, ref, }) => { const tableName = `${TABLE_NAME_PREFIX}_preserve_grants` // Step 1: Create a table with partial privileges (only SELECT and INSERT for anon) await page.getByRole('button', { name: 'New table', exact: true }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() await page.getByTestId('table-name-input').fill(tableName) // Open privilege settings and set partial privileges const settingsButton = getPrivilegeSettingsButton(page) await settingsButton.click() await expect(page.getByText('Adjust API privileges per role')).toBeVisible() // Modify anon privileges - keep only SELECT and INSERT const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)') await anonSelector.click() await page.getByRole('option', { name: 'DELETE' }).click() await page.getByRole('option', { name: 'UPDATE' }).click() await anonSelector.click() await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 }) // Keep authenticated with full privileges await page.keyboard.press('Escape') // Close popover // Create the table let createPromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-create') await page.getByRole('button', { name: 'Save' }).click() await createPromise // Wait for success toast which indicates all operations (including privilege updates) are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) await expect( page.getByRole('button', { name: `View ${tableName}`, exact: true }), 'Table should be created' ).toBeVisible() // Verify initial privileges before edit await verifyTablePrivileges(page, ref, 'public', tableName, { anon: ['SELECT', 'INSERT'], authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], }) // Navigate back to table editor let loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise // Step 2: Edit the table's description (without touching privileges) await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() await page.waitForURL(/\/editor\/\d+\?schema=public$/) await openTableContextMenu(page, tableName) await page.getByRole('menuitem', { name: 'Edit table' }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() // Add a description without modifying privileges const descriptionInput = page .getByTestId('table-editor-side-panel') .getByPlaceholder('Optional') await descriptionInput.fill('Test description for grant preservation') // Save the changes const updatePromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-update') await page.getByRole('button', { name: 'Save' }).click() await updatePromise // Wait for success toast which indicates all operations are complete await expect( page.getByText(`Successfully updated ${tableName}!`), 'Success toast should appear after table update' ).toBeVisible({ timeout: 15000 }) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) // Step 3: Verify the privileges remain unchanged after edit await verifyTablePrivileges(page, ref, 'public', tableName, { anon: ['SELECT', 'INSERT'], authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], }) // Navigate back to table editor for cleanup loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise // Clean up await deleteTable(page, ref, tableName) }) })