diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts index a831766cb30..d305bd8ccab 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts @@ -1,6 +1,6 @@ export type ResponseData = { status: number - headers: Record + headers: Record body: string } diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx index 25fe2a6cc0a..21b02412ecb 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils.tsx @@ -1,4 +1,7 @@ +import { getAnchor } from '@ui/components/CustomHTMLElements/CustomHTMLElements.utils' + import { EdgeFunction } from '@/data/edge-functions/edge-function-query' +import { DOCS_URL } from '@/lib/constants' export const generateCLICommands = ({ selectedFunction, @@ -99,3 +102,18 @@ export const generateCLICommands = ({ return { managementCommands, secretCommands, invokeCommands } } + +export const getEdgeFunctionErrorDocs = (headers: Record) => { + const header = Object.entries(headers).find( + ([name]) => name.toLowerCase() === 'sb-error-code' + )?.[1] + const code = (Array.isArray(header) ? header[0] : header)?.trim() + const anchor = code ? getAnchor(code) : undefined + + if (!code || !anchor) return undefined + + return { + code, + href: `${DOCS_URL}/guides/functions/error-codes#${anchor}`, + } +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index 250f5096474..1a719dc33da 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { Loader2, Plus, Send, X } from 'lucide-react' +import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react' import { useState } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { @@ -37,6 +37,7 @@ import * as z from 'zod' import { HTTP_METHODS } from './EdgeFunctionDetails.constants' import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' +import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils' import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useAPIKeys } from '@/data/api-keys/api-keys-query' @@ -100,6 +101,7 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester const [response, setResponse] = useState(null) const [error, setError] = useState(null) + const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys }) @@ -423,12 +425,28 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester Headers - = 400 ? 'destructive' : 'success'} - className="-translate-y-1" - > - {response.status} - +
+ {errorDocs !== undefined && ( + + )} + = 400 ? 'destructive' : 'success'}> + {response.status} + +
= {} + const responseHeaders: Record = {} response.headers.forEach((value, key) => { - responseHeaders[key] = value + const existing = responseHeaders[key] + if (existing === undefined) { + responseHeaders[key] = value + } else if (Array.isArray(existing)) { + existing.push(value) + } else { + responseHeaders[key] = [existing, value] + } }) - return res.status(response.status).json({ + return res.status(200).json({ status: response.status, headers: responseHeaders, body: responseBody, diff --git a/apps/studio/tests/components/Functions/EdgeFunctionDetails.utils.test.ts b/apps/studio/tests/components/Functions/EdgeFunctionDetails.utils.test.ts new file mode 100644 index 00000000000..af31ed7d99c --- /dev/null +++ b/apps/studio/tests/components/Functions/EdgeFunctionDetails.utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { getEdgeFunctionErrorDocs } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils' +import { DOCS_URL } from '@/lib/constants' + +describe('getEdgeFunctionErrorDocs', () => { + it('builds a documentation link from the error code header', () => { + expect( + getEdgeFunctionErrorDocs({ 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' }) + ).toEqual({ + code: 'UNAUTHORIZED_INVALID_JWT_FORMAT', + href: `${DOCS_URL}/guides/functions/error-codes#unauthorizedinvalidjwtformat`, + }) + }) + + it('uses the same anchor format as documentation headings', () => { + expect(getEdgeFunctionErrorDocs({ 'sb-error-code': 'WORKER_RESOURCE_LIMIT' })).toEqual({ + code: 'WORKER_RESOURCE_LIMIT', + href: `${DOCS_URL}/guides/functions/error-codes#workerresourcelimit`, + }) + }) + + it('matches the error code header case-insensitively', () => { + expect(getEdgeFunctionErrorDocs({ 'Sb-Error-Code': 'BOOT_ERROR' })).toEqual({ + code: 'BOOT_ERROR', + href: `${DOCS_URL}/guides/functions/error-codes#booterror`, + }) + }) + + it('uses the first value when the header contains multiple values', () => { + expect(getEdgeFunctionErrorDocs({ 'sb-error-code': ['WORKER_ERROR', 'BOOT_ERROR'] })).toEqual({ + code: 'WORKER_ERROR', + href: `${DOCS_URL}/guides/functions/error-codes#workererror`, + }) + }) + + it('omits the link when the error code header is missing', () => { + expect(getEdgeFunctionErrorDocs({})).toBeUndefined() + }) + + it.each(['', '___'])('omits the link for an unusable error code', (code) => { + expect(getEdgeFunctionErrorDocs({ 'sb-error-code': code })).toBeUndefined() + }) +}) diff --git a/apps/studio/tests/pages/api/edge-functions/test.test.ts b/apps/studio/tests/pages/api/edge-functions/test.test.ts new file mode 100644 index 00000000000..a3fdf4b656d --- /dev/null +++ b/apps/studio/tests/pages/api/edge-functions/test.test.ts @@ -0,0 +1,141 @@ +import { createMocks } from 'node-mocks-http' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import handler from '../../../../pages/api/edge-functions/test' + +vi.mock('common', () => ({ IS_PLATFORM: true })) + +const createRequest = (url = 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/test') => + createMocks({ + method: 'POST', + body: { + url, + method: 'POST', + body: '{}', + headers: {}, + }, + }) + +describe('/api/edge-functions/test', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('preserves unsuccessful edge function responses', async () => { + const body = JSON.stringify({ + code: 'UNAUTHORIZED_NO_AUTH_HEADER', + message: 'Missing authorization header', + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(body, { + status: 401, + headers: { + 'content-type': 'application/json', + 'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER', + }, + }) + ) + ) + const { req, res } = createRequest() + + await handler(req, res) + + expect(res._getStatusCode()).toBe(200) + expect(JSON.parse(res._getData())).toEqual({ + status: 401, + headers: { + 'content-type': 'application/json', + 'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER', + }, + body, + }) + }) + + it('preserves multiple Set-Cookie headers', async () => { + const headers = new Headers() + headers.append('set-cookie', 'session=one; Path=/; HttpOnly') + headers.append('set-cookie', 'csrf=two; Path=/; SameSite=Lax') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { headers }))) + const { req, res } = createRequest() + + await handler(req, res) + + expect(JSON.parse(res._getData())).toEqual({ + status: 200, + headers: { + 'set-cookie': ['session=one; Path=/; HttpOnly', 'csrf=two; Path=/; SameSite=Lax'], + }, + body: '', + }) + }) + + it.each([ + ['successful JSON', JSON.stringify({ ok: true }), 'application/json', 200], + [ + 'gateway JSON', + JSON.stringify({ message: 'Name resolution failed' }), + 'application/json', + 503, + ], + ['legacy JSON', JSON.stringify({ msg: 'Invalid JWT' }), 'application/json', 503], + [ + 'nested JSON', + JSON.stringify({ error: { message: 'Function failed' } }), + 'application/json', + 503, + ], + ['arbitrary JSON', JSON.stringify({ details: ['Function failed'] }), 'application/json', 503], + ['plain text', 'Bad Gateway', 'text/plain', 503], + ['malformed JSON', '{"message": invalid json', 'application/json', 503], + ])('preserves %s bodies without parsing them', async (_name, body, contentType, status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(body, { + status, + headers: { 'content-type': contentType }, + }) + ) + ) + const { req, res } = createRequest() + + await handler(req, res) + + expect(res._getStatusCode()).toBe(200) + expect(JSON.parse(res._getData())).toEqual({ + status, + headers: { 'content-type': contentType }, + body, + }) + }) + + it('rejects invalid URLs without making an upstream request', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { req, res } = createRequest('https://example.com/functions/v1/test') + + await handler(req, res) + + expect(fetchMock).not.toHaveBeenCalled() + expect(res._getStatusCode()).toBe(400) + expect(JSON.parse(res._getData())).toEqual({ + status: 400, + error: { message: 'Provided URL is not a valid Supabase edge function URL' }, + }) + }) + + it('returns fetch failures as proxy errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Connection refused'))) + const { req, res } = createRequest() + + await handler(req, res) + + expect(res._getStatusCode()).toBe(500) + expect(JSON.parse(res._getData())).toEqual({ + status: 500, + error: { message: 'Connection refused' }, + }) + }) +}) diff --git a/e2e/studio/features/edge-functions.spec.ts b/e2e/studio/features/edge-functions.spec.ts new file mode 100644 index 00000000000..8861185791e --- /dev/null +++ b/e2e/studio/features/edge-functions.spec.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test' + +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' + +const FUNCTION_SLUG = 'error-code-docs' + +test.describe('Edge Functions', () => { + test('links an sb-error-code response to its documentation', async ({ page, ref }) => { + await page.route(`**/api/v1/projects/${ref}/functions/${FUNCTION_SLUG}`, async (route) => { + const timestamp = Date.now() + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + id: '00000000-0000-0000-0000-000000000000', + slug: FUNCTION_SLUG, + name: FUNCTION_SLUG, + version: 1, + status: 'ACTIVE', + entrypoint_path: `supabase/functions/${FUNCTION_SLUG}/index.ts`, + created_at: timestamp, + updated_at: timestamp, + }), + }) + }) + await page.route('**/api/edge-functions/test', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + status: 401, + headers: { 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' }, + body: '', + }), + }) + }) + + await page.goto(toUrl(`/project/${ref}/functions/${FUNCTION_SLUG}`)) + await page.getByRole('button', { name: 'Test', exact: true }).click() + + const testResponse = page.waitForResponse( + (response) => + response.url().includes('/api/edge-functions/test') && + response.request().method() === 'POST' + ) + await page.getByRole('button', { name: 'Send Request' }).click() + expect( + (await testResponse).ok(), + 'Studio proxy should return a successful response envelope' + ).toBeTruthy() + + await expect( + page.getByRole('link', { + name: 'View documentation for UNAUTHORIZED_INVALID_JWT_FORMAT (opens in new tab)', + }), + 'Error response should link to its documentation section' + ).toHaveAttribute( + 'href', + 'https://supabase.com/docs/guides/functions/error-codes#unauthorizedinvalidjwtformat' + ) + }) +})