Files
supabase/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts
kemal.earthandAli Waseem 4aa34f556a feat(studio): mcp secrets interstitial polish (#50351)
## 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>
2026-09-14 17:44:42 +01:00

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')
})
})