study.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { createServer, type Server } from 'node:http'
  6. import { documentHash, serializeDocument } from '@webnovel/core'
  7. import { removeSync } from '../../core/src/repo/remove'
  8. import { StudyService } from '../src/study/service'
  9. import { createStudyHandler, trustedStudyRequest, type StudyWebRuntime } from '../src/study/web'
  10. import type { StudySave } from '../src/study/types'
  11. import { BookIndexManager } from '../src/indexing/manager'
  12. let root: string
  13. let server: Server | undefined
  14. function put(relative: string, text: string) { const target = path.join(root, relative); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, text); return target }
  15. beforeEach(() => {
  16. root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-study-'))
  17. for (const [name, id] of [['雾港', 'fog'], ['长夜', 'night']]) {
  18. put(`${name}/作品契约/契约.md`, serializeDocument({ 书id: id }, name!))
  19. put(`${name}/大纲/卷01/细纲.md`, serializeDocument({ 版本: 1 }, '真实细纲'))
  20. }
  21. put('雾港/草稿区/草稿/卷01-开篇/稿1.md', serializeDocument({ 身份: { 卷: 1, 章: 1, 章名: '开篇' }, 版本: 1, 角色: '待审稿' }, '待审稿正文'))
  22. put('雾港/.private.md', '不可暴露')
  23. put('书房/知识库/笔记.md', '# 资料\n\n原文。\n')
  24. })
  25. afterEach(async () => {
  26. if (server) { server.closeAllConnections(); await new Promise<void>(resolve => server!.close(() => resolve())); server = undefined }
  27. removeSync(root)
  28. })
  29. describe('真实书房目录服务', () => {
  30. it('章节完成数使用定稿事实,不依赖建议环节的显示措辞', () => {
  31. const service = new StudyService(root)
  32. expect(service.chapters('book:fog').chapters[0]?.finalized).toBe(false)
  33. put('雾港/定稿/卷01/0001-开篇.md', serializeDocument({ 角色: '已定稿', 版本: 1 }, '已经入档的正文'))
  34. expect(service.chapters('book:fog').chapters.find(item => item.chapter === 1)).toMatchObject({ finalized: true, status: '完成' })
  35. })
  36. it('多书与共享资料分开,逐层只返回直接子项', () => {
  37. const service = new StudyService(root)
  38. expect(service.shelf().books.map(book => book.id).sort()).toEqual(['book:fog', 'book:night'])
  39. expect(service.shelf().shared).toBe(true)
  40. const top = service.tree({ space: 'book:fog', path: '' })
  41. expect(top.map(entry => entry.name)).toEqual(['作品契约', '大纲', '草稿区'])
  42. expect(top.some(entry => entry.ref.path.includes('细纲.md'))).toBe(false)
  43. expect(service.tree({ space: 'book:fog', path: '草稿区/草稿/卷01-开篇' })[0]).toMatchObject({ badge: '待审稿', draft: true })
  44. })
  45. it('同名搜索带独立书范围,书名也可检索', () => {
  46. const service = new StudyService(root)
  47. expect(service.search('细纲').entries.map(entry => entry.ref.space).sort()).toEqual(['book:fog', 'book:night'])
  48. expect(service.search('雾港').entries.every(entry => entry.ref.space === 'book:fog')).toBe(true)
  49. expect(service.search('笔记').entries[0]?.ref.space).toBe('shared')
  50. })
  51. it('路径穿越、隐藏文件、共享目录别名拒绝', () => {
  52. const service = new StudyService(root)
  53. expect(() => service.read({ space: 'book:fog', path: '../长夜/作品契约/契约.md' })).toThrow()
  54. expect(() => service.read({ space: 'book:fog', path: '.private.md' })).toThrow()
  55. fs.symlinkSync(path.join(root, '长夜'), path.join(root, '雾港', 'alias'), process.platform === 'win32' ? 'junction' : 'dir')
  56. expect(service.tree({ space: 'book:fog', path: '' }).find(entry => entry.name === 'alias')?.error).toMatch(/链接|别名/)
  57. fs.unlinkSync(path.join(root, '雾港', 'alias'))
  58. })
  59. it('重复书id显式报错,不选择第一本', () => {
  60. put('长夜/作品契约/契约.md', serializeDocument({ 书id: 'fog' }, '同id'))
  61. const service = new StudyService(root)
  62. expect(service.shelf().books.every(book => !!book.error)).toBe(true)
  63. expect(() => service.read({ space: 'book:fog', path: '作品契约/契约.md' })).toThrow(/不唯一/)
  64. })
  65. it('解析失败只读,正文不会被截断或重写', () => {
  66. put('书房/知识库/损坏.md', '---\n不闭合\n原文')
  67. const doc = new StudyService(root).read({ space: 'shared', path: '知识库/损坏.md' })
  68. expect(doc.readOnly).toMatch(/格式异常/)
  69. expect(doc.body).toContain('不闭合')
  70. })
  71. it('对话绝对路径和书id路径都解析到正确文件,其他路径交回宿主', () => {
  72. const service = new StudyService(root)
  73. expect(service.resolve(path.join(root, '雾港', '大纲', '卷01', '细纲.md'))?.ref.space).toBe('book:fog')
  74. expect(service.resolve('fog:大纲/卷01/细纲.md')?.ref.space).toBe('book:fog')
  75. expect(service.resolve(path.join(os.tmpdir(), 'outside.txt'))).toBeNull()
  76. })
  77. it('保存共享资料后仍可读回原差异,其他会话不能重用记录', () => {
  78. const service = new StudyService(root)
  79. const ref = { space: 'shared', path: '知识库/笔记.md' }
  80. const doc = service.read(ref)
  81. const result = service.save(ref, doc.hash, '修改后的资料', { sessionId: 'owner' }, 'shared-operation-001')
  82. expect(result.commit).toBe('not-required')
  83. expect(result.changes.some(part => part.removed && part.value.includes('原文'))).toBe(true)
  84. expect(service.saved(ref, result.document.hash, result.operationId, 'owner').changes).toEqual(result.changes)
  85. expect(() => service.saved(ref, result.document.hash, result.operationId, 'other')).toThrow(/当前会话/)
  86. expect(() => service.saved(ref, doc.hash, result.operationId, 'owner')).toThrow(/已变化/)
  87. })
  88. })
  89. async function serve(followup = vi.fn(), indexing?: BookIndexManager) {
  90. const agent = { id: 'main', session: { header: { cwd: root } }, followup }
  91. const child = { ...agent, id: 'child' }
  92. const runtime: StudyWebRuntime = {
  93. agents: { get: id => id === 'main' ? agent : id === 'child' ? child : undefined, roots: () => [agent] },
  94. webRuntime: { trustedHosts: [] },
  95. webServer: { register: () => () => {} },
  96. connection: { requestRejection: request => request.headers.cookie === 'session=valid' ? undefined : 401 },
  97. }
  98. server = createServer(createStudyHandler(runtime, indexing))
  99. await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve))
  100. const address = server.address()
  101. if (!address || typeof address === 'string') throw new Error('no listener')
  102. const base = `http://127.0.0.1:${address.port}`
  103. const post = async (method: string, input: object, headers: Record<string, string> = {}) => {
  104. const response = await fetch(base + '/webnovel/api/' + method, { method: 'POST', headers: {
  105. origin: base, 'sec-fetch-site': 'same-origin', 'x-webnovel-request': '1', 'content-type': 'application/json', cookie: 'session=valid', ...headers,
  106. }, body: JSON.stringify({ sessionId: 'main', ...input }) })
  107. return { status: response.status, body: await response.json() as { ok: boolean; value: StudySave; error: string } }
  108. }
  109. return { post, followup }
  110. }
  111. describe('书房 HTTP 保存和原生通知入口', () => {
  112. it('索引控制沿用认证和主会话范围,不接受客户端指定根目录或未知操作', async () => {
  113. const indexing = new BookIndexManager({ getProvider: () => undefined })
  114. try {
  115. const { post } = await serve(vi.fn(), indexing)
  116. expect((await post('index-status', { space: 'book:fog' }, { cookie: '' })).status).toBe(401)
  117. expect((await post('index-control', { space: 'book:fog', action: 'enable', sessionId: 'child' })).status).toBe(400)
  118. expect((await post('index-status', { space: 'shared' })).status).toBe(400)
  119. expect((await post('index-control', { space: 'book:fog', action: 'invalid' })).status).toBe(400)
  120. const paused = await post('index-control', { space: 'book:fog', action: 'pause', cwd: 'C:/outside', root: 'C:/outside' })
  121. expect(paused.status).toBe(200)
  122. expect(paused.body.value).toMatchObject({ bookId: 'fog', phase: 'paused', paused: true, recipientSession: 'main' })
  123. expect(fs.existsSync(path.join(root, '雾港/.webnovel/finalized-search-state.json'))).toBe(true)
  124. expect(fs.existsSync(path.join(root, '长夜/.webnovel/finalized-search-state.json'))).toBe(false)
  125. } finally { await indexing.close() }
  126. })
  127. it('同源标识不能替代宿主认证,跨站和无浏览器来源都拒绝', async () => {
  128. expect(trustedStudyRequest({ headers: { host: '127.0.0.1', 'x-webnovel-request': '1' } }, [])).toBe(false)
  129. const { post } = await serve()
  130. expect((await post('shelf', {}, { cookie: '' })).status).toBe(401)
  131. expect((await post('shelf', {}, { 'sec-fetch-site': 'cross-site' })).status).toBe(403)
  132. expect((await post('shelf', {}, { origin: 'http://evil.example' })).status).toBe(403)
  133. })
  134. it('只接受真实根 Agent,拒绝子级或任意客户端 cwd', async () => {
  135. const { post } = await serve()
  136. expect((await post('shelf', { sessionId: 'child' })).status).toBe(400)
  137. const response = await post('shelf', { cwd: 'C:/outside' })
  138. expect(response.status).toBe(200)
  139. expect(JSON.stringify(response.body)).toContain(root.replace(/\\/g, '\\\\'))
  140. })
  141. it('保存后发送完整原生消息,同一编号重试只落一份内容', async () => {
  142. const { post, followup } = await serve()
  143. const input = { ref: { space: 'shared', path: '知识库/笔记.md' }, hash: documentHash(fs.readFileSync(path.join(root, '书房/知识库/笔记.md'), 'utf8')), body: '已保存的真实资料', operationId: 'http-operation-001' }
  144. const response = await post('save', input)
  145. expect(response.status).toBe(200)
  146. expect(response.body.value.notification).toBe('delivered')
  147. expect(JSON.stringify(followup.mock.calls[0])).toContain('已保存的真实资料')
  148. expect(JSON.stringify(followup.mock.calls[0])).toContain('plugin')
  149. expect((await post('save', input)).status).toBe(200)
  150. expect(followup).toHaveBeenCalledTimes(1)
  151. })
  152. it('通知失败与保存成功分开,重试携原始差异', async () => {
  153. const followup = vi.fn().mockImplementationOnce(() => { throw new Error('queue unavailable') })
  154. const { post } = await serve(followup)
  155. const ref = { space: 'shared', path: '知识库/笔记.md' }
  156. const hash = new StudyService(root).read(ref).hash
  157. const response = await post('save', { ref, hash, body: '作者更改', operationId: 'http-operation-002' })
  158. expect(response.body.value.notification).toBe('failed')
  159. expect(new StudyService(root).read(ref).body).toContain('作者更改')
  160. const retried = await post('notify', { ref, hash: response.body.value.document.hash, operationId: response.body.value.operationId })
  161. expect(retried.body.value.notification).toBe('delivered')
  162. expect(JSON.stringify(followup.mock.calls.at(-1))).toContain('原文')
  163. expect(JSON.stringify(followup.mock.calls.at(-1))).toContain('作者更改')
  164. })
  165. })