mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
## Problem Studio still called the legacy `/workers` Management API routes and used the old `project_worker` response contract, so Compute instances could not be listed or retrieved after the API rename. The production API type check also detected drift in the v1 and platform declarations. ## Fix - Regenerate the v1, v2, and platform API declarations from the deployed schemas. - Update Studio list and detail queries to `/compute`. - Align typed fixtures with the Compute response schemas and `project_compute_instance` resource type. - Update platform response type references to the generated `_Output` schema names. ## How to test - Run `pnpm api:verify-types`. - Run `pnpm --filter api-types test`. - Run `pnpm --filter studio test data/compute/compute.utils.test.ts "tests/pages/project/[ref]/compute/index.test.tsx"`. - Run `pnpm --filter studio typecheck`. - Run `pnpm --filter common typecheck`. - Run `pnpm --filter studio lint:ratchet`. Expected result: production API declarations are synchronized, and Studio requests the `/compute` list and detail endpoints and renders `project_compute_instance` responses successfully. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Updated API response handling across profiles, backups, notifications, integrations, warehouses, access tokens, payments, and other Studio workflows for more accurate serialized data. * Compute instance pages and queries now use the compute-specific API endpoints and response data. * Improved feature-flag type handling when disabled feature data is unavailable. * **Tests** * Updated automated coverage and fixtures to reflect current compute and API response formats. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
|
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
|
import { HttpResponse } from 'msw'
|
|
import { describe, expect, test, vi } from 'vitest'
|
|
|
|
import { MarketplaceIntegrationSettingsTab } from './MarketplaceIntegrationSettingsTab'
|
|
import { type IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
|
|
import { type components } from '@/data/api'
|
|
import { type APIKey } from '@/data/api-keys/api-keys-query'
|
|
import { type AuthConfigResponse } from '@/data/auth/auth-config-query'
|
|
import { type ProjectSecret } from '@/data/secrets/secrets-query'
|
|
import { customRender } from '@/tests/lib/custom-render'
|
|
import { addAPIMock } from '@/tests/lib/msw'
|
|
|
|
// The OAuth-apps query overrides its return type to `AuthorizedApp`, but the wire response (and so
|
|
// the MSW resolver) is the raw OpenAPI `OAuthAppResponse`. Build fixtures against the API shape.
|
|
type OAuthAppResponse = components['schemas']['OAuthAppResponse_Output']
|
|
type PartnerIntegrationListResponse = components['schemas']['PartnerIntegrationListResponse_Output']
|
|
// Same story for permissions: the frontend `Permission` type narrows nullable wire fields, so build
|
|
// the fixture against the raw `AccessControlPermission` shape the MSW resolver actually returns.
|
|
type AccessControlPermission = components['schemas']['AccessControlPermission']
|
|
|
|
// `useIntegrationDetail` resolves the integration definition from the route, the marketplace query
|
|
// and feature flags — none of which is the subject of this test. Mock it so each test can drive a
|
|
// specific integration definition directly. `useSelectedOrganizationQuery` is a composite selector
|
|
// over the org list + slug param; mock it to a fixed org so the authorized-apps query can fire.
|
|
const { detail } = vi.hoisted(() => ({
|
|
detail: { ref: 'default' as string | undefined, integration: undefined as unknown },
|
|
}))
|
|
|
|
vi.mock('@/components/interfaces/Integrations/Landing/useIntegrationDetail', () => ({
|
|
useIntegrationDetail: () => detail,
|
|
}))
|
|
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
|
|
useSelectedOrganizationQuery: () => ({ data: { slug: 'acme' } }),
|
|
}))
|
|
// `useProjectOAuthIntegrationData` gates the authorized-apps query behind a permission check, which
|
|
// requires a logged-in user and a granted `oauth_apps` permission to ever fire.
|
|
vi.mock('common', async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>()
|
|
return { ...actual, useIsLoggedIn: () => true }
|
|
})
|
|
// The auth-config query (used to detect custom SMTP) only runs on the platform.
|
|
vi.mock('@/lib/constants', async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>()
|
|
return { ...actual, IS_PLATFORM: true }
|
|
})
|
|
|
|
// The settings tab renders Radix-based modals (which rely on Web Animations).
|
|
mockAnimationsApi()
|
|
|
|
const setIntegration = (overrides: Partial<IntegrationDefinition> = {}) => {
|
|
detail.integration = {
|
|
id: 'custom',
|
|
name: 'Custom Integration',
|
|
type: 'oauth',
|
|
...overrides,
|
|
} as IntegrationDefinition
|
|
}
|
|
|
|
const secretApiKey = (name: string): APIKey =>
|
|
({
|
|
id: `key-${name}`,
|
|
name,
|
|
type: 'secret',
|
|
prefix: 'sb_secret_',
|
|
hash: 'hash',
|
|
api_key: 'sb_secret_value',
|
|
inserted_at: '2026-01-01T00:00:00Z',
|
|
secret_jwt_template: { role: 'service_role' },
|
|
}) as APIKey
|
|
|
|
const edgeSecret = (name: string): ProjectSecret =>
|
|
({ name, value: 'value', updated_at: '2026-01-01T00:00:00Z' }) as ProjectSecret
|
|
|
|
const authorizedApp = (appId: string): OAuthAppResponse => ({
|
|
id: `app-${appId}`,
|
|
app_id: appId,
|
|
name: 'Test OAuth App',
|
|
website: 'https://example.com',
|
|
created_by: 'tester',
|
|
authorized_at: '2026-01-01T00:00:00Z',
|
|
registration_type: 'manual',
|
|
})
|
|
|
|
const CAN_READ_OAUTH_APPS_PERMISSION: AccessControlPermission = {
|
|
actions: [PermissionAction.READ],
|
|
condition: null,
|
|
organization_id: null,
|
|
organization_slug: 'acme',
|
|
project_ids: null,
|
|
project_refs: [],
|
|
resources: ['oauth_apps'],
|
|
restrictive: false,
|
|
}
|
|
|
|
/**
|
|
* Registers the five endpoints behind `useProjectOAuthIntegrationData`. Each test supplies only the
|
|
* resources relevant to it; everything else defaults to "nothing connected".
|
|
*/
|
|
const mockProjectResources = ({
|
|
apiKeys = [] as APIKey[],
|
|
secrets = [] as ProjectSecret[],
|
|
oauthApps = [] as OAuthAppResponse[],
|
|
smtpHost = null as string | null,
|
|
} = {}) => {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/profile/permissions',
|
|
response: () => HttpResponse.json<AccessControlPermission[]>([CAN_READ_OAUTH_APPS_PERMISSION]),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/v1/projects/:ref/api-keys',
|
|
response: () => HttpResponse.json<APIKey[]>(apiKeys),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/v1/projects/:ref/secrets',
|
|
response: () => HttpResponse.json<ProjectSecret[]>(secrets),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/auth/:ref/config',
|
|
response: () =>
|
|
HttpResponse.json<AuthConfigResponse>({
|
|
SMTP_HOST: smtpHost,
|
|
} as unknown as AuthConfigResponse),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/integrations/partners/:ref',
|
|
response: () => HttpResponse.json<PartnerIntegrationListResponse>({ integrations: [] }),
|
|
})
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations/:slug/oauth/apps',
|
|
response: () => HttpResponse.json<OAuthAppResponse[]>(oauthApps),
|
|
})
|
|
}
|
|
|
|
describe('MarketplaceIntegrationSettingsTab', () => {
|
|
describe('section visibility follows the integration definition', () => {
|
|
test('renders the secret API key section when the integration declares a secret key prefix', async () => {
|
|
setIntegration({ secretKeyPrefix: 'custom_' })
|
|
mockProjectResources({ apiKeys: [secretApiKey('custom_metrics_key')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Secret API key' })).toBeInTheDocument()
|
|
expect(screen.getByText('custom_metrics_key')).toBeInTheDocument()
|
|
})
|
|
|
|
test('does not render a secret API key section when the integration declares no prefix, even if secret keys exist', async () => {
|
|
setIntegration()
|
|
mockProjectResources({ apiKeys: [secretApiKey('custom_metrics_key')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByText('No connected resources')).toBeInTheDocument()
|
|
expect(screen.queryByRole('heading', { name: 'Secret API key' })).not.toBeInTheDocument()
|
|
expect(screen.queryByText('custom_metrics_key')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('renders the Edge Function secret section only for integrations that declare a secret name', async () => {
|
|
setIntegration({ id: 'doppler', name: 'Doppler' })
|
|
mockProjectResources({ secrets: [edgeSecret('DOPPLER_CONFIG'), edgeSecret('UNRELATED')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(
|
|
await screen.findByRole('heading', { name: 'Edge Function secret' })
|
|
).toBeInTheDocument()
|
|
expect(screen.getByText('DOPPLER_CONFIG')).toBeInTheDocument()
|
|
// Only the integration's own secret is shown, not unrelated project secrets.
|
|
expect(screen.queryByText('UNRELATED')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('renders the SMTP section only for SMTP-configured integrations', async () => {
|
|
setIntegration({ id: 'resend', name: 'Resend' })
|
|
mockProjectResources({ smtpHost: 'smtp.resend.com' })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'SMTP settings' })).toBeInTheDocument()
|
|
expect(screen.getByText('smtp.resend.com')).toBeInTheDocument()
|
|
})
|
|
|
|
test('does not render an SMTP section for non-SMTP integrations even when the project uses the Resend host', async () => {
|
|
setIntegration({ secretKeyPrefix: 'custom_' })
|
|
mockProjectResources({
|
|
apiKeys: [secretApiKey('custom_key')],
|
|
smtpHost: 'smtp.resend.com',
|
|
})
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Secret API key' })).toBeInTheDocument()
|
|
expect(screen.queryByRole('heading', { name: 'SMTP settings' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('shows the empty state when the integration provisions nothing', async () => {
|
|
setIntegration()
|
|
mockProjectResources()
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByText('No connected resources')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText("Custom Integration doesn't have any resources connected to your project.")
|
|
).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('resource section states', () => {
|
|
test('a present resource shows the Connected badge and a Remove action', async () => {
|
|
setIntegration({ secretKeyPrefix: 'custom_' })
|
|
mockProjectResources({ apiKeys: [secretApiKey('custom_key')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByText('custom_key')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
|
})
|
|
|
|
test('uses the generic removal warning when the integration has no override copy', async () => {
|
|
setIntegration({ secretKeyPrefix: 'custom_' })
|
|
mockProjectResources({ apiKeys: [secretApiKey('custom_key')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
'Removing this key takes effect immediately and can interrupt the integration.'
|
|
)
|
|
).toBeInTheDocument()
|
|
})
|
|
|
|
test('uses the integration-specific removal warning override when one is defined', async () => {
|
|
setIntegration({ id: 'grafana', name: 'Grafana' })
|
|
mockProjectResources({ apiKeys: [secretApiKey('grafana_cloud_integration_metrics')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
'Removing this key stops Grafana from collecting metrics from your project until a new key is connected.'
|
|
)
|
|
).toBeInTheDocument()
|
|
// The generic copy must not also be present.
|
|
expect(
|
|
screen.queryByText(
|
|
'Removing this key takes effect immediately and can interrupt the integration.'
|
|
)
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('an expected-but-absent resource renders a "Not connected" zero state with its absent note', async () => {
|
|
// Grafana expects an OAuth app (present here) and an API key (absent), so the API key section
|
|
// renders as a missing zero-state rather than being skipped.
|
|
setIntegration({ id: 'grafana', name: 'Grafana', oauthAppId: 'grafana-app' })
|
|
mockProjectResources({ oauthApps: [authorizedApp('grafana-app')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByText('Not connected')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText(
|
|
'No secret API key is connected for Grafana to read your project metrics. Dashboards will not receive data without one.'
|
|
)
|
|
).toBeInTheDocument()
|
|
})
|
|
|
|
test('shows the orphaned-resources warning and hides the OAuth section when the OAuth app is missing', async () => {
|
|
setIntegration({ id: 'grafana', name: 'Grafana', oauthAppId: 'grafana-app' })
|
|
mockProjectResources({ apiKeys: [secretApiKey('grafana_cloud_integration_metrics')] })
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByText('OAuth application is missing')).toBeInTheDocument()
|
|
// The OAuth app's own section is suppressed in favor of the banner, so its description is absent.
|
|
expect(
|
|
screen.queryByText(
|
|
'Grants Grafana access to your organization so it can discover projects to monitor.'
|
|
)
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('renders the OAuth section with a Connected badge when the OAuth app is present', async () => {
|
|
setIntegration({ id: 'grafana', name: 'Grafana', oauthAppId: 'grafana-app' })
|
|
mockProjectResources({
|
|
oauthApps: [authorizedApp('grafana-app')],
|
|
apiKeys: [secretApiKey('grafana_cloud_integration_metrics')],
|
|
})
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'OAuth application' })).toBeInTheDocument()
|
|
expect(screen.getByText('Connected')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText(
|
|
'Grants Grafana access to your organization so it can discover projects to monitor.'
|
|
)
|
|
).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('removing a resource', () => {
|
|
test('deletes an Edge Function secret through the API and clears the section', async () => {
|
|
setIntegration({ id: 'doppler', name: 'Doppler' })
|
|
|
|
// Baseline: nothing else connected. The secrets endpoints below are registered afterwards so
|
|
// MSW gives them priority, backing them with a stateful store the refetch can observe.
|
|
mockProjectResources()
|
|
|
|
let secrets: ProjectSecret[] = [edgeSecret('DOPPLER_CONFIG')]
|
|
const deleteRequests: unknown[] = []
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/v1/projects/:ref/secrets',
|
|
response: () => HttpResponse.json<ProjectSecret[]>(secrets),
|
|
})
|
|
addAPIMock({
|
|
method: 'delete',
|
|
path: '/v1/projects/:ref/secrets',
|
|
response: async ({ request }) => {
|
|
deleteRequests.push(await request.json())
|
|
secrets = []
|
|
// The bulk-delete endpoint replies 200 with no content.
|
|
return HttpResponse.json([] as never)
|
|
},
|
|
})
|
|
|
|
customRender(<MarketplaceIntegrationSettingsTab />)
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
|
|
// Confirm in the destructive ConfirmationModal.
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
|
|
|
|
await waitFor(() => expect(deleteRequests).toEqual([['DOPPLER_CONFIG']]))
|
|
await waitFor(() => expect(screen.getByText('No connected resources')).toBeInTheDocument())
|
|
})
|
|
})
|
|
})
|