mirror of
https://github.com/supabase/supabase.git
synced 2026-05-09 16:27:27 +08:00
546 lines
19 KiB
TypeScript
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)
|
|
})
|
|
})
|