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:
浅念
2026-09-29 02:20:21 +08:00
parent 041cfc77b7
commit bfba76b5e6
13 changed files with 349 additions and 79 deletions
+100 -1
View File
@@ -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);
});
});