mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
## Context PR here refactors the way we manage Foreign Data Wrappers in the dashboard (Under Project -> Integrations), as there's some DX problems with the current behaviour. Currently whenever a user creates a new wrapper, the dashboard is creating both the Foreign Data Wrapper (`create foreign data wrapper...`) + server (`create server ...`). The former is **_redundant_** to create multiples of given that it just handles the `handler` and `validator`, whereas what matters more is the server which holds the connection credentials. Hence standard practice is usually one Foreign Data Wrapper with multiple servers. (The former just needs to be created once if not done yet) This also led to some problems as well when users created their own wrappers via SQL and tried to manage them through the dashboard GUI, leading to us having to add some guard rails to prevent managing wrappers sharing the same Foreign Data Wrapper ([ref](https://github.com/supabase/supabase/pull/50785)) ## Changes involved - When creating a wrapper, if the Foreign Data Wrapper has yet to be set up for the wrapper type, the dashboard will initialize one and subsequently use that same Foreign Data Wrapper for any new wrappers - When creating / editing a wrapper, users will name the **server** instead of the **wrapper** <img width="500" alt="image" src="https://github.com/user-attachments/assets/b3e61204-0e16-4599-84ac-af2aab5b93c2" /> - When deleting a wrapper, the clean up for vault secrets are now deterministic by referencing the wrapper's server options - RE backwards compatibility: Existing wrappers will _not_ be affected by the changes here - they can be edited / deleted as per normal ## Unrelated fixes + UI refactors added - Fix Iceberg Wrapper not showing the right form when adding new wrapper - Adjust form layouts in side panel to be horizontal instead of vertical (Follows Database -> Pipelines) - Clean up to use newer UI components like `ButtonTooltip` - Opt to hide Docs + Create CTA under `WrappersTab` if marketplace feature preview is enabled (Since these actions are already in the header, will be duplicates) - Consolidate foreign tables configuration for create + edit wrapper sheet into one component `ForeignTablesSelector` ## To test - [ ] Verify that existing wrappers with their own Foreign Data Wrapper can be edited correctly - [ ] Verify that existing wrappers with their own Foreign Data Wrapper can be deleted - [ ] Verify that existing wrappers with shared Foreign Data Wrapper can be edited correctly - [ ] Verify that existing wrappers with shared Foreign Data Wrapper can be deleted - [ ] Verify that new wrappers can be created - [ ] Verify that newly created wrappers can be edited correctly - [ ] Verify that newly created wrappers can be deleted
291 lines
9.2 KiB
TypeScript
291 lines
9.2 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { SUPABASE_TARGET_SCHEMA_OPTION, WRAPPER_HANDLERS } from './Wrappers.constants'
|
|
import { Table } from './Wrappers.types'
|
|
import {
|
|
getEditionFormSchema,
|
|
getRequiredExtensionsToInstall,
|
|
getTableFormSchema,
|
|
getWrapperCreationFormSchema,
|
|
hasForeignSchemaSupport,
|
|
} from './Wrappers.utils'
|
|
|
|
const stripe_wrapper = {
|
|
name: 'stripe_wrapper',
|
|
handlerName: WRAPPER_HANDLERS.STRIPE,
|
|
validatorName: 'stripe_fdw_validator',
|
|
icon: '',
|
|
description: 'Payment processing and subscription management',
|
|
extensionName: 'StripeFdw',
|
|
label: 'Stripe',
|
|
docsUrl: '',
|
|
server: {
|
|
options: [
|
|
{
|
|
name: 'api_key_id',
|
|
label: 'Stripe Secret Key',
|
|
required: true,
|
|
encrypted: true,
|
|
secureEntry: true,
|
|
urlHelper: 'https://stripe.com/docs/keys',
|
|
},
|
|
{
|
|
name: 'api_url',
|
|
label: 'Stripe API URL',
|
|
defaultValue: 'https://api.stripe.com/v1',
|
|
required: false,
|
|
encrypted: false,
|
|
secureEntry: false,
|
|
},
|
|
SUPABASE_TARGET_SCHEMA_OPTION,
|
|
],
|
|
},
|
|
tables: [],
|
|
}
|
|
|
|
describe('getWrapperCreationFormSchema', () => {
|
|
it('should build a schema that validates tables mode', () => {
|
|
const schema = getWrapperCreationFormSchema(stripe_wrapper)
|
|
const result = schema.safeParse({ mode: 'tables' })
|
|
|
|
expect(result.success).toEqual(false)
|
|
// Common required field
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'server_name'))
|
|
).toBeDefined()
|
|
// Table mode specific field
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'tables'))
|
|
).toBeDefined()
|
|
})
|
|
|
|
it('should build a schema that validates schema mode', () => {
|
|
const schema = getWrapperCreationFormSchema(stripe_wrapper)
|
|
const result = schema.safeParse({ mode: 'schema' })
|
|
|
|
expect(result.success).toEqual(false)
|
|
// Common required field
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'server_name'))
|
|
).toBeDefined()
|
|
// Schema mode specific field
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'source_schema'))
|
|
).toBeDefined()
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'target_schema'))
|
|
).toBeDefined()
|
|
})
|
|
|
|
it('should build a schema that validates wrapper specific options', () => {
|
|
const schema = getWrapperCreationFormSchema(stripe_wrapper)
|
|
const result = schema.safeParse({ mode: 'tables' })
|
|
expect(result.success).toEqual(false)
|
|
// Required option
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_key_id'))
|
|
).toBeDefined()
|
|
// Optional option
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_url'))
|
|
).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('getEditionFormSchema', () => {
|
|
it('should build a schema that validates wrapper specific options', () => {
|
|
const schema = getEditionFormSchema(stripe_wrapper)
|
|
const result = schema.safeParse({ mode: 'tables' })
|
|
expect(result.success).toEqual(false)
|
|
// Common required field
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'server_name'))
|
|
).toBeDefined()
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'tables'))
|
|
).toBeDefined()
|
|
// Required option
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_key_id'))
|
|
).toBeDefined()
|
|
// Optional option
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'api_url'))
|
|
).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('getTableFormSchema', () => {
|
|
const table_schema = {
|
|
label: 'Accounts',
|
|
description: 'List of accounts on your Stripe account',
|
|
availableColumns: [
|
|
{
|
|
name: 'id',
|
|
type: 'text',
|
|
},
|
|
{
|
|
name: 'business_type',
|
|
type: 'text',
|
|
},
|
|
{
|
|
name: 'country',
|
|
type: 'text',
|
|
},
|
|
{
|
|
name: 'email',
|
|
type: 'text',
|
|
},
|
|
{
|
|
name: 'type',
|
|
type: 'text',
|
|
},
|
|
{
|
|
name: 'created',
|
|
type: 'timestamp',
|
|
},
|
|
{
|
|
name: 'attrs',
|
|
type: 'jsonb',
|
|
},
|
|
],
|
|
options: [
|
|
{
|
|
name: 'object',
|
|
defaultValue: 'accounts',
|
|
editable: false,
|
|
required: true,
|
|
type: 'text',
|
|
},
|
|
{
|
|
name: 'rowid_column',
|
|
label: 'Row ID Column',
|
|
defaultValue: 'id',
|
|
editable: true,
|
|
required: false,
|
|
type: 'text',
|
|
},
|
|
],
|
|
} satisfies Table
|
|
|
|
it('should build a schema that validates tables with existing schema', () => {
|
|
const schema = getTableFormSchema(table_schema)
|
|
const result = schema.safeParse({})
|
|
expect(result.success).toEqual(false)
|
|
// Common required field
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'table_name'))
|
|
).toBeDefined()
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'schema'))
|
|
).toBeDefined()
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'columns'))
|
|
).toBeDefined()
|
|
// Required options
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'object'))
|
|
).toBeDefined()
|
|
// Optional options
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'rowid_column'))
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('should build a schema that validates tables with custom schema', () => {
|
|
const schema = getTableFormSchema(table_schema)
|
|
// Because the schema_name is validated in zod superRefine, the normal validation
|
|
// must have succeeded first
|
|
const result = schema.safeParse({
|
|
table_name: 'test',
|
|
schema: 'custom',
|
|
columns: [],
|
|
object: 'acounts',
|
|
})
|
|
|
|
expect(result.success).toEqual(false)
|
|
expect(
|
|
result.error?.issues.find((issue) => issue.path.some((p) => p === 'schema_name'))
|
|
).toBeDefined()
|
|
})
|
|
})
|
|
|
|
// ─── getRequiredExtensionsToInstall ──────────────────────────────────────────
|
|
|
|
describe('getRequiredExtensionsToInstall', () => {
|
|
const wrappers = { name: 'wrappers', installed_version: null }
|
|
const vault = { name: 'supabase_vault', installed_version: null }
|
|
const wrappersInstalled = { name: 'wrappers', installed_version: '0.4.1' }
|
|
const vaultInstalled = { name: 'supabase_vault', installed_version: '0.2.8' }
|
|
const unrelated = { name: 'pgmq', installed_version: null }
|
|
|
|
it('returns null when extensions are undefined (still loading)', () => {
|
|
expect(getRequiredExtensionsToInstall(undefined, ['wrappers'])).toBeNull()
|
|
})
|
|
|
|
it('returns empty array when required list is empty', () => {
|
|
expect(getRequiredExtensionsToInstall([wrappers], [])).toEqual([])
|
|
})
|
|
|
|
it('returns empty array when all required extensions are already installed', () => {
|
|
expect(
|
|
getRequiredExtensionsToInstall(
|
|
[wrappersInstalled, vaultInstalled],
|
|
['wrappers', 'supabase_vault']
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('returns extensions that are required but not installed', () => {
|
|
const result = getRequiredExtensionsToInstall(
|
|
[wrappers, vaultInstalled],
|
|
['wrappers', 'supabase_vault']
|
|
)
|
|
expect(result).toHaveLength(1)
|
|
expect(result![0].name).toBe('wrappers')
|
|
})
|
|
|
|
it('returns all required extensions when none are installed', () => {
|
|
const result = getRequiredExtensionsToInstall([wrappers, vault], ['wrappers', 'supabase_vault'])
|
|
expect(result).toHaveLength(2)
|
|
})
|
|
|
|
it('excludes extensions that are not in the required list', () => {
|
|
const result = getRequiredExtensionsToInstall([wrappers, unrelated], ['wrappers'])
|
|
expect(result).toHaveLength(1)
|
|
expect(result![0].name).toBe('wrappers')
|
|
})
|
|
|
|
it('returns empty array when extensions list is empty', () => {
|
|
expect(getRequiredExtensionsToInstall([], ['wrappers'])).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ─── hasForeignSchemaSupport ──────────────────────────────────────────────────
|
|
|
|
describe('hasForeignSchemaSupport', () => {
|
|
it('returns false when extension is undefined', () => {
|
|
expect(hasForeignSchemaSupport(undefined)).toBe(false)
|
|
})
|
|
|
|
it('returns false when installed_version is null (not installed)', () => {
|
|
expect(hasForeignSchemaSupport({ installed_version: null })).toBe(false)
|
|
})
|
|
|
|
it('returns false for version below 0.5.0', () => {
|
|
expect(hasForeignSchemaSupport({ installed_version: '0.4.9' })).toBe(false)
|
|
expect(hasForeignSchemaSupport({ installed_version: '0.3.0' })).toBe(false)
|
|
expect(hasForeignSchemaSupport({ installed_version: '0.1.0' })).toBe(false)
|
|
})
|
|
|
|
it('returns true for exactly version 0.5.0', () => {
|
|
expect(hasForeignSchemaSupport({ installed_version: '0.5.0' })).toBe(true)
|
|
})
|
|
|
|
it('returns true for version above 0.5.0', () => {
|
|
expect(hasForeignSchemaSupport({ installed_version: '0.6.0' })).toBe(true)
|
|
expect(hasForeignSchemaSupport({ installed_version: '0.5.1' })).toBe(true)
|
|
expect(hasForeignSchemaSupport({ installed_version: '1.0.0' })).toBe(true)
|
|
})
|
|
})
|