mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-11 19:20:16 +00:00
44795ff46c
* Delete voice provider settings on remove * fix voice provider delete UI test
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { nextTick } from 'vue'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('@/router', () => ({
|
|
default: {
|
|
currentRoute: { value: { name: 'hermes.chat' } },
|
|
replace: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
import router from '@/router'
|
|
import { hasApiKey } from '../../packages/client/src/api/client'
|
|
import { clearSttSecret, deleteSttBaseUrlPreset, deleteSttProvider, fetchSttSettings, saveActiveSttProvider, saveSttSettings } from '../../packages/client/src/api/hermes/stt-settings'
|
|
import { transcribeSpeech } from '../../packages/client/src/api/hermes/stt'
|
|
|
|
const mockFetch = vi.fn()
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
function getHeader(headers: RequestInit['headers'] | undefined, name: string): string | undefined {
|
|
if (!headers) return undefined
|
|
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
|
if (Array.isArray(headers)) {
|
|
const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase())
|
|
return match?.[1]
|
|
}
|
|
|
|
const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
|
return typeof match?.[1] === 'string' ? match[1] : undefined
|
|
}
|
|
|
|
describe('stt api wrappers', () => {
|
|
beforeEach(() => {
|
|
mockFetch.mockReset()
|
|
vi.clearAllMocks()
|
|
localStorage.clear()
|
|
localStorage.setItem('hermes_server_url', 'https://hermes.example')
|
|
localStorage.setItem('hermes_api_key', 'jwt-token')
|
|
})
|
|
|
|
it('fetches provider settings through the shared client request flow and normalizes settings arrays to providers', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({
|
|
activeProvider: 'custom',
|
|
settings: [
|
|
{
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe', language: 'en' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
const result = await fetchSttSettings()
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'Content-Type': 'application/json',
|
|
'X-Hermes-Profile': 'research',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
activeProvider: 'custom',
|
|
providers: [
|
|
{
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe', language: 'en' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it('uses shared 401 handling for settings requests', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
await expect(fetchSttSettings()).rejects.toThrow('Unauthorized')
|
|
|
|
expect(hasApiKey()).toBe(false)
|
|
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'X-Hermes-Profile': 'research',
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('saves the active STT provider without a provider settings row', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ activeProvider: 'browser' }),
|
|
})
|
|
|
|
const result = await saveActiveSttProvider('browser')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/active',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({ provider: 'browser' }),
|
|
}),
|
|
)
|
|
expect(result).toBe('browser')
|
|
})
|
|
|
|
it('saves masked stt settings and unwraps the response setting', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
setting: {
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 2,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const result = await saveSttSettings('openai', {
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: 'new-secret' },
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/openai',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
body: JSON.stringify({
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: 'new-secret' },
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 2,
|
|
})
|
|
})
|
|
|
|
it('clears stored api keys via the delete secret endpoint', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
setting: {
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: {},
|
|
updatedAt: 3,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const result = await clearSttSecret('openai', 'apiKey')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/openai/secret/apiKey',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
provider: 'openai',
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: {},
|
|
updatedAt: 3,
|
|
})
|
|
})
|
|
|
|
it('deletes stored provider settings via the provider endpoint', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
deleted: true,
|
|
activeProvider: 'browser',
|
|
}),
|
|
})
|
|
|
|
const result = await deleteSttProvider('openai')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/openai',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
success: true,
|
|
deleted: true,
|
|
activeProvider: 'browser',
|
|
})
|
|
})
|
|
|
|
it('deletes saved base URL presets via the provider preset endpoint', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
setting: {
|
|
provider: 'custom',
|
|
settings: {
|
|
baseUrl: 'https://stt.example.test/openai/v1',
|
|
baseUrlPresets: ['https://stt.example.test/openai/v1'],
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 4,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const result = await deleteSttBaseUrlPreset('custom', 'https://api.groq.com/openai/v1')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/settings/custom/base-url-preset?url=https%3A%2F%2Fapi.groq.com%2Fopenai%2Fv1',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
}),
|
|
}),
|
|
)
|
|
expect(result).toEqual({
|
|
provider: 'custom',
|
|
settings: {
|
|
baseUrl: 'https://stt.example.test/openai/v1',
|
|
baseUrlPresets: ['https://stt.example.test/openai/v1'],
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
secrets: { apiKey: '[stored]' },
|
|
updatedAt: 4,
|
|
})
|
|
})
|
|
|
|
it('rejects transcription requests without a provider', async () => {
|
|
await expect(transcribeSpeech({
|
|
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
|
} as any)).rejects.toThrow('STT provider is required')
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends multipart transcription requests without serializing api keys or forcing JSON content type', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
text: 'transcribed text',
|
|
provider: 'openai',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
durationMs: 42,
|
|
}),
|
|
})
|
|
|
|
const result = await transcribeSpeech({
|
|
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
|
provider: 'openai',
|
|
language: 'en',
|
|
prompt: 'keep punctuation',
|
|
baseUrl: 'http://127.0.0.1:8000/v1/audio/transcriptions',
|
|
apiKey: 'attacker-secret',
|
|
secrets: { apiKey: 'body-secret' },
|
|
headers: {
|
|
Authorization: 'Bearer attacker-secret',
|
|
'X-Api-Key': 'body-secret',
|
|
},
|
|
} as any)
|
|
|
|
expect(result).toEqual({
|
|
text: 'transcribed text',
|
|
provider: 'openai',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
durationMs: 42,
|
|
})
|
|
|
|
expect(mockFetch).toHaveBeenCalledOnce()
|
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://hermes.example/api/hermes/stt/transcribe')
|
|
expect(init.method).toBe('POST')
|
|
expect(getHeader(init.headers, 'Authorization')).toBe('Bearer jwt-token')
|
|
expect(getHeader(init.headers, 'Content-Type')).toBeUndefined()
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
|
|
const form = init.body as FormData
|
|
expect(form.get('provider')).toBe('openai')
|
|
expect(form.get('language')).toBe('en')
|
|
expect(form.get('prompt')).toBe('keep punctuation')
|
|
expect(form.get('baseUrl')).toBeNull()
|
|
expect(form.get('apiKey')).toBeNull()
|
|
expect(form.get('secrets')).toBeNull()
|
|
expect(form.get('headers')).toBeNull()
|
|
|
|
const keys = Array.from(form.keys())
|
|
expect(keys).toEqual(expect.arrayContaining(['audio', 'provider', 'language', 'prompt']))
|
|
expect(keys).not.toContain('baseUrl')
|
|
expect(keys).not.toContain('apiKey')
|
|
expect(keys).not.toContain('secrets')
|
|
expect(keys).not.toContain('headers')
|
|
|
|
const upload = form.get('audio')
|
|
expect(upload).toBeInstanceOf(File)
|
|
expect((upload as File).name).toBe('speech.webm')
|
|
expect((upload as File).type).toBe('audio/webm')
|
|
|
|
const serialized = JSON.stringify(Array.from(form.entries()).map(([key, value]) => [
|
|
key,
|
|
typeof value === 'string'
|
|
? value
|
|
: { name: value.name, type: value.type },
|
|
]))
|
|
expect(serialized).not.toContain('attacker-secret')
|
|
expect(serialized).not.toContain('body-secret')
|
|
expect(serialized).not.toContain('127.0.0.1:8000')
|
|
})
|
|
|
|
it('does not persist raw STT API keys to localStorage when composable state changes', async () => {
|
|
const { clearSttSettingsAuthState, useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
|
|
|
|
clearSttSettingsAuthState()
|
|
const settings = useSttSettings()
|
|
|
|
settings.setOpenaiApiKey('openai-secret-sentinel')
|
|
settings.setCustomApiKey('custom-secret-sentinel')
|
|
settings.setOpenaiPrompt('transcribe carefully')
|
|
settings.setCustomBaseUrl('https://transcribe.example.com/v1')
|
|
await nextTick()
|
|
|
|
const raw = localStorage.getItem('hermes-stt-settings-v1') || '{}'
|
|
expect(raw).toContain('transcribe carefully')
|
|
expect(raw).toContain('https://transcribe.example.com/v1')
|
|
expect(raw).not.toContain('openai-secret-sentinel')
|
|
expect(raw).not.toContain('custom-secret-sentinel')
|
|
})
|
|
|
|
it('uses shared 401 handling for transcription requests', async () => {
|
|
localStorage.setItem('hermes_active_profile_name', 'research')
|
|
const listener = vi.fn()
|
|
window.addEventListener('hermes-auth-notice', listener)
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
await expect(transcribeSpeech({
|
|
audio: new Blob(['audio'], { type: 'audio/webm' }),
|
|
provider: 'openai',
|
|
})).rejects.toThrow('Unauthorized')
|
|
|
|
expect(hasApiKey()).toBe(false)
|
|
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'expired' })
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hermes.example/api/hermes/stt/transcribe',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer jwt-token',
|
|
'X-Hermes-Profile': 'research',
|
|
}),
|
|
}),
|
|
)
|
|
|
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(getHeader(init.headers, 'Content-Type')).toBeUndefined()
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
window.removeEventListener('hermes-auth-notice', listener)
|
|
})
|
|
})
|