Files
atlas-seller-kit/apps/api/test/product-image-library.database.test.ts

102 lines
8.1 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { createApplicationDatabase, type Database, type DatabaseTransaction } from '@atlas/database';
import { describe, expect, it, vi } from 'vitest';
import { IdempotencyService } from '../src/common/idempotency.service.js';
import { MediaService } from '../src/media/media.service.js';
const bytes = Buffer.from('validated-product-image');
vi.mock('../src/media/product-image-download.js', () => ({
downloadProductImage: vi.fn(async () => ({ bytes, contentType: 'image/png', width: 800, height: 800 })),
}));
/** 归属验证只在回滚事务中创建数据,存储和公网下载由测试替身承担。 */
describe.skipIf(!process.env.DATABASE_URL)('商品上传与网络图片数据库隔离', () => {
it('候选只属于当前商品,重复素材可独立绑定,FK 与 RLS 拒绝越权', async () => {
const db = createApplicationDatabase(process.env.DATABASE_URL!);
const rollback = Symbol('rollback');
try {
await db.transaction(async (tx) => {
const workspaceId = randomUUID(), otherWorkspace = randomUUID(), userId = randomUUID();
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')", [userId, `${userId}@example.test`]);
for (const id of [workspaceId, otherWorkspace])
await tx.query("INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '图片归属测试', $1::text)", [id]);
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
const product = async (ws: string) => {
await setWorkspace(ws);
const id = randomUUID(), itemId = randomUUID();
await tx.query("INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)", [itemId, ws]);
await tx.query('INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)', [id, ws, itemId]);
return id;
};
const productA = await product(workspaceId), productB = await product(workspaceId), foreignProduct = await product(otherWorkspace);
await setWorkspace(workspaceId);
const assetA = randomUUID(), assetB = randomUUID(), legacyAsset = randomUUID();
const checksum = createHash('sha256').update(bytes).digest('hex');
for (const id of [assetA, assetB, legacyAsset])
await tx.query(`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/png', $3, $4, 800, 800)`,
[id, workspaceId, bytes.length, id === assetA ? checksum : id]);
const upload = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.media_upload_intents
(workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
expected_byte_size, expected_checksum_sha256, status, asset_id, expires_at, completed_at, product_id)
VALUES ($1, 'product_image', 'local_minio', 'test', $2, 'image/png', $3, $4, 'ready', $5, now() + interval '1 hour', now(), $6)`,
[workspaceId, randomUUID(), bytes.length, checksum, assetId, productId]);
const imported = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.product_image_imports
(workspace_id, product_id, source_url, asset_id) VALUES ($1, $2, 'https://example.test/shared.png', $3)`,
[workspaceId, productId, assetId]);
await upload(productA, assetA);
await upload(productB, assetB);
await upload(null, legacyAsset);
await imported(productA, assetA);
await imported(productB, assetB);
await imported(null, legacyAsset);
const scoped = { workspaceTransaction: async <T>(ws: string, work: (tx: DatabaseTransaction) => Promise<T>) => {
await setWorkspace(ws);
return work(tx);
} } as Database;
const media = new MediaService(new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'image-test-secret' } as never), scoped, {
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test',
OBJECT_STORAGE_PROVIDER: 'local_minio', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test',
} as never);
Object.assign(media, { ensureBucket: async () => undefined, inspectObject: async () => ({
byteSize: bytes.length, checksum, contentType: 'image/png', width: 800, height: 800,
}) });
const ids = (rows: Array<{ id: string }>) => rows.map((row) => row.id).sort();
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetB]);
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetB]);
const input = { purpose: 'product_image' as const, product_id: productB, filename: 'shared.png',
content_type: 'image/png' as const, byte_size: bytes.length, checksum_sha256: checksum };
const key = randomUUID();
const intent = await media.createIntent(workspaceId, userId, key, input);
const uploadId = (intent.body as { data: { upload_id: string } }).data.upload_id;
const repeated = await media.createIntent(workspaceId, userId, key, input);
expect((repeated.body as { data: { upload_id: string } }).data.upload_id).toBe(uploadId);
expect((await tx.query('SELECT product_id FROM atlas.media_upload_intents WHERE id = $1', [uploadId])).rows[0].product_id).toBe(productB);
await media.completeIntent(workspaceId, userId, uploadId, randomUUID(), { checksum_sha256: checksum });
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetA, assetB].sort());
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
await media.importProductImage(workspaceId, userId, randomUUID(), { product_id: productB, url: 'https://example.test/shared.png' });
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetA]);
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
const forbidden = async (work: () => Promise<unknown>) => {
await tx.query('SAVEPOINT forbidden_image');
try { await expect(work()).rejects.toMatchObject({ code: '23503' }); }
finally { await tx.query('ROLLBACK TO SAVEPOINT forbidden_image'); }
};
await forbidden(() => upload(foreignProduct, assetA));
await forbidden(() => imported(foreignProduct, assetA));
await tx.query('SAVEPOINT unknown_product');
await expect(media.createIntent(workspaceId, userId, randomUUID(), { ...input, product_id: foreignProduct })).rejects.toMatchObject({ status: 404 });
await tx.query('ROLLBACK TO SAVEPOINT unknown_product');
expect(await media.productUploads(otherWorkspace, productA)).toEqual([]);
expect(await media.productImports(otherWorkspace, productA)).toEqual([]);
throw rollback;
});
} catch (error) { if (error !== rollback) throw error; }
finally { await db.close(); }
});
});