mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55: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? - We made the Next step copy a bit more generic so it doesn't read like it's pointing you back to Inspector UI. - Added CTA on key stored screen to send you to Edge Function Secrets directly. - Tidies up footer area to always be centrally aligned across all states. ### 1. Enable the feature flag ### 2. Preview states via URL Mock mode is enabled automatically in local/staging. Navigate to `/mcp/secrets` with a `state` query param: http://localhost:8082/mcp/secrets?state=<state> States that need no other params: | `state` value | What it shows | | ----------------- | --------------------------------------- | | `loading` | Loading skeleton | | `expired` | Link expired | | `cancelled` | Request cancelled | | `paused` | Storing keys paused | | `wrong-account` | Signed in as the wrong account | | `error` | Generic failure | States that need a real project —ame=<KEY_NAME>`: | `state` value | What it sh | | -------------------- | ---------------------- | | `form` | The "st | | `stored` | Success | | `stored-timeout` | Successopped waiting | | `already-stored` | Key was already stored, nothing to do | Example: http://localhost:8082/mcp/secretsJECT_REF&name=OPENAI_API_KEY ### 3. What to check - [ ] `stored` / `already-stored`tions secrets"** button linking to `/project/<ref>/functions/secrets - [ ] States without a project re `paused`, `error`) don't show that button - [ ] Footer text is centered on - [ ] `wrong-account` → **Switch its footer is centered - [ ] The provider-dashboard link` state, use a `name` like`OPENAI_API_KEY` or `RESEND_API_Khint) is centered too <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a project-specific link to Edge Functions secrets from the MCP setup screen when a project is available. * Added a separator to distinguish the secrets link from the remaining setup guidance. * **Improvements** * Updated completion guidance to tell users to return to their agent and confirm the setup is finished. * Standardized interstitial footer content with centered guidance and consistent provider dashboard instructions. * **Tests** * Added coverage for displaying the project-specific secrets link and hiding it when no project is associated. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
253 lines
8.8 KiB
TypeScript
253 lines
8.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { SecretRequest } from './McpSecrets.types'
|
|
import {
|
|
getOverwriteWarning,
|
|
getSecretHelperText,
|
|
getSecretPrefixWarning,
|
|
getSecretsAnnouncement,
|
|
getSecretsCopy,
|
|
} from './McpSecrets.utils'
|
|
|
|
const request: SecretRequest = {
|
|
tool: 'create_edge_function_secret',
|
|
ref: 'abcdefghijklmnopqrst',
|
|
project: 'billing-staging',
|
|
account: 'ops@example.com',
|
|
keyName: 'RESEND_API_KEY',
|
|
providerHint: { name: 'Resend', prefix: 're_', dashboardUrl: 'https://resend.com/api-keys' },
|
|
}
|
|
|
|
const unrecognizedRequest: SecretRequest = {
|
|
...request,
|
|
keyName: 'MY_WEBHOOK_TOKEN',
|
|
providerHint: undefined,
|
|
}
|
|
|
|
describe('getSecretsCopy', () => {
|
|
it('interpolates the key name and project into the stored state', () => {
|
|
const copy = getSecretsCopy({ status: 'stored', request, timedOut: false })
|
|
|
|
expect(copy.title).toBe('Key stored')
|
|
expect(copy.subtitle).toBe('RESEND_API_KEY is saved for billing-staging.')
|
|
})
|
|
|
|
it('names the generic client, because v1 never learns which one sent the user', () => {
|
|
const copy = getSecretsCopy({ status: 'stored', request, timedOut: false })
|
|
|
|
expect(copy.calloutBody).toBe(`Head back to your agent and let it know you're finished.`)
|
|
})
|
|
|
|
it('sends the user back to the agent instead of the client on timeout', () => {
|
|
const copy = getSecretsCopy({ status: 'stored', request, timedOut: true })
|
|
|
|
expect(copy.subtitle).toContain('so it may have stopped listening')
|
|
expect(copy.calloutBody).toBe(
|
|
'Ask your agent to store the key again. It will find the saved key and finish without sending you back here.'
|
|
)
|
|
})
|
|
|
|
it('says nothing was stored on the generic error, and offers both ways out', () => {
|
|
const copy = getSecretsCopy({ status: 'error' })
|
|
|
|
expect(copy.title).toBe("Couldn't complete this request")
|
|
expect(copy.subtitle).toBe('Nothing was stored.')
|
|
expect(copy.calloutBody).toBe(
|
|
'Ask your agent to run the tool again, or set the key in Edge Functions secrets instead.'
|
|
)
|
|
})
|
|
|
|
it('never leaks a failure reason the user cannot act on', () => {
|
|
const copy = getSecretsCopy({ status: 'error' })
|
|
|
|
expect(Object.values(copy).join(' ')).not.toMatch(/403|forbidden|permission|error code/i)
|
|
})
|
|
|
|
it('gives every terminal state a recovery path and never claims the key was checked', () => {
|
|
const states = [
|
|
{ status: 'stored', request, timedOut: false },
|
|
{ status: 'stored', request, timedOut: true },
|
|
{ status: 'already-stored', request },
|
|
{ status: 'expired' },
|
|
{ status: 'cancelled' },
|
|
{ status: 'paused' },
|
|
{ status: 'error' },
|
|
] as const
|
|
|
|
for (const state of states) {
|
|
const copy = getSecretsCopy(state)
|
|
|
|
expect(copy.calloutTitle).toBe('Next step')
|
|
expect(copy.calloutBody.length).toBeGreaterThan(0)
|
|
expect(copy.footer.length).toBeGreaterThan(0)
|
|
expect(Object.values(copy).join(' ')).not.toMatch(/valid|verified|works? correctly/i)
|
|
}
|
|
})
|
|
|
|
it('never leaks a provider into a request that does not name one', () => {
|
|
const rendered = [
|
|
getSecretsCopy({ status: 'stored', request: unrecognizedRequest, timedOut: false }),
|
|
getSecretsCopy({ status: 'already-stored', request: unrecognizedRequest }),
|
|
getSecretsCopy({ status: 'expired' }),
|
|
getSecretsCopy({ status: 'cancelled' }),
|
|
getSecretsCopy({ status: 'paused' }),
|
|
getSecretsCopy({ status: 'error' }),
|
|
]
|
|
.flatMap((copy) => Object.values(copy))
|
|
.join(' ')
|
|
|
|
expect(rendered).not.toMatch(/openai|anthropic|resend|stripe/i)
|
|
})
|
|
})
|
|
|
|
describe('getSecretHelperText', () => {
|
|
it('names the project the secret is scoped to', () => {
|
|
expect(getSecretHelperText('my-project')).toBe(
|
|
'Stored encrypted for my-project. Anyone with write access to this project can use it. Remove it any time from Edge Functions secrets.'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('getOverwriteWarning', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-09-04T12:00:00Z'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('stays quiet when the name is not in use', () => {
|
|
expect(getOverwriteWarning(request)).toBeUndefined()
|
|
})
|
|
|
|
it('names the key and how long ago it was written', () => {
|
|
const warning = getOverwriteWarning({
|
|
...request,
|
|
existingSecret: { updatedAt: '2026-09-04T11:58:00Z' },
|
|
})
|
|
|
|
expect(warning).toBe(
|
|
'RESEND_API_KEY already exists — updated 2 minutes ago. Storing will replace it.'
|
|
)
|
|
})
|
|
|
|
it('reads at seconds and hours granularity too', () => {
|
|
expect(
|
|
getOverwriteWarning({ ...request, existingSecret: { updatedAt: '2026-09-04T11:59:55Z' } })
|
|
).toContain('updated a few seconds ago')
|
|
expect(
|
|
getOverwriteWarning({ ...request, existingSecret: { updatedAt: '2026-09-04T09:00:00Z' } })
|
|
).toContain('updated 3 hours ago')
|
|
})
|
|
|
|
it('accepts unix microseconds, which the secrets endpoint also returns', () => {
|
|
const twoMinutesAgoInMicros = String(new Date('2026-09-04T11:58:00Z').getTime() * 1000)
|
|
|
|
expect(twoMinutesAgoInMicros).toHaveLength(16)
|
|
expect(
|
|
getOverwriteWarning({ ...request, existingSecret: { updatedAt: twoMinutesAgoInMicros } })
|
|
).toContain('updated 2 minutes ago')
|
|
})
|
|
|
|
it('still warns when the platform gave no timestamp', () => {
|
|
expect(getOverwriteWarning({ ...request, existingSecret: { updatedAt: undefined } })).toBe(
|
|
'RESEND_API_KEY already exists. Storing will replace it.'
|
|
)
|
|
expect(getOverwriteWarning({ ...request, existingSecret: { updatedAt: 'not a date' } })).toBe(
|
|
'RESEND_API_KEY already exists. Storing will replace it.'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('getSecretPrefixWarning', () => {
|
|
it('stays quiet while the field is empty', () => {
|
|
expect(getSecretPrefixWarning('', request.providerHint)).toBeUndefined()
|
|
})
|
|
|
|
it('stays quiet when the value matches the hinted prefix', () => {
|
|
expect(getSecretPrefixWarning('re_abc123', request.providerHint)).toBeUndefined()
|
|
})
|
|
|
|
it('stays quiet when the request carries no provider hint', () => {
|
|
expect(getSecretPrefixWarning('anything', undefined)).toBeUndefined()
|
|
})
|
|
|
|
it('stays quiet when the hint has no prefix to compare against', () => {
|
|
expect(getSecretPrefixWarning('anything', { name: 'Acme' })).toBeUndefined()
|
|
})
|
|
|
|
it('softly flags a mismatch without blocking the store', () => {
|
|
expect(getSecretPrefixWarning('sk-abc123', request.providerHint)).toBe(
|
|
'Resend keys usually start with re_. You can still save this one.'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('getSecretsAnnouncement', () => {
|
|
it('is empty before anything has resolved, so the region mounts silent', () => {
|
|
// A live region never announces content that was present when it mounted.
|
|
// Rendering nothing first is what makes the first real state a change.
|
|
expect(getSecretsAnnouncement(undefined)).toBe('')
|
|
})
|
|
|
|
it('says what is happening while the queries resolve', () => {
|
|
expect(getSecretsAnnouncement({ status: 'loading' })).toBe('Loading request details')
|
|
})
|
|
|
|
it('names the key and project once the form is ready', () => {
|
|
expect(getSecretsAnnouncement({ status: 'form', request })).toBe(
|
|
'Ready to save RESEND_API_KEY for billing-staging'
|
|
)
|
|
})
|
|
|
|
it('announces the outcome, which the card heading alone never reads out', () => {
|
|
expect(getSecretsAnnouncement({ status: 'stored', request, timedOut: false })).toBe(
|
|
'Key stored. RESEND_API_KEY is saved for billing-staging.'
|
|
)
|
|
expect(getSecretsAnnouncement({ status: 'error' })).toBe(
|
|
"Couldn't complete this request. Nothing was stored."
|
|
)
|
|
})
|
|
|
|
it('never repeats the callout, which Admonition already announces via role="alert"', () => {
|
|
const states = [
|
|
{ status: 'stored', request, timedOut: false },
|
|
{ status: 'already-stored', request },
|
|
{ status: 'expired' },
|
|
{ status: 'cancelled' },
|
|
{ status: 'paused' },
|
|
{ status: 'error' },
|
|
] as const
|
|
|
|
for (const state of states) {
|
|
expect(getSecretsAnnouncement(state)).not.toContain(getSecretsCopy(state).calloutBody)
|
|
}
|
|
})
|
|
|
|
it('gives every state something to announce', () => {
|
|
const states = [
|
|
{ status: 'loading' },
|
|
{ status: 'form', request },
|
|
{ status: 'wrong-account', signedInAs: 'ops@example.com' },
|
|
{ status: 'stored', request, timedOut: true },
|
|
{ status: 'already-stored', request },
|
|
{ status: 'expired' },
|
|
{ status: 'cancelled' },
|
|
{ status: 'paused' },
|
|
{ status: 'error' },
|
|
] as const
|
|
|
|
for (const state of states) {
|
|
expect(getSecretsAnnouncement(state).length).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('does not leak the signed-in account into the wrong-account announcement', () => {
|
|
expect(
|
|
getSecretsAnnouncement({ status: 'wrong-account', signedInAs: 'ops@example.com' })
|
|
).not.toContain('ops@example.com')
|
|
})
|
|
})
|