list.spec.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. /** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */
  2. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  3. import { mkdir, symlink, writeFile } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
  6. let harness: Harness
  7. let workspace: string
  8. let outside: string
  9. beforeEach(async () => {
  10. harness = await openWorkspace('dsh-workspace-files-list-')
  11. workspace = harness.workspace
  12. outside = harness.outside
  13. })
  14. afterEach(async () => {
  15. await harness.dispose()
  16. })
  17. const endpoint = (caps?: { maxEntries?: number }): ReturnType<Harness['endpoint']> => harness.endpoint(caps)
  18. describe('workspaceFiles.list — the happy path', () => {
  19. it('lists the root as the empty workspace path, with types and file sizes', async () => {
  20. await mkdir(join(workspace, 'src'))
  21. await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
  22. await writeFile(join(workspace, '.hidden'), '', 'utf8')
  23. const listing = await endpoint().list(harness.scope, '.', signal())
  24. expect(listing.path).toBe('')
  25. expect(listing.truncated).toBe(false)
  26. expect(listing.entries).toEqual([
  27. { name: '.hidden', type: 'file', size: 0 },
  28. { name: 'notes.txt', type: 'file', size: 5 },
  29. { name: 'src', type: 'directory' },
  30. ])
  31. })
  32. it('accepts the absolute workspace root and reports the same empty path', async () => {
  33. const listing = await endpoint().list(harness.scope, workspace, signal())
  34. expect(listing.path).toBe('')
  35. expect(listing.entries).toEqual([])
  36. })
  37. it('reports a nested directory as its `/`-joined path relative to the root, decoded', async () => {
  38. await mkdir(join(workspace, 'src', 'my dir', '子目录'), { recursive: true })
  39. await writeFile(join(workspace, 'src', 'my dir', '子目录', 'a.ts'), '', 'utf8')
  40. const listing = await endpoint().list(harness.scope, 'src/my dir/子目录', signal())
  41. expect(listing.path).toBe('src/my dir/子目录')
  42. expect(listing.entries.map(entry => entry.name)).toEqual(['a.ts'])
  43. })
  44. it('reports a symlink child as what it points to, and a dangling one as other', async () => {
  45. await writeFile(join(workspace, 'real.txt'), 'x', 'utf8')
  46. await mkdir(join(workspace, 'dir'))
  47. await symlink(join(workspace, 'real.txt'), join(workspace, 'to-file'))
  48. await symlink(join(workspace, 'dir'), join(workspace, 'to-dir'))
  49. await symlink(join(workspace, 'missing'), join(workspace, 'dangling'))
  50. const listing = await endpoint().list(harness.scope, '.', signal())
  51. expect(listing.entries).toEqual([
  52. { name: 'dangling', type: 'other' },
  53. { name: 'dir', type: 'directory' },
  54. { name: 'real.txt', type: 'file', size: 1 },
  55. { name: 'to-dir', type: 'directory' },
  56. { name: 'to-file', type: 'file', size: 1 },
  57. ])
  58. })
  59. })
  60. describe('workspaceFiles.list — the entry cap', () => {
  61. it('cuts at the cap in name order and says so', async () => {
  62. for (const name of ['a', 'b', 'c', 'd', 'e']) await writeFile(join(workspace, name), '', 'utf8')
  63. const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal())
  64. expect(listing.entries.map(entry => entry.name)).toEqual(['a', 'b'])
  65. expect(listing.truncated).toBe(true)
  66. })
  67. it('does not report a cut at exactly the cap', async () => {
  68. for (const name of ['a', 'b']) await writeFile(join(workspace, name), '', 'utf8')
  69. const listing = await endpoint({ maxEntries: 2 }).list(harness.scope, '.', signal())
  70. expect(listing.entries).toHaveLength(2)
  71. expect(listing.truncated).toBe(false)
  72. })
  73. })
  74. describe('workspaceFiles.list — gates', () => {
  75. it('rejects an absolute directory outside the workspace', async () => {
  76. const failure = await failureOf(endpoint().list(harness.scope, outside, signal()))
  77. expect(failure.code).toBe('workspace-file/outside-workspace')
  78. })
  79. it('rejects a traversal that climbs out of the workspace', async () => {
  80. const failure = await failureOf(endpoint().list(harness.scope, '..', signal()))
  81. expect(failure.code).toBe('workspace-file/outside-workspace')
  82. })
  83. it('rejects a symlinked directory before following it, wherever it points', async () => {
  84. await symlink(outside, join(workspace, 'escape'))
  85. const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal()))
  86. expect(failure.code).toBe('workspace-file/not-directory')
  87. expect(failure.details).toMatchObject({ kind: 'symlink' })
  88. })
  89. it('rejects a file, which has no children to list', async () => {
  90. await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
  91. const failure = await failureOf(endpoint().list(harness.scope, 'notes.txt', signal()))
  92. expect(failure.code).toBe('workspace-file/not-directory')
  93. expect(failure.details).toMatchObject({ path: 'notes.txt', kind: 'file' })
  94. })
  95. it('reports a missing path as not found', async () => {
  96. const failure = await failureOf(endpoint().list(harness.scope, 'nope', signal()))
  97. expect(failure.code).toBe('workspace-file/not-found')
  98. })
  99. it('refuses an empty path as a bad request', async () => {
  100. const failure = await failureOf(endpoint().list(harness.scope, '', signal()))
  101. expect(failure.code).toBe('gateway/bad-request')
  102. })
  103. })