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
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
describe('stt settings controller', () => {
|
|
let db: any = null
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
const { DatabaseSync } = await import('node:sqlite')
|
|
db = new DatabaseSync(':memory:')
|
|
vi.doMock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => db,
|
|
getStoragePath: () => ':memory:',
|
|
}))
|
|
})
|
|
|
|
afterEach(() => {
|
|
db?.close()
|
|
db = null
|
|
vi.doUnmock('../../packages/server/src/db/index')
|
|
vi.resetModules()
|
|
})
|
|
|
|
async function initController() {
|
|
const schemas = await import('../../packages/server/src/db/hermes/schemas')
|
|
schemas.initAllHermesTables()
|
|
return await import('../../packages/server/src/controllers/hermes/stt')
|
|
}
|
|
|
|
function makeCtx(user: any | null, body: any = {}, params: Record<string, string> = {}, query: Record<string, string> = {}) {
|
|
return {
|
|
state: user ? { user } : {},
|
|
request: { body },
|
|
params,
|
|
query,
|
|
status: 200,
|
|
body: null,
|
|
set: vi.fn(),
|
|
get: vi.fn(() => ''),
|
|
} as any
|
|
}
|
|
|
|
it('saves masked settings rows and lists them for the authenticated user without leaking secrets', async () => {
|
|
const ctrl = await initController()
|
|
const user = { id: 9, username: 'alice', role: 'admin' }
|
|
|
|
const saveCtx = makeCtx(user, {
|
|
settings: {
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
},
|
|
secrets: {
|
|
apiKey: 'server-secret',
|
|
},
|
|
}, { provider: 'openai' })
|
|
|
|
await ctrl.saveSettings(saveCtx)
|
|
|
|
expect(saveCtx.status).toBe(200)
|
|
expect(saveCtx.body.setting).toMatchObject({
|
|
provider: 'openai',
|
|
settings: {
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
},
|
|
secrets: {
|
|
apiKey: '[stored]',
|
|
},
|
|
})
|
|
expect(JSON.stringify(saveCtx.body)).not.toContain('server-secret')
|
|
|
|
const profileProviderRow = db.prepare(
|
|
'SELECT provider, settings_json, secrets_json FROM stt_profile_provider_settings WHERE profile = ? AND provider = ?'
|
|
).get('default', 'openai') as { provider: string; settings_json: string; secrets_json: string }
|
|
expect(profileProviderRow.provider).toBe('openai')
|
|
expect(JSON.parse(profileProviderRow.settings_json)).toMatchObject({
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
})
|
|
expect(JSON.parse(profileProviderRow.secrets_json)).toEqual({ apiKey: 'server-secret' })
|
|
|
|
const profileActiveRow = db.prepare(
|
|
'SELECT active_provider FROM stt_profile_settings WHERE profile = ?'
|
|
).get('default') as { active_provider: string }
|
|
expect(profileActiveRow.active_provider).toBe('openai')
|
|
|
|
const listCtx = makeCtx(user)
|
|
await ctrl.listSettings(listCtx)
|
|
|
|
expect(listCtx.status).toBe(200)
|
|
expect(listCtx.body).toEqual({
|
|
settings: [saveCtx.body.setting],
|
|
activeProvider: 'openai',
|
|
})
|
|
expect(JSON.stringify(listCtx.body)).not.toContain('server-secret')
|
|
})
|
|
|
|
it('deletes stored secrets while keeping the settings row masked', async () => {
|
|
const ctrl = await initController()
|
|
const user = { id: 7, username: 'bob', role: 'admin' }
|
|
|
|
const saveCtx = makeCtx(user, {
|
|
settings: {
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {
|
|
apiKey: 'server-secret',
|
|
},
|
|
}, { provider: 'openai' })
|
|
await ctrl.saveSettings(saveCtx)
|
|
|
|
const deleteCtx = makeCtx(user, {}, { provider: 'openai', secretName: 'apiKey' })
|
|
await ctrl.deleteSecret(deleteCtx)
|
|
|
|
expect(deleteCtx.status).toBe(200)
|
|
expect(deleteCtx.body).toEqual({
|
|
success: true,
|
|
setting: expect.objectContaining({
|
|
provider: 'openai',
|
|
settings: {
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {},
|
|
}),
|
|
})
|
|
expect(JSON.stringify(deleteCtx.body)).not.toContain('server-secret')
|
|
|
|
const listCtx = makeCtx(user)
|
|
await ctrl.listSettings(listCtx)
|
|
expect(listCtx.body).toEqual({
|
|
settings: [deleteCtx.body.setting],
|
|
activeProvider: 'openai',
|
|
})
|
|
})
|
|
|
|
it('deletes a stored STT provider row and falls back to browser when it was active', async () => {
|
|
const ctrl = await initController()
|
|
const user = { id: 7, username: 'bob', role: 'admin' }
|
|
|
|
await ctrl.saveSettings(makeCtx(user, {
|
|
settings: { model: 'gpt-4o-transcribe' },
|
|
secrets: { apiKey: 'server-secret' },
|
|
}, { provider: 'openai' }))
|
|
|
|
const deleteCtx = makeCtx(user, {}, { provider: 'openai' })
|
|
await ctrl.deleteProvider(deleteCtx)
|
|
|
|
expect(deleteCtx.status).toBe(200)
|
|
expect(deleteCtx.body).toEqual({
|
|
success: true,
|
|
deleted: true,
|
|
activeProvider: 'browser',
|
|
})
|
|
expect(db.prepare(
|
|
'SELECT COUNT(*) AS count FROM stt_profile_provider_settings WHERE profile = ? AND provider = ?'
|
|
).get('default', 'openai').count).toBe(0)
|
|
|
|
const activeRow = db.prepare('SELECT active_provider FROM stt_profile_settings WHERE profile = ?').get('default') as { active_provider: string }
|
|
expect(activeRow.active_provider).toBe('browser')
|
|
})
|
|
|
|
it('deletes saved custom base URL presets without deleting the current setting or secret', async () => {
|
|
const ctrl = await initController()
|
|
const user = { id: 13, username: 'dana', role: 'admin' }
|
|
|
|
const saveFirstCtx = makeCtx(user, {
|
|
settings: {
|
|
baseUrl: 'https://api.groq.com/openai/v1',
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
secrets: { apiKey: 'server-secret' },
|
|
}, { provider: 'custom' })
|
|
await ctrl.saveSettings(saveFirstCtx)
|
|
|
|
const saveSecondCtx = makeCtx(user, {
|
|
settings: {
|
|
baseUrl: 'https://stt.example.test/openai/v1',
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
}, { provider: 'custom' })
|
|
await ctrl.saveSettings(saveSecondCtx)
|
|
expect(saveSecondCtx.body.setting.settings.baseUrlPresets).toEqual([
|
|
'https://stt.example.test/openai/v1',
|
|
'https://api.groq.com/openai/v1',
|
|
])
|
|
|
|
const deleteCtx = makeCtx(
|
|
user,
|
|
{},
|
|
{ provider: 'custom' },
|
|
{ url: 'https://api.groq.com/openai/v1' },
|
|
)
|
|
await ctrl.deleteBaseUrlPreset(deleteCtx)
|
|
|
|
expect(deleteCtx.status).toBe(200)
|
|
expect(deleteCtx.body.setting).toMatchObject({
|
|
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]' },
|
|
})
|
|
expect(JSON.stringify(deleteCtx.body)).not.toContain('server-secret')
|
|
|
|
const deleteCurrentCtx = makeCtx(
|
|
user,
|
|
{},
|
|
{ provider: 'custom' },
|
|
{ url: 'https://stt.example.test/openai/v1' },
|
|
)
|
|
await ctrl.deleteBaseUrlPreset(deleteCurrentCtx)
|
|
|
|
expect(deleteCurrentCtx.status).toBe(200)
|
|
expect(deleteCurrentCtx.body.setting).toMatchObject({
|
|
provider: 'custom',
|
|
settings: {
|
|
model: 'whisper-large-v3-turbo',
|
|
},
|
|
secrets: { apiKey: '[stored]' },
|
|
})
|
|
expect(deleteCurrentCtx.body.setting.settings.baseUrl).toBeUndefined()
|
|
expect(deleteCurrentCtx.body.setting.settings.baseUrlPresets).toBeUndefined()
|
|
})
|
|
|
|
it('rejects unauthenticated requests and invalid provider inputs', async () => {
|
|
const ctrl = await initController()
|
|
|
|
const listCtx = makeCtx(null)
|
|
await ctrl.listSettings(listCtx)
|
|
expect(listCtx.status).toBe(401)
|
|
expect(listCtx.body).toEqual({ error: 'Unauthorized' })
|
|
|
|
const saveCtx = makeCtx(null, {}, { provider: 'openai' })
|
|
await ctrl.saveSettings(saveCtx)
|
|
expect(saveCtx.status).toBe(401)
|
|
expect(saveCtx.body).toEqual({ error: 'Unauthorized' })
|
|
|
|
const deletePresetCtx = makeCtx(null, {}, { provider: 'custom' }, { url: 'https://api.groq.com/openai/v1' })
|
|
await ctrl.deleteBaseUrlPreset(deletePresetCtx)
|
|
expect(deletePresetCtx.status).toBe(401)
|
|
expect(deletePresetCtx.body).toEqual({ error: 'Unauthorized' })
|
|
|
|
const deleteCtx = makeCtx(null, {}, { provider: 'openai', secretName: 'apiKey' })
|
|
await ctrl.deleteSecret(deleteCtx)
|
|
expect(deleteCtx.status).toBe(401)
|
|
expect(deleteCtx.body).toEqual({ error: 'Unauthorized' })
|
|
|
|
const authedUser = { id: 4, username: 'eve', role: 'admin' }
|
|
const badProviderCtx = makeCtx(authedUser, {}, { provider: 'nope' })
|
|
await ctrl.saveSettings(badProviderCtx)
|
|
expect(badProviderCtx.status).toBe(400)
|
|
expect(badProviderCtx.body).toEqual({ error: 'unknown STT provider' })
|
|
|
|
const badSecretCtx = makeCtx(authedUser, {}, { provider: 'openai', secretName: 'token' })
|
|
await ctrl.deleteSecret(badSecretCtx)
|
|
expect(badSecretCtx.status).toBe(400)
|
|
expect(badSecretCtx.body).toEqual({ error: 'unknown STT provider secret' })
|
|
|
|
const badActiveCtx = makeCtx(authedUser, { provider: 'nope' })
|
|
await ctrl.saveActiveProvider(badActiveCtx)
|
|
expect(badActiveCtx.status).toBe(400)
|
|
expect(badActiveCtx.body).toEqual({ error: 'unknown STT provider' })
|
|
})
|
|
|
|
it('saves browser as the active STT provider without creating a provider-secret row', async () => {
|
|
const ctrl = await initController()
|
|
const user = { id: 11, username: 'carol', role: 'admin' }
|
|
|
|
const ctx = makeCtx(user, { provider: 'browser' })
|
|
await ctrl.saveActiveProvider(ctx)
|
|
expect(ctx.body).toEqual({ activeProvider: 'browser' })
|
|
|
|
const listCtx = makeCtx(user)
|
|
await ctrl.listSettings(listCtx)
|
|
expect(listCtx.body).toEqual({ settings: [], activeProvider: 'browser' })
|
|
})
|
|
})
|
|
|
|
describe('stt routes', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
vi.doUnmock('../../packages/server/src/routes/hermes/stt')
|
|
vi.doUnmock('../../packages/server/src/controllers/hermes/stt')
|
|
})
|
|
|
|
it('registers the protected STT settings and transcribe routes', async () => {
|
|
const listSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'listSettings' } })
|
|
const saveActiveProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveActiveProvider' } })
|
|
const saveSettings = vi.fn(async (ctx: any) => { ctx.body = { route: 'saveSettings' } })
|
|
const deleteProvider = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteProvider' } })
|
|
const deleteSecret = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteSecret' } })
|
|
const deleteBaseUrlPreset = vi.fn(async (ctx: any) => { ctx.body = { route: 'deleteBaseUrlPreset' } })
|
|
const profileStatus = vi.fn(async (ctx: any) => { ctx.body = { route: 'profileStatus' } })
|
|
const missingProfileAudio = vi.fn(async (ctx: any) => { ctx.body = { route: 'missingProfileAudio' } })
|
|
const mcuVoiceTurn = vi.fn(async (ctx: any) => { ctx.body = { route: 'mcuVoiceTurn' } })
|
|
const transcribe = vi.fn(async (ctx: any) => { ctx.body = { route: 'transcribe' } })
|
|
|
|
vi.doMock('../../packages/server/src/controllers/hermes/stt', () => ({
|
|
listSettings,
|
|
saveActiveProvider,
|
|
saveSettings,
|
|
deleteProvider,
|
|
deleteSecret,
|
|
deleteBaseUrlPreset,
|
|
profileStatus,
|
|
missingProfileAudio,
|
|
mcuVoiceTurn,
|
|
transcribe,
|
|
}))
|
|
|
|
const { sttProtectedRoutes } = await import('../../packages/server/src/routes/hermes/stt')
|
|
const protectedPaths = sttProtectedRoutes.stack.map((entry: any) => entry.path)
|
|
|
|
expect(protectedPaths).toEqual(expect.arrayContaining([
|
|
'/api/hermes/stt/settings',
|
|
'/api/hermes/stt/profile-status',
|
|
'/api/hermes/stt/profile-status/missing-audio',
|
|
'/api/hermes/mcu/voice-turn',
|
|
'/api/hermes/stt/settings/active',
|
|
'/api/hermes/stt/settings/:provider',
|
|
'/api/hermes/stt/settings/:provider',
|
|
'/api/hermes/stt/settings/:provider/base-url-preset',
|
|
'/api/hermes/stt/settings/:provider/secret/:secretName',
|
|
'/api/hermes/stt/transcribe',
|
|
]))
|
|
|
|
const transcribeLayer: any = sttProtectedRoutes.stack.find((entry: any) => entry.path === '/api/hermes/stt/transcribe')
|
|
const ctx: any = { request: { body: {} }, body: null }
|
|
|
|
await transcribeLayer.stack[0](ctx, undefined)
|
|
|
|
expect(transcribe).toHaveBeenCalledWith(ctx, undefined)
|
|
expect(ctx.body).toEqual({ route: 'transcribe' })
|
|
})
|
|
})
|