|
|
@@ -1,6 +1,6 @@
|
|
|
import { mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
-import { join } from 'node:path'
|
|
|
+import { join, sep } from 'node:path'
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import { SessionMediaReferences } from '../src/media-references.ts'
|
|
|
@@ -20,16 +20,24 @@ interface MountedRoute {
|
|
|
/** Direct request access (custom URLs and headers). */
|
|
|
raw(url: string, init?: RequestInit): Promise<Response>
|
|
|
unregister: ReturnType<typeof vi.fn>
|
|
|
+ dispose(): Promise<void>
|
|
|
}
|
|
|
|
|
|
describe('SessionMediaReferences /api/file', () => {
|
|
|
let root: string
|
|
|
- let route: MountedRoute
|
|
|
const contexts: Context[] = []
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
// Real workspace roots are canonical; tmpdir may sit behind a symlink.
|
|
|
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-')))
|
|
|
+ })
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
|
+ await rm(root, { recursive: true, force: true })
|
|
|
+ })
|
|
|
+
|
|
|
+ async function mount(workspaceRoot: string = root): Promise<MountedRoute> {
|
|
|
const ctx = new Context()
|
|
|
contexts.push(ctx)
|
|
|
let handler: ((request: Request) => Promise<Response>) | undefined
|
|
|
@@ -42,28 +50,30 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
},
|
|
|
},
|
|
|
} as never)
|
|
|
- ctx.provide('workspaceRegistry', { list: () => [{ path: root }] } as never)
|
|
|
+ ctx.provide('workspaceRegistry', {
|
|
|
+ list: () => [{ path: workspaceRoot }],
|
|
|
+ } as never)
|
|
|
await ctx.plugin(SessionMediaReferences).await()
|
|
|
const fetchFile = (request: Request) => {
|
|
|
if (handler === undefined) throw new Error('route not registered')
|
|
|
return handler(request)
|
|
|
}
|
|
|
- route = {
|
|
|
+ return {
|
|
|
call: (path, init) => {
|
|
|
const url = `http://127.0.0.1:3080/api/file?path=${encodeURIComponent(path)}`
|
|
|
return fetchFile(new Request(url, init))
|
|
|
},
|
|
|
raw: (url, init) => fetchFile(new Request(url, init)),
|
|
|
unregister,
|
|
|
+ dispose: () => ctx.fiber.dispose().then(() => {
|
|
|
+ const index = contexts.indexOf(ctx)
|
|
|
+ if (index >= 0) contexts.splice(index, 1)
|
|
|
+ }),
|
|
|
}
|
|
|
- })
|
|
|
-
|
|
|
- afterEach(async () => {
|
|
|
- await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
|
- await rm(root, { recursive: true, force: true })
|
|
|
- })
|
|
|
+ }
|
|
|
|
|
|
it('serves images from the workspace root and nested directories', async () => {
|
|
|
+ const route = await mount()
|
|
|
const direct = join(root, 'graph.png')
|
|
|
await writeFile(direct, PNG_BYTES)
|
|
|
const response = await route.call(direct)
|
|
|
@@ -83,6 +93,7 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
})
|
|
|
|
|
|
it('serves every allowlisted media category by extension', async () => {
|
|
|
+ const route = await mount()
|
|
|
const video = join(root, 'clip.mp4')
|
|
|
await writeFile(video, MP4_BYTES)
|
|
|
const videoResponse = await route.call(video)
|
|
|
@@ -99,6 +110,7 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
it('serves allowlisted image extensions regardless of payload bytes', async () => {
|
|
|
// Media bytes are never sniffed on this route: a corrupt payload fails
|
|
|
// in the browser, not here.
|
|
|
+ const route = await mount()
|
|
|
const fake = join(root, 'fake.png')
|
|
|
await writeFile(fake, TEXT_BYTES)
|
|
|
const response = await route.call(fake)
|
|
|
@@ -107,6 +119,7 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
})
|
|
|
|
|
|
it('refuses non-media, unknown, and denied content types', async () => {
|
|
|
+ const route = await mount()
|
|
|
const cases: ReadonlyArray<[string, Uint8Array]> = [
|
|
|
['note.txt', TEXT_BYTES],
|
|
|
['run.exe', TEXT_BYTES],
|
|
|
@@ -124,6 +137,7 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
})
|
|
|
|
|
|
it('answers bounded, open-ended, suffix, and clamped range requests', async () => {
|
|
|
+ const route = await mount()
|
|
|
const path = join(root, 'graph.png')
|
|
|
await writeFile(path, PNG_BYTES)
|
|
|
const total = PNG_BYTES.length
|
|
|
@@ -149,6 +163,7 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
})
|
|
|
|
|
|
it('answers unsatisfiable and malformed ranges with 416 and the total size', async () => {
|
|
|
+ const route = await mount()
|
|
|
const path = join(root, 'graph.png')
|
|
|
await writeFile(path, PNG_BYTES)
|
|
|
for (const range of ['bytes=999-', 'bytes=abc']) {
|
|
|
@@ -158,17 +173,38 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ it('ignores multi-range headers and serves the full 200 body', async () => {
|
|
|
+ const route = await mount()
|
|
|
+ const path = join(root, 'graph.png')
|
|
|
+ await writeFile(path, PNG_BYTES)
|
|
|
+ const response = await route.call(path, { headers: { range: 'bytes=0-1,3-4' } })
|
|
|
+ expect(response.status).toBe(200)
|
|
|
+ expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
|
|
|
+ expect(response.headers.get('content-range')).toBeNull()
|
|
|
+ expect(await responseBytes(response)).toEqual(PNG_BYTES)
|
|
|
+ })
|
|
|
+
|
|
|
it('answers HEAD without a body', async () => {
|
|
|
+ const route = await mount()
|
|
|
const path = join(root, 'graph.png')
|
|
|
await writeFile(path, PNG_BYTES)
|
|
|
const response = await route.call(path, { method: 'HEAD' })
|
|
|
expect(response.status).toBe(200)
|
|
|
expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
|
|
|
+ expect(response.headers.get('content-range')).toBeNull()
|
|
|
expect(response.body).toBeNull()
|
|
|
+
|
|
|
+ const ranged = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } })
|
|
|
+ expect(ranged.status).toBe(206)
|
|
|
+ expect(ranged.headers.get('content-range')).toBe(`bytes 0-3/${PNG_BYTES.length}`)
|
|
|
+ expect(ranged.headers.get('content-length')).toBe('4')
|
|
|
+ expect(ranged.body).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('denies malformed, missing, and uncontained requests', async () => {
|
|
|
+ it('denies malformed, missing, empty, and uncontained requests', async () => {
|
|
|
+ const route = await mount()
|
|
|
expect((await route.raw('http://127.0.0.1:3080/api/file')).status).toBe(400)
|
|
|
+ expect((await route.raw('http://127.0.0.1:3080/api/file?path=')).status).toBe(400)
|
|
|
expect((await route.raw(`http://127.0.0.1:3080/api/file?path=${encodeURIComponent('x.png')}`)).status)
|
|
|
.toBe(400)
|
|
|
expect((await route.raw(`http://127.0.0.1:3080/api/file?path=${encodeURIComponent('/a\0b.png')}`)).status)
|
|
|
@@ -187,20 +223,54 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('refuses the workspace-root directory itself', async () => {
|
|
|
- const response = await route.call(root)
|
|
|
+ it('answers 404 when an existing file cannot be opened for reading', async () => {
|
|
|
+ // A permission-less file passes realpath/stat but fails the open; the
|
|
|
+ // route treats the unreadable file like a missing one. chmod has no
|
|
|
+ // effect on Windows, where this case is skipped.
|
|
|
+ if (process.platform === 'win32') return
|
|
|
+ const route = await mount()
|
|
|
+ const path = join(root, 'locked.png')
|
|
|
+ await writeFile(path, PNG_BYTES)
|
|
|
+ const { chmod } = await import('node:fs/promises')
|
|
|
+ await chmod(path, 0o000)
|
|
|
+ try {
|
|
|
+ const response = await route.call(path)
|
|
|
+ expect(response.status).toBe(404)
|
|
|
+ } finally {
|
|
|
+ await chmod(path, 0o600)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('refuses a directory even when its name carries a media extension', async () => {
|
|
|
+ const route = await mount()
|
|
|
+ const directory = join(root, 'frames.png')
|
|
|
+ await mkdir(directory)
|
|
|
+ const response = await route.call(directory)
|
|
|
expect(response.status).toBe(403)
|
|
|
expect(await response.text()).toBe('not a regular file')
|
|
|
})
|
|
|
|
|
|
+ it('serves files when the registered workspace is the filesystem root', async () => {
|
|
|
+ const route = await mount(sep)
|
|
|
+ const path = join(root, 'graph.png')
|
|
|
+ await writeFile(path, PNG_BYTES)
|
|
|
+ const response = await route.call(path)
|
|
|
+ expect(response.status).toBe(200)
|
|
|
+ expect(await responseBytes(response)).toEqual(PNG_BYTES)
|
|
|
+ })
|
|
|
+
|
|
|
it('follows symlinks for the containment check', async () => {
|
|
|
+ const route = await mount()
|
|
|
const real = join(root, 'real.png')
|
|
|
await writeFile(real, PNG_BYTES)
|
|
|
const link = join(root, 'link.png')
|
|
|
await symlink(real, link)
|
|
|
expect((await route.call(link)).status).toBe(200)
|
|
|
|
|
|
- const outsideTarget = join(await realpath(tmpdir()), 'dsh-media-references-target.png')
|
|
|
+ // The escaping target lives in a private directory so concurrent test
|
|
|
+ // processes can never share or delete it.
|
|
|
+ const outside = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-target-')))
|
|
|
+ const outsideTarget = join(outside, 'secret.png')
|
|
|
await writeFile(outsideTarget, PNG_BYTES)
|
|
|
try {
|
|
|
const escaping = join(root, 'escape.png')
|
|
|
@@ -209,11 +279,12 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
expect(denied.status).toBe(403)
|
|
|
expect(await denied.text()).toBe('outside workspace roots')
|
|
|
} finally {
|
|
|
- await rm(outsideTarget, { force: true })
|
|
|
+ await rm(outside, { recursive: true, force: true })
|
|
|
}
|
|
|
})
|
|
|
|
|
|
it('streams a slice of a large sparse file without buffering it whole', async () => {
|
|
|
+ const route = await mount()
|
|
|
const path = join(root, 'huge.mp4')
|
|
|
const handle = await open(path, 'w')
|
|
|
try {
|
|
|
@@ -227,7 +298,48 @@ describe('SessionMediaReferences /api/file', () => {
|
|
|
expect((await responseBytes(response)).length).toBe(10)
|
|
|
})
|
|
|
|
|
|
- it('keeps the route registered until the contribution is disposed', () => {
|
|
|
+ it('closes the file stream when the client aborts mid-response', async () => {
|
|
|
+ const route = await mount()
|
|
|
+ const path = join(root, 'huge.mp4')
|
|
|
+ const handle = await open(path, 'w')
|
|
|
+ try {
|
|
|
+ await handle.truncate(64 * 1024 * 1024)
|
|
|
+ } finally {
|
|
|
+ await handle.close()
|
|
|
+ }
|
|
|
+ const controller = new AbortController()
|
|
|
+ const response = await route.call(path, { signal: controller.signal })
|
|
|
+ expect(response.status).toBe(200)
|
|
|
+ const reader = response.body?.getReader()
|
|
|
+ expect(reader).toBeDefined()
|
|
|
+ await reader?.read()
|
|
|
+ controller.abort()
|
|
|
+ // The abort listener destroys the underlying file stream; reading the web
|
|
|
+ // stream then fails instead of draining the remaining 64 MiB.
|
|
|
+ await expect(reader?.read()).rejects.toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('destroys the stream immediately for an already-aborted request', async () => {
|
|
|
+ const route = await mount()
|
|
|
+ const path = join(root, 'huge.mp4')
|
|
|
+ const handle = await open(path, 'w')
|
|
|
+ try {
|
|
|
+ await handle.truncate(16 * 1024 * 1024)
|
|
|
+ } finally {
|
|
|
+ await handle.close()
|
|
|
+ }
|
|
|
+ const controller = new AbortController()
|
|
|
+ controller.abort()
|
|
|
+ const response = await route.call(path, { signal: controller.signal })
|
|
|
+ expect(response.status).toBe(200)
|
|
|
+ // No stream is opened for a client that is already gone.
|
|
|
+ expect(response.body).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('unregisters the route when the contribution is disposed', async () => {
|
|
|
+ const route = await mount()
|
|
|
expect(route.unregister).not.toHaveBeenCalled()
|
|
|
+ await route.dispose()
|
|
|
+ expect(route.unregister).toHaveBeenCalledTimes(1)
|
|
|
})
|
|
|
})
|