102 lines
8.1 KiB
TypeScript
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(); }
|
|
});
|
|
});
|