250 lines
8.0 KiB
TypeScript
250 lines
8.0 KiB
TypeScript
import { Readable } from 'stream'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const provider = {
|
|
listDir: vi.fn(),
|
|
stat: vi.fn(),
|
|
readFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
deleteDir: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
}
|
|
const createFileProviderMock = vi.fn(async () => provider)
|
|
const resolveHermesPathMock = vi.fn((relativePath: string) => {
|
|
const normalized = relativePath.replace(/^\/+/, '')
|
|
return normalized ? `/home/agent/.hermes/${normalized}` : '/home/agent/.hermes'
|
|
})
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/file-provider', () => ({
|
|
createFileProvider: createFileProviderMock,
|
|
resolveHermesPath: resolveHermesPathMock,
|
|
isSensitivePath: vi.fn(() => false),
|
|
MAX_EDIT_SIZE: 10 * 1024 * 1024,
|
|
}))
|
|
|
|
async function runFileRoute(path: string, ctx: any) {
|
|
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
|
const layer = fileRoutes.stack.find((entry: any) => entry.path === path)
|
|
if (!layer) throw new Error(`Missing file route ${path}`)
|
|
|
|
let index = -1
|
|
async function dispatch(nextIndex: number): Promise<void> {
|
|
if (nextIndex <= index) throw new Error('next() called multiple times')
|
|
index = nextIndex
|
|
const fn = layer.stack[nextIndex]
|
|
if (!fn) return
|
|
await fn(ctx, () => dispatch(nextIndex + 1))
|
|
}
|
|
|
|
await dispatch(0)
|
|
}
|
|
|
|
function superAdminState(profile = 'research') {
|
|
return {
|
|
profile: { name: profile },
|
|
user: { id: 1, username: 'owner', role: 'super_admin' },
|
|
}
|
|
}
|
|
|
|
describe('file routes path metadata', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
createFileProviderMock.mockClear()
|
|
resolveHermesPathMock.mockClear()
|
|
provider.listDir.mockReset()
|
|
provider.stat.mockReset()
|
|
provider.readFile.mockReset()
|
|
provider.deleteFile.mockReset()
|
|
provider.deleteDir.mockReset()
|
|
provider.writeFile.mockReset()
|
|
})
|
|
|
|
it('returns absolute paths for listed entries while preserving relative operation paths', async () => {
|
|
provider.listDir.mockResolvedValue([
|
|
{ name: 'app.log', path: 'logs/app.log', isDir: false, size: 12, modTime: '2026-05-20T00:00:00.000Z' },
|
|
])
|
|
|
|
const ctx: any = { query: { path: 'logs' }, state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await runFileRoute('/api/hermes/files/list', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs', 'research')
|
|
expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs')
|
|
expect(ctx.body).toEqual({
|
|
path: 'logs',
|
|
absolutePath: '/home/agent/.hermes/logs',
|
|
entries: [
|
|
{
|
|
name: 'app.log',
|
|
path: 'logs/app.log',
|
|
absolutePath: '/home/agent/.hermes/logs/app.log',
|
|
isDir: false,
|
|
size: 12,
|
|
modTime: '2026-05-20T00:00:00.000Z',
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it('returns an absolute path in stat responses', async () => {
|
|
provider.stat.mockResolvedValue({
|
|
name: 'app.log',
|
|
path: 'logs/app.log',
|
|
isDir: false,
|
|
size: 12,
|
|
modTime: '2026-05-20T00:00:00.000Z',
|
|
})
|
|
|
|
const ctx: any = { query: { path: 'logs/app.log' }, state: { profile: { name: 'research' } }, body: null }
|
|
|
|
await runFileRoute('/api/hermes/files/stat', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs/app.log', 'research')
|
|
expect(ctx.body).toEqual({
|
|
name: 'app.log',
|
|
path: 'logs/app.log',
|
|
absolutePath: '/home/agent/.hermes/logs/app.log',
|
|
isDir: false,
|
|
size: 12,
|
|
modTime: '2026-05-20T00:00:00.000Z',
|
|
})
|
|
})
|
|
|
|
it('deletes files from the parsed request body', async () => {
|
|
provider.deleteFile.mockResolvedValue(undefined)
|
|
|
|
const ctx: any = {
|
|
request: { body: { path: 'workspace/weather.txt', recursive: false } },
|
|
state: superAdminState(),
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/delete', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('workspace/weather.txt', 'research')
|
|
expect(provider.deleteFile).toHaveBeenCalledWith('/home/agent/.hermes/workspace/weather.txt')
|
|
expect(provider.deleteDir).not.toHaveBeenCalled()
|
|
expect(ctx.body).toEqual({ ok: true })
|
|
})
|
|
|
|
it('returns missing_path instead of throwing when delete body is absent', async () => {
|
|
const ctx: any = {
|
|
request: { body: undefined },
|
|
state: superAdminState(),
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/delete', ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'Missing path parameter', code: 'missing_path' })
|
|
expect(createFileProviderMock).not.toHaveBeenCalled()
|
|
expect(provider.deleteFile).not.toHaveBeenCalled()
|
|
expect(provider.deleteDir).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uploads files with boundary parameters and RFC 5987 filenames', async () => {
|
|
provider.writeFile.mockResolvedValue(undefined)
|
|
const boundary = 'files-boundary'
|
|
const body = Buffer.from([
|
|
`--${boundary}`,
|
|
'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'daily%20report.txt',
|
|
'Content-Type: text/plain',
|
|
'',
|
|
'hello',
|
|
`--${boundary}--`,
|
|
'',
|
|
].join('\r\n'))
|
|
|
|
const ctx: any = {
|
|
query: { path: 'workspace' },
|
|
req: Readable.from([body]),
|
|
request: {},
|
|
state: superAdminState(),
|
|
body: null,
|
|
status: 200,
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type'
|
|
? `multipart/form-data; boundary=${boundary}; charset=utf-8`
|
|
: ''),
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/upload', ctx)
|
|
|
|
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
|
expect(resolveHermesPathMock).toHaveBeenCalledWith('workspace/daily report.txt', 'research')
|
|
expect(provider.writeFile).toHaveBeenCalledWith(
|
|
'/home/agent/.hermes/workspace/daily report.txt',
|
|
Buffer.from('hello'),
|
|
)
|
|
expect(ctx.body).toEqual({
|
|
files: [{ name: 'daily report.txt', path: 'workspace/daily report.txt' }],
|
|
})
|
|
})
|
|
|
|
it('returns invalid_request for malformed RFC 5987 filenames', async () => {
|
|
const boundary = 'files-boundary'
|
|
const body = Buffer.from([
|
|
`--${boundary}`,
|
|
'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'bad%ZZname.txt',
|
|
'Content-Type: text/plain',
|
|
'',
|
|
'hello',
|
|
`--${boundary}--`,
|
|
'',
|
|
].join('\r\n'))
|
|
|
|
const ctx: any = {
|
|
query: { path: 'workspace' },
|
|
req: Readable.from([body]),
|
|
request: {},
|
|
state: superAdminState(),
|
|
body: null,
|
|
status: 200,
|
|
get: vi.fn((header: string) => header.toLowerCase() === 'content-type'
|
|
? `multipart/form-data; boundary=${boundary}`
|
|
: ''),
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/upload', ctx)
|
|
|
|
expect(ctx.status).toBe(400)
|
|
expect(ctx.body).toEqual({ error: 'Malformed multipart filename', code: 'invalid_request' })
|
|
expect(provider.writeFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('requires a super administrator for file editor content and mutations', async () => {
|
|
const readCtx: any = {
|
|
query: { path: 'workspace/weather.txt' },
|
|
state: {
|
|
profile: { name: 'research' },
|
|
user: { id: 2, username: 'admin', role: 'admin' },
|
|
},
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/read', readCtx)
|
|
|
|
expect(readCtx.status).toBe(403)
|
|
expect(readCtx.body).toEqual({ error: 'Super administrator privileges are required' })
|
|
expect(provider.readFile).not.toHaveBeenCalled()
|
|
|
|
const writeCtx: any = {
|
|
request: { body: { path: 'workspace/weather.txt', content: 'rain' } },
|
|
state: {
|
|
profile: { name: 'research' },
|
|
user: { id: 2, username: 'admin', role: 'admin' },
|
|
},
|
|
body: null,
|
|
}
|
|
|
|
await runFileRoute('/api/hermes/files/write', writeCtx)
|
|
|
|
expect(writeCtx.status).toBe(403)
|
|
expect(writeCtx.body).toEqual({ error: 'Super administrator privileges are required' })
|
|
expect(provider.writeFile).not.toHaveBeenCalled()
|
|
})
|
|
})
|