eac8da6d6c
P2 Task 5(海外紧急停用入口): - ov 新增 POST /v1/internal/emergency/disable|enable(InternalSyncGuard HMAC,albumToken/albumId 恰好其一、都缺省 400;副本不存在 404 ALBUM_NOT_FOUND):直写 albums_ov.emergencyDisabled(唯一 ov 本地可写 位),独立于同步版本链——更高版本 album.updated 重放不解除(e2e 断言 entityVersion 推进而标志保持),恢复仅经显式 enable;幂等语义 (alreadyDisabled/wasDisabled 入审计行) - cn 侧不感知:无对应 cn 状态位、无反向同步(契约描述文档化) - 审计:pino child(audit=emergency 字段常驻每行)结构化日志——偏差 登记:计划原文「写 ov 侧日志文件」落地为字段标记而非独立文件通道 (免新增文件 transport 依赖,P5 运维按字段路由聚合);注入令牌 EMERGENCY_AUDIT_LOGGER,测试覆写为内存目的地断言字段 - isAlbumVisibleOv 共享判定(status=active && publicEnabled && !emergencyDisabled——镜像 cn 公开语义叠加本地停用位):Task 6 公开 API / Task 7 WS subscribe 的 404 ALBUM_NOT_PUBLIC 语义单一实现, 判定矩阵单测锁定 - 契约 [internal] 增 emergency disable/enable 操作与 schema(gen 已跑、 redocly 通过);e2e:token/id 定位、幂等、enable 恢复、未签名 401、 均缺省 400、未知相册 404、同步事件不解除 Co-Authored-By: Claude Code <noreply@anthropic.com>
259 lines
8.6 KiB
TypeScript
259 lines
8.6 KiB
TypeScript
// services/api/test/emergency.e2e-spec.ts —— P2 Task 5 ov 紧急停用通道 E2E。
|
||
// 进程内起 ov 入口(AppOvModule + rawBody),审计 logger 覆写为内存目的地
|
||
// 的 pino 实例(断言 audit=emergency 字段结构);相册副本直接落 ov 库
|
||
// (不依赖 cn 链路——本通道纯 ov 本地)。覆盖:
|
||
// · disable by albumToken → 200 + albums_ov.emergencyDisabled=true + 审计行;
|
||
// · disable by albumId 幂等(重复返回同结果,审计 alreadyDisabled=true);
|
||
// · enable by albumId 恢复(显式唯一通道);
|
||
// · HMAC 缺头/错签 → 401 SYNC_AUTH_FAILED(与同步端点同一守卫);
|
||
// · albumToken/albumId 均缺省 → 400 VALIDATION_FAILED;未知相册 → 404
|
||
// ALBUM_NOT_FOUND;
|
||
// · 同步事件不得解除停用:album.updated 直推 content-batches(更高版本)
|
||
// → 副本其余字段更新、emergencyDisabled 保持 true。
|
||
import { Test } from '@nestjs/testing';
|
||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||
import { pino } from 'pino';
|
||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||
import { ErrorCodes, signSyncBody } from '@photo/contracts';
|
||
import { AppOvModule } from '../src/app-ov.module';
|
||
import { configureApp } from '../src/shared/configure-app';
|
||
import { EMERGENCY_AUDIT_LOGGER } from '../src/emergency/emergency.provider';
|
||
import { resetOvDb } from './reset-ov';
|
||
|
||
jest.setTimeout(60_000);
|
||
|
||
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||
const ALBUM_ID = 'e2e-emergency-album';
|
||
const PUBLIC_TOKEN = 'emergency-token-01';
|
||
|
||
interface SignOverrides {
|
||
signature?: string;
|
||
omitSignature?: boolean;
|
||
omitTimestamp?: boolean;
|
||
}
|
||
|
||
async function signedPost(
|
||
base: string,
|
||
path: string,
|
||
body: unknown,
|
||
overrides: SignOverrides = {},
|
||
): Promise<Response> {
|
||
const raw = JSON.stringify(body);
|
||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||
if (!overrides.omitTimestamp) {
|
||
headers['x-sync-timestamp'] = String(Math.floor(Date.now() / 1000));
|
||
}
|
||
if (!overrides.omitSignature) {
|
||
headers['x-sync-signature'] =
|
||
overrides.signature ?? signSyncBody(SYNC_KEY, raw);
|
||
}
|
||
return fetch(`${base}${path}`, { method: 'POST', headers, body: raw });
|
||
}
|
||
|
||
describe('ov emergency disable channel (e2e)', () => {
|
||
let ovApp: NestExpressApplication;
|
||
let ovBase: string;
|
||
let prismaOv: OvPrismaClient;
|
||
let auditLines: string[];
|
||
|
||
beforeAll(async () => {
|
||
expect(SYNC_KEY).not.toBe('');
|
||
await resetOvDb();
|
||
|
||
auditLines = [];
|
||
// 与默认 provider 同构(child 携带 audit=emergency 常驻字段),仅输出
|
||
// 目的地换成内存收集。
|
||
const auditLogger = pino(
|
||
{ level: 'info' },
|
||
{ write: (line: string) => void auditLines.push(line) },
|
||
).child({ audit: 'emergency' });
|
||
// overrideProvider 在 TestingModule 上(NestFactory 产物没有)——审计
|
||
// logger 以内存目的地实例覆写,断言 audit=emergency 字段。
|
||
const moduleRef = await Test.createTestingModule({
|
||
imports: [AppOvModule],
|
||
})
|
||
.overrideProvider(EMERGENCY_AUDIT_LOGGER)
|
||
.useValue(auditLogger)
|
||
.compile();
|
||
ovApp =
|
||
moduleRef.createNestApplication<NestExpressApplication>({
|
||
rawBody: true,
|
||
logger: false,
|
||
});
|
||
configureApp(ovApp);
|
||
await ovApp.listen(0);
|
||
ovBase = await ovApp.getUrl();
|
||
|
||
prismaOv = new OvPrismaClient();
|
||
await prismaOv.albums_ov.create({
|
||
data: {
|
||
albumId: ALBUM_ID,
|
||
publicToken: PUBLIC_TOKEN,
|
||
status: 'active',
|
||
publicEnabled: true,
|
||
downloadEnabled: true,
|
||
originalDownloadEnabled: false,
|
||
emergencyDisabled: false,
|
||
translations: [],
|
||
categories: [],
|
||
defaultLocale: 'zh-CN',
|
||
timezone: 'Asia/Shanghai',
|
||
startAt: new Date('2026-10-01T10:00:00.000Z'),
|
||
endAt: null,
|
||
entityVersion: 1,
|
||
},
|
||
});
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await ovApp.close();
|
||
await prismaOv.$disconnect();
|
||
});
|
||
|
||
it('未签名请求 → 401 SYNC_AUTH_FAILED(与同步端点同一 HMAC 守卫)', async () => {
|
||
const res = await signedPost(
|
||
ovBase,
|
||
'/v1/internal/emergency/disable',
|
||
{ albumToken: PUBLIC_TOKEN, reason: 'x' },
|
||
{ omitSignature: true, omitTimestamp: true },
|
||
);
|
||
expect(res.status).toBe(401);
|
||
expect(((await res.json()) as { code: string }).code).toBe(
|
||
ErrorCodes.SYNC_AUTH_FAILED,
|
||
);
|
||
expect(
|
||
(
|
||
await prismaOv.albums_ov.findUniqueOrThrow({
|
||
where: { albumId: ALBUM_ID },
|
||
})
|
||
).emergencyDisabled,
|
||
).toBe(false);
|
||
});
|
||
|
||
it('disable by albumToken → 200 + 置位 + 审计行(audit=emergency)', async () => {
|
||
const res = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
||
albumToken: PUBLIC_TOKEN,
|
||
reason: 'content review',
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(await res.json()).toEqual({ disabled: true, albumId: ALBUM_ID });
|
||
expect(
|
||
(
|
||
await prismaOv.albums_ov.findUniqueOrThrow({
|
||
where: { albumId: ALBUM_ID },
|
||
})
|
||
).emergencyDisabled,
|
||
).toBe(true);
|
||
|
||
expect(auditLines.length).toBe(1);
|
||
const audit = JSON.parse(auditLines[0]) as Record<string, unknown>;
|
||
expect(audit).toMatchObject({
|
||
audit: 'emergency',
|
||
action: 'disable',
|
||
albumId: ALBUM_ID,
|
||
reason: 'content review',
|
||
alreadyDisabled: false,
|
||
});
|
||
});
|
||
|
||
it('disable by albumId 幂等:重复停用返回同结果,审计 alreadyDisabled=true', async () => {
|
||
const res = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
||
albumId: ALBUM_ID,
|
||
reason: 'repeat',
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(await res.json()).toEqual({ disabled: true, albumId: ALBUM_ID });
|
||
|
||
expect(auditLines.length).toBe(2);
|
||
expect(JSON.parse(auditLines[1])).toMatchObject({
|
||
audit: 'emergency',
|
||
action: 'disable',
|
||
alreadyDisabled: true,
|
||
});
|
||
});
|
||
|
||
it('同步事件不得解除停用:更高版本 album.updated 直推 → 标志保持 true', async () => {
|
||
const res = await signedPost(ovBase, '/v1/internal/sync/content-batches', {
|
||
batch: [
|
||
{
|
||
eventId: 'e2e-emergency-sync-1',
|
||
schemaVersion: 1,
|
||
entityType: 'album',
|
||
entityId: ALBUM_ID,
|
||
albumId: ALBUM_ID,
|
||
entityVersion: 2,
|
||
tombstone: false,
|
||
priority: 'normal',
|
||
payload: {
|
||
albumId: ALBUM_ID,
|
||
publicToken: PUBLIC_TOKEN,
|
||
status: 'active',
|
||
uploadEnabled: true,
|
||
publicEnabled: true,
|
||
downloadEnabled: true,
|
||
originalDownloadEnabled: false,
|
||
defaultLocale: 'zh-CN',
|
||
timezone: 'Asia/Shanghai',
|
||
startAt: '2026-10-01T10:00:00.000Z',
|
||
endAt: null,
|
||
translations: [],
|
||
entityVersion: 2,
|
||
},
|
||
},
|
||
],
|
||
});
|
||
expect(res.status).toBe(200);
|
||
// 副本推进到 v2(其余字段照常同步),紧急停用位不受任何同步事件影响。
|
||
expect(
|
||
await prismaOv.albums_ov.findUniqueOrThrow({
|
||
where: { albumId: ALBUM_ID },
|
||
}),
|
||
).toMatchObject({ entityVersion: 2, emergencyDisabled: true });
|
||
});
|
||
|
||
it('enable by albumId → 显式恢复(唯一解除通道)', async () => {
|
||
const res = await signedPost(ovBase, '/v1/internal/emergency/enable', {
|
||
albumId: ALBUM_ID,
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(await res.json()).toEqual({ disabled: false, albumId: ALBUM_ID });
|
||
expect(
|
||
(
|
||
await prismaOv.albums_ov.findUniqueOrThrow({
|
||
where: { albumId: ALBUM_ID },
|
||
})
|
||
).emergencyDisabled,
|
||
).toBe(false);
|
||
|
||
const enableAudit = auditLines
|
||
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||
.find((line) => line.action === 'enable');
|
||
expect(enableAudit).toMatchObject({
|
||
audit: 'emergency',
|
||
albumId: ALBUM_ID,
|
||
wasDisabled: true,
|
||
});
|
||
});
|
||
|
||
it('albumToken 与 albumId 均缺省 → 400 VALIDATION_FAILED', async () => {
|
||
const res = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
||
reason: 'no target',
|
||
});
|
||
expect(res.status).toBe(400);
|
||
expect(((await res.json()) as { code: string }).code).toBe(
|
||
ErrorCodes.VALIDATION_FAILED,
|
||
);
|
||
});
|
||
|
||
it('未知相册 → 404 ALBUM_NOT_FOUND', async () => {
|
||
const res = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
||
albumToken: 'never-synced-token',
|
||
reason: 'x',
|
||
});
|
||
expect(res.status).toBe(404);
|
||
expect(((await res.json()) as { code: string }).code).toBe(
|
||
ErrorCodes.ALBUM_NOT_FOUND,
|
||
);
|
||
});
|
||
});
|