fix(ads): 终审修复——staging 前缀+运行时上限核验/失效绑定编辑/白名单去 heic/杂项
- verifyStaging 硬化(services/api/src/ads/ads.service.ts):新增键前缀核验 (仅 OBJECT_KEY_ROOT/ads/staging/ 布局,防照片域任意键被提升为公开广告) 与运行时 2MB 上限(head.bytes > AD_UPLOAD_MAX_BYTES → 400 报文含限额值 ——init 申明管不住实际 PUT 体,create/update 是唯一核验点);e2e ⑰ 以 测试 storage 直接 presignPut 真实对象覆盖两分支 - 失效绑定编辑 400(admin AdsFormDialog):PATCH 仅在 albumIds 实际变化时 携带(albumIdsChanged 序敏感深比较,纯函数落 adsLogic + vitest 用例); preloadOptions 解析失败的绑定 ID 加「已失效,可移除」后缀(zh/en 新增 adsManage.form.staleAlbum)——软删绑定不再挡住其它字段保存 - 白名单去 heic(原图直出无变体兜底,Chromium/Firefox 不渲染=破图): contracts 三处 enum 收窄 [image/jpeg, image/png] 并重生成类型;dto 定义 广告域独立 AD_IMAGE_CONTENT_TYPES(与照片域 UPLOAD_CONTENT_TYPES 分离), AD_IMAGE_EXTENSIONS 由其派生;init 对 .heic 文件名以广告域集合二次核验 拒绝(共享 extOfFilename 仍认 heic,杜绝 init 放行→create 中段断裂); e2e heic 矩阵 400;admin accept/文案/类型同步收窄 - searchAlbums 补 catch(http 层已弹 toast,本地静默保持旧选项) - ActivityPage 关闭记忆测试补位(activity.spec):dismissed 不拉取不渲染、 close 写 sessionStorage 并清空轮播两用例(getPublicAds 入替身防真实 fetch) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,14 @@
|
||||
// ⑮ 权限:非 admin 全端点 403(list/create/patch/delete);匿名 401;
|
||||
// ⑯ 同步贯通(衔接 Task 5 的 prismaOv 断言):创建→drainSender→ads_ov 行
|
||||
// 就位;停用→drainSender→行删;删除→drainSender→行删(账本收敛墓碑版)。
|
||||
//
|
||||
// 终审修复追加段(verifyStaging 硬化 + heic 白名单收窄):
|
||||
// ⑰ 非 ads/staging 前缀键(照片域路径冒充 staging,真实 PUT 对象后创建)→
|
||||
// 400 VALIDATION_FAILED——防任意域对象被提升为公开广告;
|
||||
// 合法前缀 + 实际 2MB+1 字节(init 申明管不住 PUT 体)→ 400
|
||||
// VALIDATION_FAILED(报文含限额值——create 是运行时上限唯一核验点);
|
||||
// ⑧ 追加 heic 矩阵:contentType=image/heic 与 .heic 文件名(类型合法)均
|
||||
// 400(白名单收窄为 jpeg/png,heic 桌面浏览器不渲染)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
@@ -57,7 +65,11 @@ import {
|
||||
type AdUpdatedPayload,
|
||||
ErrorCodes,
|
||||
} from '@photo/contracts';
|
||||
import { createStorageFromEnv, type StoragePort } from '@photo/storage';
|
||||
import {
|
||||
OBJECT_KEY_ROOT,
|
||||
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';
|
||||
@@ -532,6 +544,26 @@ describe('ads domain (e2e)', () => {
|
||||
});
|
||||
expect(noExt.status).toBe(400);
|
||||
expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
// heic 收窄(终审 Fix 3):contentType 非广告白名单 → DTO @IsIn 拒绝。
|
||||
const heicType = await post({
|
||||
filename: 'a.heic',
|
||||
bytes: 100,
|
||||
contentType: 'image/heic',
|
||||
});
|
||||
expect(heicType.status).toBe(400);
|
||||
expect(heicType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
// heic 文件名 + 合法 contentType:共享 extOfFilename 仍认 heic(照片域
|
||||
// 白名单),init 以广告域 AD_IMAGE_EXTENSIONS 二次核验拒绝(杜绝 init
|
||||
// 放行 .heic staging 键 → create 中段断裂)。
|
||||
const heicExt = await post({
|
||||
filename: 'a.heic',
|
||||
bytes: 100,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(heicExt.status).toBe(400);
|
||||
expect(heicExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
});
|
||||
|
||||
// ── Task 7 追加段:/v1/admin/ads CRUD(cn HTTP + outbox + audit)──
|
||||
@@ -947,4 +979,71 @@ describe('ads domain (e2e)', () => {
|
||||
).toBeNull();
|
||||
await expectOvAppliedVersion(adId, 3);
|
||||
});
|
||||
|
||||
// ── 终审修复追加段:verifyStaging 硬化(键前缀 + 运行时字节上限)──
|
||||
|
||||
it('⑰ 非 ads/staging 前缀键 / 超 2MB 实际字节 → 400 VALIDATION_FAILED', async () => {
|
||||
const rowsBefore = await prismaCn.ads.count();
|
||||
|
||||
// (i) 照片域路径冒充 staging:扩展名/字节/类型三核全会过,仅键前缀不符
|
||||
//(防照片域 staging/原图/banner 等任意键被提升为公开广告)。绕过 init
|
||||
// 端点、以测试 storage 直接 presignPut 后 PUT 真实对象,再走创建。
|
||||
const foreignKey = `${OBJECT_KEY_ROOT}/albums/fake/staging/x.jpg`;
|
||||
const foreignCred = await storage.presignPut(
|
||||
foreignKey,
|
||||
100,
|
||||
'image/jpeg',
|
||||
300,
|
||||
);
|
||||
const putForeign = await fetch(foreignCred.url, {
|
||||
method: foreignCred.method,
|
||||
headers: foreignCred.headers,
|
||||
body: new Uint8Array(Buffer.alloc(100, 9)),
|
||||
});
|
||||
expect(putForeign.ok).toBe(true);
|
||||
const foreign = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
linkUrl: 'https://sponsor.example/landing',
|
||||
imageStagingKey: foreignKey,
|
||||
imageBytes: 100,
|
||||
imageContentType: 'image/jpeg',
|
||||
});
|
||||
expect(foreign.status).toBe(400);
|
||||
expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
// (ii) 合法前缀 + 实际 2MB+1 字节:init 的申明字节管不住 PUT 体(预签名
|
||||
// PUT 无服务端强制),create 是运行时上限唯一核验点;申明如实(DTO 无
|
||||
// @Max)仍须 400,报文含限额值。
|
||||
const overKey = `${OBJECT_KEY_ROOT}/ads/staging/e2e-oversize-${randomBytes(4).toString('hex')}.jpg`;
|
||||
const overCred = await storage.presignPut(
|
||||
overKey,
|
||||
AD_UPLOAD_MAX_BYTES + 1,
|
||||
'image/jpeg',
|
||||
300,
|
||||
);
|
||||
const putOver = await fetch(overCred.url, {
|
||||
method: overCred.method,
|
||||
headers: overCred.headers,
|
||||
body: new Uint8Array(Buffer.alloc(AD_UPLOAD_MAX_BYTES + 1, 9)),
|
||||
});
|
||||
expect(putOver.ok).toBe(true);
|
||||
const over = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
linkUrl: 'https://sponsor.example/landing',
|
||||
imageStagingKey: overKey,
|
||||
imageBytes: AD_UPLOAD_MAX_BYTES + 1,
|
||||
imageContentType: 'image/jpeg',
|
||||
});
|
||||
expect(over.status).toBe(400);
|
||||
expect(over.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
// 服务层限额报文含限额值(运行时上限与 init 同一口径)。
|
||||
expect(over.body.message).toContain(String(AD_UPLOAD_MAX_BYTES));
|
||||
|
||||
// 两笔拒绝均不落行(失败是请求级错误,DB 存量不受污染)。
|
||||
expect(await prismaCn.ads.count()).toBe(rowsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user