import { createServer, request as httpRequest } from 'node:http'; import { once } from 'node:events'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { handleCodingAttachmentRoutes } from '../../electron/api/routes/coding-attachments'; import { getHostApiToken, startHostApiServer } from '../../electron/api/server'; import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; import type { HostApiContext } from '../../electron/api/context'; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); async function startAttachmentServer(maxBytes = 16 * 1024 * 1024) { const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-attachments-')); roots.push(root); const attachments = new CodingAttachmentStore(root, { createId: () => 'attachment-1', maxBytes, }); const context = { codingProducts: { attachments } } as unknown as HostApiContext; const server = createServer((request, response) => { const url = new URL(request.url ?? '/', 'http://127.0.0.1'); void handleCodingAttachmentRoutes(request, response, url, context).then((handled) => { if (!handled) { response.statusCode = 404; response.end(); } }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Attachment test server failed'); return { baseUrl: `http://127.0.0.1:${address.port}`, close: async () => await new Promise((resolve, reject) => { server.closeAllConnections(); server.close((error) => error ? reject(error) : resolve()); }), }; } async function startAttachmentFailureServer() { const attachments = { put: vi.fn(async () => { throw new Error('write failed'); }), read: vi.fn(async () => { throw new Error('read failed'); }), }; const context = { codingProducts: { attachments } } as unknown as HostApiContext; const server = createServer((request, response) => { const url = new URL(request.url ?? '/', 'http://127.0.0.1'); void handleCodingAttachmentRoutes(request, response, url, context).then((handled) => { if (!handled) { response.statusCode = 404; response.end(); } }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Attachment test server failed'); return { baseUrl: `http://127.0.0.1:${address.port}`, close: async () => await new Promise((resolve, reject) => { server.closeAllConnections(); server.close((error) => error ? reject(error) : resolve()); }), }; } async function startAuthenticatedHostServer() { const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-host-attachments-')); roots.push(root); const attachments = new CodingAttachmentStore(root, { createId: () => 'host-attachment-1' }); const context = { codingProducts: { attachments }, } as unknown as HostApiContext; const server = startHostApiServer(context, 0); await once(server, 'listening'); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Host API test server failed'); return { baseUrl: `http://127.0.0.1:${address.port}`, token: getHostApiToken(), close: async () => await new Promise((resolve, reject) => { server.closeAllConnections(); server.close((error) => error ? reject(error) : resolve()); }), }; } async function postDeclaredLength( url: string, contentLength: number, ): Promise<{ status: number; body: Record }> { return await new Promise((resolve, reject) => { const request = httpRequest(url, { method: 'POST', headers: { 'Content-Type': 'image/png', 'Content-Length': String(contentLength), }, }, (response) => { const chunks: Buffer[] = []; response.on('data', (chunk) => chunks.push(Buffer.from(chunk))); response.on('end', () => resolve({ status: response.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record, })); }); request.on('error', reject); request.end(); }); } describe('coding attachment routes', () => { it('accepts authenticated binary uploads through the production Host API gate', async () => { const server = await startAuthenticatedHostServer(); try { const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]); const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { Authorization: `Bearer ${server.token}`, 'Content-Type': 'image/png', }, body: bytes, }); expect(upload.status).toBe(201); await expect(upload.json()).resolves.toEqual({ attachmentId: 'host-attachment-1', mime: 'image/png', byteLength: bytes.byteLength, }); const unrelatedMutation = await fetch(`${server.baseUrl}/api/coding/projects`, { method: 'POST', headers: { Authorization: `Bearer ${server.token}`, 'Content-Type': 'image/png', }, body: bytes, }); expect(unrelatedMutation.status).toBe(415); } finally { await server.close(); } }); it('round-trips bounded image bytes through attachment ids', async () => { const server = await startAttachmentServer(); try { const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]); const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: bytes, }); expect(upload.status).toBe(201); await expect(upload.json()).resolves.toEqual({ attachmentId: 'attachment-1', mime: 'image/png', byteLength: bytes.byteLength, }); const preview = await fetch( `${server.baseUrl}/api/coding/attachments/attachment-1/content`, ); expect(preview.status).toBe(200); expect(preview.headers.get('content-type')).toBe('image/png'); expect(preview.headers.get('cache-control')).toBe('private, no-store'); expect(Array.from(new Uint8Array(await preview.arrayBuffer()))).toEqual(Array.from(bytes)); } finally { await server.close(); } }); it('rejects unsupported media before writing and maps missing content to 404', async () => { const server = await startAttachmentServer(); try { const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: 'not an image', }); expect(upload.status).toBe(400); await expect(upload.json()).resolves.toMatchObject({ code: 'CODING_ATTACHMENT_INVALID', }); const missing = await fetch( `${server.baseUrl}/api/coding/attachments/missing/content`, ); expect(missing.status).toBe(404); await expect(missing.json()).resolves.toMatchObject({ code: 'CODING_ATTACHMENT_NOT_FOUND', }); } finally { await server.close(); } }); it('rejects bytes that do not match the declared image MIME', async () => { const server = await startAttachmentServer(); try { const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'not actually a png', }); expect(upload.status).toBe(400); await expect(upload.json()).resolves.toMatchObject({ code: 'CODING_ATTACHMENT_INVALID', }); } finally { await server.close(); } }); it.each([ ['image/jpeg', new Uint8Array([0xff, 0xd8, 0xff, 0x01])], ['image/gif', new TextEncoder().encode('GIF89a!')], ['image/webp', new TextEncoder().encode('RIFF\x04\x00\x00\x00WEBP')], ])('accepts the minimal %s image signature', async (mime, bytes) => { const server = await startAttachmentServer(); try { const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { 'Content-Type': mime }, body: bytes, }); expect(upload.status).toBe(201); await expect(upload.json()).resolves.toMatchObject({ mime }); } finally { await server.close(); } }); it('rejects a declared body larger than the route limit', async () => { const server = await startAttachmentServer(); try { const response = await postDeclaredLength( `${server.baseUrl}/api/coding/attachments`, 16 * 1024 * 1024 + 1, ); expect(response.status).toBe(413); expect(response.body).toMatchObject({ code: 'CODING_ATTACHMENT_INVALID', }); } finally { await server.close(); } }); it('uses registered errors for unreadable content and upload storage failures', async () => { const server = await startAttachmentFailureServer(); try { const content = await fetch( `${server.baseUrl}/api/coding/attachments/attachment-1/content`, ); expect(content.status).toBe(404); await expect(content.json()).resolves.toMatchObject({ code: 'CODING_ATTACHMENT_NOT_FOUND', }); const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), }); expect(upload.status).toBe(500); await expect(upload.json()).resolves.toMatchObject({ code: 'CODING_STORAGE_WRITE_FAILED', error: '本地数据写入失败,请检查存储后重试。', }); } finally { await server.close(); } }); it('maps empty bodies and invalid opaque ids to stable client errors', async () => { const server = await startAttachmentServer(); try { const empty = await fetch(`${server.baseUrl}/api/coding/attachments`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, }); expect(empty.status).toBe(400); await expect(empty.json()).resolves.toMatchObject({ code: 'CODING_ATTACHMENT_INVALID', }); for (const id of ['bad%2Fid', '%ZZ']) { const invalid = await fetch(`${server.baseUrl}/api/coding/attachments/${id}/content`); expect(invalid.status).toBe(400); await expect(invalid.json()).resolves.toMatchObject({ code: 'CODING_ATTACHMENT_INVALID', }); } } finally { await server.close(); } }); });