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:
ekko
2026-04-16 20:24:09 +08:00
parent 26423984d1
commit 076a7c2a38
12 changed files with 707 additions and 21 deletions
+119
View File
@@ -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)
})
})
})
+105
View File
@@ -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)
})
})