Files
浅念 8e870de9d7 fix(storage): correct presign content-type claim; head returns contentType
Spec review (live negative test) found s3-request-presigner unconditionally
unsigns content-type: header-less PUT returns 200, so the previous docblock
claiming signature enforcement was a false guarantee. Content-Type is now
documented as convention-only; head() returns contentType so the complete
step (Task 19) can verify it server-side against the session declaration.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 20:35:08 +08:00

95 lines
3.9 KiB
TypeScript

// packages/storage/test/minio.adapter.spec.ts —— MinIO 真实集成测试(依赖
// docker compose 的 photo-live-minio-1 与仓库根 .env 的 S3_* 配置)。
// 每次运行使用唯一前缀 test/<ts>-<uuid>/,避免历史残留对象干扰断言。
import { randomBytes, randomUUID } from 'node:crypto';
import { ErrorCodes } from '@photo/contracts';
import { StorageError, createStorageFromEnv, type MinioStorageAdapter } from '../src/index';
const PREFIX = `test/${Date.now()}-${randomUUID()}`;
describe('MinioStorageAdapter(真实 MinIO 集成)', () => {
let storage: MinioStorageAdapter;
beforeAll(() => {
storage = createStorageFromEnv();
});
it('presignPut: 客户端按凭证直接 PUT 后,head 返回精确字节数与 etag', async () => {
const key = `${PREFIX}/presign-put.bin`;
const body = randomBytes(2048);
const credential = await storage.presignPut(key, body.length, 'application/octet-stream', 300);
expect(credential.mode).toBe('presigned');
expect(credential.method).toBe('PUT');
expect(credential.headers['Content-Type']).toBe('application/octet-stream');
expect(new Date(credential.expiresAt).getTime()).toBeGreaterThan(Date.now());
const res = await fetch(credential.url, { method: credential.method, headers: credential.headers, body });
expect(res.ok).toBe(true);
const head = await storage.head(key);
expect(head).not.toBeNull();
expect(head?.bytes).toBe(body.length);
expect(head?.etag).toBeTruthy();
expect(head?.contentType).toBe('application/octet-stream');
});
it('put + getStream: 写入后流式读回的字节与写入一致', async () => {
const key = `${PREFIX}/put-get.bin`;
const body = randomBytes(4096);
const { etag } = await storage.put(key, body, 'application/octet-stream');
expect(etag).toBeTruthy();
const stream = await storage.getStream(key);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Uint8Array));
}
expect(Buffer.concat(chunks).equals(body)).toBe(true);
});
it('copyIfAbsent: 首次 created:true;同参数重复调用幂等 created:false 且 etag 不变', async () => {
const srcKey = `${PREFIX}/copy-src.bin`;
const destKey = `${PREFIX}/copy-dest.bin`;
const body = randomBytes(512);
await storage.put(srcKey, body, 'application/octet-stream');
const first = await storage.copyIfAbsent(srcKey, destKey, body.length);
expect(first.created).toBe(true);
expect(first.etag).toBeTruthy();
const second = await storage.copyIfAbsent(srcKey, destKey, body.length);
expect(second.created).toBe(false);
expect(second.etag).toBe(first.etag);
});
it('copyIfAbsent: dest 已存在但字节数不符 → StorageError(UPLOAD_VERIFY_FAILED)', async () => {
const srcKey = `${PREFIX}/mismatch-src.bin`;
const destKey = `${PREFIX}/mismatch-dest.bin`;
const srcBody = randomBytes(64);
await storage.put(srcKey, srcBody, 'application/octet-stream');
// 预置一个不同大小的 dest 对象,制造核验失败
await storage.put(destKey, randomBytes(128), 'application/octet-stream');
const promise = storage.copyIfAbsent(srcKey, destKey, srcBody.length);
await expect(promise).rejects.toBeInstanceOf(StorageError);
await expect(promise).rejects.toMatchObject({ code: ErrorCodes.UPLOAD_VERIFY_FAILED });
});
it('head: 缺失键返回 null(不抛错)', async () => {
const head = await storage.head(`${PREFIX}/missing.bin`);
expect(head).toBeNull();
});
it('presignGet: 预签名 URL 取回的字节与对象一致', async () => {
const key = `${PREFIX}/presign-get.bin`;
const body = randomBytes(1024);
await storage.put(key, body, 'application/octet-stream');
const url = await storage.presignGet(key, 300);
const res = await fetch(url);
expect(res.ok).toBe(true);
expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true);
});
});