feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -16,14 +16,31 @@
|
||||
// ⑤ ad.deleted 墓碑 v4(high 优先级)→ 删行,账本收敛 v4。
|
||||
// ④ 在 ⑤ 之前执行:stale 重放的断言基线是「行处于 v3 态」,墓碑(v4)之后
|
||||
// 无法回到该基线(此后 v3 亦为 stale)。
|
||||
//
|
||||
// Task 6 追加段(cn HTTP):/v1/admin/ads/uploads 上传初始化——
|
||||
// ⑥ admin 201:staging 键形态 + 凭证真实可用(PUT 实写 MinIO 后 head 可核,
|
||||
// 对照 uploads.e2e 的 presign 断言写法;无会话概念=不落任何 DB 行);
|
||||
// ⑦ 非 admin 403;匿名 401(入参合法);
|
||||
// ⑧ 超过 AD_UPLOAD_MAX_BYTES(2MB)400(服务层,报文含限额值);
|
||||
// contentType 非白名单 400(DTO @IsIn);扩展名不接受 400(服务层)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
import request from 'supertest';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||||
import type { AdDeletedPayload, AdUpdatedPayload } from '@photo/contracts';
|
||||
import {
|
||||
AD_UPLOAD_MAX_BYTES,
|
||||
type AdDeletedPayload,
|
||||
type AdUpdatedPayload,
|
||||
ErrorCodes,
|
||||
} from '@photo/contracts';
|
||||
import { createStorageFromEnv, type StoragePort } from '@photo/storage';
|
||||
import { AppCnModule } from '../src/app-cn.module';
|
||||
import { AppOvModule } from '../src/app-ov.module';
|
||||
import { seedAdmin } from '../src/scripts/seed-admin';
|
||||
import { hashSessionToken } from '../src/auth/session.guard';
|
||||
import { configureApp } from '../src/shared/configure-app';
|
||||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||||
import { resetDb } from './reset';
|
||||
@@ -33,6 +50,8 @@ import { OutboxSender } from '../../sync/src/sender';
|
||||
jest.setTimeout(120_000);
|
||||
|
||||
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||||
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||||
|
||||
/** 本 spec 全程操作的单个广告位 ID(链条用例共享;VarChar(32) 内)。 */
|
||||
const AD_ID = 'ads-e2e-0001';
|
||||
@@ -54,10 +73,14 @@ interface AdsOvRow {
|
||||
describe('ads sync projection (e2e)', () => {
|
||||
let cnApp: NestExpressApplication;
|
||||
let ovApp: NestExpressApplication;
|
||||
let cnBase: string;
|
||||
let ovBase: string;
|
||||
let prismaCn: PrismaClient;
|
||||
let prismaOv: OvPrismaClient;
|
||||
let sender: OutboxSender;
|
||||
let storage: StoragePort;
|
||||
let adminToken: string;
|
||||
let staffToken: string;
|
||||
|
||||
// outbox 行 occurredAt 基准 + 单调序号:保证发送器取批顺序(occurredAt
|
||||
// 升序)与用例推进顺序一致——④ 的迟到 v2 必须晚于 ③ 的 v3 被推送。
|
||||
@@ -149,12 +172,14 @@ describe('ads sync projection (e2e)', () => {
|
||||
await resetDb();
|
||||
await resetOvDb();
|
||||
|
||||
// cn 入口仅作骨架就位(本 spec 不走 cn HTTP;outbox 行直插 cn 库)。
|
||||
// cn 入口仅作骨架就位(①-⑤ 不走 cn HTTP;outbox 行直插 cn 库)——Task 6
|
||||
// 追加段起复用同一实例走 /v1/admin/ads/uploads。
|
||||
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
configureApp(cnApp);
|
||||
await cnApp.listen(0);
|
||||
cnBase = await cnApp.getUrl();
|
||||
|
||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
rawBody: true,
|
||||
@@ -171,6 +196,35 @@ describe('ads sync projection (e2e)', () => {
|
||||
ovApiUrl: ovBase,
|
||||
internalKey: SYNC_KEY,
|
||||
});
|
||||
storage = createStorageFromEnv();
|
||||
|
||||
// Task 6 追加段身份:admin(seedAdmin + 真实登录)与 staff 对照账号
|
||||
// (直插 users + sessions,embed.e2e 同款);匿名 = 不带 Authorization。
|
||||
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||||
const login = await request(cnBase).post('/v1/auth/password').send({
|
||||
account: ADMIN_ACCOUNT,
|
||||
password: ADMIN_PASSWORD,
|
||||
});
|
||||
expect(login.status).toBe(200);
|
||||
adminToken = login.body.sessionToken as string;
|
||||
const staff = await prismaCn.users.create({
|
||||
data: {
|
||||
account: 'ads-staff',
|
||||
role: 'staff',
|
||||
passwordHash: 'x',
|
||||
displayName: 'ads-staff',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const staffSession = randomBytes(32).toString('hex');
|
||||
await prismaCn.sessions.create({
|
||||
data: {
|
||||
userId: staff.id,
|
||||
tokenHash: hashSessionToken(staffSession),
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
},
|
||||
});
|
||||
staffToken = staffSession;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -268,4 +322,99 @@ describe('ads sync projection (e2e)', () => {
|
||||
).toBeNull();
|
||||
await expectAppliedVersion(4);
|
||||
});
|
||||
|
||||
// ── Task 6 追加段:/v1/admin/ads/uploads 上传初始化(cn HTTP,admin-only)──
|
||||
|
||||
it('⑥ admin 初始化 → 201 staging 键 + 凭证(真实 PUT 实写 MinIO,head 可核)', async () => {
|
||||
const res = await request(cnBase)
|
||||
.post('/v1/admin/ads/uploads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ filename: 'ad-banner.jpeg', bytes: 256, contentType: 'image/jpeg' });
|
||||
expect(res.status).toBe(201);
|
||||
// 响应形状精确断言(契约 AdUploadInitResponse;无会话概念=无其他字段)。
|
||||
expect(Object.keys(res.body).sort()).toEqual(['credential', 'objectKey']);
|
||||
expect(Object.keys(res.body.credential).sort()).toEqual(
|
||||
['expiresAt', 'headers', 'method', 'mode', 'url'],
|
||||
);
|
||||
expect(res.body.credential).toMatchObject({
|
||||
mode: 'presigned',
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
|
||||
Date.now(),
|
||||
);
|
||||
// staging 键形态:photo-live/dev/ads/staging/{cuid 25 字符}.{ext};jpeg 归一
|
||||
// 为 jpg(扩展名白名单映射,同上传域口径)。
|
||||
expect(res.body.objectKey).toMatch(
|
||||
/^photo-live\/dev\/ads\/staging\/c[0-9a-z]{24}\.jpg$/,
|
||||
);
|
||||
|
||||
// 凭证真实可用:按凭证 PUT 后 head 可见精确字节数与类型(fetch,Node 22)。
|
||||
const put = await fetch(res.body.credential.url, {
|
||||
method: res.body.credential.method,
|
||||
headers: res.body.credential.headers,
|
||||
body: new Uint8Array(Buffer.alloc(256, 7)),
|
||||
});
|
||||
expect(put.ok).toBe(true);
|
||||
expect(await storage.head(res.body.objectKey)).toMatchObject({
|
||||
bytes: 256,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('⑦ 非 admin 403;匿名 401(入参合法)', async () => {
|
||||
const body = { filename: 'ad.png', bytes: 100, contentType: 'image/png' };
|
||||
const staff = await request(cnBase)
|
||||
.post('/v1/admin/ads/uploads')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send(body);
|
||||
expect(staff.status).toBe(403);
|
||||
expect(staff.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||
|
||||
const anon = await request(cnBase).post('/v1/admin/ads/uploads').send(body);
|
||||
expect(anon.status).toBe(401);
|
||||
});
|
||||
|
||||
it('⑧ 超过 2MB / contentType 非白名单 / 扩展名不接受 → 400 VALIDATION_FAILED', async () => {
|
||||
const post = (payload: object) =>
|
||||
request(cnBase)
|
||||
.post('/v1/admin/ads/uploads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(payload);
|
||||
|
||||
const over = await post({
|
||||
filename: 'big.jpg',
|
||||
bytes: AD_UPLOAD_MAX_BYTES + 1,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(over.status).toBe(400);
|
||||
expect(over.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
// 服务层限额报文含限额值(DTO 不设 @Max,限额唯一裁决点在服务层)。
|
||||
expect(over.body.message).toContain(String(AD_UPLOAD_MAX_BYTES));
|
||||
|
||||
const badType = await post({
|
||||
filename: 'a.gif',
|
||||
bytes: 100,
|
||||
contentType: 'image/gif',
|
||||
});
|
||||
expect(badType.status).toBe(400);
|
||||
expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const badExt = await post({
|
||||
filename: 'a.gif',
|
||||
bytes: 100,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(badExt.status).toBe(400);
|
||||
expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const noExt = await post({
|
||||
filename: 'noext',
|
||||
bytes: 100,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(noExt.status).toBe(400);
|
||||
expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user