599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
|
|
// 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);
|
|||
|
|
});
|
|||
|
|
});
|