Files
hermes-web-ui/tests/client/stt-settings-api.test.ts
ekko 44795ff46c [codex] Delete voice provider settings on remove (#1782)
* Delete voice provider settings on remove

* fix voice provider delete UI test
2026-06-25 10:30:16 +08:00

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