Files
supabase/e2e/studio/features/api-access-toggle.spec.ts
Charis d27bb74258 test(studio): add tests for data api toggles (#42051)
* test(studio): add tests for data api toggles
2026-01-22 12:46:54 -05:00

546 lines
19 KiB
TypeScript

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<Array<Record<string, unknown>>> {
// 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<string> = []
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<Record<string, unknown>> = []
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<string, unknown> = {}
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<string>
authenticated: Array<string>
}
) {
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<string, Array<string>> = {
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)
})
})