Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-23 18:44:53 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
+54 -17
View File
@@ -12,9 +12,15 @@ vi.mock('@/router', () => ({
},
}))
import { getApiKey, setApiKey, clearApiKey, hasApiKey, request } from '../../packages/client/src/api/client'
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
import router from '@/router'
function fakeJwt(payload: Record<string, unknown>) {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `${header}.${body}.signature`
}
describe('API Client', () => {
beforeEach(() => {
localStorage.clear()
@@ -42,6 +48,17 @@ describe('API Client', () => {
expect(hasApiKey()).toBe(false)
expect(getApiKey()).toBe('')
})
it('reads the role from the stored JWT payload', () => {
setApiKey(fakeJwt({ sub: '1', role: 'super_admin' }))
expect(getStoredUserRole()).toBe('super_admin')
expect(isStoredSuperAdmin()).toBe(true)
setApiKey(fakeJwt({ sub: '2', role: 'admin' }))
expect(getStoredUserRole()).toBe('admin')
expect(isStoredSuperAdmin()).toBe(false)
})
})
describe('request', () => {
@@ -56,6 +73,16 @@ describe('API Client', () => {
expect(options.headers.Authorization).toBe('Bearer secret-key')
})
it('adds the active profile header, including default', async () => {
localStorage.setItem('hermes_active_profile_name', 'default')
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
await request('/api/hermes/sessions')
const [, options] = mockFetch.mock.calls[0]
expect(options.headers['X-Hermes-Profile']).toBe('default')
})
it('does not add Authorization header when no token', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
@@ -74,6 +101,32 @@ describe('API Client', () => {
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
})
it('emits a global auth notice on local 403 responses', async () => {
const listener = vi.fn()
window.addEventListener('hermes-auth-notice', listener)
mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve('Forbidden') })
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
expect(listener).toHaveBeenCalledOnce()
expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'forbidden' })
window.removeEventListener('hermes-auth-notice', listener)
})
it('clears token and redirects when the JWT user no longer exists', async () => {
setApiKey('stale-jwt')
mockFetch.mockResolvedValue({
ok: false,
status: 403,
text: () => Promise.resolve('{"error":"User is disabled or does not exist"}'),
})
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
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('') })
@@ -82,22 +135,6 @@ describe('API Client', () => {
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,
+3 -2
View File
@@ -49,9 +49,10 @@ describe('Kanban API', () => {
it('builds board-scoped kanban event websocket URLs with auth token', () => {
mockGetBaseUrlValue.mockReturnValue('https://wui.example.test')
mockGetApiKey.mockReturnValue('token value')
localStorage.setItem('hermes_active_profile_name', 'research')
expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value')
expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value')
expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value&profile=research')
expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value&profile=research')
})
it('serializes board, list filters, and archived inclusion into query params', async () => {
+15 -20
View File
@@ -32,43 +32,38 @@ vi.mock('@/api/auth', () => ({
import LoginView from '@/views/LoginView.vue'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('LoginView token login', () => {
describe('LoginView password login', () => {
beforeEach(() => {
delete (window as any).__LOGIN_TOKEN__
vi.clearAllMocks()
mockHasApiKey.mockReturnValue(false)
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: false })
mockFetch.mockResolvedValue({ ok: true, status: 200 })
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: true, username: 'admin' })
})
it('validates token login against the Hermes sessions endpoint', async () => {
it('logs in with username and password', async () => {
mockLoginWithPassword.mockResolvedValue('jwt-token')
const wrapper = mount(LoginView)
await wrapper.find('input.login-input').setValue('secret-token')
const inputs = wrapper.findAll('input.login-input')
await inputs[0].setValue('admin')
await inputs[1].setValue('123456')
await wrapper.find('form.login-form').trigger('submit')
expect(mockFetch).toHaveBeenCalledOnce()
expect(mockFetch).toHaveBeenCalledWith('/api/hermes/sessions', {
headers: { Authorization: 'Bearer secret-token' },
})
expect(mockSetApiKey).toHaveBeenCalledWith('secret-token')
expect(mockLoginWithPassword).toHaveBeenCalledWith('admin', '123456')
expect(mockSetApiKey).toHaveBeenCalledWith('jwt-token')
expect(mockReplace).toHaveBeenCalledWith('/hermes/chat')
})
it('keeps the existing invalid-token behavior on 401', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 401 })
it('shows an error when password login fails', async () => {
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
const wrapper = mount(LoginView)
await wrapper.find('input.login-input').setValue('bad-token')
const inputs = wrapper.findAll('input.login-input')
await inputs[0].setValue('admin')
await inputs[1].setValue('bad-password')
await wrapper.find('form.login-form').trigger('submit')
expect(mockFetch).toHaveBeenCalledWith('/api/hermes/sessions', {
headers: { Authorization: 'Bearer bad-token' },
})
expect(wrapper.find('.login-error').text()).toBe('login.invalidToken')
expect(wrapper.find('.login-error').text()).toBe('Invalid username or password')
expect(mockSetApiKey).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
})
+4 -8
View File
@@ -207,12 +207,11 @@ describe('Profiles Store', () => {
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
})
it('switchProfile rolls back if backend reports different active profile', async () => {
it('switchProfile keeps the local selected profile independent of backend active flags', async () => {
const initialName = 'default'
localStorage.setItem('hermes_active_profile_name', initialName)
mockProfilesApi.switchProfile.mockResolvedValue(true)
// Backend returns success, but active profile is still default (not the one we switched to)
mockProfilesApi.fetchProfiles.mockResolvedValue([
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
@@ -222,11 +221,8 @@ describe('Profiles Store', () => {
store.activeProfileName = initialName
const result = await store.switchProfile('dev')
// Should return false (backend verification failed)
expect(result).toBe(false)
// activeProfileName should be rolled back to default
expect(store.activeProfileName).toBe('default')
// localStorage should be rolled back
expect(localStorage.getItem('hermes_active_profile_name')).toBe('default')
expect(result).toBe(true)
expect(store.activeProfileName).toBe('dev')
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
})
})