fix: harden Web image admission

This commit is contained in:
Tianyi Cui
2026-07-30 01:58:36 +08:00
parent d6c82001b3
commit 515d48875e
52 changed files with 999 additions and 444 deletions

View File

@@ -13,6 +13,7 @@ import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
afterEach(cleanup)
@@ -78,7 +79,7 @@ function bench(over?: BenchOptions) {
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const removeImage = vi.fn((id: string) => { shell.removeImage(id) })
const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
@@ -523,7 +524,7 @@ describe('image draft rail', () => {
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
const send = view.getByRole('button', { name: 'Send message' }) as HTMLButtonElement
expect(send.disabled).toBe(false)
@@ -536,7 +537,7 @@ describe('image draft rail', () => {
it('opens the original preview on double-click and closes it with Escape', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
const { view } = bench({ attachments: [attachment] })
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()

View File

@@ -6,17 +6,19 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import { InputHub } from '../src/client/input/hub.ts'
import { ConversationService } from '../src/client/service.ts'
async function bench() {
async function bench(readAttachment?: SessionFace['readAttachment']) {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
await runtime.sessions.add({
id: 's1',
session: { prompt, cancel, loadOlder },
session: { prompt, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
@@ -25,7 +27,7 @@ async function bench() {
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, hub, root, scoped, prompt, cancel, loadOlder }
return { runtime, fiber, hub, root, scoped, prompt, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -122,6 +124,37 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('does not publish a historical image URL after disposal', async () => {
let resolveRead!: (result: Awaited<ReturnType<SessionFace['readAttachment']>>) => void
const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise<Awaited<ReturnType<SessionFace['readAttachment']>>>(
(resolve) => { resolveRead = resolve },
))
const b = await bench(readAttachment)
const created = vi.spyOn(URL, 'createObjectURL')
const sessionId = b.runtime.sessions.behavior('s1').sessionId
const attachment = {
attachmentId: AttachmentId('image-1'),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
} as const
const pending = b.root.resolveImage(sessionId, attachment)
await b.fiber.dispose()
await expect(b.root.resolveImage(sessionId, attachment)).rejects.toThrow('service is disposed')
resolveRead({
ok: true,
value: {
attachment,
data: Uint8Array.of(1),
},
})
await expect(pending).rejects.toThrow('service was disposed before loading completed')
expect(created).not.toHaveBeenCalled()
created.mockRestore()
await b.runtime.dispose()
})
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)