Files
photo-live/services/api/test/assets.e2e-spec.ts
T

599 lines
22 KiB
TypeScript
Raw Normal View History

2026-09-18 08:40:32 +08:00
// 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<string, string>;
};
/** 按凭证实写对象(contentType 可覆盖以构造核验失败用例)。 */
async function putByCredential(
credential: Credential,
body: Buffer,
contentType?: string,
): Promise<boolean> {
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<string, string>) =>
request(app.getHttpServer())
.get(`/${API_PREFIX}/public/albums/${token}`)
.query(query ?? {});
/** 完整链路 helper:init → PUT 真图 → complete,返回锚点与响应。 */
const uploadAsset = async (
jpeg: Buffer,
overrides: Record<string, unknown> = {},
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<string> => {
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);
});
});