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:
+9
-2
@@ -35,7 +35,10 @@
|
||||
"dev:client": "vite --host",
|
||||
"dev:server": "nodemon --signal SIGTERM --watch packages/server/src -e ts,tsx --exec node -r ts-node/register packages/server/src/index.ts",
|
||||
"build": "vue-tsc -b && vite build && tsc -p packages/server/tsconfig.json",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
@@ -65,6 +68,7 @@
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pinia/testing": "^1.0.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/koa": "^2.15.0",
|
||||
"@types/koa__cors": "^5.0.0",
|
||||
@@ -76,13 +80,16 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"nodemon": "^3.1.14",
|
||||
"sass": "^1.99.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,13 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
|
||||
// Global 401 handler — clear auth and redirect to login
|
||||
if (res.status === 401) {
|
||||
// Global 401 handler — only redirect to login for local BFF endpoints
|
||||
// Proxied gateway requests should not trigger logout
|
||||
const isLocalBff = !path.startsWith('/api/hermes/v1/') &&
|
||||
!path.startsWith('/api/hermes/jobs') &&
|
||||
!path.startsWith('/api/hermes/skills')
|
||||
|
||||
if (res.status === 401 && isLocalBff) {
|
||||
clearApiKey()
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.replace({ name: 'login' })
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -8,27 +8,22 @@ export async function proxy(ctx: Context) {
|
||||
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)
|
||||
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
|
||||
const url = `${upstream}${upstreamPath}${ctx.search || ''}`
|
||||
console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`)
|
||||
|
||||
// Build headers — forward most, strip browser-specific ones
|
||||
// Build headers — forward most, strip browser/web-ui specific ones
|
||||
const headers: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(ctx.headers)) {
|
||||
if (value == null) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'host') {
|
||||
headers['host'] = new URL(upstream).host
|
||||
} else if (lower !== 'origin' && lower !== 'referer' && lower !== 'connection') {
|
||||
} else if (lower === 'authorization' || lower === 'origin' || lower === 'referer' || lower === 'connection') {
|
||||
continue
|
||||
} else {
|
||||
const v = Array.isArray(value) ? value[0] : value
|
||||
if (v) headers[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add SSE-friendly headers
|
||||
if (ctx.path.match(/\/events$/)) {
|
||||
headers['x-accel-buffering'] = 'no'
|
||||
headers['cache-control'] = 'no-cache'
|
||||
}
|
||||
|
||||
try {
|
||||
// Build request body from raw body
|
||||
let body: string | undefined
|
||||
@@ -43,20 +38,14 @@ export async function proxy(ctx: Context) {
|
||||
})
|
||||
|
||||
// Set response headers
|
||||
const resHeaders: Record<string, string> = {}
|
||||
res.headers.forEach((value, key) => {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower !== 'transfer-encoding' && lower !== 'connection') {
|
||||
resHeaders[key] = value
|
||||
ctx.set(key, value)
|
||||
}
|
||||
})
|
||||
if (ctx.path.match(/\/events$/)) {
|
||||
resHeaders['x-accel-buffering'] = 'no'
|
||||
resHeaders['cache-control'] = 'no-cache'
|
||||
}
|
||||
|
||||
ctx.status = res.status
|
||||
ctx.set(resHeaders)
|
||||
|
||||
// Stream response body
|
||||
if (res.body) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
vi.mock('../../packages/server/src/config', () => ({
|
||||
config: { dataDir: '/tmp/hermes-test-data' },
|
||||
}))
|
||||
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { getToken, authMiddleware } from '../../packages/server/src/services/auth'
|
||||
|
||||
const mockedReadFile = vi.mocked(readFile)
|
||||
const mockedWriteFile = vi.mocked(writeFile)
|
||||
|
||||
describe('Auth Service', () => {
|
||||
const originalEnv = process.env
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv
|
||||
})
|
||||
|
||||
describe('getToken', () => {
|
||||
it('returns null when AUTH_DISABLED=1', async () => {
|
||||
process.env.AUTH_DISABLED = '1'
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockedReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when AUTH_DISABLED=true', async () => {
|
||||
process.env.AUTH_DISABLED = 'true'
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns AUTH_TOKEN env var if set', async () => {
|
||||
process.env.AUTH_TOKEN = 'my-custom-token'
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBe('my-custom-token')
|
||||
expect(mockedReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads token from file if exists', async () => {
|
||||
mockedReadFile.mockResolvedValue('file-token\n')
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBe('file-token')
|
||||
expect(mockedReadFile).toHaveBeenCalledWith('/tmp/hermes-test-data/.token', 'utf-8')
|
||||
})
|
||||
|
||||
it('generates and saves new token if file missing', async () => {
|
||||
mockedReadFile.mockRejectedValue(new Error('ENOENT'))
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeTruthy()
|
||||
expect(token).toHaveLength(64) // 32 bytes hex
|
||||
expect(mockedWriteFile).toHaveBeenCalledWith(
|
||||
'/tmp/hermes-test-data/.token',
|
||||
expect.stringMatching(/^[a-f0-9]{64}\n$/),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
function createMockCtx(path: string, headers: Record<string, string> = {}, query: Record<string, string> = {}) {
|
||||
return {
|
||||
path,
|
||||
headers,
|
||||
query,
|
||||
status: 200,
|
||||
body: null,
|
||||
set: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
const next = vi.fn()
|
||||
|
||||
it('allows all requests when auth is disabled (null token)', async () => {
|
||||
const middleware = await authMiddleware(null)
|
||||
const ctx = createMockCtx('/api/hermes/sessions')
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips /health path', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/health')
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips non-API paths', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/index.html')
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires auth for /webhook path (it is an API-like endpoint)', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/webhook', {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request without auth header', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request with wrong token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' })
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows request with correct Bearer token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer secret' })
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows request with correct query token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', {}, { token: 'secret' })
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns 401 JSON on auth failure', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' })
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(ctx.set).toHaveBeenCalledWith('Content-Type', 'application/json')
|
||||
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock hermes-cli
|
||||
vi.mock('../../packages/server/src/services/hermes-cli', () => ({
|
||||
listProfiles: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
useProfile: vi.fn(),
|
||||
stopGateway: vi.fn(),
|
||||
startGateway: vi.fn(),
|
||||
startGatewayBackground: vi.fn(),
|
||||
setupReset: vi.fn(),
|
||||
exportProfile: vi.fn(),
|
||||
importProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as hermesCli from '../../packages/server/src/services/hermes-cli'
|
||||
|
||||
describe('Profile Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ensureApiServerConfig (via active profile switch)', () => {
|
||||
it('should inject api_server config when missing', async () => {
|
||||
// This tests the logic that profiles.ts ensures api_server config exists
|
||||
// We test the ensureApiServerConfig behavior indirectly through the module
|
||||
const { existsSync, readFileSync, writeFileSync } = await import('fs')
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn().mockReturnValue('platforms: {}'),
|
||||
writeFileSync: vi.fn(),
|
||||
createReadStream: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hermes-cli wrapper', () => {
|
||||
it('listProfiles returns array', async () => {
|
||||
const mockProfiles = [{ name: 'default', active: true }]
|
||||
vi.mocked(hermesCli.listProfiles).mockResolvedValue(mockProfiles as any)
|
||||
|
||||
const result = await hermesCli.listProfiles()
|
||||
expect(result).toEqual(mockProfiles)
|
||||
})
|
||||
|
||||
it('getProfile returns profile detail', async () => {
|
||||
const mockDetail = { name: 'default', path: '/tmp/default' }
|
||||
vi.mocked(hermesCli.getProfile).mockResolvedValue(mockDetail as any)
|
||||
|
||||
const result = await hermesCli.getProfile('default')
|
||||
expect(result).toEqual(mockDetail)
|
||||
expect(hermesCli.getProfile).toHaveBeenCalledWith('default')
|
||||
})
|
||||
|
||||
it('createProfile calls CLI with name and clone flag', async () => {
|
||||
vi.mocked(hermesCli.createProfile).mockResolvedValue('Profile created')
|
||||
|
||||
await hermesCli.createProfile('test', true)
|
||||
|
||||
expect(hermesCli.createProfile).toHaveBeenCalledWith('test', true)
|
||||
})
|
||||
|
||||
it('deleteProfile calls CLI with name', async () => {
|
||||
vi.mocked(hermesCli.deleteProfile).mockResolvedValue(true)
|
||||
|
||||
await hermesCli.deleteProfile('test')
|
||||
|
||||
expect(hermesCli.deleteProfile).toHaveBeenCalledWith('test')
|
||||
})
|
||||
|
||||
it('renameProfile calls CLI with old and new name', async () => {
|
||||
vi.mocked(hermesCli.renameProfile).mockResolvedValue(true)
|
||||
|
||||
await hermesCli.renameProfile('old', 'new')
|
||||
|
||||
expect(hermesCli.renameProfile).toHaveBeenCalledWith('old', 'new')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock config
|
||||
vi.mock('../../packages/server/src/config', () => ({
|
||||
config: { upstream: 'http://127.0.0.1:8642' },
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
import { proxy } from '../../packages/server/src/routes/hermes/proxy-handler'
|
||||
|
||||
function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
let headersSent = false
|
||||
const ctx: any = {
|
||||
path: '/api/hermes/jobs',
|
||||
method: 'GET',
|
||||
headers: { host: 'localhost:8648', 'content-type': 'application/json' },
|
||||
query: {},
|
||||
search: '',
|
||||
req: { method: 'GET' },
|
||||
res: {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
},
|
||||
request: { rawBody: undefined },
|
||||
status: 200,
|
||||
set: vi.fn(),
|
||||
body: null,
|
||||
...overrides,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Proxy Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rewrites /api/hermes/v1/* to /v1/*', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: null,
|
||||
json: () => Promise.resolve({ ok: true }),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx({ path: '/api/hermes/v1/runs', search: '' })
|
||||
await proxy(ctx)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const url = mockFetch.mock.calls[0][0]
|
||||
expect(url).toContain('/v1/runs')
|
||||
expect(url).not.toContain('/api/hermes')
|
||||
})
|
||||
|
||||
it('rewrites /api/hermes/* to /api/*', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: null,
|
||||
json: () => Promise.resolve({ ok: true }),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx({ path: '/api/hermes/jobs', search: '' })
|
||||
await proxy(ctx)
|
||||
|
||||
const url = mockFetch.mock.calls[0][0]
|
||||
expect(url).toContain('/api/jobs')
|
||||
expect(url).not.toContain('/api/hermes')
|
||||
})
|
||||
|
||||
it('strips authorization header', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: null,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx({
|
||||
headers: { host: 'localhost:8648', authorization: 'Bearer web-ui-token' },
|
||||
})
|
||||
await proxy(ctx)
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers.authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces host header with upstream host', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: null,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx()
|
||||
await proxy(ctx)
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers.host).toBe('127.0.0.1:8642')
|
||||
})
|
||||
|
||||
it('forwards query string', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'text/event-stream' }),
|
||||
body: null,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx({ search: '?include_disabled=true' })
|
||||
await proxy(ctx)
|
||||
|
||||
const url = mockFetch.mock.calls[0][0]
|
||||
expect(url).toContain('?include_disabled=true')
|
||||
})
|
||||
|
||||
it('returns 502 on connection failure', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
|
||||
const ctx = createMockCtx()
|
||||
await proxy(ctx)
|
||||
|
||||
expect(ctx.status).toBe(502)
|
||||
expect(ctx.body).toEqual({ error: { message: 'Proxy error: ECONNREFUSED' } })
|
||||
})
|
||||
|
||||
it('passes through non-200 status codes', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: null,
|
||||
json: () => Promise.resolve({ error: 'Not found' }),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx()
|
||||
await proxy(ctx)
|
||||
|
||||
expect(ctx.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Client-only setup (window/localStorage only exist in jsdom)
|
||||
if (typeof window !== 'undefined') {
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { for (const k of Object.keys(store)) delete store[k] }),
|
||||
get length() { return Object.keys(store).length },
|
||||
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
||||
},
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'packages/client/src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user