Files
hermes-ui/tests/server/files-routes.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +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()
})
})