Files
photo-live/services/api/test/emergency.e2e-spec.ts
T

263 lines
8.9 KiB
TypeScript
Raw Normal View History

// 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';
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
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,
});
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
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,
);
});
});