mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(studio): tighten webhook endpoint validation (#43892)
## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Webhook endpoint validation is inconsistent across Studio forms. The webhook sheet accepts incomplete hostnames like `https://webhook`, event type validation is not surfaced clearly, and HTTP endpoint validation differs between webhooks, log drains, cron jobs, and database hooks. ## What is the new behavior? - Tightens webhook endpoint URL validation and rejects incomplete hostnames while still allowing localhost and IP-based endpoints. - Surfaces the Event types validation through the standard form error styling and highlights the existing accordion item border when invalid. - **Extracts a shared HTTP endpoint URL validator and reuses it in webhooks, log drains, cron jobs, and database hooks.** - Adds focused regression tests for the webhook sheet and the shared/consumer validation paths. | After | | --- | | <img width="1728" height="997" alt="Webhooks Settings AWS Healthy Toolshed Supabase-CB0D999C-D0BF-47AA-A10F-342A2E328DF9" src="https://github.com/user-attachments/assets/bcbe4876-f9a7-497a-b288-460087a65546" /> | ## To test Form behaviour (in particular URL validation) on: - Webhook endpoint - Log drains - Cron jobs - Database hooks
This commit is contained in:
1 parent
46a793a32d
commit
fd17b246e1
11 files changed
+567
-218
No files matched your search
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FormSchema } from './EditHookPanel.constants'
|
||||
|
||||
describe('EditHookPanel FormSchema', () => {
|
||||
it('rejects incomplete http_request hostnames', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Test hook',
|
||||
table_id: 'public.messages',
|
||||
http_method: 'POST' as const,
|
||||
timeout_ms: 1000,
|
||||
events: ['INSERT'],
|
||||
httpHeaders: [],
|
||||
httpParameters: [],
|
||||
function_type: 'http_request' as const,
|
||||
http_url: 'https://webhook',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.message === 'Please provide a valid URL')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects http_request URLs without an explicit protocol', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Test hook',
|
||||
table_id: 'public.messages',
|
||||
http_method: 'POST' as const,
|
||||
timeout_ms: 1000,
|
||||
events: ['INSERT'],
|
||||
httpHeaders: [],
|
||||
httpParameters: [],
|
||||
function_type: 'http_request' as const,
|
||||
http_url: 'hooks.example.com/webhook',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some(
|
||||
(issue) => issue.message === 'Please prefix your URL with http:// or https://'
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { isValidHttpUrl } from '@/lib/helpers'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const httpRequestSchema = z.object({
|
||||
function_type: z.literal('http_request'),
|
||||
http_url: z
|
||||
.string()
|
||||
.min(1, 'Please provide a URL')
|
||||
.refine((val) => val.startsWith('http'), 'Please prefix your URL with http or https')
|
||||
.refine((val) => isValidHttpUrl(val), 'Please provide a valid URL'),
|
||||
http_url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Please provide a URL',
|
||||
invalidMessage: 'Please provide a valid URL',
|
||||
prefixMessage: 'Please prefix your URL with http:// or https://',
|
||||
}),
|
||||
})
|
||||
|
||||
const supabaseFunctionSchema = z.object({
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FormSchema } from './CreateCronJobSheet.constants'
|
||||
|
||||
describe('CreateCronJobSheet FormSchema', () => {
|
||||
it('rejects incomplete http_request hostnames', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Send webhook',
|
||||
supportsSeconds: false,
|
||||
schedule: '* * * * *',
|
||||
values: {
|
||||
type: 'http_request' as const,
|
||||
method: 'POST' as const,
|
||||
endpoint: 'https://webhook',
|
||||
timeoutMs: 1000,
|
||||
httpHeaders: [],
|
||||
snippet: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.message === 'Please provide a valid URL')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects http_request URLs without an explicit protocol', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Send webhook',
|
||||
supportsSeconds: false,
|
||||
schedule: '* * * * *',
|
||||
values: {
|
||||
type: 'http_request' as const,
|
||||
method: 'POST' as const,
|
||||
endpoint: 'hooks.example.com/webhook',
|
||||
timeoutMs: 1000,
|
||||
httpHeaders: [],
|
||||
snippet: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some(
|
||||
(issue) => issue.message === 'Please prefix your URL with http:// or https://'
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
+6
-7
@@ -1,8 +1,8 @@
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import z from 'zod'
|
||||
|
||||
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
|
||||
import { cronPattern, secondsPattern } from '../CronJobs.constants'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const convertCronToString = (schedule: string) => {
|
||||
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
|
||||
@@ -40,12 +40,11 @@ const edgeFunctionSchema = z.object({
|
||||
const httpRequestSchema = z.object({
|
||||
type: z.literal('http_request'),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
endpoint: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please provide a URL')
|
||||
.regex(urlRegex(), 'Please provide a valid URL')
|
||||
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
|
||||
endpoint: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Please provide a URL',
|
||||
invalidMessage: 'Please provide a valid URL',
|
||||
prefixMessage: 'Please prefix your URL with http:// or https://',
|
||||
}),
|
||||
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
|
||||
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
httpBody: z
|
||||
|
||||
@@ -39,7 +39,6 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { urlRegex } from '../Auth/Auth.constants'
|
||||
import {
|
||||
DATADOG_REGIONS,
|
||||
LAST9_REGIONS,
|
||||
@@ -51,19 +50,18 @@ import {
|
||||
getHeadersSectionDescription as getHeadersDescription,
|
||||
validateNewHeader,
|
||||
} from './LogDrains.utils'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const FORM_ID = 'log-drain-destination-form'
|
||||
|
||||
const formUnion = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('webhook'),
|
||||
url: z
|
||||
.string()
|
||||
.regex(urlRegex(), 'Endpoint URL is required and must be a valid URL')
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'Endpoint URL must start with http:// or https://'
|
||||
),
|
||||
url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Endpoint URL is required',
|
||||
invalidMessage: 'Endpoint URL must be a valid URL',
|
||||
prefixMessage: 'Endpoint URL must start with http:// or https://',
|
||||
}),
|
||||
http: z.enum(['http1', 'http2']),
|
||||
gzip: z.boolean(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
@@ -75,13 +73,11 @@ const formUnion = z.discriminatedUnion('type', [
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('loki'),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, { message: 'Loki URL is required' })
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'Loki URL must start with http:// or https://'
|
||||
),
|
||||
url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Loki URL is required',
|
||||
invalidMessage: 'Loki URL must be a valid URL',
|
||||
prefixMessage: 'Loki URL must start with http:// or https://',
|
||||
}),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
@@ -130,13 +126,11 @@ const formUnion = z.discriminatedUnion('type', [
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('otlp'),
|
||||
endpoint: z
|
||||
.string()
|
||||
.min(1, { message: 'OTLP endpoint is required' })
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'OTLP endpoint must start with http:// or https://'
|
||||
),
|
||||
endpoint: httpEndpointUrlSchema({
|
||||
requiredMessage: 'OTLP endpoint is required',
|
||||
invalidMessage: 'OTLP endpoint must be a valid URL',
|
||||
prefixMessage: 'OTLP endpoint must start with http:// or https://',
|
||||
}),
|
||||
protocol: z.string().optional().default('http/protobuf'),
|
||||
gzip: z.boolean().optional().default(true),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
|
||||
@@ -223,6 +223,18 @@ describe('otlpConfigSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects endpoint with an incomplete hostname', () => {
|
||||
const config = {
|
||||
type: 'otlp' as const,
|
||||
endpoint: 'https://webhook',
|
||||
}
|
||||
const result = otlpConfigSchema.safeParse(config)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toContain('must be a valid URL')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects wrong type field', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LogDrainType } from './LogDrains.constants'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
/**
|
||||
* Get the description text for the custom headers section based on log drain type
|
||||
@@ -62,13 +63,11 @@ export function validateNewHeader(
|
||||
*/
|
||||
export const otlpConfigSchema = z.object({
|
||||
type: z.literal('otlp'),
|
||||
endpoint: z
|
||||
.string()
|
||||
.min(1, { message: 'OTLP endpoint is required' })
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'OTLP endpoint must start with http:// or https://'
|
||||
),
|
||||
endpoint: httpEndpointUrlSchema({
|
||||
requiredMessage: 'OTLP endpoint is required',
|
||||
invalidMessage: 'OTLP endpoint must be a valid URL',
|
||||
prefixMessage: 'OTLP endpoint must start with http:// or https://',
|
||||
}),
|
||||
protocol: z.string().optional().default('http/protobuf'),
|
||||
gzip: z.boolean().optional().default(true),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
|
||||
+116
-1
@@ -55,6 +55,15 @@ const renderEndpointSheet = (
|
||||
return { onClose, onSubmit }
|
||||
}
|
||||
|
||||
const submitForm = () =>
|
||||
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
|
||||
|
||||
const getUrlInput = () => screen.getByPlaceholderText('https://api.example.com/webhooks/supabase')
|
||||
|
||||
const findEventTypeCheckbox = (eventType: string) =>
|
||||
screen.findByRole('checkbox', {
|
||||
name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
})
|
||||
describe('PlatformWebhooksEndpointSheet', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -85,7 +94,7 @@ describe('PlatformWebhooksEndpointSheet', () => {
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Billing events')
|
||||
await user.clear(nameInput)
|
||||
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
@@ -97,4 +106,110 @@ describe('PlatformWebhooksEndpointSheet', () => {
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Please provide a URL')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL is malformed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://not a url')
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL uses an incomplete hostname', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://webhook')
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL does not include a protocol', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'hooks.example.com/billing')
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please prefix your URL with http:// or https://')
|
||||
).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when no event types are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the event type error after selecting an event and allows submit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
|
||||
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Select at least one event type')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://hooks.example.com/billing',
|
||||
eventTypes: ['project.updated'],
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('allows submit when subscribe all is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
|
||||
await user.click(screen.getByRole('checkbox', { name: /subscribe to all events/i }))
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscribeAll: true,
|
||||
eventTypes: PROJECT_EVENT_TYPES,
|
||||
url: 'https://hooks.example.com/billing',
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
+182
-175
@@ -38,11 +38,16 @@ import type {
|
||||
WebhookScope,
|
||||
} from './PlatformWebhooks.types'
|
||||
import { generateWebhookEndpointName } from './PlatformWebhooks.utils'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const endpointFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().max(64, 'Name cannot exceed 64 characters'),
|
||||
url: z.string().trim().url('Please enter a valid URL'),
|
||||
url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Please provide a URL',
|
||||
invalidMessage: 'Please provide a valid URL',
|
||||
prefixMessage: 'Please prefix your URL with http:// or https://',
|
||||
}),
|
||||
description: z.string().trim().max(512, 'Description cannot exceed 512 characters'),
|
||||
enabled: z.boolean().default(true),
|
||||
subscribeAll: z.boolean().default(false),
|
||||
@@ -345,188 +350,190 @@ export const PlatformWebhooksEndpointSheet = ({
|
||||
<Separator />
|
||||
|
||||
<div className="px-5 space-y-3">
|
||||
<FormItemLayout
|
||||
label="Event types"
|
||||
description={
|
||||
scope === 'organization' ? (
|
||||
<>
|
||||
Project events are triggered when any project in this organization matches
|
||||
the event type. Add a{' '}
|
||||
<InlineLink href="/project/_/settings/webhooks">
|
||||
project endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events on an individual project only.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Project events are triggered for this project only. Add an{' '}
|
||||
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
|
||||
organization endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events from any project in your organization.
|
||||
</>
|
||||
)
|
||||
}
|
||||
layout="vertical"
|
||||
className="gap-3"
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="subscribeAll"
|
||||
render={({ field }) => {
|
||||
const subscribeAllId = 'subscribe-all-events'
|
||||
return (
|
||||
<div className="rounded-md border bg-surface-100 overflow-hidden">
|
||||
<Label
|
||||
htmlFor={subscribeAllId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
|
||||
field.value ? 'bg-surface-100' : 'bg-surface-200'
|
||||
)}
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field, fieldState }) => {
|
||||
const selectedTypes = field.value ?? []
|
||||
const hasEventTypeError = !!fieldState.error
|
||||
|
||||
return (
|
||||
<FormItemLayout
|
||||
label="Event types"
|
||||
description={
|
||||
scope === 'organization' ? (
|
||||
<>
|
||||
Project events are triggered when any project in this organization
|
||||
matches the event type. Add a{' '}
|
||||
<InlineLink href="/project/_/settings/webhooks">
|
||||
project endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events on an individual project only.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Project events are triggered for this project only. Add an{' '}
|
||||
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
|
||||
organization endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events from any project in your organization.
|
||||
</>
|
||||
)
|
||||
}
|
||||
layout="vertical"
|
||||
className="gap-2"
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="subscribeAll"
|
||||
render={({ field }) => {
|
||||
const subscribeAllId = 'subscribe-all-events'
|
||||
return (
|
||||
<div className="rounded-md border bg-surface-100 overflow-hidden">
|
||||
<Label
|
||||
htmlFor={subscribeAllId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
|
||||
field.value ? 'bg-surface-100' : 'bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Checkbox
|
||||
id={subscribeAllId}
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValue = Boolean(checked)
|
||||
field.onChange(nextValue)
|
||||
|
||||
if (nextValue) {
|
||||
form.setValue('eventTypes', eventTypes, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue('eventTypes', [], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<span className="text-sm text-foreground">
|
||||
Subscribe to all events{' '}
|
||||
<code className="text-code-inline">(*)</code>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl_Shadcn_>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={openEventGroups}
|
||||
onValueChange={setOpenEventGroups}
|
||||
className="mt-2 space-y-2"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Checkbox
|
||||
id={subscribeAllId}
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValue = Boolean(checked)
|
||||
field.onChange(nextValue)
|
||||
{groupedEventTypes.map((group) => {
|
||||
const selectedInGroup = group.eventTypes.filter((eventType) =>
|
||||
selectedTypes.includes(eventType)
|
||||
)
|
||||
const allSelected = selectedInGroup.length === group.eventTypes.length
|
||||
const isGroupOpen = openEventGroups.includes(group.id)
|
||||
|
||||
if (nextValue) {
|
||||
form.setValue('eventTypes', eventTypes, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue('eventTypes', [], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<span className="text-sm text-foreground">
|
||||
Subscribe to all events <code className="text-code-inline">(*)</code>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field }) => {
|
||||
const selectedTypes = field.value ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl_Shadcn_>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={openEventGroups}
|
||||
onValueChange={setOpenEventGroups}
|
||||
className="mt-2 space-y-2"
|
||||
>
|
||||
{groupedEventTypes.map((group) => {
|
||||
const selectedInGroup = group.eventTypes.filter((eventType) =>
|
||||
selectedTypes.includes(eventType)
|
||||
)
|
||||
const allSelected =
|
||||
selectedInGroup.length === group.eventTypes.length
|
||||
const isGroupOpen = openEventGroups.includes(group.id)
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className="overflow-hidden rounded-md border"
|
||||
return (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border',
|
||||
hasEventTypeError && 'border-destructive-400'
|
||||
)}
|
||||
>
|
||||
<AccordionTrigger
|
||||
hideIcon
|
||||
className="group px-4 py-3 hover:no-underline"
|
||||
>
|
||||
<AccordionTrigger
|
||||
hideIcon
|
||||
className="group px-4 py-3 hover:no-underline"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{group.label}
|
||||
</p>
|
||||
{selectedInGroup.length > 0 && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{selectedInGroup.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isGroupOpen && group.eventTypes.length > 1 && (
|
||||
<span
|
||||
className="text-xs text-foreground-muted hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{group.label}
|
||||
</p>
|
||||
{selectedInGroup.length > 0 && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{selectedInGroup.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isGroupOpen && group.eventTypes.length > 1 && (
|
||||
<span
|
||||
className="text-xs text-foreground-muted hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
field.onChange(
|
||||
toggleEventTypeGroup(
|
||||
selectedTypes,
|
||||
group.eventTypes,
|
||||
!allSelected
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
{allSelected ? 'Clear all' : 'Select all'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
|
||||
<div className="divide-y border-t">
|
||||
{group.eventTypes.map((eventType) => {
|
||||
const checked = selectedTypes.includes(eventType)
|
||||
const eventTypeId = toControlId('event-type', eventType)
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={eventType}
|
||||
htmlFor={eventTypeId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
|
||||
checked && 'bg-surface-100'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={eventTypeId}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
field.onChange(
|
||||
toggleEventTypeGroup(
|
||||
toggleEventType(
|
||||
selectedTypes,
|
||||
group.eventTypes,
|
||||
!allSelected
|
||||
eventType,
|
||||
Boolean(next)
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
{allSelected ? 'Clear all' : 'Select all'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
|
||||
<div className="divide-y border-t">
|
||||
{group.eventTypes.map((eventType) => {
|
||||
const checked = selectedTypes.includes(eventType)
|
||||
const eventTypeId = toControlId('event-type', eventType)
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={eventType}
|
||||
htmlFor={eventTypeId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
|
||||
checked && 'bg-surface-100'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={eventTypeId}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
field.onChange(
|
||||
toggleEventType(
|
||||
selectedTypes,
|
||||
eventType,
|
||||
Boolean(next)
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<code className="text-code-inline">{eventType}</code>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl_Shadcn_>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
/>
|
||||
<code className="text-code-inline">{eventType}</code>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { httpEndpointUrlSchema, isValidHttpEndpointUrl } from './http-url'
|
||||
|
||||
const schema = httpEndpointUrlSchema({
|
||||
requiredMessage: 'required',
|
||||
invalidMessage: 'invalid',
|
||||
prefixMessage: 'prefix',
|
||||
})
|
||||
|
||||
describe('isValidHttpEndpointUrl', () => {
|
||||
it('accepts valid http and https endpoints', () => {
|
||||
expect(isValidHttpEndpointUrl('https://api.supabase.com/webhooks')).toBe(true)
|
||||
expect(isValidHttpEndpointUrl('http://localhost:3000/hooks')).toBe(true)
|
||||
expect(isValidHttpEndpointUrl('https://127.0.0.1:4318/v1/logs')).toBe(true)
|
||||
expect(isValidHttpEndpointUrl('https://[::1]:4318/v1/logs')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid endpoint URLs', () => {
|
||||
expect(isValidHttpEndpointUrl('https://webhook')).toBe(false)
|
||||
expect(isValidHttpEndpointUrl('ftp://api.supabase.com/webhooks')).toBe(false)
|
||||
expect(isValidHttpEndpointUrl('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('httpEndpointUrlSchema', () => {
|
||||
it('rejects empty values', () => {
|
||||
const result = schema.safeParse('')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('required')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects URLs without an http or https prefix', () => {
|
||||
const result = schema.safeParse('api.supabase.com/webhooks')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('prefix')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects incomplete hostnames', () => {
|
||||
const result = schema.safeParse('https://webhook')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid endpoints after trimming', () => {
|
||||
const result = schema.safeParse(' https://api.supabase.com/webhooks ')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data).toBe('https://api.supabase.com/webhooks')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const HTTP_URL_PROTOCOL_REGEX = /^https?:\/\//
|
||||
const IPV4_SEGMENT = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
|
||||
const IPV4_REGEX = new RegExp(`^(?:${IPV4_SEGMENT}\\.){3}${IPV4_SEGMENT}$`)
|
||||
const BRACKETED_IPV6_REGEX = /^\[[0-9a-f:.]+\]$/i
|
||||
|
||||
export const hasHttpUrlProtocol = (value: string) => HTTP_URL_PROTOCOL_REGEX.test(value)
|
||||
|
||||
export const isValidHttpEndpointUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
||||
|
||||
const { hostname } = url
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname.includes('.') ||
|
||||
IPV4_REGEX.test(hostname) ||
|
||||
BRACKETED_IPV6_REGEX.test(hostname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type HttpEndpointUrlSchemaOptions = {
|
||||
requiredMessage: string
|
||||
invalidMessage: string
|
||||
prefixMessage: string
|
||||
}
|
||||
|
||||
export const httpEndpointUrlSchema = ({
|
||||
requiredMessage,
|
||||
invalidMessage,
|
||||
prefixMessage,
|
||||
}: HttpEndpointUrlSchemaOptions) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, requiredMessage)
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value) return
|
||||
|
||||
if (!hasHttpUrlProtocol(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: prefixMessage,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidHttpEndpointUrl(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: invalidMessage,
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user