mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? If Realtime's admin_suspended_at is set we display a banner. ## What is the current behavior? REAL-1095 ## What is the new behavior? <img width="1160" height="442" alt="Screenshot 2026-09-17 at 12 06 30 pm" src="https://github.com/user-attachments/assets/f5abe900-a163-48c9-ab55-945fc62df0d1" /> ## Additional context Depends on https://github.com/supabase/platform/pull/38476 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added a notice to Realtime settings when the service is suspended, with instructions to contact support. - **Bug Fixes** - Improved invitation resending and role updates for roles without a linked base role. - **Tests** - Added coverage to verify the suspension notice appears only when applicable. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import type { components } from 'api-types'
|
|
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
|
import { HttpResponse } from 'msw'
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
import { z } from 'zod'
|
|
|
|
import { RealtimeSettings } from './RealtimeSettings'
|
|
import type { Entitlement, FeatureKey } from '@/data/entitlements/entitlements-query'
|
|
import type { RealtimeConfigurationData } from '@/data/realtime/realtime-config-query'
|
|
import { customRender } from '@/tests/lib/custom-render'
|
|
import { addAPIMock } from '@/tests/lib/msw'
|
|
|
|
mockAnimationsApi()
|
|
|
|
const {
|
|
mockUseAsyncCheckPermissions,
|
|
mockUseMaxConnectionsQuery,
|
|
mockUseSelectedOrganizationQuery,
|
|
mockUseSelectedProjectQuery,
|
|
mockUseDatabasePoliciesQuery,
|
|
} = vi.hoisted(() => ({
|
|
mockUseAsyncCheckPermissions: vi.fn(),
|
|
mockUseMaxConnectionsQuery: vi.fn(),
|
|
mockUseSelectedOrganizationQuery: vi.fn(),
|
|
mockUseSelectedProjectQuery: vi.fn(),
|
|
mockUseDatabasePoliciesQuery: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
|
|
useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
|
useSelectedProjectQuery: mockUseSelectedProjectQuery,
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
|
|
useSelectedOrganizationQuery: mockUseSelectedOrganizationQuery,
|
|
}))
|
|
|
|
vi.mock('@/data/database/max-connections-query', () => ({
|
|
useMaxConnectionsQuery: mockUseMaxConnectionsQuery,
|
|
}))
|
|
|
|
vi.mock('@/data/database-policies/database-policies-query', () => ({
|
|
useDatabasePoliciesQuery: mockUseDatabasePoliciesQuery,
|
|
}))
|
|
|
|
vi.mock('@/lib/constants', async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>()
|
|
return { ...actual, IS_PLATFORM: true }
|
|
})
|
|
|
|
const REALTIME_CONFIG = {
|
|
connection_pool: 2,
|
|
postgres_changes_pool: 2,
|
|
max_bytes_per_second: 100000,
|
|
max_channels_per_client: 100,
|
|
max_concurrent_users: 200,
|
|
max_events_per_second: 100,
|
|
max_joins_per_second: 100,
|
|
max_payload_size_in_kb: 100,
|
|
max_presence_events_per_second: 100,
|
|
presence_enabled: true,
|
|
private_only: false,
|
|
suspend: false,
|
|
admin_suspended_at: null,
|
|
} as const satisfies RealtimeConfigurationData
|
|
|
|
const buildRealtimeEntitlements = (
|
|
overrides: Partial<Record<FeatureKey, number | 'unlimited'>> = {}
|
|
): Entitlement[] =>
|
|
(
|
|
[
|
|
['realtime.max_concurrent_users', 50_000],
|
|
['realtime.max_events_per_second', 50_000],
|
|
['realtime.max_presence_events_per_second', 5_000],
|
|
['realtime.max_payload_size_in_kb', 3_000],
|
|
] satisfies [FeatureKey, number][]
|
|
).map(([key, value]) => {
|
|
const override = overrides[key]
|
|
const unlimited = override === 'unlimited'
|
|
return {
|
|
config: { enabled: true, unit: '', unlimited, value: unlimited ? 0 : (override ?? value) },
|
|
feature: { key, type: 'numeric' },
|
|
hasAccess: true,
|
|
type: 'numeric',
|
|
}
|
|
})
|
|
|
|
const REALTIME_ENTITLEMENTS: Entitlement[] = buildRealtimeEntitlements()
|
|
|
|
describe('RealtimeSettings', () => {
|
|
beforeEach(() => {
|
|
mockUseAsyncCheckPermissions.mockReturnValue({ can: true, isSuccess: true })
|
|
mockUseSelectedProjectQuery.mockReturnValue({
|
|
data: { ref: 'default', connectionString: 'postgresql://example' },
|
|
})
|
|
mockUseSelectedOrganizationQuery.mockReturnValue({
|
|
data: { slug: 'default', plan: { id: 'pro' }, usage_billing_enabled: true },
|
|
isSuccess: true,
|
|
})
|
|
mockUseMaxConnectionsQuery.mockReturnValue({ data: { maxConnections: 20 } })
|
|
mockUseDatabasePoliciesQuery.mockReturnValue({ data: [], isSuccess: true })
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref/config/realtime',
|
|
response: () => HttpResponse.json<RealtimeConfigurationData>(REALTIME_CONFIG),
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations/:slug/entitlements',
|
|
response: { entitlements: REALTIME_ENTITLEMENTS },
|
|
})
|
|
})
|
|
|
|
test('renders the fetched postgres changes pool value', async () => {
|
|
customRender(<RealtimeSettings />)
|
|
|
|
expect(await screen.findByLabelText('Postgres Changes connection pool size')).toHaveValue(2)
|
|
})
|
|
|
|
test('falls back to the default postgres changes pool value when omitted', async () => {
|
|
const { postgres_changes_pool, ...configWithoutPool } = REALTIME_CONFIG
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref/config/realtime',
|
|
response: () =>
|
|
HttpResponse.json<components['schemas']['RealtimeConfigResponse_Output']>(
|
|
configWithoutPool as any
|
|
),
|
|
})
|
|
|
|
customRender(<RealtimeSettings />)
|
|
|
|
expect(await screen.findByLabelText('Postgres Changes connection pool size')).toHaveValue(2)
|
|
})
|
|
|
|
test('submits the postgres changes pool value when saving', async () => {
|
|
const updateBodySchema = z.object({ postgres_changes_pool: z.number() })
|
|
|
|
const requests: z.infer<typeof updateBodySchema>[] = []
|
|
addAPIMock({
|
|
method: 'patch',
|
|
path: '/platform/projects/:ref/config/realtime',
|
|
response: async ({ request }) => {
|
|
requests.push(updateBodySchema.parse(await request.json()))
|
|
return new HttpResponse(null, { status: 204 })
|
|
},
|
|
})
|
|
|
|
customRender(<RealtimeSettings />)
|
|
|
|
const postgresChangesPoolInput = await screen.findByLabelText(
|
|
'Postgres Changes connection pool size'
|
|
)
|
|
await userEvent.clear(postgresChangesPoolInput)
|
|
await userEvent.type(postgresChangesPoolInput, '5')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
|
|
const dialog = await screen.findByRole('dialog')
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }))
|
|
|
|
await waitFor(() => expect(requests).toHaveLength(1))
|
|
expect(requests[0]).toMatchObject({ postgres_changes_pool: 5 })
|
|
})
|
|
|
|
test.each([
|
|
{ value: '1', accepted: true },
|
|
{ value: '20', accepted: true },
|
|
{ value: '0', accepted: false },
|
|
{ value: '21', accepted: false },
|
|
])('$accepted for postgres changes pool value of $value', async ({ value, accepted }) => {
|
|
const requests: unknown[] = []
|
|
addAPIMock({
|
|
method: 'patch',
|
|
path: '/platform/projects/:ref/config/realtime',
|
|
response: async ({ request }) => {
|
|
requests.push(await request.json())
|
|
return new HttpResponse(null, { status: 204 })
|
|
},
|
|
})
|
|
|
|
customRender(<RealtimeSettings />)
|
|
|
|
const postgresChangesPoolInput = await screen.findByLabelText(
|
|
'Postgres Changes connection pool size'
|
|
)
|
|
await userEvent.clear(postgresChangesPoolInput)
|
|
await userEvent.type(postgresChangesPoolInput, value)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
|
|
if (accepted) {
|
|
const dialog = await screen.findByRole('dialog')
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }))
|
|
|
|
await waitFor(() => expect(requests).toHaveLength(1))
|
|
expect(requests[0]).toMatchObject({ postgres_changes_pool: Number(value) })
|
|
} else {
|
|
await waitFor(() => expect(postgresChangesPoolInput).toHaveAttribute('aria-invalid', 'true'))
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
expect(requests).toHaveLength(0)
|
|
}
|
|
})
|
|
|
|
test.each([
|
|
{ value: '300000', accepted: true },
|
|
{ value: '300001', accepted: false },
|
|
])(
|
|
'$accepted for max concurrent clients of $value when the entitlement is unlimited',
|
|
async ({ value, accepted }) => {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations/:slug/entitlements',
|
|
response: {
|
|
entitlements: buildRealtimeEntitlements({ 'realtime.max_concurrent_users': 'unlimited' }),
|
|
},
|
|
})
|
|
|
|
const requests: unknown[] = []
|
|
addAPIMock({
|
|
method: 'patch',
|
|
path: '/platform/projects/:ref/config/realtime',
|
|
response: async ({ request }) => {
|
|
requests.push(await request.json())
|
|
return new HttpResponse(null, { status: 204 })
|
|
},
|
|
})
|
|
|
|
customRender(<RealtimeSettings />)
|
|
|
|
const maxConcurrentUsersInput = await screen.findByLabelText('Max concurrent clients')
|
|
await userEvent.clear(maxConcurrentUsersInput)
|
|
await userEvent.type(maxConcurrentUsersInput, value)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
|
|
if (accepted) {
|
|
const dialog = await screen.findByRole('dialog')
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' }))
|
|
|
|
await waitFor(() => expect(requests).toHaveLength(1))
|
|
expect(requests[0]).toMatchObject({ max_concurrent_users: Number(value) })
|
|
} else {
|
|
expect(
|
|
await screen.findByText('Cannot exceed 300,000 concurrent clients')
|
|
).toBeInTheDocument()
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
expect(requests).toHaveLength(0)
|
|
}
|
|
}
|
|
)
|
|
|
|
test('rejects max concurrent clients above the entitlement when it is below the soft limit', async () => {
|
|
customRender(<RealtimeSettings />)
|
|
|
|
const maxConcurrentUsersInput = await screen.findByLabelText('Max concurrent clients')
|
|
await userEvent.clear(maxConcurrentUsersInput)
|
|
await userEvent.type(maxConcurrentUsersInput, '50001')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
|
|
expect(await screen.findByText('Cannot exceed 50,000 concurrent clients')).toBeInTheDocument()
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows a suspension banner when Realtime has been admin-suspended', async () => {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref/config/realtime',
|
|
response: () =>
|
|
HttpResponse.json<RealtimeConfigurationData>({
|
|
...REALTIME_CONFIG,
|
|
admin_suspended_at: '2026-01-01T00:00:00+00:00',
|
|
}),
|
|
})
|
|
|
|
customRender(<RealtimeSettings />)
|
|
|
|
expect(
|
|
await screen.findByText('Supabase has suspended Realtime for this project')
|
|
).toBeInTheDocument()
|
|
})
|
|
|
|
test('does not show a suspension banner when Realtime has not been admin-suspended', async () => {
|
|
customRender(<RealtimeSettings />)
|
|
|
|
await screen.findByLabelText('Postgres Changes connection pool size')
|
|
expect(
|
|
screen.queryByText('Supabase has suspended Realtime for this project')
|
|
).not.toBeInTheDocument()
|
|
})
|
|
})
|