Unify Settings navigation, credential drafts, translated form validation, and appearance controls. Add explicit WebMCP access modes and searchable MCP tool permissions while preserving execution and credential safeguards.
437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
import { beforeEach, afterEach, expect, test } from 'bun:test'
|
|
|
|
import { effectScope, ref } from 'vue'
|
|
|
|
import {
|
|
createMCPConnectionDraft,
|
|
mcpConnectionSettings,
|
|
type MCPConnection
|
|
} from '@/app/integrations/mcp'
|
|
import { useMCPConnectionSettings } from '@/app/integrations/mcp/settings/use'
|
|
|
|
const connection: MCPConnection = {
|
|
id: 'mcp-test',
|
|
name: 'Test',
|
|
enabled: true,
|
|
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
|
|
authentication: { type: 'none' }
|
|
}
|
|
|
|
let previousConnections = mcpConnectionSettings.value.connections
|
|
beforeEach(() => {
|
|
previousConnections = mcpConnectionSettings.value.connections
|
|
mcpConnectionSettings.value.connections = [structuredClone(connection)]
|
|
})
|
|
afterEach(() => {
|
|
mcpConnectionSettings.value.connections = previousConnections
|
|
})
|
|
|
|
test('MCP save commits staged clearing without changing a new draft', async () => {
|
|
const scope = effectScope()
|
|
let finish: () => void = () => undefined
|
|
const pending = new Promise<void>((resolve) => {
|
|
finish = resolve
|
|
})
|
|
const saved: boolean[] = []
|
|
const token = ref('')
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
token,
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => 'configured',
|
|
setCredential: async () => pending,
|
|
save: (draft) => {
|
|
saved.push(draft.enabled)
|
|
return connection
|
|
},
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
state.draft.value.id = connection.id
|
|
state.clearCredential()
|
|
expect(saved).toEqual([])
|
|
const clearing = state.save()
|
|
state.startAdd()
|
|
state.draft.value.enabled = true
|
|
token.value = 'new-key'
|
|
finish()
|
|
await clearing
|
|
expect(saved).toEqual([false, false])
|
|
expect(state.credentialCleared.value).toBe(false)
|
|
expect(state.draft.value.enabled).toBe(true)
|
|
expect(token.value).toBe('new-key')
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('MCP edit ignores credential status after another draft opens', async () => {
|
|
const previous = mcpConnectionSettings.value.connections
|
|
mcpConnectionSettings.value.connections = [connection]
|
|
const scope = effectScope()
|
|
let finish: () => void = () => undefined
|
|
const pending = new Promise<void>((resolve) => {
|
|
finish = resolve
|
|
})
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref(''),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => {
|
|
await pending
|
|
return 'configured'
|
|
},
|
|
setCredential: async () => undefined,
|
|
save: () => connection,
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
const editing = state.startEdit(connection.id)
|
|
state.startAdd()
|
|
finish()
|
|
expect(await editing).toBe(false)
|
|
expect(state.tokenStatus.value).toBe('missing')
|
|
} finally {
|
|
scope.stop()
|
|
mcpConnectionSettings.value.connections = previous
|
|
}
|
|
})
|
|
|
|
test('MCP failed writes preserve replacements and staged removals', async () => {
|
|
const scope = effectScope()
|
|
const token = ref('replacement')
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
token,
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => 'configured',
|
|
setCredential: async () => {
|
|
throw new Error('Offline')
|
|
},
|
|
save: () => connection,
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
state.draft.value.id = connection.id
|
|
expect(await state.save()).toBe(false)
|
|
expect(state.error.value).toBe('Offline')
|
|
expect(token.value).toBe('replacement')
|
|
state.clearCredential()
|
|
expect(state.error.value).toBe('')
|
|
expect(token.value).toBe('')
|
|
expect(state.credentialCleared.value).toBe(true)
|
|
expect(await state.save()).toBe(false)
|
|
expect(state.error.value).toBe('Offline')
|
|
expect(state.credentialCleared.value).toBe(true)
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('failed bearer write never enables the saved connection', async () => {
|
|
const scope = effectScope()
|
|
const saved: boolean[] = []
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref('token'),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => 'configured',
|
|
setCredential: async () => {
|
|
throw new Error('Write failed')
|
|
},
|
|
save: (draft) => {
|
|
saved.push(draft.enabled)
|
|
return connection
|
|
},
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
state.draft.value.authenticationType = 'bearer'
|
|
state.draft.value.enabled = true
|
|
expect(await state.save()).toBe(false)
|
|
expect(saved).toEqual([false])
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('clear never persists unsaved fields and cancel discards removal', async () => {
|
|
const scope = effectScope()
|
|
const names: string[] = []
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref(''),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => 'configured',
|
|
setCredential: async () => undefined,
|
|
save: (draft) => {
|
|
names.push(draft.name)
|
|
expect(draft.url).toBe(connection.transport.url)
|
|
return connection
|
|
},
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
state.draft.value.id = connection.id
|
|
state.draft.value.name = ''
|
|
state.draft.value.url = 'invalid'
|
|
state.clearCredential()
|
|
expect(names).toEqual([])
|
|
expect(state.draft.value.url).toBe('invalid')
|
|
expect(state.credentialCleared.value).toBe(true)
|
|
state.cancel()
|
|
expect(state.credentialCleared.value).toBe(false)
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('status lookup failure opens the editor with an error', async () => {
|
|
const scope = effectScope()
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref(''),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => {
|
|
throw new Error('Locked')
|
|
},
|
|
setCredential: async () => undefined,
|
|
save: () => connection,
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
expect(await state.startEdit(connection.id)).toBe(true)
|
|
expect(state.error.value).toBe('Locked')
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('serializes overlapping credential replacements', async () => {
|
|
const scope = effectScope()
|
|
const writes: string[] = []
|
|
let finish: () => void = () => undefined
|
|
const blocked = new Promise<void>((resolve) => {
|
|
finish = resolve
|
|
})
|
|
const token = ref('first')
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
token,
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: async () => 'configured',
|
|
setCredential: async (_id, value) => {
|
|
writes.push(value)
|
|
if (value === 'first') await blocked
|
|
},
|
|
save: () => connection,
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
state.draft.value.id = connection.id
|
|
state.draft.value.authenticationType = 'bearer'
|
|
const first = state.save()
|
|
await Promise.resolve()
|
|
token.value = 'second'
|
|
const second = state.save()
|
|
await Promise.resolve()
|
|
expect(writes).toEqual(['first'])
|
|
finish()
|
|
await Promise.all([first, second])
|
|
expect(writes).toEqual(['first', 'second'])
|
|
} finally {
|
|
finish()
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('reopened editors serialize writes to the same connection while other connections proceed', async () => {
|
|
const firstScope = effectScope()
|
|
const secondScope = effectScope()
|
|
let finish: () => void = () => undefined
|
|
const blocked = new Promise<void>((resolve) => {
|
|
finish = resolve
|
|
})
|
|
const writes: string[] = []
|
|
const services = {
|
|
status: async () => 'configured' as const,
|
|
setCredential: async (_id: string, value: string) => {
|
|
writes.push(value)
|
|
if (value === 'first') await blocked
|
|
},
|
|
save: (draft: { id: `mcp-${string}` | null }) => ({
|
|
...connection,
|
|
id: draft.id ?? connection.id
|
|
}),
|
|
remove: async () => undefined
|
|
}
|
|
try {
|
|
const first = firstScope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref('first'),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
services
|
|
)
|
|
)
|
|
const second = secondScope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref('second'),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
services
|
|
)
|
|
)
|
|
const other = secondScope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref('other'),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
services
|
|
)
|
|
)
|
|
if (!first || !second || !other) throw new Error('Missing scope')
|
|
for (const editor of [first, second, other]) {
|
|
editor.draft.value.id = connection.id
|
|
editor.draft.value.authenticationType = 'bearer'
|
|
}
|
|
other.draft.value.id = 'mcp-other'
|
|
const savingFirst = first.save()
|
|
await Promise.resolve()
|
|
firstScope.stop()
|
|
const savingSecond = second.save()
|
|
await other.save()
|
|
expect(writes).toEqual(['first', 'other'])
|
|
finish()
|
|
await Promise.all([savingFirst, savingSecond])
|
|
expect(writes).toEqual(['first', 'other', 'second'])
|
|
} finally {
|
|
finish()
|
|
firstScope.stop()
|
|
secondScope.stop()
|
|
}
|
|
})
|
|
|
|
test('blank-token save rechecks status after a different editor clears the credential', async () => {
|
|
const scope = effectScope()
|
|
let configured = true
|
|
const enabled: boolean[] = []
|
|
const services = {
|
|
status: async () => (configured ? ('configured' as const) : ('missing' as const)),
|
|
setCredential: async (_id: string, value: string) => {
|
|
configured = Boolean(value)
|
|
},
|
|
save: (draft: { enabled: boolean }) => {
|
|
enabled.push(draft.enabled)
|
|
return connection
|
|
},
|
|
remove: async () => undefined
|
|
}
|
|
try {
|
|
const first = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref(''),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
services
|
|
)
|
|
)
|
|
const second = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
ref(''),
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
services
|
|
)
|
|
)
|
|
if (!first || !second) throw new Error('Missing scope')
|
|
await first.startEdit(connection.id)
|
|
await second.startEdit(connection.id)
|
|
second.draft.value.authenticationType = 'bearer'
|
|
second.draft.value.enabled = true
|
|
first.clearCredential()
|
|
expect(configured).toBe(true)
|
|
const clearing = first.save()
|
|
const saving = second.save()
|
|
await clearing
|
|
expect(await saving).toBe(false)
|
|
expect(second.error.value).toBe('Required')
|
|
expect(enabled).toEqual([false, false, false])
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('cancel clears secrets and ignores a pending credential lookup', async () => {
|
|
const scope = effectScope()
|
|
const token = ref('')
|
|
const lookup = Promise.withResolvers<'configured'>()
|
|
try {
|
|
const state = scope.run(() =>
|
|
useMCPConnectionSettings(
|
|
ref(createMCPConnectionDraft()),
|
|
token,
|
|
ref({ bearerTokenRequired: 'Required' }),
|
|
{
|
|
status: () => lookup.promise,
|
|
save: () => connection,
|
|
setCredential: async () => undefined,
|
|
remove: async () => undefined
|
|
}
|
|
)
|
|
)
|
|
if (!state) throw new Error('Missing scope')
|
|
const editing = state.startEdit(connection.id)
|
|
expect(state.busy.value).toBe(true)
|
|
token.value = 'discard-me'
|
|
state.cancel()
|
|
expect(token.value).toBe('')
|
|
expect(state.draft.value.id).toBeNull()
|
|
lookup.resolve('configured')
|
|
expect(await editing).toBe(false)
|
|
expect(state.busy.value).toBe(false)
|
|
expect(state.tokenStatus.value).toBe('missing')
|
|
token.value = 'discard-on-close'
|
|
scope.stop()
|
|
expect(token.value).toBe('')
|
|
} finally {
|
|
lookup.resolve('configured')
|
|
scope.stop()
|
|
}
|
|
})
|