229 lines
8.2 KiB
TypeScript
229 lines
8.2 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
|
import {
|
|
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<typeof MediaService>[0],
|
|
{} 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<typeof MediaService>[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<string, unknown>) => 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('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
|
|
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
|
rows: [],
|
|
}));
|
|
const run = vi.fn(
|
|
async (
|
|
_request: unknown,
|
|
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
|
) => callback({ query }),
|
|
);
|
|
const service = new MediaService(
|
|
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
|
|
{} 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<typeof MediaService>[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<unknown>,
|
|
) => {
|
|
expect(workspaceId).toBe('workspace-id');
|
|
return callback({ query });
|
|
},
|
|
);
|
|
const service = new MediaService(
|
|
{} as ConstructorParameters<typeof MediaService>[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<typeof MediaService>[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();
|
|
});
|
|
});
|