// services/api/test/assets.e2e-spec.ts —— P1b Task 3 相册资产上传链路 e2e // (先写后实现)。认证同 uploads spec:Prisma 直种 sessions 行换 Bearer 令牌。 // 真实 MinIO:presign PUT 实写 head 可核验;真实 JPEG 经 sharp 生成(无二进制 // 入库)。覆盖: // init/complete 主链路:owner init banner(zh-CN) → 201 {assetId, objectKey, // credential}(键形 photo-live/dev/albums/{albumId}/assets/{assetId}.jpg, // DB 行 state=pending + 申明值 expectedBytes/contentType 行内持久化); // PUT 真图 → complete 200 {assetId, url, expiresAt},签名 URL 可取回 // 真实字节;行置 effective;audit album.asset.add(actor=操作者、 // resourceId=albumId、metadata assetId/kind/locale)。 // 公开消费 + 语言回退(需求 4.7):GET /public/albums/{token}?locale=zh-CN // banner=该资产签名 URL;en 请求无 en 行且无全局行 → null;补全局行 // (locale 缺省)后 en 取全局、zh-CN 取 zh-CN;仅全局行的相册 zh-CN // 请求 → 全局(回退默认图)。 // 替换语义:同 kind(zh-CN banner)二次上传 → 公开取新资产行,旧行保留。 // 权限:协作者 init/complete → 403 AUTH_FORBIDDEN(资产是相册配置,owner/ // admin 专属);局外人 → 404 ALBUM_NOT_FOUND(不可见同码防枚举); // uploadEnabled=false 不拦截资产(开关只管照片直传,§4.9)。 // 校验:bytes 超 UPLOAD_MAX_BYTES / contentType 白名单外 → 400;complete 前 // 未 PUT → 400 UPLOAD_VERIFY_FAILED(行保持 pending);字节数不符 → 400; // contentType 不符(presign 不签名 content-type,服务端兜底)→ 400。 // 幂等:重复 complete → 200 同 assetId(URL 现签),audit 不重复;对象已 // 删除后重复 complete → 仍 200(已生效,不依赖 staging 核验)。 // cover/logo 建链就绪:logo 冒烟一例。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import sharp from 'sharp'; import { PrismaClient } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { AppCnModule } from '../src/app-cn.module'; import { hashSessionToken } from '../src/auth/session.guard'; import { API_PREFIX } from '../src/shared/constants'; import { resetDb } from './reset'; jest.setTimeout(120_000); /** 契约 AssetInitResponse / AssetCompleteResponse 字段全集(形状精确断言用)。 */ const INIT_KEYS = ['assetId', 'objectKey', 'credential'].sort(); const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort(); const COMPLETE_KEYS = ['assetId', 'url', 'expiresAt'].sort(); const KEY_ROOT = 'photo-live/dev'; /** 测试内生成真实 JPEG(尺寸/配色区分内容:新旧资产字节各不相同)。 */ const makeJpeg = ( width: number, height: number, rgb: { r: number; g: number; b: number }, ) => sharp({ create: { width, height, channels: 3, background: rgb }, }) .jpeg({ quality: 85 }) .toBuffer(); type Credential = { url: string; method: string; headers: Record; }; /** 按凭证实写对象(contentType 可覆盖以构造核验失败用例)。 */ async function putByCredential( credential: Credential, body: Buffer, contentType?: string, ): Promise { const res = await fetch(credential.url, { method: credential.method, headers: contentType === undefined ? credential.headers : { 'Content-Type': contentType }, body: new Uint8Array(body), }); return res.ok; } describe('album assets upload (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let ownerId: string; let collaboratorId: string; let outsiderId: string; let ownerToken: string; let collaboratorToken: string; let outsiderToken: string; let albumId: string; let albumToken: string; /** 仅全局 banner 的第二相册(zh-CN 请求回退全局用例)。 */ let globalOnlyAlbumId: string; let globalOnlyToken: string; /** T1 主链路资产锚点(幂等/对象删除用例复用)。 */ let t1Asset: { assetId: string; objectKey: string; jpeg: Buffer }; const initAsset = (token: string, album: string, body: object) => request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${album}/assets`) .set('Authorization', `Bearer ${token}`) .send(body); const completeAsset = (token: string, album: string, assetId: string) => request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${album}/assets/${assetId}/complete`) .set('Authorization', `Bearer ${token}`); const getPublic = (token: string, query?: Record) => request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${token}`) .query(query ?? {}); /** 完整链路 helper:init → PUT 真图 → complete,返回锚点与响应。 */ const uploadAsset = async ( jpeg: Buffer, overrides: Record = {}, album: string = albumId, ) => { const initRes = await initAsset(ownerToken, album, { kind: 'banner', locale: 'zh-CN', filename: 'b.jpg', bytes: jpeg.length, contentType: 'image/jpeg', ...overrides, }); expect(initRes.status).toBe(201); const { assetId, objectKey, credential } = initRes.body as { assetId: string; objectKey: string; credential: Credential; }; expect(await putByCredential(credential, jpeg)).toBe(true); const completeRes = await completeAsset(ownerToken, album, assetId); expect(completeRes.status).toBe(200); return { assetId, objectKey, jpeg, complete: completeRes.body as { assetId: string; url: string }, }; }; /** 取公开 banner 并断言字节与预期一致(签名 URL 可取回真实对象)。 */ const expectPublicBannerBytes = async ( token: string, locale: string, jpeg: Buffer, ) => { const res = await getPublic(token, { locale }); expect(res.status).toBe(200); const banner = res.body.banner as { url: string; expiresAt: string }; expect(banner).not.toBeNull(); expect(Date.parse(banner.expiresAt)).toBeGreaterThan(Date.now()); const fetched = await fetch(banner.url); expect(fetched.status).toBe(200); expect(fetched.headers.get('content-type')).toBe('image/jpeg'); expect(Buffer.from(await fetched.arrayBuffer())).toEqual(jpeg); }; /** 经 API 建册(owner),返回 {id, publicToken}(publicEnabled 默认 true)。 */ const createAlbum = async (title: string): Promise<{ id: string; publicToken: string; }> => { const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${ownerToken}`) .send({ translations: [ { locale: 'zh-CN', title }, { locale: 'en', title: `${title} (en)` }, ], defaultLocale: 'zh-CN', startAt: '2026-12-01T02:00:00.000Z', }); expect(res.status).toBe(201); const album = await prisma.albums.findUnique({ where: { id: res.body.id as string }, select: { publicToken: true }, }); expect(album).not.toBeNull(); return { id: res.body.id as string, publicToken: album!.publicToken }; }; beforeAll(async () => { await resetDb(); const moduleRef = await Test.createTestingModule({ imports: [AppCnModule], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix(API_PREFIX); await app.init(); prisma = new PrismaClient(); const mkUser = (account: string) => prisma.users.create({ data: { account, passwordHash: 'x', displayName: account }, select: { id: true }, }); ownerId = (await mkUser('as-owner')).id; collaboratorId = (await mkUser('as-collab')).id; outsiderId = (await mkUser('as-outsider')).id; const mkToken = async (userId: string): Promise => { const token = randomBytes(32).toString('hex'); await prisma.sessions.create({ data: { userId, tokenHash: hashSessionToken(token), expiresAt: new Date(Date.now() + 3_600_000), }, }); return token; }; ownerToken = await mkToken(ownerId); collaboratorToken = await mkToken(collaboratorId); outsiderToken = await mkToken(outsiderId); const main = await createAlbum('资产相册'); albumId = main.id; albumToken = main.publicToken; await prisma.album_members.create({ data: { albumId, userId: collaboratorId, role: 'collaborator' }, }); const globalOnly = await createAlbum('仅全局资产相册'); globalOnlyAlbumId = globalOnly.id; globalOnlyToken = globalOnly.publicToken; }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); it('owner 主链路:init → 201 行内会话;PUT 真图;complete → 200 可取回;DB effective + audit', async () => { const jpeg = await makeJpeg(64, 40, { r: 96, g: 130, b: 182 }); const initRes = await initAsset(ownerToken, albumId, { kind: 'banner', locale: 'zh-CN', filename: 'b.jpg', bytes: jpeg.length, contentType: 'image/jpeg', }); expect(initRes.status).toBe(201); expect(Object.keys(initRes.body).sort()).toEqual(INIT_KEYS); expect(Object.keys(initRes.body.credential).sort()).toEqual(CREDENTIAL_KEYS); expect(initRes.body.credential).toMatchObject({ mode: 'presigned', method: 'PUT', headers: { 'Content-Type': 'image/jpeg' }, }); expect( new Date(initRes.body.credential.expiresAt).getTime(), ).toBeGreaterThan(Date.now()); const { assetId, objectKey, credential } = initRes.body as { assetId: string; objectKey: string; credential: Credential; }; t1Asset = { assetId, objectKey, jpeg }; // 对象键:photo-live/dev/albums/{albumId}/assets/{assetId}.jpg(最终键即键)。 expect(objectKey).toBe(`${KEY_ROOT}/albums/${albumId}/assets/${assetId}.jpg`); // DB:init 即建行,state=pending,申明值行内持久化(complete 核验依据)。 const pendingRow = await prisma.album_assets.findUnique({ where: { id: assetId }, }); expect(pendingRow).toMatchObject({ albumId, kind: 'banner', locale: 'zh-CN', objectKey, state: 'pending', expectedBytes: jpeg.length, contentType: 'image/jpeg', }); expect(await putByCredential(credential, jpeg)).toBe(true); const completeRes = await completeAsset(ownerToken, albumId, assetId); expect(completeRes.status).toBe(200); expect(Object.keys(completeRes.body).sort()).toEqual(COMPLETE_KEYS); expect(completeRes.body.assetId).toBe(assetId); expect(Date.parse(completeRes.body.expiresAt)).toBeGreaterThan(Date.now()); // 签名 URL 直取 MinIO:真实字节 + JPEG content-type。 const fetched = await fetch(completeRes.body.url); expect(fetched.status).toBe(200); expect(fetched.headers.get('content-type')).toBe('image/jpeg'); expect(Buffer.from(await fetched.arrayBuffer())).toEqual(jpeg); // DB:行置 effective(挂载生效)。 const effectiveRow = await prisma.album_assets.findUnique({ where: { id: assetId }, }); expect(effectiveRow?.state).toBe('effective'); // audit album.asset.add:actor/resourceId=albumId/metadata 三元组。 const audits = await prisma.audit_logs.findMany({ where: { action: 'album.asset.add', resourceId: albumId }, }); expect(audits).toHaveLength(1); expect(audits[0]).toMatchObject({ actorId: ownerId, metadata: { assetId, kind: 'banner', locale: 'zh-CN' }, }); // 公开消费:GET /public/albums/{token}?locale=zh-CN banner=该资产。 await expectPublicBannerBytes(albumToken, 'zh-CN', jpeg); }); it('需求 4.7 语言回退:en 请求无 en 行且无全局行 → banner null', async () => { const res = await getPublic(albumToken, { locale: 'en' }); expect(res.status).toBe(200); expect(res.body.resolvedLocale).toBe('en'); expect(res.body.banner).toBeNull(); }); it('全局行(locale 缺省):en 取全局、zh-CN 仍取 zh-CN 行', async () => { const globalJpeg = await makeJpeg(80, 50, { r: 182, g: 96, b: 130 }); const uploaded = await uploadAsset(globalJpeg, { locale: undefined }); // DB:全局行 locale=NULL。 const row = await prisma.album_assets.findUnique({ where: { id: uploaded.assetId }, }); expect(row?.locale).toBeNull(); await expectPublicBannerBytes(albumToken, 'en', globalJpeg); await expectPublicBannerBytes(albumToken, 'zh-CN', t1Asset.jpeg); }); it('仅全局行的相册:zh-CN 请求 → 全局(回退默认图,需求 4.7)', async () => { const jpeg = await makeJpeg(48, 30, { r: 130, g: 182, b: 96 }); await uploadAsset(jpeg, { locale: undefined }, globalOnlyAlbumId); await expectPublicBannerBytes(globalOnlyToken, 'zh-CN', jpeg); }); it('替换语义:同 kind 二次上传 → 公开取新资产;旧行保留', async () => { const newer = await uploadAsset(await makeJpeg(96, 60, { r: 200, g: 80, b: 80 })); // 公开 zh-CN 取新资产行(最新 effective)。 await expectPublicBannerBytes(albumToken, 'zh-CN', newer.jpeg); // 旧行保留(P1b 无删除端点;zh-CN 行数=2:T1 + 本次)。 const rows = await prisma.album_assets.findMany({ where: { albumId, kind: 'banner', locale: 'zh-CN' }, }); expect(rows).toHaveLength(2); expect(rows.map((r) => r.state)).toEqual(['effective', 'effective']); }); it('协作者 init/complete → 403 AUTH_FORBIDDEN;局外人 → 404 ALBUM_NOT_FOUND', async () => { const collabInit = await initAsset(collaboratorToken, albumId, { kind: 'banner', locale: 'zh-CN', filename: 'c.jpg', bytes: 1000, contentType: 'image/jpeg', }); expect(collabInit.status).toBe(403); expect(collabInit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); // 协作者对既有资产 complete 亦 403(可见但无管理权)。 const collabComplete = await completeAsset( collaboratorToken, albumId, t1Asset.assetId, ); expect(collabComplete.status).toBe(403); expect(collabComplete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsiderInit = await initAsset(outsiderToken, albumId, { kind: 'banner', filename: 'o.jpg', bytes: 1000, contentType: 'image/jpeg', }); expect(outsiderInit.status).toBe(404); expect(outsiderInit.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const outsiderComplete = await completeAsset( outsiderToken, albumId, t1Asset.assetId, ); expect(outsiderComplete.status).toBe(404); expect(outsiderComplete.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // 幽灵资产 id:可见册内不存在 → 403 AUTH_FORBIDDEN(契约:不区分防枚举)。 const ghost = await completeAsset(ownerToken, albumId, 'no-such-asset'); expect(ghost.status).toBe(403); expect(ghost.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); }); it('uploadEnabled=false 不拦截资产(开关只管照片直传,§4.9)', async () => { await prisma.albums.update({ where: { id: albumId }, data: { uploadEnabled: false }, }); try { const jpeg = await makeJpeg(32, 32, { r: 10, g: 10, b: 10 }); const uploaded = await uploadAsset(jpeg, { kind: 'cover' }); expect(uploaded.complete.assetId).toBe(uploaded.assetId); const row = await prisma.album_assets.findUnique({ where: { id: uploaded.assetId }, }); expect(row).toMatchObject({ kind: 'cover', state: 'effective' }); } finally { await prisma.albums.update({ where: { id: albumId }, data: { uploadEnabled: true }, }); } }); it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调)→ 400;contentType 白名单外 → 400', async () => { const previous = process.env.UPLOAD_MAX_BYTES; process.env.UPLOAD_MAX_BYTES = '500'; try { const tooBig = await initAsset(ownerToken, albumId, { kind: 'banner', filename: 'big.jpg', bytes: 501, contentType: 'image/jpeg', }); expect(tooBig.status).toBe(400); expect(tooBig.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } finally { if (previous === undefined) { delete process.env.UPLOAD_MAX_BYTES; } else { process.env.UPLOAD_MAX_BYTES = previous; } } const badType = await initAsset(ownerToken, albumId, { kind: 'banner', filename: 't.txt', bytes: 100, contentType: 'text/plain', }); expect(badType.status).toBe(400); expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 扩展名白名单与 kind 枚举(DTO 层)。 const badExt = await initAsset(ownerToken, albumId, { kind: 'banner', filename: 'virus.exe', bytes: 100, contentType: 'image/jpeg', }); expect(badExt.status).toBe(400); const badKind = await initAsset(ownerToken, albumId, { kind: 'mascot', filename: 'm.jpg', bytes: 100, contentType: 'image/jpeg', }); expect(badKind.status).toBe(400); }); it('complete 前未 PUT → 400 UPLOAD_VERIFY_FAILED,行保持 pending', async () => { const initRes = await initAsset(ownerToken, albumId, { kind: 'logo', filename: 'l.png', bytes: 800, contentType: 'image/png', }); expect(initRes.status).toBe(201); const { assetId } = initRes.body as { assetId: string }; const res = await completeAsset(ownerToken, albumId, assetId); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); const row = await prisma.album_assets.findUnique({ where: { id: assetId }, }); expect(row?.state).toBe('pending'); }); it('核验兜底:字节数不符 → 400;contentType 不符 → 400(行均保持 pending)', async () => { const jpeg = await makeJpeg(40, 30, { r: 1, g: 2, b: 3 }); // ① 申明 bytes 与实传不符。 const wrongBytes = await initAsset(ownerToken, albumId, { kind: 'banner', filename: 'wb.jpg', bytes: jpeg.length + 1000, contentType: 'image/jpeg', }); expect(wrongBytes.status).toBe(201); const wb = wrongBytes.body as { assetId: string; credential: Credential }; expect(await putByCredential(wb.credential, jpeg)).toBe(true); const bytesRes = await completeAsset(ownerToken, albumId, wb.assetId); expect(bytesRes.status).toBe(400); expect(bytesRes.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); // ② 申明 image/jpeg 实传 application/octet-stream(presign 不签名 content-type)。 const wrongType = await initAsset(ownerToken, albumId, { kind: 'banner', filename: 'wt.jpg', bytes: jpeg.length, contentType: 'image/jpeg', }); const wt = wrongType.body as { assetId: string; credential: Credential }; expect( await putByCredential(wt.credential, jpeg, 'application/octet-stream'), ).toBe(true); const typeRes = await completeAsset(ownerToken, albumId, wt.assetId); expect(typeRes.status).toBe(400); expect(typeRes.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); for (const id of [wb.assetId, wt.assetId]) { const row = await prisma.album_assets.findUnique({ where: { id }, }); expect(row?.state).toBe('pending'); } }); it('重复 complete 幂等 → 200 同 assetId 且 URL 可取回;audit 不重复', async () => { const again = await completeAsset(ownerToken, albumId, t1Asset.assetId); expect(again.status).toBe(200); expect(Object.keys(again.body).sort()).toEqual(COMPLETE_KEYS); expect(again.body.assetId).toBe(t1Asset.assetId); const fetched = await fetch(again.body.url); expect(fetched.status).toBe(200); expect(Buffer.from(await fetched.arrayBuffer())).toEqual(t1Asset.jpeg); // audit 锚 resourceId=albumId:T1 + 全局 + 替换 + cover(uploadDisabled 用例) // 各一条,幂等重复不新增。 expect( await prisma.audit_logs.count({ where: { action: 'album.asset.add', resourceId: albumId }, }), ).toBe(4); }); it('对象删除后重复 complete → 仍 200(已生效,不依赖 staging 核验)', async () => { const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3'); const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION, credentials: { accessKeyId: process.env.S3_ACCESS_KEY as string, secretAccessKey: process.env.S3_SECRET_KEY as string, }, forcePathStyle: true, }); try { await s3.send( new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET as string, Key: t1Asset.objectKey, }), ); } finally { await s3.destroy(); } const res = await completeAsset(ownerToken, albumId, t1Asset.assetId); expect(res.status).toBe(200); expect(res.body.assetId).toBe(t1Asset.assetId); }); it('logo 冒烟:init+PUT+complete → 200;DB kind=logo 行生效', async () => { const jpeg = await makeJpeg(24, 24, { r: 5, g: 5, b: 5 }); const uploaded = await uploadAsset(jpeg, { kind: 'logo', locale: undefined, filename: 'logo.jpg', }); const row = await prisma.album_assets.findUnique({ where: { id: uploaded.assetId }, }); expect(row).toMatchObject({ kind: 'logo', locale: null, state: 'effective' }); }); it('未认证 → 401(init 与 complete)', async () => { const initRes = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/assets`) .send({ kind: 'banner', filename: 'a.jpg', bytes: 10, contentType: 'image/jpeg' }); expect(initRes.status).toBe(401); const completeRes = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/assets/${t1Asset.assetId}/complete`); expect(completeRes.status).toBe(401); }); });