file-manager.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  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], ['server', 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('server', '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. fs.mkdirSync(path.join(config.dataDir, 'home', '.codex'), { recursive: true })
  70. fs.writeFileSync(path.join(config.dataDir, 'home', '.codex', 'auth.json'), '{}')
  71. fs.writeFileSync(path.join(config.dataDir, 'home', '.codex', 'config.toml'), '')
  72. fs.mkdirSync(path.join(config.dataDir, 'home', '.claude'), { recursive: true })
  73. fs.writeFileSync(path.join(config.dataDir, 'home', '.claude', '.credentials.json'), '{}')
  74. expect(app.fileManager.list('server', '').entries.map(e => e.name)).toEqual(expect.arrayContaining(['data', 'home', 'workspaces']))
  75. expect(app.fileManager.list('server', 'home/.codex').entries.map(e => e.name)).toEqual(['config.toml'])
  76. expect(app.fileManager.list('server', 'home/.claude').entries.map(e => e.name)).not.toContain('.credentials.json')
  77. expect(() => app.fileManager.readText('server', 'home/.codex/auth.json')).toThrow(/不允许访问/)
  78. expect(app.fileManager.list('server', 'data').entries.map(e => e.name)).toContain('agent.db')
  79. expect(() => app.fileManager.openFile('server', 'data/agent.db')).toThrow(/数据库文件/)
  80. expect(() => app.fileManager.writeText('server', 'workspaces/x.md', 'x', null)).toThrow(/只读/)
  81. expect(() => app.fileManager.zipDir('server', 'data')).not.toThrow()
  82. // 包含服务数据目录的路径不能登记为资料库
  83. expect(() => app.knowledge.create({ name: '数据目录上层', path: config.dataDir, description: '', access: 'read', enabled: true })).toThrow(/全局禁止项/)
  84. })
  85. it('二进制文件不能在线编辑;目录可以打包下载', () => {
  86. fs.writeFileSync(path.join(ws, 'outputs', 'img.bin'), Buffer.from([0, 1, 2, 0, 5]))
  87. expect(() => app.fileManager.readText('workspaces', `${sid}/outputs/img.bin`)).toThrow(/二进制/)
  88. const z = app.fileManager.zipDir('workspaces', `${sid}/outputs`)
  89. expect(z.name).toBe('outputs.zip')
  90. expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['final.md', 'img.bin'])
  91. })
  92. })