Files
hermes-desktop/tests/server/files-routes.test.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

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()
})
})