file-manager.test.ts 5.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. import fs from 'node:fs'
  2. import os from 'node:os'
  3. import path from 'node:path'
  4. import pino from 'pino'
  5. import { unzipSync } from 'fflate'
  6. import { beforeAll, describe, expect, it } from 'vitest'
  7. import { createApp, type App } from '../src/app'
  8. import { config } from '../src/config'
  9. let app: App
  10. let sid: string
  11. let ws: string
  12. let kbDir: string
  13. beforeAll(async () => {
  14. app = createApp(pino({ level: 'silent' }), { providers: {} })
  15. const builtin = app.profiles.getBuiltin().id
  16. const key = app.apiKeys.create('fm', { allowedProfileIds: [builtin], defaultProfileId: builtin })
  17. const s = await app.sessions.create(key.apiKey.id, {})
  18. sid = s.id
  19. ws = s.workspaceDir
  20. fs.writeFileSync(path.join(ws, 'uploads', 'data.csv'), 'a,b\n1,2\n')
  21. kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
  22. fs.writeFileSync(path.join(kbDir, 'note.md'), '# 账单\n')
  23. fs.symlinkSync('/etc', path.join(kbDir, 'etc-link'))
  24. fs.mkdirSync(path.join(kbDir, '.ssh-like'))
  25. app.knowledge.create({ name: '账单', path: kbDir, description: '家庭账单', access: 'read', enabled: true })
  26. })
  27. const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id
  28. describe('后台文件管理', () => {
  29. it('位置:会话工作区与资料库可写,本地技能、已安装技能与运行环境只读;常用目录包含最近的会话与资料库', () => {
  30. const roots = app.fileManager.roots()
  31. expect(roots.map(r => [r.id.split(':')[0], r.writable])).toEqual([['workspaces', true], ['kb', true], ['skills', false], ['managed-skills', false], ['runtime', false]])
  32. const sc = app.fileManager.shortcuts()
  33. expect(sc[0]).toMatchObject({ rootId: 'workspaces', path: '' })
  34. expect(sc.some(s => s.rootId === 'workspaces' && s.path === sid)).toBe(true)
  35. expect(sc.some(s => s.label === '账单')).toBe(true)
  36. })
  37. it('浏览、编辑(带冲突检查)、上传、新建目录、移动、删除;会话的文件记录随之更新', async () => {
  38. expect(app.fileManager.list('workspaces', sid).entries.map(e => [e.name, e.type])).toEqual(expect.arrayContaining([['uploads', 'dir'], ['outputs', 'dir']]))
  39. const t = app.fileManager.readText('workspaces', `${sid}/uploads/data.csv`)
  40. expect(t.content).toBe('a,b\n1,2\n')
  41. const saved = app.fileManager.writeText('workspaces', `${sid}/uploads/data.csv`, 'a,b\n3,4\n', t.mtime)
  42. expect(fs.readFileSync(path.join(ws, 'uploads/data.csv'), 'utf8')).toBe('a,b\n3,4\n')
  43. // 打开后文件被修改:拒绝保存
  44. fs.writeFileSync(path.join(ws, 'uploads/data.csv'), 'changed')
  45. fs.utimesSync(path.join(ws, 'uploads/data.csv'), new Date(), new Date(Date.now() + 5000))
  46. expect(() => app.fileManager.writeText('workspaces', `${sid}/uploads/data.csv`, 'x', saved.mtime)).toThrow(/修改过/)
  47. app.fileManager.mkdir('workspaces', `${sid}/outputs/drafts`)
  48. app.fileManager.upload('workspaces', `${sid}/outputs/drafts`, 'report.md', Buffer.from('# 报告'))
  49. await new Promise(r => setTimeout(r, 50))
  50. expect(app.files.list(sid).map(f => f.path)).toContain('outputs/drafts/report.md')
  51. app.fileManager.move('workspaces', `${sid}/outputs/drafts/report.md`, `${sid}/outputs/final.md`)
  52. app.fileManager.delete('workspaces', `${sid}/outputs/drafts`)
  53. await new Promise(r => setTimeout(r, 50))
  54. const paths = app.files.list(sid).map(f => f.path)
  55. expect(paths).toContain('outputs/final.md')
  56. expect(paths).not.toContain('outputs/drafts/report.md')
  57. })
  58. it('保护:只读位置不能写;路径不能越出位置;外部符号链接不跟随;会话目录不能在这里删除', () => {
  59. expect(() => app.fileManager.writeText('skills', 'x.md', 'x', null)).toThrow(/只读/)
  60. expect(() => app.fileManager.list('workspaces', '../')).toThrow(/路径不正确/)
  61. expect(() => app.fileManager.list('workspaces', `${sid}/../..`)).toThrow(/路径不正确/)
  62. const kb = kbRoot()
  63. expect(app.fileManager.list(kb, '').entries.find(e => e.name === 'etc-link')!.type).toBe('link')
  64. expect(() => app.fileManager.readText(kb, 'etc-link/hostname')).toThrow(/位置之外/)
  65. expect(() => app.fileManager.delete('workspaces', sid)).toThrow(/会话页面/)
  66. expect(() => app.fileManager.delete('workspaces', '')).toThrow(/根目录/)
  67. })
  68. it('服务数据目录与全局禁止项不可见', () => {
  69. // 包含服务数据目录的路径不能登记为资料库,文件管理也就无从进入
  70. expect(() => app.knowledge.create({ name: '数据目录上层', path: config.dataDir, description: '', access: 'read', enabled: true })).toThrow(/全局禁止项/)
  71. app.settings.update({ security: { globalDeny: [path.join(kbDir, '.ssh-like')] } })
  72. expect(app.fileManager.list(kbRoot(), '').entries.map(e => e.name)).not.toContain('.ssh-like')
  73. app.settings.update({ security: { globalDeny: [] } })
  74. })
  75. it('二进制文件不能在线编辑;目录可以打包下载', () => {
  76. fs.writeFileSync(path.join(ws, 'outputs', 'img.bin'), Buffer.from([0, 1, 2, 0, 5]))
  77. expect(() => app.fileManager.readText('workspaces', `${sid}/outputs/img.bin`)).toThrow(/二进制/)
  78. const z = app.fileManager.zipDir('workspaces', `${sid}/outputs`)
  79. expect(z.name).toBe('outputs.zip')
  80. expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['final.md', 'img.bin'])
  81. })
  82. })