Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/channels/dingtalk/src/media.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { downloadMedia } from './media.js';

describe('downloadMedia (DingTalk)', () => {
let fetchSpy: MockInstance<typeof fetch>;

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});

afterEach(() => {
fetchSpy.mockRestore();
});

// First fetch is the DingTalk download-code API that returns a downloadUrl;
// the second fetch is the actual file download that the size cap guards.
function mockApiResponse(
downloadUrl = 'https://dl.dingtalk.example/file',
): Response {
return {
ok: true,
json: vi.fn().mockResolvedValue({ downloadUrl }),
} as unknown as Response;
}

it('downloads a file within the size limit', async () => {
const mockData = new Uint8Array([1, 2, 3, 4]);
const fileResp = {
ok: true,
headers: {
get: (key: string) => {
if (key === 'content-length') return '4';
if (key === 'content-type') return 'image/png';
return null;
},
},
body: {
getReader: () => ({
read: vi
.fn()
.mockResolvedValueOnce({ done: false, value: mockData })
.mockResolvedValueOnce({ done: true, value: undefined }),
cancel: vi.fn(),
}),
},
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).not.toBeNull();
expect(result?.buffer).toEqual(Buffer.from(mockData));
expect(result?.mimeType).toBe('image/png');
});

it('rejects a Content-Length exceeding 50MB and releases the connection', async () => {
const largeSize = 60 * 1024 * 1024; // 60 MB
const bodyCancel = vi.fn();
const fileResp = {
ok: true,
headers: {
get: (key: string) =>
key === 'content-length' ? largeSize.toString() : null,
},
body: {
cancel: bodyCancel,
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
cancel: vi.fn(),
}),
},
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).toBeNull();
expect(bodyCancel).toHaveBeenCalled();
});

it('rejects a stream exceeding 50MB with no Content-Length header', async () => {
const chunkSize = 10 * 1024 * 1024; // 10 MB per chunk
const mockData = new Uint8Array(chunkSize);
const cancelMock = vi.fn();
const fileResp = {
ok: true,
headers: {
get: () => null, // no content-length → must be caught while streaming
},
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: false, value: mockData }), // never ends
cancel: cancelMock,
}),
},
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).toBeNull();
expect(cancelMock).toHaveBeenCalled();
});

it('returns null when the file response body is null', async () => {
const fileResp = {
ok: true,
headers: { get: () => null },
body: null,
};

fetchSpy
.mockResolvedValueOnce(mockApiResponse())
.mockResolvedValueOnce(fileResp as unknown as Response);

const result = await downloadMedia('code', 'robot', 'token');

expect(result).toBeNull();
});
});
46 changes: 43 additions & 3 deletions packages/channels/dingtalk/src/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,58 @@ export async function downloadMedia(
return null;
}

// Step 2: Download the actual file
const fileResp = await fetch(downloadUrl);
// Step 2: Download the actual file. Bound the request so a stalled CDN
// connection can't hang the streaming loop below indefinitely (matches
// the Feishu adapter).
const fileResp = await fetch(downloadUrl, {
signal: AbortSignal.timeout(30_000),
});
if (!fileResp.ok) {
process.stderr.write(
`[DingTalk] downloadMedia file fetch failed: HTTP ${fileResp.status}\n`,
);
return null;
}

const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
const contentLength = fileResp.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) {
// Release the connection instead of letting the unread body pin it
// until GC.
await fileResp.body?.cancel();
process.stderr.write(
`[DingTalk] downloadMedia rejected: size ${contentLength} exceeds ${MAX_DOWNLOAD_BYTES} byte limit\n`,
);
return null;
}

const mimeType =
fileResp.headers.get('content-type') || 'application/octet-stream';
const buffer = Buffer.from(await fileResp.arrayBuffer());

// Stream-read with size enforcement (handles chunked transfer without Content-Length)
const reader = fileResp.body?.getReader();
if (!reader) {
return null;
}
Comment on lines +94 to +97

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The download fetch() call lacks an AbortSignal.timeout(), which means the new streaming loop below can block indefinitely if the server stalls mid-stream without closing the connection. The Feishu reference implementation guards against this with signal: AbortSignal.timeout(30_000) on its download fetch (packages/channels/feishu/src/media.ts:50). In the long-running daemon, repeated stalls on a degraded DingTalk CDN would accumulate open connections, eventually exhausting the socket pool.

Failure scenario: DingTalk CDN accepts the connection but delivers bytes extremely slowly or stalls mid-stream → reader.read() waits forever → each stalled invocation holds a file descriptor and connection pool slot.

Consider adding signal: AbortSignal.timeout(30_000) to the fetch(downloadUrl) call above, matching the Feishu adapter.

— qwen3.7-max via Qwen Code /review

const chunks: Buffer[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > MAX_DOWNLOAD_BYTES) {
// Await the cancel so a stream error during teardown surfaces here
// instead of becoming an unhandled rejection (matches the body.cancel
// above).
await reader.cancel();
process.stderr.write(
`[DingTalk] downloadMedia rejected: actual size exceeds ${MAX_DOWNLOAD_BYTES} byte limit\n`,
);
return null;
}
chunks.push(Buffer.from(value));
}
const buffer = Buffer.concat(chunks);

return { buffer, mimeType };
} catch (err) {
Expand Down
Loading