const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998'; 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_REGION: 'us-east-1', 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_REGION: 'cn-east-1', 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(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/'); expect(new URL(download.url).searchParams.get('response-content-disposition')) .toBe(`attachment; filename="atlas-image-${assetId}.png"`); expect(workspaceTransaction).toHaveBeenCalledTimes(2); }); it('开发环境为旧 MinIO 素材签发本地预览地址', async () => { const query = vi.fn(async () => ({ rows: [{ storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'collections/test/image', content_type: 'image/jpeg', }], })); const database = { workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise) => work({ query }), }; const service = new MediaService( {} as ConstructorParameters[0], database as unknown as ConstructorParameters[1], { NODE_ENV: 'development', OBJECT_STORAGE_PROVIDER: 'qiniu_kodo', OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com', OBJECT_STORAGE_REGION: 'cn-east-1', OBJECT_STORAGE_BUCKET: 'atlas-dev-assets', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', LEGACY_MINIO_ENDPOINT: 'http://localhost:9000', MINIO_ROOT_USER: 'local-test', MINIO_ROOT_PASSWORD: 'local-secret', } as ConstructorParameters[2], ); const preview = await service.accessUrl('workspace-id', 'asset-id'); expect(new URL(preview.url).host).toBe('localhost:9000'); expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image'); }); it('七牛素材使用绑定域名签发预览和附件下载链接', async () => { const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79'; const query = vi.fn(async () => ({ rows: [{ storage_provider: 'qiniu_kodo', bucket: 'atlas-dev-assets', object_key: 'collections/workspace/image', content_type: 'image/jpeg', }], })); const database = { workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise) => work({ query }), }; const service = new MediaService( {} as ConstructorParameters[0], database as unknown as ConstructorParameters[1], { OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com', OBJECT_STORAGE_REGION: 'cn-east-1', OBJECT_STORAGE_BUCKET: 'atlas-dev-assets', objectStorageForcePathStyle: true, objectStorageAccessKey: 's3-key', objectStorageSecretKey: 's3-secret', qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn', qiniuDownloadAccessKey: 'download-key', qiniuDownloadSecretKey: 'download-secret', } as ConstructorParameters[2], ); const preview = await service.accessUrl('workspace-id', assetId); const download = await service.accessUrl('workspace-id', assetId, 'download'); const previewUrl = new URL(preview.url); const downloadUrl = new URL(download.url); expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn'); expect(previewUrl.pathname).toBe('/collections/workspace/image'); expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false); expect(previewUrl.searchParams.has('attname')).toBe(false); expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/); expect(preview.url.split('&').at(-1)).toMatch(/^token=/); expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`); expect(download.url.split('&').at(-1)).toMatch(/^token=/); }); it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => { const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [{ id: productId }], })); 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_REGION: 'us-east-1', 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', product_id: productId, filename: 'sku.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64), }, ); const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[]; expect(args[10]).toBe(productId); 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', product_id: productId, }).success, ).toBe(true); expect( createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', }).success, ).toBe(false); expect( createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image', product_id: productId, byte_size: 21 * 1024 * 1024, }).success, ).toBe(false); }); it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => { const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) }; expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false); expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true); expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false); expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).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'"); expect(sql).toContain('i.product_id = $1'); 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_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test', } as ConstructorParameters[2], ); const uploads = await service.productUploads('workspace-id', productId); 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', product_id: productId, }).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(); }); });