import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import pino from 'pino' import { unzipSync } from 'fflate' import { beforeAll, describe, expect, it } from 'vitest' import { createApp, type App } from '../src/app' import { config } from '../src/config' let app: App let sid: string let ws: string let kbDir: string beforeAll(async () => { app = createApp(pino({ level: 'silent' }), { providers: {} }) const builtin = app.profiles.getBuiltin().id const key = app.apiKeys.create('fm', { allowedProfileIds: [builtin], defaultProfileId: builtin }) const s = await app.sessions.create(key.apiKey.id, {}) sid = s.id ws = s.workspaceDir fs.writeFileSync(path.join(ws, 'uploads', 'data.csv'), 'a,b\n1,2\n') kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-')) fs.writeFileSync(path.join(kbDir, 'note.md'), '# 账单\n') fs.symlinkSync('/etc', path.join(kbDir, 'etc-link')) fs.mkdirSync(path.join(kbDir, '.ssh-like')) app.knowledge.create({ name: '账单', path: kbDir, description: '家庭账单', access: 'read', enabled: true }) }) const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id describe('后台文件管理', () => { it('位置:会话工作区与资料库可写,本地技能、已安装技能与运行环境只读;常用目录包含最近的会话与资料库', () => { const roots = app.fileManager.roots() expect(roots.map(r => [r.id.split(':')[0], r.writable])).toEqual([['workspaces', true], ['kb', true], ['skills', false], ['managed-skills', false], ['runtime', false]]) const sc = app.fileManager.shortcuts() expect(sc[0]).toMatchObject({ rootId: 'workspaces', path: '' }) expect(sc.some(s => s.rootId === 'workspaces' && s.path === sid)).toBe(true) expect(sc.some(s => s.label === '账单')).toBe(true) }) it('浏览、编辑(带冲突检查)、上传、新建目录、移动、删除;会话的文件记录随之更新', async () => { expect(app.fileManager.list('workspaces', sid).entries.map(e => [e.name, e.type])).toEqual(expect.arrayContaining([['uploads', 'dir'], ['outputs', 'dir']])) const t = app.fileManager.readText('workspaces', `${sid}/uploads/data.csv`) expect(t.content).toBe('a,b\n1,2\n') const saved = app.fileManager.writeText('workspaces', `${sid}/uploads/data.csv`, 'a,b\n3,4\n', t.mtime) expect(fs.readFileSync(path.join(ws, 'uploads/data.csv'), 'utf8')).toBe('a,b\n3,4\n') // 打开后文件被修改:拒绝保存 fs.writeFileSync(path.join(ws, 'uploads/data.csv'), 'changed') fs.utimesSync(path.join(ws, 'uploads/data.csv'), new Date(), new Date(Date.now() + 5000)) expect(() => app.fileManager.writeText('workspaces', `${sid}/uploads/data.csv`, 'x', saved.mtime)).toThrow(/修改过/) app.fileManager.mkdir('workspaces', `${sid}/outputs/drafts`) app.fileManager.upload('workspaces', `${sid}/outputs/drafts`, 'report.md', Buffer.from('# 报告')) await new Promise(r => setTimeout(r, 50)) expect(app.files.list(sid).map(f => f.path)).toContain('outputs/drafts/report.md') app.fileManager.move('workspaces', `${sid}/outputs/drafts/report.md`, `${sid}/outputs/final.md`) app.fileManager.delete('workspaces', `${sid}/outputs/drafts`) await new Promise(r => setTimeout(r, 50)) const paths = app.files.list(sid).map(f => f.path) expect(paths).toContain('outputs/final.md') expect(paths).not.toContain('outputs/drafts/report.md') }) it('保护:只读位置不能写;路径不能越出位置;外部符号链接不跟随;会话目录不能在这里删除', () => { expect(() => app.fileManager.writeText('skills', 'x.md', 'x', null)).toThrow(/只读/) expect(() => app.fileManager.list('workspaces', '../')).toThrow(/路径不正确/) expect(() => app.fileManager.list('workspaces', `${sid}/../..`)).toThrow(/路径不正确/) const kb = kbRoot() expect(app.fileManager.list(kb, '').entries.find(e => e.name === 'etc-link')!.type).toBe('link') expect(() => app.fileManager.readText(kb, 'etc-link/hostname')).toThrow(/位置之外/) expect(() => app.fileManager.delete('workspaces', sid)).toThrow(/会话页面/) expect(() => app.fileManager.delete('workspaces', '')).toThrow(/根目录/) }) it('服务数据目录与全局禁止项不可见', () => { // 包含服务数据目录的路径不能登记为资料库,文件管理也就无从进入 expect(() => app.knowledge.create({ name: '数据目录上层', path: config.dataDir, description: '', access: 'read', enabled: true })).toThrow(/全局禁止项/) app.settings.update({ security: { globalDeny: [path.join(kbDir, '.ssh-like')] } }) expect(app.fileManager.list(kbRoot(), '').entries.map(e => e.name)).not.toContain('.ssh-like') app.settings.update({ security: { globalDeny: [] } }) }) it('二进制文件不能在线编辑;目录可以打包下载', () => { fs.writeFileSync(path.join(ws, 'outputs', 'img.bin'), Buffer.from([0, 1, 2, 0, 5])) expect(() => app.fileManager.readText('workspaces', `${sid}/outputs/img.bin`)).toThrow(/二进制/) const z = app.fileManager.zipDir('workspaces', `${sid}/outputs`) expect(z.name).toBe('outputs.zip') expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['final.md', 'img.bin']) }) })