import { createHash } from 'node:crypto'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { createMediaAccessUrlRequestSchema, createMediaUploadIntentRequestSchema, importProductImageRequestSchema, } from '@atlas/contracts'; import { describe, expect, it, vi } from 'vitest'; import { MediaService } from '../src/media/media.service.js'; import { downloadProductImage, publicIpv4, } from '../src/media/product-image-download.js'; function serviceWithObject(bytes: Buffer) { const service = new MediaService( {} as ConstructorParameters[0], {} as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', } as ConstructorParameters[2], ); const send = vi.fn(async (command: unknown) => { if (command instanceof HeadObjectCommand) { return { ContentLength: bytes.length, ContentType: 'image/png' }; } if (command instanceof GetObjectCommand) { return { Body: (async function* () { yield bytes; })(), }; } throw new Error('未知 S3 命令'); }); Object.assign(service, { s3: { send } }); return service as unknown as { inspectObject: (intent: Record) => Promise<{ width: number; height: number; checksum: string; }>; }; } function png(width: number, height: number) { const bytes = Buffer.alloc(24); Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); bytes.writeUInt32BE(width, 16); bytes.writeUInt32BE(height, 20); return bytes; } describe('商品图片上传策略', () => { it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => { const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79'; const query = vi.fn(async () => ({ rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }], })); const workspaceTransaction = vi.fn(async ( _workspaceId: string, callback: (tx: { query: typeof query }) => Promise, ) => callback({ query })); const service = new MediaService( {} as ConstructorParameters[0], { workspaceTransaction } as unknown as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', } as ConstructorParameters[2], ); expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true); const preview = await service.accessUrl('workspace-id', assetId); const download = await service.accessUrl('workspace-id', assetId, 'download'); expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false); expect(new URL(download.url).searchParams.get('response-content-disposition')) .toBe(`attachment; filename="atlas-image-${assetId}.png"`); expect(workspaceTransaction).toHaveBeenCalledTimes(2); }); it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => { const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [], })); const run = vi.fn( async ( _request: unknown, callback: (tx: { query: typeof query }) => Promise, ) => callback({ query }), ); const service = new MediaService( { run } as unknown as ConstructorParameters[0], {} as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', } as ConstructorParameters[2], ); Object.assign(service, { ensureBucket: async () => undefined }); const result = await service.createIntent( 'workspace-id', 'user-id', 'key', { purpose: 'product_image', filename: 'sku.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64), }, ); const args = query.mock.calls[0]?.[1] as unknown[]; expect(args[1]).toBe('product_image'); expect(args[3]).toContain('/product-images/'); const body = result.body as { data: { upload: { url: string } } }; expect(body.data.upload.url).toContain('product-images'); }); it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => { const request = { filename: 'sku.png', content_type: 'image/png', byte_size: 6 * 1024 * 1024, checksum_sha256: 'a'.repeat(64), }; expect( createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image', }).success, ).toBe(true); expect( createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', }).success, ).toBe(false); expect( createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image', byte_size: 21 * 1024 * 1024, }).success, ).toBe(false); }); it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => { const bytes = png(9000, 9000); const service = serviceWithObject(bytes); const intent = { bucket: 'test', object_key: 'product.png', expected_content_type: 'image/png', purpose: 'product_image', }; await expect(service.inspectObject(intent)).resolves.toMatchObject({ width: 9000, height: 9000, checksum: createHash('sha256').update(bytes).digest('hex'), }); await expect( service.inspectObject({ ...intent, purpose: 'watermark_image' }), ).rejects.toThrow(); }); it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => { const query = vi.fn(async (sql: string) => { expect(sql).toContain("i.purpose = 'product_image'"); expect(sql).toContain("i.status = 'ready'"); return { rows: [ { id: '7c910684-7070-4f36-8a7b-b10bd91acd79', content_type: 'image/png', byte_size: '24', width_px: 800, height_px: 800, created_at: new Date('2026-09-24T00:00:00Z'), bucket: 'test', object_key: 'uploads/test.png', }, ], }; }); const workspaceTransaction = vi.fn( async ( workspaceId: string, callback: (tx: { query: typeof query }) => Promise, ) => { expect(workspaceId).toBe('workspace-id'); return callback({ query }); }, ); const service = new MediaService( {} as ConstructorParameters[0], { workspaceTransaction } as unknown as ConstructorParameters< typeof MediaService >[1], { OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', } as ConstructorParameters[2], ); const uploads = await service.productUploads('workspace-id'); expect(uploads).toHaveLength(1); expect(uploads[0]).toMatchObject({ id: '7c910684-7070-4f36-8a7b-b10bd91acd79', byte_size: 24, }); expect(uploads[0].preview_url).toContain('uploads/test.png'); }); it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => { expect( importProductImageRequestSchema.safeParse({ url: 'https://example.com/image.jpg', }).success, ).toBe(true); expect( importProductImageRequestSchema.safeParse({ url: 'http://example.com/image.jpg', }).success, ).toBe(false); expect( importProductImageRequestSchema.safeParse({ url: 'https://example.com/image.svg', }).success, ).toBe(false); expect(publicIpv4('8.8.8.8')).toBe(true); for (const address of [ '127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.1.1', '100.64.0.1', '224.0.0.1', ]) { expect(publicIpv4(address)).toBe(false); } await expect( downloadProductImage('https://127.0.0.1/image.jpg'), ).rejects.toThrow(); }); });