feat: add Vitest testing framework, fix proxy auth stripping and 401 handling
- Set up Vitest with jsdom for client tests, node for server tests - Add tests for auth service, proxy handler, API client, and profiles store - Strip Authorization header in proxy to prevent web-ui token leaking to gateway - Distinguish local BFF vs proxied gateway 401s to avoid false logouts - Remove unused hero.png asset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// vi.mock is hoisted, so mockReplace must be inside the factory
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { name: 'hermes.chat' } },
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, request } from '../../packages/client/src/api/client'
|
||||
import router from '@/router'
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('token management', () => {
|
||||
it('hasApiKey returns false when no token', () => {
|
||||
expect(hasApiKey()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasApiKey returns true after setApiKey', () => {
|
||||
setApiKey('test-token')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('getApiKey returns the stored token', () => {
|
||||
setApiKey('my-token')
|
||||
expect(getApiKey()).toBe('my-token')
|
||||
})
|
||||
|
||||
it('clearApiKey removes the token', () => {
|
||||
setApiKey('my-token')
|
||||
clearApiKey()
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(getApiKey()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('request', () => {
|
||||
it('adds Authorization header when token exists', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers.Authorization).toBe('Bearer secret-key')
|
||||
})
|
||||
|
||||
it('does not add Authorization header when no token', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears token and redirects on 401 for local BFF endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401 })
|
||||
|
||||
await expect(request('/api/hermes/sessions')).rejects.toThrow('Unauthorized')
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied v1 endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
|
||||
await expect(request('/api/hermes/v1/runs')).rejects.toThrow('API Error 401')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied jobs endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
|
||||
await expect(request('/api/hermes/jobs')).rejects.toThrow('API Error 401')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied skills endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
|
||||
await expect(request('/api/hermes/skills')).rejects.toThrow('API Error 401')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('throws error on non-401 failure', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
})
|
||||
|
||||
await expect(request('/api/hermes/sessions')).rejects.toThrow('API Error 500: Internal Server Error')
|
||||
})
|
||||
|
||||
it('returns parsed JSON on success', async () => {
|
||||
const data = { sessions: [{ id: '1' }] }
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(data) })
|
||||
|
||||
const result = await request('/api/hermes/sessions')
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
const mockProfilesApi = vi.hoisted(() => ({
|
||||
fetchProfiles: vi.fn(),
|
||||
fetchProfileDetail: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
switchProfile: vi.fn(),
|
||||
exportProfile: vi.fn(),
|
||||
importProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/profiles', () => mockProfilesApi)
|
||||
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
|
||||
describe('Profiles Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchProfiles loads profiles and sets active', async () => {
|
||||
const profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
]
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue(profiles)
|
||||
|
||||
const store = useProfilesStore()
|
||||
await store.fetchProfiles()
|
||||
|
||||
expect(store.profiles).toEqual(profiles)
|
||||
expect(store.activeProfile?.name).toBe('default')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchProfiles sets loading state', async () => {
|
||||
mockProfilesApi.fetchProfiles.mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve([]), 10))
|
||||
)
|
||||
|
||||
const store = useProfilesStore()
|
||||
const fetchPromise = store.fetchProfiles()
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
await fetchPromise
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('createProfile calls API and refreshes list', async () => {
|
||||
mockProfilesApi.createProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'new-profile', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
const ok = await store.createProfile('new-profile', false)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockProfilesApi.createProfile).toHaveBeenCalledWith('new-profile', false)
|
||||
expect(store.profiles).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deleteProfile clears detail cache', async () => {
|
||||
mockProfilesApi.deleteProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', gateway: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
await store.deleteProfile('test')
|
||||
|
||||
expect(store.detailMap['test']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fetchProfileDetail uses cache', async () => {
|
||||
const detail = { name: 'cached', path: '/tmp/cached', model: 'gpt-4', provider: 'openai', gateway: 'running', skills: 5, hasEnv: true, hasSoulMd: false }
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['cached'] = detail
|
||||
|
||||
const result = await store.fetchProfileDetail('cached')
|
||||
|
||||
expect(result).toEqual(detail)
|
||||
expect(mockProfilesApi.fetchProfileDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switchProfile sets switching state', async () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([])
|
||||
|
||||
const store = useProfilesStore()
|
||||
const switchPromise = store.switchProfile('dev')
|
||||
|
||||
expect(store.switching).toBe(true)
|
||||
await switchPromise
|
||||
expect(store.switching).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user