Share persistent alerts, distinguish partial persistence from validation errors, preserve model identity on retries, and apply the store's deletion eligibility before clearing credentials. Document toast, field-error, alert, and Storybook ownership.
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('partial')
|
|
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('partial')
|
|
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('partial')
|
|
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('partial')
|
|
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()
|
|
}
|
|
})
|