controller.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /** Authorized source identity and cancellation survive Host rendering. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { SessionId } from '@deepseek-ai/dsh-session/types'
  4. import type { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files'
  5. import { OfficeToPdfError, OfficeToPdfGeneration, OfficeToPdfKey, type OfficeToPdf, type OfficeToPdfResult } from '@deepseek-ai/dsh-office-to-pdf'
  6. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  7. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  8. import DocumentRenderController from '../src/index.ts'
  9. const scope = { sessionId: SessionId('document-test'), workspaceRoot: '/workspace' }
  10. const source = { absolutePath: '/workspace/report.DOCX', version: 'source-v1', offset: 0, eof: true, bytes: 4, data: 'UEsDBA==' }
  11. const generation = OfficeToPdfGeneration('test-generation')
  12. const cacheKey = OfficeToPdfKey('test-result')
  13. const pdf = new Uint8Array([37, 80, 68, 70, 45])
  14. let ctx: Context
  15. let read: ReturnType<typeof vi.fn<WorkspaceFiles['readAllBounded']>>
  16. let authorize: ReturnType<typeof vi.fn<WorkspaceFiles['readBytes']>>
  17. let metadata: ReturnType<typeof vi.fn<WorkspaceFiles['stat']>>
  18. let render: ReturnType<typeof vi.fn<OfficeToPdf['convert']>>
  19. beforeEach(async () => {
  20. ctx = new Context()
  21. read = vi.fn<WorkspaceFiles['readAllBounded']>().mockResolvedValue(source)
  22. authorize = vi.fn<WorkspaceFiles['readBytes']>().mockResolvedValue(source)
  23. metadata = vi.fn<WorkspaceFiles['stat']>().mockResolvedValue(source)
  24. render = vi.fn<OfficeToPdf['convert']>().mockImplementation(async (request, signal) => {
  25. await request.source.read(signal!, 4)
  26. return { pdf, missingFonts: ['Missing Serif'], generation, cacheKey }
  27. })
  28. ctx.provide('workspaceFiles', { stat: metadata, readAllBounded: read, readBytes: authorize } as never)
  29. ctx.provide('officeToPdf', { convert: render, generation } as never)
  30. await ctx.plugin(DocumentRenderController)
  31. })
  32. afterEach(async () => { await ctx.fiber.dispose() })
  33. it.each(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])('converts authorized %s bytes and returns the PDF under source identity', async (extension) => {
  34. const path = `report.${extension.toUpperCase()}`
  35. const result = await ctx.documentRenderController.render(scope, path, 'foreground', new AbortController().signal)
  36. expect(read).toHaveBeenCalledExactlyOnceWith(scope, path, 4, expect.any(AbortSignal))
  37. expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { offset: 0, length: 1 }, expect.any(AbortSignal))
  38. expect(render).toHaveBeenCalledOnce()
  39. expect(render.mock.calls[0]?.[0]).toMatchObject({ extension, priority: 'foreground', source: { version: source.version, bytes: 4 } })
  40. expect(result).toEqual({ ...source, data: Buffer.from(pdf).toString('base64'), bytes: pdf.length, missingFonts: ['Missing Serif'], generation })
  41. expect(ctx.get('agents')).toBeUndefined()
  42. })
  43. it('preserves authorization failures without starting conversion', async () => {
  44. const failure = new RemoteError('workspace-file/not-found', 'File missing', { path: 'report.DOCX' })
  45. metadata.mockRejectedValueOnce(failure)
  46. await expect(ctx.documentRenderController.render(scope, 'report.DOCX', 'foreground', new AbortController().signal)).rejects.toBe(failure)
  47. expect(render).not.toHaveBeenCalled()
  48. })
  49. it('keeps an unknown source size absent so the provider can reserve its input limit', async () => {
  50. const { bytes: _bytes, ...unknownSize } = source
  51. metadata.mockResolvedValue(unknownSize)
  52. await ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)
  53. expect(render.mock.calls[0]![0].source).not.toHaveProperty('bytes')
  54. expect(read).toHaveBeenCalledExactlyOnceWith(scope, 'report.docx', 4, expect.any(AbortSignal))
  55. })
  56. it('refuses a cached PDF when source metadata remains readable but content access is denied', async () => {
  57. let cached: OfficeToPdfResult | undefined
  58. render.mockImplementation(async (request, signal) => {
  59. if (cached !== undefined) return cached
  60. await request.source.read(signal!, 4)
  61. cached = { pdf, missingFonts: [], generation, cacheKey }
  62. return cached
  63. })
  64. await ctx.documentRenderController.render(scope, 'report.DOCX', 'foreground', new AbortController().signal)
  65. const failure = new Error('read denied')
  66. authorize.mockRejectedValueOnce(failure)
  67. await expect(ctx.documentRenderController.render(scope, 'report.DOCX', 'foreground', new AbortController().signal)).rejects.toBe(failure)
  68. expect(render).toHaveBeenCalledOnce()
  69. expect(read).toHaveBeenCalledOnce()
  70. })
  71. it('refuses a source replaced while checking read access before cache lookup', async () => {
  72. metadata.mockResolvedValueOnce({ ...source, version: 'v2' })
  73. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal))
  74. .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'source-changed' } })
  75. expect(render).not.toHaveBeenCalled()
  76. })
  77. it('rejects unsupported extensions before reading a source', async () => {
  78. await expect(ctx.documentRenderController.render(scope, 'report.odt', 'foreground', new AbortController().signal))
  79. .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'unsupported-format' } })
  80. expect(read).not.toHaveBeenCalled()
  81. })
  82. it.each(['invalid-document', 'output-too-large', 'timeout', 'invalid-output'] as const)('maps %s without returning source or diagnostic bytes', async (code) => {
  83. render.mockRejectedValueOnce(new OfficeToPdfError(code, 'private engine detail'))
  84. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal))
  85. .rejects.toMatchObject({ code: 'document-render/failed', message: 'Office conversion failed.', details: { reason: code } })
  86. })
  87. it('cancels before reading and refuses a late authorized read after cancellation', async () => {
  88. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', AbortSignal.abort())).rejects.toMatchObject({ code: 'gateway/cancelled' })
  89. expect(read).not.toHaveBeenCalled()
  90. const entered = Promise.withResolvers<undefined>()
  91. const release = Promise.withResolvers<typeof source>()
  92. read.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise })
  93. const controller = new AbortController()
  94. const work = ctx.documentRenderController.render(scope, 'report.docx', 'foreground', controller.signal)
  95. const rejected = expect(work).rejects.toMatchObject({ code: 'gateway/cancelled' })
  96. await entered.promise
  97. controller.abort()
  98. release.resolve(source)
  99. await rejected
  100. expect(render).toHaveBeenCalledOnce()
  101. })
  102. it('cancels and joins an outstanding conversion when the controller unloads', async () => {
  103. const entered = Promise.withResolvers<AbortSignal>()
  104. const release = Promise.withResolvers<OfficeToPdfResult>()
  105. render.mockImplementationOnce((_input, signal) => { entered.resolve(signal!); return release.promise })
  106. const work = ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)
  107. const rejected = expect(work).rejects.toMatchObject({ code: 'gateway/cancelled' })
  108. const signal = await entered.promise
  109. let disposed = false
  110. const closing = ctx.fiber.dispose().then(() => { disposed = true })
  111. try {
  112. await vi.waitFor(() => { expect(signal.aborted).toBe(true) })
  113. expect(disposed).toBe(false)
  114. } finally { release.resolve({ pdf, missingFonts: [], generation, cacheKey }); await rejected; await closing }
  115. })
  116. it('exposes the current renderer generation and refuses busy work before loading source bytes', async () => {
  117. expect(ctx.documentRenderController.generation(new AbortController().signal)).toBe(generation)
  118. render.mockRejectedValueOnce(new OfficeToPdfError('busy', 'capacity'))
  119. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'background', new AbortController().signal))
  120. .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'busy' } })
  121. expect(metadata).toHaveBeenCalledOnce()
  122. expect(read).not.toHaveBeenCalled()
  123. })
  124. it('refuses bytes when the source changes during the bounded read', async () => {
  125. metadata.mockResolvedValueOnce(source).mockResolvedValueOnce({ ...source, version: 'v2' })
  126. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal))
  127. .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'source-changed' } })
  128. })
  129. it('classifies a grown queued source as changed without increasing its read reservation', async () => {
  130. const failure = new RemoteError('workspace-file/too-large', 'File exceeds 4 bytes.', { path: 'report.docx', limit: 4 })
  131. read.mockRejectedValueOnce(failure)
  132. metadata.mockResolvedValueOnce(source).mockResolvedValueOnce({ ...source, bytes: 8, version: 'v2' })
  133. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal))
  134. .rejects.toMatchObject({ code: 'document-render/failed', details: { reason: 'source-changed' } })
  135. expect(read).toHaveBeenCalledExactlyOnceWith(scope, 'report.docx', 4, expect.any(AbortSignal))
  136. })
  137. it('preserves a genuine bounded-read size failure when source identity is unchanged', async () => {
  138. const failure = new RemoteError('workspace-file/too-large', 'File exceeds configured size cap.', { path: 'report.docx', limit: 4 })
  139. read.mockRejectedValueOnce(failure)
  140. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)).rejects.toBe(failure)
  141. expect(metadata).toHaveBeenCalledTimes(2)
  142. })
  143. it.each([
  144. new Error('Source read denied'),
  145. new RemoteError('workspace-file/not-found', 'Source missing', { path: 'report.docx' }),
  146. ])('preserves a non-size bounded-read failure without another metadata query: %s', async (failure) => {
  147. read.mockRejectedValueOnce(failure)
  148. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)).rejects.toBe(failure)
  149. expect(metadata).toHaveBeenCalledOnce()
  150. })
  151. it.each([
  152. new Error('Current source metadata denied'),
  153. new RemoteError('workspace-file/not-found', 'Current source missing', { path: 'report.docx' }),
  154. ])('preserves a source metadata failure during the size recheck: %s', async (failure) => {
  155. read.mockRejectedValueOnce(new RemoteError('workspace-file/too-large', 'File exceeds its reservation.', { path: 'report.docx', limit: 4 }))
  156. metadata.mockResolvedValueOnce(source).mockRejectedValueOnce(failure)
  157. await expect(ctx.documentRenderController.render(scope, 'report.docx', 'foreground', new AbortController().signal)).rejects.toBe(failure)
  158. expect(metadata).toHaveBeenCalledTimes(2)
  159. })