Files
photo-live/services/api/test/emergency.e2e-spec.ts
T
浅念 eac8da6d6c 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>
2026-09-18 16:19:02 +08:00

259 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
);
});
});