mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25: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? Initial build of the URL mode interstitial. Has complete mock data to be able to view different states (this will be stripped out in the end). A starting point for us to use as an intercept. This is linked with @barryroodt ticket in the MCP project. https://github.com/supabase/mcp/pull/412 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added an authenticated MCP Secrets page for securely storing API secrets requested by AI tools. - Added provider-aware guidance for OpenAI, Anthropic, Resend, and Stripe keys, including validation warnings and dashboard links. - Added clear success, cancellation, expiration, error, and wrong-account states with account switching. - Added loading placeholders, secret visibility controls, overwrite warnings, and accessibility announcements. - **Bug Fixes** - Improved interstitial animations to respect reduced-motion preferences. - Preserved return destinations and related parameters during sign-in flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import { act, waitFor } from '@testing-library/react'
|
||
import { delay, HttpResponse } from 'msw'
|
||
import { beforeEach, describe, expect, it } from 'vitest'
|
||
|
||
import type { SecretsParams } from './McpSecrets.params'
|
||
import { useSecretRequest } from './useSecretRequest'
|
||
import type { components } from '@/data/api'
|
||
import { customRenderHook } from '@/tests/lib/custom-render'
|
||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||
|
||
const REF_A = 'aaaaaaaaaaaaaaaaaaaa'
|
||
const NAME_A = 'OPENAI_API_KEY'
|
||
const NAME_B = 'RESEND_API_KEY'
|
||
|
||
const paramsFor = (ref: string, name: string): SecretsParams => ({
|
||
ref,
|
||
name,
|
||
dev: { state: undefined },
|
||
})
|
||
|
||
const WRITE_DELAY_MS = 120
|
||
|
||
const PROJECT: components['schemas']['ProjectDetailResponse'] = {
|
||
cloud_provider: 'AWS',
|
||
db_host: 'db.example.supabase.co',
|
||
high_availability: false,
|
||
id: 1,
|
||
inserted_at: '2026-01-01T00:00:00Z',
|
||
integration_source: null,
|
||
is_branch_enabled: false,
|
||
is_physical_backups_enabled: false,
|
||
name: 'acme-production',
|
||
organization_id: 1,
|
||
ref: REF_A,
|
||
region: 'us-east-1',
|
||
restUrl: 'https://example.supabase.co/rest/v1/',
|
||
status: 'ACTIVE_HEALTHY',
|
||
subscription_id: 'sub_1',
|
||
updated_at: '2026-01-01T00:00:00Z',
|
||
}
|
||
|
||
let writtenNames: string[] = []
|
||
|
||
beforeEach(() => {
|
||
writtenNames = []
|
||
|
||
addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT })
|
||
addAPIMock({ method: 'get', path: '/v1/projects/:ref/secrets', response: [] })
|
||
|
||
addAPIMock({
|
||
method: 'post',
|
||
path: '/v1/projects/:ref/secrets',
|
||
response: async ({ request }) => {
|
||
const body = (await request.json()) as { name: string }[]
|
||
writtenNames.push(...body.map((secret) => secret.name))
|
||
await delay(WRITE_DELAY_MS)
|
||
return HttpResponse.json([])
|
||
},
|
||
})
|
||
})
|
||
|
||
describe('useSecretRequest', () => {
|
||
it('reports a stored outcome for the request that was actually written', async () => {
|
||
const { result } = customRenderHook(() => useSecretRequest(paramsFor(REF_A, NAME_A)))
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('form'))
|
||
|
||
act(() => result.current.saveSecret('sk-value'))
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('stored'))
|
||
expect(writtenNames).toEqual([NAME_A])
|
||
})
|
||
|
||
it('does not offer the form when the existing secrets cannot be read', async () => {
|
||
// Without the secrets list there is no way to know an overwrite is about to
|
||
// happen, so the form would be missing its warning.
|
||
addAPIMock({
|
||
method: 'get',
|
||
path: '/v1/projects/:ref/secrets',
|
||
response: () => HttpResponse.json<APIErrorBody>({ message: 'nope' }, { status: 500 }),
|
||
})
|
||
|
||
const { result } = customRenderHook(() => useSecretRequest(paramsFor(REF_A, NAME_A)))
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('error'))
|
||
expect(writtenNames).toEqual([])
|
||
})
|
||
|
||
it('does not attribute request A’s settled write to request B', async () => {
|
||
let params = paramsFor(REF_A, NAME_A)
|
||
const { result, rerender } = customRenderHook(() => useSecretRequest(params))
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('form'))
|
||
|
||
act(() => result.current.saveSecret('sk-belongs-to-a'))
|
||
params = paramsFor(REF_A, NAME_B)
|
||
rerender()
|
||
|
||
await waitFor(() => {
|
||
const { state } = result.current
|
||
expect(state.status === 'form' && state.request.keyName).toBe(NAME_B)
|
||
})
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, WRITE_DELAY_MS * 3))
|
||
|
||
expect(result.current.state.status).toBe('form')
|
||
expect(writtenNames).toEqual([NAME_A])
|
||
})
|
||
|
||
it('does not carry a cancellation from request A onto request B', async () => {
|
||
let params = paramsFor(REF_A, NAME_A)
|
||
const { result, rerender } = customRenderHook(() => useSecretRequest(params))
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('form'))
|
||
|
||
act(() => result.current.cancelRequest())
|
||
expect(result.current.state.status).toBe('cancelled')
|
||
|
||
params = paramsFor(REF_A, NAME_B)
|
||
rerender()
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('form'))
|
||
})
|
||
|
||
it('still shows A’s outcome if the user navigates back to A', async () => {
|
||
let params = paramsFor(REF_A, NAME_A)
|
||
const { result, rerender } = customRenderHook(() => useSecretRequest(params))
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('form'))
|
||
|
||
act(() => result.current.cancelRequest())
|
||
params = paramsFor(REF_A, NAME_B)
|
||
rerender()
|
||
await waitFor(() => expect(result.current.state.status).toBe('form'))
|
||
|
||
params = paramsFor(REF_A, NAME_A)
|
||
rerender()
|
||
|
||
await waitFor(() => expect(result.current.state.status).toBe('cancelled'))
|
||
})
|
||
})
|