feat(ov): emergency disable channel independent of sync
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>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user