Scope files jobs and plugins to request profile
This commit is contained in:
@@ -13,6 +13,8 @@ vi.mock('@/router', () => ({
|
||||
}))
|
||||
|
||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
|
||||
import { getDownloadUrl } from '../../packages/client/src/api/hermes/download'
|
||||
import { uploadFiles } from '../../packages/client/src/api/hermes/files'
|
||||
import router from '@/router'
|
||||
|
||||
function fakeJwt(payload: Record<string, unknown>) {
|
||||
@@ -153,4 +155,41 @@ describe('API Client', () => {
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download URLs', () => {
|
||||
it('adds the active profile selector to direct download URLs', () => {
|
||||
setApiKey('secret-key')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
|
||||
const url = new URL(getDownloadUrl('/tmp/report.txt', 'report.txt'), 'http://localhost')
|
||||
|
||||
expect(url.pathname).toBe('/api/hermes/download')
|
||||
expect(url.searchParams.get('path')).toBe('/tmp/report.txt')
|
||||
expect(url.searchParams.get('name')).toBe('report.txt')
|
||||
expect(url.searchParams.get('profile')).toBe('research')
|
||||
expect(url.searchParams.get('token')).toBe('secret-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('file upload', () => {
|
||||
it('adds auth and active profile headers to multipart uploads', async () => {
|
||||
setApiKey('secret-key')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ files: [] }),
|
||||
})
|
||||
|
||||
await uploadFiles('notes', [new File(['hello'], 'hello.txt', { type: 'text/plain' })])
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/hermes/files/upload?path=notes')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.headers.Authorization).toBe('Bearer secret-key')
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
scheduleToEditableInput,
|
||||
updateJob,
|
||||
} from '../../packages/client/src/api/hermes/jobs'
|
||||
import { listCronRuns } from '../../packages/client/src/api/hermes/cron-history'
|
||||
import type { Job } from '../../packages/client/src/api/hermes/jobs'
|
||||
|
||||
function makeJob(overrides: Partial<Job> = {}): Job {
|
||||
@@ -118,4 +119,20 @@ describe('Hermes jobs edit payloads', () => {
|
||||
schedule: 'every 14400m',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends active profile header when loading job run history', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ runs: [] }),
|
||||
})
|
||||
|
||||
await listCronRuns('job-1')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/cron-history?jobId=job-1')
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ test('renders authenticated shell and navigates between key product routes', asy
|
||||
const jobsRequest = api.requests.find((request) => request.pathname === '/api/hermes/jobs')
|
||||
expect(jobsRequest?.headers.authorization).toBe(`Bearer ${TEST_ACCESS_KEY}`)
|
||||
expect(jobsRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
const cronHistoryRequest = api.requests.find((request) => request.pathname === '/api/cron-history')
|
||||
expect(cronHistoryRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
|
||||
await page.locator('aside.sidebar').getByRole('button', { name: /^Models$/ }).click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/models$/)
|
||||
|
||||
@@ -3,10 +3,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const profileDirState = vi.hoisted(() => ({ value: '' }))
|
||||
const profileDirState = vi.hoisted(() => ({
|
||||
value: '',
|
||||
dirs: {} as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileDir: () => profileDirState.value,
|
||||
getActiveProfileName: () => 'default',
|
||||
getProfileDir: (profile: string) => profileDirState.dirs[profile] || profileDirState.value,
|
||||
}))
|
||||
|
||||
function createCtx(overrides: Record<string, any> = {}) {
|
||||
@@ -19,8 +23,8 @@ function createCtx(overrides: Record<string, any> = {}) {
|
||||
} as any
|
||||
}
|
||||
|
||||
function writeJobs(jobs: unknown[]) {
|
||||
const cronDir = join(profileDirState.value, 'cron')
|
||||
function writeJobs(jobs: unknown[], profileDir = profileDirState.value) {
|
||||
const cronDir = join(profileDir, 'cron')
|
||||
mkdirSync(cronDir, { recursive: true })
|
||||
writeFileSync(join(cronDir, 'jobs.json'), JSON.stringify({ jobs }))
|
||||
}
|
||||
@@ -29,10 +33,45 @@ describe('Hermes cron history controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-cron-history-'))
|
||||
profileDirState.dirs = { default: profileDirState.value }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
|
||||
for (const dir of Object.values(profileDirState.dirs)) {
|
||||
if (dir !== profileDirState.value) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads run history from the request profile directory', async () => {
|
||||
const researchDir = mkdtempSync(join(tmpdir(), 'hwui-cron-history-research-'))
|
||||
profileDirState.dirs.research = researchDir
|
||||
writeJobs([
|
||||
{
|
||||
id: 'default-job',
|
||||
name: 'Default job',
|
||||
last_run_at: '2026-05-05T01:00:00+00:00',
|
||||
},
|
||||
])
|
||||
writeJobs([
|
||||
{
|
||||
id: 'research-job',
|
||||
name: 'Research job',
|
||||
last_run_at: '2026-05-05T02:00:00+00:00',
|
||||
},
|
||||
], researchDir)
|
||||
|
||||
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ state: { profile: { name: 'research' } } })
|
||||
await listRuns(ctx)
|
||||
|
||||
expect(ctx.body.runs).toEqual([
|
||||
expect.objectContaining({
|
||||
jobId: 'research-job',
|
||||
runTime: '2026-05-05 02:00:00',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces scheduler metadata when a job ran without an output artifact', async () => {
|
||||
|
||||
@@ -33,10 +33,12 @@ describe('file routes path metadata', () => {
|
||||
|
||||
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
||||
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/list')
|
||||
const ctx: any = { query: { path: 'logs' }, body: null }
|
||||
const ctx: any = { query: { path: 'logs' }, state: { profile: { name: 'research' } }, body: null }
|
||||
|
||||
await layer.stack[0](ctx)
|
||||
|
||||
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
||||
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs', 'research')
|
||||
expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs')
|
||||
expect(ctx.body).toEqual({
|
||||
path: 'logs',
|
||||
@@ -65,10 +67,12 @@ describe('file routes path metadata', () => {
|
||||
|
||||
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
||||
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/stat')
|
||||
const ctx: any = { query: { path: 'logs/app.log' }, body: null }
|
||||
const ctx: any = { query: { path: 'logs/app.log' }, state: { profile: { name: 'research' } }, body: null }
|
||||
|
||||
await layer.stack[0](ctx)
|
||||
|
||||
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
||||
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs/app.log', 'research')
|
||||
expect(ctx.body).toEqual({
|
||||
name: 'app.log',
|
||||
path: 'logs/app.log',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Readable } from 'stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mkdirMock = vi.hoisted(() => vi.fn())
|
||||
const writeFileMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
mkdir: mkdirMock,
|
||||
writeFile: writeFileMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileName: vi.fn(() => 'default'),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/upload-paths', () => ({
|
||||
getProfileUploadDir: vi.fn((profile: string) => `/tmp/hermes-web-ui/upload/${profile}`),
|
||||
}))
|
||||
|
||||
function multipartBody(boundary: string, name: string, content: string): Buffer {
|
||||
return Buffer.from([
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="file"; filename="${name}"`,
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
content,
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join('\r\n'))
|
||||
}
|
||||
|
||||
describe('upload controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mkdirMock.mockResolvedValue(undefined)
|
||||
writeFileMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('stores chat uploads under the request-scoped profile upload directory', async () => {
|
||||
const boundary = 'test-boundary'
|
||||
const { handleUpload } = await import('../../packages/server/src/controllers/upload')
|
||||
const ctx: any = {
|
||||
get: vi.fn((header: string) => header === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
|
||||
req: Readable.from([multipartBody(boundary, 'note.txt', 'hello')]),
|
||||
state: { profile: { name: 'research' } },
|
||||
body: undefined,
|
||||
status: 200,
|
||||
}
|
||||
|
||||
await handleUpload(ctx)
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp/hermes-web-ui/upload/research', { recursive: true })
|
||||
expect(writeFileMock).toHaveBeenCalledOnce()
|
||||
const [savedPath, data] = writeFileMock.mock.calls[0]
|
||||
expect(savedPath).toMatch(/^\/tmp\/hermes-web-ui\/upload\/research\/[a-f0-9]+\.txt$/)
|
||||
expect(data.toString('utf-8')).toBe('hello')
|
||||
expect(ctx.body.files[0]).toMatchObject({ name: 'note.txt', path: savedPath })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user