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