Files
hermes-web-ui/tests/client/api.test.ts
ekko 076a7c2a38 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>
2026-04-16 20:24:09 +08:00

120 lines
3.8 KiB
TypeScript

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