feat: 开放QQBot消息推送管理接口
This commit is contained in:
parent
d64d5aad49
commit
1c865319bf
33
API.md
33
API.md
@ -415,6 +415,39 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id,并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id` 挂载、`SYS_ADMIN`、`apparmor=unconfined`、`seccomp=unconfined` 和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1`;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。派生镜像 entrypoint 会用同一设备 profile 覆盖 QQCore 实际打开的 DMI、boot_id、kernel release/version/proc version、CPU model、uptime、TTY active、mountinfo、`/etc/hosts` 和 `/proc/devices` 等探针;NapCat fork native login 和 core session config 的 `machineId` 与 `systemVersion` 也从该 profile 读取,避免 QQ native 入参和 Docker 可见探针不一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。
|
||||
|
||||
### System Message Push
|
||||
|
||||
所有接口先通过 Admin JWT,再按路由声明的权限码执行 OR 校验;只有启用且未删除的角色/菜单生效,启用且未删除的 `super` 可放行。下表中 `/` 分隔的权限表示任一项即可,写接口始终只接受该动作自己的权限。
|
||||
|
||||
| 方法 | 路径 | 权限 |
|
||||
| -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/qqbot/message-push/sources` | Subscription `List/Create/Update`;Template `List/Create/Update/Preview`;Account MessagePush `List/Create/Update` |
|
||||
| `GET` | `/qqbot/message-push/sources/:sourceKey` | 同 source list |
|
||||
| `GET` | `/qqbot/message-push/sources/network.stun.mapping-port-changed/options` | Subscription `Create/Update`;Account MessagePush `Create/Update` |
|
||||
| `GET` | `/qqbot/message-push/subscriptions` | Subscription `List`;Account MessagePush `List/Create/Update` |
|
||||
| `POST` | `/qqbot/message-push/subscriptions` | Subscription `Create` |
|
||||
| `PUT` | `/qqbot/message-push/subscriptions/:id` | Subscription `Update` |
|
||||
| `PUT` | `/qqbot/message-push/subscriptions/:id/enabled` | Subscription `Toggle` |
|
||||
| `DELETE` | `/qqbot/message-push/subscriptions/:id` | Subscription `Delete` |
|
||||
| `GET` | `/qqbot/message-push/templates` | Template `List`;Account MessagePush `List/Create/Update` |
|
||||
| `POST` | `/qqbot/message-push/templates` | Template `Create` |
|
||||
| `PUT` | `/qqbot/message-push/templates/:id` | Template `Update` |
|
||||
| `PUT` | `/qqbot/message-push/templates/:id/enabled` | Template `Toggle` |
|
||||
| `DELETE` | `/qqbot/message-push/templates/:id` | Template `Delete` |
|
||||
| `POST` | `/qqbot/message-push/templates/preview` | Template `Preview` |
|
||||
| `GET` | `/qqbot/accounts/:selfId/message-push/bindings` | Account MessagePush `List` |
|
||||
| `POST` | `/qqbot/accounts/:selfId/message-push/bindings` | Account MessagePush `Create` |
|
||||
| `PUT` | `/qqbot/accounts/:selfId/message-push/bindings/:id` | Account MessagePush `Update` |
|
||||
| `PUT` | `/qqbot/accounts/:selfId/message-push/bindings/:id/enabled` | Account MessagePush `Toggle` |
|
||||
| `DELETE` | `/qqbot/accounts/:selfId/message-push/bindings/:id` | Account MessagePush `Delete` |
|
||||
| `GET` | `/qqbot/accounts/:selfId/message-push/targets` | Account MessagePush `Create/Update` |
|
||||
|
||||
完整权限码前缀分别为 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*`。订阅与模板列表返回 `data.items/data.total`,source 与 binding 列表直接返回数组,其余接口返回单个对象或布尔值;全部 `POST` 使用 HTTP 200。账号离线或 OneBot 不可用时 targets 仍返回 HTTP 200 和 `{ available: false, options: [], reasonCode }`。
|
||||
|
||||
请求采用严格白名单:Snowflake/外键 ID 是 1–24 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1–100 字符且不能全空白,`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`;模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1–100 个严格嵌套 target,类型仅 `group/private`,`targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝,query boolean 只接受字面量 `true/false`。
|
||||
|
||||
响应仅返回管理契约字段:source definition/field/variable 白名单;STUN 的 port-forward/DDNS 候选白名单;subscription、template、preview、binding/target 和 target option 视图。不会返回 adapter、entity/repository、`activeKey`、digest、软删除字段、账号内部 ID、事件 payload/delivery/lease/retry 状态、凭据、access token、Provider/OneBot/MQTT 原始对象。系统事件只能通过 Nest 内部 Outbox stager 暂存,不存在 publish、event、delivery、fan-out、retry 或 worker HTTP 发布接口。
|
||||
|
||||
### NapCat Runtime Profile
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、系统消息源/订阅/模板/账号绑定、耐久投递、发送/接收日志和插件平台 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 |
|
||||
@ -84,6 +84,8 @@ System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事
|
||||
|
||||
同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 IPv4 取值,AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值;DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL,并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git;`DNSPOD` 是腾讯云官方 SDK 的技术服务名。
|
||||
|
||||
QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`,Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由;管理响应仅返回字段白名单,不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc/<pid>/mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q <uin>` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。
|
||||
|
||||
运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。
|
||||
|
||||
@ -0,0 +1,196 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { QqbotAccountMessagePushService } from '../../application/message-push/qqbot-account-message-push.service';
|
||||
import { QqbotMessageTargetOptionsService } from '../../application/message-push/qqbot-message-target-options.service';
|
||||
import {
|
||||
AccountMessagePushBindingParamDto,
|
||||
AccountMessagePushParamDto,
|
||||
MessagePublishBindingInputDto,
|
||||
MessagePushEnabledDto,
|
||||
} from './qqbot-message-push.dto';
|
||||
import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator';
|
||||
import { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard';
|
||||
import type {
|
||||
QqbotMessagePublishBindingView,
|
||||
QqbotMessagePushTargetOptionsResponse,
|
||||
} from './qqbot-message-push.types';
|
||||
|
||||
/**
|
||||
* Narrows one account binding to the public management view and target fields.
|
||||
* @param view - Detached account binding service result.
|
||||
* @returns Only the locked binding and nested target response fields.
|
||||
*/
|
||||
const allowlistBinding = (
|
||||
view: QqbotMessagePublishBindingView,
|
||||
): QqbotMessagePublishBindingView => ({
|
||||
available: view.available,
|
||||
createTime: view.createTime,
|
||||
enabled: view.enabled,
|
||||
id: view.id,
|
||||
invalidReasonCode: view.invalidReasonCode,
|
||||
sourceKey: view.sourceKey,
|
||||
sourceName: view.sourceName,
|
||||
subscriptionId: view.subscriptionId,
|
||||
subscriptionName: view.subscriptionName,
|
||||
targets: view.targets.map((target) => ({
|
||||
enabled: target.enabled,
|
||||
id: target.id,
|
||||
targetId: target.targetId,
|
||||
targetName: target.targetName,
|
||||
targetType: target.targetType,
|
||||
})),
|
||||
templateId: view.templateId,
|
||||
templateName: view.templateName,
|
||||
updateTime: view.updateTime,
|
||||
});
|
||||
|
||||
/**
|
||||
* Narrows OneBot candidates to stable labels and string identifiers only.
|
||||
* @param value - Safe target-option service result.
|
||||
* @returns Detached availability result without runtime/provider objects.
|
||||
*/
|
||||
const allowlistTargetOptions = (
|
||||
value: QqbotMessagePushTargetOptionsResponse,
|
||||
): QqbotMessagePushTargetOptionsResponse => ({
|
||||
available: value.available,
|
||||
options: value.options.map((option) => ({
|
||||
label: option.label,
|
||||
targetId: option.targetId,
|
||||
targetType: option.targetType,
|
||||
})),
|
||||
reasonCode: value.reasonCode,
|
||||
});
|
||||
|
||||
@Controller('qqbot/accounts/:selfId/message-push')
|
||||
@UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard)
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
}),
|
||||
)
|
||||
export class QqbotAccountMessagePushController {
|
||||
/**
|
||||
* Initializes strict account-scoped binding and target management routes.
|
||||
* @param bindingService - Account binding lifecycle service.
|
||||
* @param targetOptionsService - HTTP-safe OneBot candidate service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly bindingService: QqbotAccountMessagePushService,
|
||||
private readonly targetOptionsService: QqbotMessageTargetOptionsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Lists binding views for exactly one account.
|
||||
* @param params - Validated QQ account route identity.
|
||||
*/
|
||||
@Get('bindings')
|
||||
@QqbotMessagePushPermission('QqBot:Account:MessagePush:List')
|
||||
async listBindings(@Param() params: AccountMessagePushParamDto) {
|
||||
const views = await this.bindingService.listBindings(params.selfId);
|
||||
return vbenSuccess(views.map(allowlistBinding));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one binding for exactly one account.
|
||||
* @param params - Validated QQ account route identity.
|
||||
* @param body - Strict subscription, template, target, and enabled input.
|
||||
*/
|
||||
@Post('bindings')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@QqbotMessagePushPermission('QqBot:Account:MessagePush:Create')
|
||||
async createBinding(
|
||||
@Param() params: AccountMessagePushParamDto,
|
||||
@Body() body: MessagePublishBindingInputDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistBinding(
|
||||
await this.bindingService.createBinding(params.selfId, body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one binding for exactly one account.
|
||||
* @param params - Validated QQ account and binding route identities.
|
||||
* @param body - Strict subscription, template, target, and enabled input.
|
||||
*/
|
||||
@Put('bindings/:id')
|
||||
@QqbotMessagePushPermission('QqBot:Account:MessagePush:Update')
|
||||
async updateBinding(
|
||||
@Param() params: AccountMessagePushBindingParamDto,
|
||||
@Body() body: MessagePublishBindingInputDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistBinding(
|
||||
await this.bindingService.updateBinding(params.selfId, params.id, body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one binding for exactly one account.
|
||||
* @param params - Validated QQ account and binding route identities.
|
||||
* @param body - Required JSON boolean state.
|
||||
*/
|
||||
@Put('bindings/:id/enabled')
|
||||
@QqbotMessagePushPermission('QqBot:Account:MessagePush:Toggle')
|
||||
async toggleBinding(
|
||||
@Param() params: AccountMessagePushBindingParamDto,
|
||||
@Body() body: MessagePushEnabledDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistBinding(
|
||||
await this.bindingService.setBindingEnabled(
|
||||
params.selfId,
|
||||
params.id,
|
||||
body.enabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes one binding for exactly one account.
|
||||
* @param params - Validated QQ account and binding route identities.
|
||||
*/
|
||||
@Delete('bindings/:id')
|
||||
@QqbotMessagePushPermission('QqBot:Account:MessagePush:Delete')
|
||||
async removeBinding(@Param() params: AccountMessagePushBindingParamDto) {
|
||||
return vbenSuccess(
|
||||
await this.bindingService.removeBinding(params.selfId, params.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists HTTP-safe group and friend candidates for exactly one account.
|
||||
* @param params - Validated QQ account route identity.
|
||||
*/
|
||||
@Get('targets')
|
||||
@QqbotMessagePushPermission(
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
)
|
||||
async listTargets(@Param() params: AccountMessagePushParamDto) {
|
||||
return vbenSuccess(
|
||||
allowlistTargetOptions(
|
||||
await this.targetOptionsService.listTargetOptions(params.selfId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const QQBOT_MESSAGE_PUSH_PERMISSION = 'qqbot_message_push_permission';
|
||||
|
||||
/**
|
||||
* Declares route auth codes using OR semantics for the message-push permission guard.
|
||||
* @param authCodes - Exact active Admin menu auth codes accepted by the route.
|
||||
* @returns Nest route metadata decorator consumed after JWT authentication.
|
||||
*/
|
||||
export const QqbotMessagePushPermission = (...authCodes: string[]) =>
|
||||
SetMetadata(QQBOT_MESSAGE_PUSH_PERMISSION, authCodes);
|
||||
@ -0,0 +1,63 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { throwVbenError } from '@/common';
|
||||
import type { AdminRequest } from '@/modules/admin/contract/admin.types';
|
||||
import { QQBOT_MESSAGE_PUSH_PERMISSION } from './qqbot-message-push-permission.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class QqbotMessagePushPermissionGuard implements CanActivate {
|
||||
/**
|
||||
* Initializes the fail-closed route permission guard.
|
||||
* @param reflector - Nest metadata reader used for handler and controller permissions.
|
||||
*/
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
/**
|
||||
* Allows an active super role or any exact active menu code declared by the route.
|
||||
* @param context - Current authenticated HTTP request execution context.
|
||||
* @returns `true` when the request's database-backed Admin roles grant the route.
|
||||
* @throws {HttpException} Vben-safe HTTP 403 for missing metadata, user, or permission.
|
||||
*/
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(
|
||||
QQBOT_MESSAGE_PUSH_PERMISSION,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!required?.length) this.forbidden();
|
||||
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const activeRoles = (request.adminUser?.roles || []).filter(
|
||||
(role) => !role.isDeleted && role.status === 1,
|
||||
);
|
||||
if (activeRoles.some((role) => role.roleCode === 'super')) return true;
|
||||
|
||||
const authCodes = new Set(
|
||||
activeRoles.flatMap((role) =>
|
||||
(role.menus || [])
|
||||
.filter(
|
||||
(menu) =>
|
||||
!menu.isDeleted &&
|
||||
menu.status === 1 &&
|
||||
typeof menu.authCode === 'string' &&
|
||||
menu.authCode.length > 0,
|
||||
)
|
||||
.map((menu) => menu.authCode),
|
||||
),
|
||||
);
|
||||
if (required.some((authCode) => authCodes.has(authCode))) return true;
|
||||
this.forbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws the project-standard non-sensitive authorization response.
|
||||
* @throws {HttpException} Always throws HTTP 403.
|
||||
*/
|
||||
private forbidden(): never {
|
||||
return throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,383 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { vbenPage, vbenSuccess } from '@/common';
|
||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { QqbotMessageSubscriptionService } from '../../application/message-push/qqbot-message-subscription.service';
|
||||
import { QqbotMessageTemplateService } from '../../application/message-push/qqbot-message-template.service';
|
||||
import { SystemMessageSourceRegistry } from '../../application/message-push/system-message-source.registry';
|
||||
import type {
|
||||
MessageSubscriptionView,
|
||||
MessageTemplatePreview,
|
||||
MessageTemplateView,
|
||||
StunMappingPortChangedOptionsResponse,
|
||||
SystemMessageSourceDefinition,
|
||||
} from './qqbot-message-push.types';
|
||||
import {
|
||||
MessagePushEnabledDto,
|
||||
MessagePushIdParamDto,
|
||||
MessagePushSourceParamDto,
|
||||
MessageSubscriptionInputDto,
|
||||
MessageSubscriptionListQueryDto,
|
||||
MessageTemplateInputDto,
|
||||
MessageTemplateListQueryDto,
|
||||
MessageTemplatePreviewDto,
|
||||
} from './qqbot-message-push.dto';
|
||||
import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator';
|
||||
import { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard';
|
||||
|
||||
const SOURCE_READ_PERMISSIONS = [
|
||||
'QqBot:MessageSubscription:List',
|
||||
'QqBot:MessageSubscription:Create',
|
||||
'QqBot:MessageSubscription:Update',
|
||||
'QqBot:MessageTemplate:List',
|
||||
'QqBot:MessageTemplate:Create',
|
||||
'QqBot:MessageTemplate:Update',
|
||||
'QqBot:MessageTemplate:Preview',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Returns an allowlisted detached source definition without exposing its adapter.
|
||||
* @param definition - Registered source definition owned by an internal adapter.
|
||||
* @returns A cloned definition containing only the public management contract.
|
||||
*/
|
||||
const cloneSourceDefinition = (
|
||||
definition: SystemMessageSourceDefinition,
|
||||
): SystemMessageSourceDefinition => ({
|
||||
description: definition.description,
|
||||
displayName: definition.displayName,
|
||||
sourceKey: definition.sourceKey,
|
||||
subscriptionFields: definition.subscriptionFields.map((field) => ({
|
||||
...(field.dependsOn === undefined ? {} : { dependsOn: field.dependsOn }),
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
optionCollection: field.optionCollection,
|
||||
required: field.required,
|
||||
type: field.type,
|
||||
})),
|
||||
variables: definition.variables.map((variable) => ({
|
||||
description: variable.description,
|
||||
example: variable.example,
|
||||
key: variable.key,
|
||||
label: variable.label,
|
||||
type: variable.type,
|
||||
})),
|
||||
version: definition.version,
|
||||
});
|
||||
|
||||
/**
|
||||
* Narrows weakly typed adapter options to the locked STUN management response.
|
||||
* @param value - Source adapter result after its domain-side eligibility checks.
|
||||
* @returns Detached port-forward and DDNS option rows with public fields only.
|
||||
*/
|
||||
const allowlistStunOptions = (
|
||||
value: Record<string, unknown>,
|
||||
): StunMappingPortChangedOptionsResponse => {
|
||||
const input = value as unknown as StunMappingPortChangedOptionsResponse;
|
||||
return {
|
||||
ddnsRecords: input.ddnsRecords.map((item) => ({
|
||||
disabledReasonCode: item.disabledReasonCode,
|
||||
eligible: item.eligible,
|
||||
fqdn: item.fqdn,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
portForwardId: item.portForwardId,
|
||||
})),
|
||||
portForwards: input.portForwards.map((item) => ({
|
||||
disabledReasonCode: item.disabledReasonCode,
|
||||
eligible: item.eligible,
|
||||
externalPort: item.externalPort,
|
||||
id: item.id,
|
||||
internalPort: item.internalPort,
|
||||
name: item.name,
|
||||
protocol: item.protocol,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrows one subscription result to the public management view.
|
||||
* @param view - Detached service result that may evolve independently of HTTP.
|
||||
* @returns Only the locked subscription response fields and STUN config keys.
|
||||
*/
|
||||
const allowlistSubscription = (
|
||||
view: MessageSubscriptionView,
|
||||
): MessageSubscriptionView => ({
|
||||
createTime: view.createTime,
|
||||
enabled: view.enabled,
|
||||
id: view.id,
|
||||
invalidReasonCode: view.invalidReasonCode,
|
||||
name: view.name,
|
||||
remark: view.remark,
|
||||
sourceConfig: {
|
||||
ddnsRecordId: view.sourceConfig.ddnsRecordId,
|
||||
portForwardId: view.sourceConfig.portForwardId,
|
||||
},
|
||||
sourceKey: view.sourceKey,
|
||||
sourceName: view.sourceName,
|
||||
sourceSummary: view.sourceSummary,
|
||||
updateTime: view.updateTime,
|
||||
valid: view.valid,
|
||||
});
|
||||
|
||||
/**
|
||||
* Narrows one template result to the public management view.
|
||||
* @param view - Detached service result that may evolve independently of HTTP.
|
||||
* @returns Only the locked template response fields.
|
||||
*/
|
||||
const allowlistTemplate = (view: MessageTemplateView): MessageTemplateView => ({
|
||||
content: view.content,
|
||||
createTime: view.createTime,
|
||||
enabled: view.enabled,
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
referenceCount: view.referenceCount,
|
||||
remark: view.remark,
|
||||
sourceKey: view.sourceKey,
|
||||
sourceName: view.sourceName,
|
||||
updateTime: view.updateTime,
|
||||
});
|
||||
|
||||
/**
|
||||
* Narrows a renderer preview to the public message and example variable values.
|
||||
* @param preview - Template service preview result.
|
||||
* @returns Detached preview without parser or event internals.
|
||||
*/
|
||||
const allowlistPreview = (
|
||||
preview: MessageTemplatePreview,
|
||||
): MessageTemplatePreview => ({
|
||||
renderedMessage: preview.renderedMessage,
|
||||
variables: structuredClone(preview.variables),
|
||||
});
|
||||
|
||||
@Controller('qqbot/message-push')
|
||||
@UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard)
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
}),
|
||||
)
|
||||
export class QqbotMessagePushController {
|
||||
/**
|
||||
* Initializes global source, subscription, and template management routes.
|
||||
* @param sourceRegistry - Process-local public source definitions and adapters.
|
||||
* @param subscriptionService - Global subscription lifecycle service.
|
||||
* @param templateService - Global template lifecycle and preview service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sourceRegistry: SystemMessageSourceRegistry,
|
||||
private readonly subscriptionService: QqbotMessageSubscriptionService,
|
||||
private readonly templateService: QqbotMessageTemplateService,
|
||||
) {}
|
||||
|
||||
/** Lists detached source definitions available to authorized management flows. */
|
||||
@Get('sources')
|
||||
@QqbotMessagePushPermission(...SOURCE_READ_PERMISSIONS)
|
||||
listSources() {
|
||||
return vbenSuccess(this.sourceRegistry.list().map(cloneSourceDefinition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the concrete STUN configuration candidates without provider state.
|
||||
*/
|
||||
@Get('sources/network.stun.mapping-port-changed/options')
|
||||
@QqbotMessagePushPermission(
|
||||
'QqBot:MessageSubscription:Create',
|
||||
'QqBot:MessageSubscription:Update',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
)
|
||||
async listStunSourceOptions() {
|
||||
const result = await this.sourceRegistry
|
||||
.get('network.stun.mapping-port-changed')
|
||||
.listSubscriptionOptions();
|
||||
return vbenSuccess(allowlistStunOptions(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one known public source definition.
|
||||
* @param params - Validated source key route parameters.
|
||||
*/
|
||||
@Get('sources/:sourceKey')
|
||||
@QqbotMessagePushPermission(...SOURCE_READ_PERMISSIONS)
|
||||
sourceDetail(@Param() params: MessagePushSourceParamDto) {
|
||||
const definition = this.sourceRegistry.get(params.sourceKey).definition;
|
||||
return vbenSuccess(cloneSourceDefinition(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages global subscriptions.
|
||||
* @param query - Strict name, source, enabled, and pagination filters.
|
||||
*/
|
||||
@Get('subscriptions')
|
||||
@QqbotMessagePushPermission(
|
||||
'QqBot:MessageSubscription:List',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
)
|
||||
async pageSubscriptions(@Query() query: MessageSubscriptionListQueryDto) {
|
||||
const page = await this.subscriptionService.page(query);
|
||||
return vbenPage(page.items.map(allowlistSubscription), page.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one global subscription.
|
||||
* @param body - Complete strict STUN subscription input.
|
||||
*/
|
||||
@Post('subscriptions')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@QqbotMessagePushPermission('QqBot:MessageSubscription:Create')
|
||||
async createSubscription(@Body() body: MessageSubscriptionInputDto) {
|
||||
return vbenSuccess(
|
||||
allowlistSubscription(await this.subscriptionService.create(body)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one global subscription.
|
||||
* @param params - Validated string Snowflake route identity.
|
||||
* @param body - Complete strict STUN subscription input.
|
||||
*/
|
||||
@Put('subscriptions/:id')
|
||||
@QqbotMessagePushPermission('QqBot:MessageSubscription:Update')
|
||||
async updateSubscription(
|
||||
@Param() params: MessagePushIdParamDto,
|
||||
@Body() body: MessageSubscriptionInputDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistSubscription(
|
||||
await this.subscriptionService.update(params.id, body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one global subscription.
|
||||
* @param params - Validated string Snowflake route identity.
|
||||
* @param body - Required JSON boolean state.
|
||||
*/
|
||||
@Put('subscriptions/:id/enabled')
|
||||
@QqbotMessagePushPermission('QqBot:MessageSubscription:Toggle')
|
||||
async toggleSubscription(
|
||||
@Param() params: MessagePushIdParamDto,
|
||||
@Body() body: MessagePushEnabledDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistSubscription(
|
||||
await this.subscriptionService.setEnabled(params.id, body.enabled),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes one global subscription.
|
||||
* @param params - Validated string Snowflake route identity.
|
||||
*/
|
||||
@Delete('subscriptions/:id')
|
||||
@QqbotMessagePushPermission('QqBot:MessageSubscription:Delete')
|
||||
async removeSubscription(@Param() params: MessagePushIdParamDto) {
|
||||
return vbenSuccess(await this.subscriptionService.remove(params.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages global templates.
|
||||
* @param query - Strict name, source, enabled, and pagination filters.
|
||||
*/
|
||||
@Get('templates')
|
||||
@QqbotMessagePushPermission(
|
||||
'QqBot:MessageTemplate:List',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
)
|
||||
async pageTemplates(@Query() query: MessageTemplateListQueryDto) {
|
||||
const page = await this.templateService.page(query);
|
||||
return vbenPage(page.items.map(allowlistTemplate), page.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one global template.
|
||||
* @param body - Complete strict source template input.
|
||||
*/
|
||||
@Post('templates')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@QqbotMessagePushPermission('QqBot:MessageTemplate:Create')
|
||||
async createTemplate(@Body() body: MessageTemplateInputDto) {
|
||||
return vbenSuccess(
|
||||
allowlistTemplate(await this.templateService.create(body)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one global template.
|
||||
* @param params - Validated string Snowflake route identity.
|
||||
* @param body - Complete strict source template input.
|
||||
*/
|
||||
@Put('templates/:id')
|
||||
@QqbotMessagePushPermission('QqBot:MessageTemplate:Update')
|
||||
async updateTemplate(
|
||||
@Param() params: MessagePushIdParamDto,
|
||||
@Body() body: MessageTemplateInputDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistTemplate(await this.templateService.update(params.id, body)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one global template.
|
||||
* @param params - Validated string Snowflake route identity.
|
||||
* @param body - Required JSON boolean state.
|
||||
*/
|
||||
@Put('templates/:id/enabled')
|
||||
@QqbotMessagePushPermission('QqBot:MessageTemplate:Toggle')
|
||||
async toggleTemplate(
|
||||
@Param() params: MessagePushIdParamDto,
|
||||
@Body() body: MessagePushEnabledDto,
|
||||
) {
|
||||
return vbenSuccess(
|
||||
allowlistTemplate(
|
||||
await this.templateService.setEnabled(params.id, body.enabled),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes one unreferenced global template.
|
||||
* @param params - Validated string Snowflake route identity.
|
||||
*/
|
||||
@Delete('templates/:id')
|
||||
@QqbotMessagePushPermission('QqBot:MessageTemplate:Delete')
|
||||
async removeTemplate(@Param() params: MessagePushIdParamDto) {
|
||||
return vbenSuccess(await this.templateService.remove(params.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely renders one unsaved template with source examples.
|
||||
* @param body - Strict source key and content only.
|
||||
*/
|
||||
@Post('templates/preview')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@QqbotMessagePushPermission('QqBot:MessageTemplate:Preview')
|
||||
previewTemplate(@Body() body: MessageTemplatePreviewDto) {
|
||||
return vbenSuccess(allowlistPreview(this.templateService.preview(body)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,201 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDefined,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
|
||||
export const QQ_TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/;
|
||||
const SOURCE_KEY_MAX_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* Converts only literal query booleans, leaving every other value for `IsBoolean` to reject.
|
||||
* @param value - Raw query-string value supplied by Nest.
|
||||
* @returns A boolean for `true`/`false`, otherwise the original invalid value.
|
||||
*/
|
||||
const strictQueryBoolean = ({ value }: { value: unknown }): unknown => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class MessagePushSourceParamDto {
|
||||
@IsString()
|
||||
@Length(1, SOURCE_KEY_MAX_LENGTH)
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
export class MessagePushIdParamDto {
|
||||
@IsString()
|
||||
@Matches(SNOWFLAKE_ID_PATTERN)
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class AccountMessagePushParamDto {
|
||||
@IsString()
|
||||
@Matches(QQ_TARGET_ID_PATTERN)
|
||||
selfId: string;
|
||||
}
|
||||
|
||||
export class AccountMessagePushBindingParamDto extends AccountMessagePushParamDto {
|
||||
@IsString()
|
||||
@Matches(SNOWFLAKE_ID_PATTERN)
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class MessagePushListQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageNo?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, SOURCE_KEY_MAX_LENGTH)
|
||||
sourceKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(strictQueryBoolean)
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class MessageSubscriptionListQueryDto extends MessagePushListQueryDto {}
|
||||
|
||||
export class MessageTemplateListQueryDto extends MessagePushListQueryDto {}
|
||||
|
||||
export class StunMappingPortChangedSubscriptionConfigDto {
|
||||
[key: string]: unknown;
|
||||
|
||||
@IsString()
|
||||
@Matches(SNOWFLAKE_ID_PATTERN)
|
||||
portForwardId: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(SNOWFLAKE_ID_PATTERN)
|
||||
ddnsRecordId: string;
|
||||
}
|
||||
|
||||
export class MessageSubscriptionInputDto {
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
@Matches(/\S/)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, SOURCE_KEY_MAX_LENGTH)
|
||||
sourceKey: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => StunMappingPortChangedSubscriptionConfigDto)
|
||||
@IsDefined()
|
||||
@IsObject()
|
||||
sourceConfig: StunMappingPortChangedSubscriptionConfigDto;
|
||||
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class MessageTemplateInputDto {
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
@Matches(/\S/)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, SOURCE_KEY_MAX_LENGTH)
|
||||
sourceKey: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
content: string;
|
||||
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class MessageTemplatePreviewDto {
|
||||
@IsString()
|
||||
@Length(1, SOURCE_KEY_MAX_LENGTH)
|
||||
sourceKey: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class MessagePushEnabledDto {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class MessagePublishTargetInputDto {
|
||||
@IsIn(['group', 'private'])
|
||||
targetType: 'group' | 'private';
|
||||
|
||||
@IsString()
|
||||
@Matches(QQ_TARGET_ID_PATTERN)
|
||||
targetId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
targetName?: string;
|
||||
}
|
||||
|
||||
export class MessagePublishBindingInputDto {
|
||||
@IsString()
|
||||
@Matches(SNOWFLAKE_ID_PATTERN)
|
||||
subscriptionId: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(SNOWFLAKE_ID_PATTERN)
|
||||
templateId: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MessagePublishTargetInputDto)
|
||||
targets: MessagePublishTargetInputDto[];
|
||||
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
}
|
||||
@ -61,6 +61,9 @@ import {
|
||||
SYSTEM_MESSAGE_DELIVERY_COORDINATOR,
|
||||
SYSTEM_MESSAGE_EVENT_STAGER,
|
||||
} from './contract/message-push/qqbot-message-push.types';
|
||||
import { QqbotMessagePushController } from './contract/message-push/qqbot-message-push.controller';
|
||||
import { QqbotAccountMessagePushController } from './contract/message-push/qqbot-account-message-push.controller';
|
||||
import { QqbotMessagePushPermissionGuard } from './contract/message-push/qqbot-message-push-permission.guard';
|
||||
|
||||
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||
|
||||
@ -87,9 +90,11 @@ export const QQBOT_CORE_ENTITIES = [
|
||||
|
||||
export const QQBOT_CORE_CONTROLLERS = [
|
||||
QqbotAccountController,
|
||||
QqbotAccountMessagePushController,
|
||||
QqbotCommandController,
|
||||
QqbotDashboardController,
|
||||
QqbotMessageController,
|
||||
QqbotMessagePushController,
|
||||
QqbotPermissionController,
|
||||
QqbotRuleController,
|
||||
QqbotSendController,
|
||||
@ -114,6 +119,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
||||
QqbotMessageTemplateService,
|
||||
QqbotAccountMessagePushService,
|
||||
QqbotMessageTargetOptionsService,
|
||||
QqbotMessagePushPermissionGuard,
|
||||
QqbotAccountService,
|
||||
QqbotBusService,
|
||||
QqbotCommandEngineService,
|
||||
|
||||
@ -17,12 +17,28 @@ Primary tables:
|
||||
- `qqbot_blocklist`
|
||||
- `qqbot_rule`
|
||||
- `qqbot_send_log`
|
||||
- `qqbot_message_subscription`
|
||||
- `qqbot_message_template`
|
||||
- `qqbot_message_publish_binding`
|
||||
- `qqbot_message_publish_target`
|
||||
- `qqbot_message_event`
|
||||
- `qqbot_message_delivery`
|
||||
|
||||
Seed linkage:
|
||||
|
||||
- Online command rows bind to accounts through `qqbot_account_ability`.
|
||||
- Core command rows refer to plugin capabilities by `plugin_key` and
|
||||
`operation_key`; execution is delegated through `QQBOT_PLUGIN_EXECUTION_PORT`.
|
||||
- A subscription owns one canonical active source/config key; soft deletion
|
||||
clears that unique key so historical rows remain available.
|
||||
- A live publish binding links one account and subscription to one compatible
|
||||
template. Any live binding prevents its template from being deleted.
|
||||
- Binding `active_key` uniquely identifies account/subscription ownership;
|
||||
target `active_key` uniquely identifies binding/type/target ownership.
|
||||
- Event `event_id` is globally unique. Delivery uniqueness on
|
||||
`(message_event_id, publish_target_id)` makes fan-out idempotent.
|
||||
- Event and delivery dispatch/lease indexes support durable worker claims,
|
||||
retries, lease recovery, and source-resource supersession ordering.
|
||||
|
||||
Verification SQL:
|
||||
|
||||
@ -30,4 +46,34 @@ Verification SQL:
|
||||
SELECT COUNT(*) FROM qqbot_account WHERE is_deleted = 0;
|
||||
SELECT COUNT(*) FROM qqbot_command WHERE is_deleted = 0;
|
||||
SELECT COUNT(*) FROM qqbot_account_ability WHERE is_deleted = 0;
|
||||
SELECT COUNT(*) FROM qqbot_message_subscription WHERE is_deleted = 0;
|
||||
SELECT COUNT(*) FROM qqbot_message_template WHERE is_deleted = 0;
|
||||
SELECT COUNT(*) FROM qqbot_message_publish_binding WHERE is_deleted = 0;
|
||||
SELECT COUNT(*) FROM qqbot_message_publish_target WHERE is_deleted = 0;
|
||||
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (
|
||||
'qqbot_message_subscription',
|
||||
'qqbot_message_template',
|
||||
'qqbot_message_publish_binding',
|
||||
'qqbot_message_publish_target',
|
||||
'qqbot_message_event',
|
||||
'qqbot_message_delivery'
|
||||
)
|
||||
ORDER BY table_name;
|
||||
|
||||
SELECT table_name, index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (
|
||||
'qqbot_message_subscription',
|
||||
'qqbot_message_template',
|
||||
'qqbot_message_publish_binding',
|
||||
'qqbot_message_publish_target',
|
||||
'qqbot_message_event',
|
||||
'qqbot_message_delivery'
|
||||
)
|
||||
ORDER BY table_name, index_name;
|
||||
```
|
||||
|
||||
@ -5,6 +5,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module';
|
||||
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { DictModule } from '../../../../src/modules/admin/platform-config/dict/dict.module';
|
||||
import { AppModule } from '../../../../src/app.module';
|
||||
import { QqbotAccountController } from '../../../../src/modules/qqbot/core/contract/account/qqbot-account.controller';
|
||||
@ -14,6 +15,9 @@ import { QqbotMessageController } from '../../../../src/modules/qqbot/core/contr
|
||||
import { QqbotPermissionController } from '../../../../src/modules/qqbot/core/contract/permission/qqbot-permission.controller';
|
||||
import { QqbotRuleController } from '../../../../src/modules/qqbot/core/contract/rule/qqbot-rule.controller';
|
||||
import { QqbotSendController } from '../../../../src/modules/qqbot/core/contract/send/qqbot-send.controller';
|
||||
import { QqbotAccountMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller';
|
||||
import { QqbotMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller';
|
||||
import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard';
|
||||
import { SystemMessageEventStagerService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-event-stager.service';
|
||||
import { SystemMessageFanoutService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-fanout.service';
|
||||
import { SystemMessageDeliveryCoordinatorService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-delivery-coordinator.service';
|
||||
@ -137,6 +141,41 @@ describe('QQBot core module contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('registers the strict message-push HTTP boundary without internal worker routes', () => {
|
||||
const messagePushRoutes = collectControllerRoutes([
|
||||
QqbotMessagePushController,
|
||||
QqbotAccountMessagePushController,
|
||||
]).map(routeKey);
|
||||
|
||||
expect(messagePushRoutes).toEqual(
|
||||
[
|
||||
'DELETE /qqbot/accounts/:selfId/message-push/bindings/:id',
|
||||
'DELETE /qqbot/message-push/subscriptions/:id',
|
||||
'DELETE /qqbot/message-push/templates/:id',
|
||||
'GET /qqbot/accounts/:selfId/message-push/bindings',
|
||||
'GET /qqbot/accounts/:selfId/message-push/targets',
|
||||
'GET /qqbot/message-push/sources',
|
||||
'GET /qqbot/message-push/sources/:sourceKey',
|
||||
'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options',
|
||||
'GET /qqbot/message-push/subscriptions',
|
||||
'GET /qqbot/message-push/templates',
|
||||
'POST /qqbot/accounts/:selfId/message-push/bindings',
|
||||
'POST /qqbot/message-push/subscriptions',
|
||||
'POST /qqbot/message-push/templates',
|
||||
'POST /qqbot/message-push/templates/preview',
|
||||
'PUT /qqbot/accounts/:selfId/message-push/bindings/:id',
|
||||
'PUT /qqbot/accounts/:selfId/message-push/bindings/:id/enabled',
|
||||
'PUT /qqbot/message-push/subscriptions/:id',
|
||||
'PUT /qqbot/message-push/subscriptions/:id/enabled',
|
||||
'PUT /qqbot/message-push/templates/:id',
|
||||
'PUT /qqbot/message-push/templates/:id/enabled',
|
||||
].sort(),
|
||||
);
|
||||
expect(messagePushRoutes.join('\n')).not.toMatch(
|
||||
/\/(?:publish|events|deliveries|fanout|retry)(?:\/|$)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes QQBot through the core module as the owning Nest boundary', () => {
|
||||
const legacyWrapperName = ['Qqbot', 'Module'].join('');
|
||||
const legacyWrapperPath = join(
|
||||
@ -195,6 +234,32 @@ describe('QQBot core module contract', () => {
|
||||
(provider) => provider === SystemMessageFanoutService,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
QQBOT_CORE_PROVIDERS.filter(
|
||||
(provider) =>
|
||||
typeof provider === 'object' &&
|
||||
provider !== null &&
|
||||
'provide' in provider &&
|
||||
provider.provide === SYSTEM_MESSAGE_EVENT_STAGER,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
provide: SYSTEM_MESSAGE_EVENT_STAGER,
|
||||
useExisting: SystemMessageEventStagerService,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
QQBOT_CORE_PROVIDERS.filter(
|
||||
(provider) => provider === QqbotMessagePushPermissionGuard,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(new Set(QQBOT_CORE_CONTROLLERS).size).toBe(
|
||||
QQBOT_CORE_CONTROLLERS.length,
|
||||
);
|
||||
expect(new Set(QQBOT_CORE_PROVIDERS).size).toBe(
|
||||
QQBOT_CORE_PROVIDERS.length,
|
||||
);
|
||||
expect(QQBOT_CORE_PROVIDERS).not.toContain(JwtAuthGuard);
|
||||
expect(
|
||||
QQBOT_CORE_PROVIDERS.filter(
|
||||
(provider) => provider === SystemMessageDeliveryRunnerService,
|
||||
@ -241,9 +306,11 @@ describe('QQBot core module contract', () => {
|
||||
expect(QQBOT_CORE_CONTROLLERS).toEqual(
|
||||
expect.arrayContaining([
|
||||
QqbotAccountController,
|
||||
QqbotAccountMessagePushController,
|
||||
QqbotCommandController,
|
||||
QqbotDashboardController,
|
||||
QqbotMessageController,
|
||||
QqbotMessagePushController,
|
||||
QqbotPermissionController,
|
||||
QqbotRuleController,
|
||||
QqbotSendController,
|
||||
|
||||
@ -0,0 +1,273 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard';
|
||||
import { QqbotMessagePushPermission } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator';
|
||||
|
||||
type RoleInput = {
|
||||
isDeleted?: boolean;
|
||||
menus?: Array<{
|
||||
authCode?: string;
|
||||
isDeleted?: boolean;
|
||||
status?: number;
|
||||
}>;
|
||||
roleCode?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
/** Builds the smallest HTTP execution context used by the permission guard. */
|
||||
const contextFor = (
|
||||
roles: RoleInput[],
|
||||
handler: () => void = () => undefined,
|
||||
): ExecutionContext =>
|
||||
({
|
||||
getClass: () => class TestController {},
|
||||
getHandler: () => handler,
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ adminUser: { roles } }),
|
||||
}),
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
/** Creates a handler decorated with one exact management permission. */
|
||||
const handlerWithPermission = (...authCodes: string[]): (() => void) => {
|
||||
class PermissionFixture {
|
||||
@QqbotMessagePushPermission(...authCodes)
|
||||
run(): void {}
|
||||
}
|
||||
return PermissionFixture.prototype.run;
|
||||
};
|
||||
|
||||
describe('QqbotMessagePushPermissionGuard', () => {
|
||||
const guard = new QqbotMessagePushPermissionGuard(new Reflector());
|
||||
const handler = handlerWithPermission('QqBot:MessageTemplate:Preview');
|
||||
|
||||
it('allows only an active non-deleted super assignment to bypass', () => {
|
||||
expect(
|
||||
guard.canActivate(
|
||||
contextFor(
|
||||
[{ isDeleted: false, roleCode: 'super', status: 1 }],
|
||||
handler,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(() =>
|
||||
guard.canActivate(
|
||||
contextFor(
|
||||
[{ isDeleted: true, roleCode: 'super', status: 1 }],
|
||||
handler,
|
||||
),
|
||||
),
|
||||
).toThrow(HttpException);
|
||||
expect(() =>
|
||||
guard.canActivate(
|
||||
contextFor(
|
||||
[{ isDeleted: false, roleCode: 'super', status: 0 }],
|
||||
handler,
|
||||
),
|
||||
),
|
||||
).toThrow(HttpException);
|
||||
});
|
||||
|
||||
it('uses only active roles and active exact-code menus', () => {
|
||||
expect(
|
||||
guard.canActivate(
|
||||
contextFor(
|
||||
[
|
||||
{
|
||||
isDeleted: false,
|
||||
menus: [
|
||||
{
|
||||
authCode: 'QqBot:MessageSubscription:List',
|
||||
isDeleted: false,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
authCode: 'QqBot:MessageTemplate:Preview',
|
||||
isDeleted: false,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
handler,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
for (const roles of [
|
||||
[
|
||||
{
|
||||
isDeleted: true,
|
||||
menus: [
|
||||
{
|
||||
authCode: 'QqBot:MessageTemplate:Preview',
|
||||
isDeleted: false,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
isDeleted: false,
|
||||
menus: [
|
||||
{
|
||||
authCode: 'QqBot:MessageTemplate:Preview',
|
||||
isDeleted: false,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
isDeleted: false,
|
||||
menus: [
|
||||
{
|
||||
authCode: 'QqBot:MessageTemplate:Preview',
|
||||
isDeleted: true,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
isDeleted: false,
|
||||
menus: [
|
||||
{
|
||||
authCode: 'QqBot:MessageTemplate:Preview',
|
||||
isDeleted: false,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
]) {
|
||||
expect(() => guard.canActivate(contextFor(roles, handler))).toThrow(
|
||||
HttpException,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when route metadata is missing or empty', () => {
|
||||
expect(() => guard.canActivate(contextFor([]))).toThrow(HttpException);
|
||||
|
||||
class EmptyPermissionFixture {
|
||||
@QqbotMessagePushPermission()
|
||||
run(): void {}
|
||||
}
|
||||
expect(() =>
|
||||
guard.canActivate(contextFor([], EmptyPermissionFixture.prototype.run)),
|
||||
).toThrow(HttpException);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'source read',
|
||||
[
|
||||
'QqBot:MessageSubscription:List',
|
||||
'QqBot:MessageSubscription:Create',
|
||||
'QqBot:MessageSubscription:Update',
|
||||
'QqBot:MessageTemplate:List',
|
||||
'QqBot:MessageTemplate:Create',
|
||||
'QqBot:MessageTemplate:Update',
|
||||
'QqBot:MessageTemplate:Preview',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
],
|
||||
[
|
||||
'source options',
|
||||
[
|
||||
'QqBot:MessageSubscription:Create',
|
||||
'QqBot:MessageSubscription:Update',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
],
|
||||
[
|
||||
'subscription read',
|
||||
[
|
||||
'QqBot:MessageSubscription:List',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
],
|
||||
[
|
||||
'template read',
|
||||
[
|
||||
'QqBot:MessageTemplate:List',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
],
|
||||
[
|
||||
'target read',
|
||||
['QqBot:Account:MessagePush:Create', 'QqBot:Account:MessagePush:Update'],
|
||||
],
|
||||
])('accepts every independent %s OR permission', (_label, authCodes) => {
|
||||
const routeHandler = handlerWithPermission(...authCodes);
|
||||
authCodes.forEach((authCode) => {
|
||||
expect(
|
||||
guard.canActivate(
|
||||
contextFor(
|
||||
[
|
||||
{
|
||||
isDeleted: false,
|
||||
menus: [{ authCode, isDeleted: false, status: 1 }],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
routeHandler,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:List'],
|
||||
['QqBot:MessageSubscription:Update', 'QqBot:MessageSubscription:Create'],
|
||||
['QqBot:MessageTemplate:Delete', 'QqBot:MessageTemplate:List'],
|
||||
['QqBot:Account:MessagePush:Toggle', 'QqBot:Account:MessagePush:Update'],
|
||||
])(
|
||||
'does not let neighboring code %s grant mutation %s',
|
||||
(required, neighboring) => {
|
||||
expect(() =>
|
||||
guard.canActivate(
|
||||
contextFor(
|
||||
[
|
||||
{
|
||||
isDeleted: false,
|
||||
menus: [{ authCode: neighboring, isDeleted: false, status: 1 }],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
handlerWithPermission(required),
|
||||
),
|
||||
),
|
||||
).toThrow(HttpException);
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed when JwtAuthGuard did not populate adminUser', () => {
|
||||
const missingUserContext = {
|
||||
getClass: () => class TestController {},
|
||||
getHandler: () => handler,
|
||||
switchToHttp: () => ({ getRequest: () => ({}) }),
|
||||
} as unknown as ExecutionContext;
|
||||
expect(() => guard.canActivate(missingUserContext)).toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,851 @@
|
||||
import { GUARDS_METADATA, PIPES_METADATA } from '@nestjs/common/constants';
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service';
|
||||
import { QqbotMessageSubscriptionService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service';
|
||||
import { QqbotMessageTargetOptionsService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service';
|
||||
import { QqbotMessageTemplateService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-template.service';
|
||||
import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
|
||||
import { QqbotAccountMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller';
|
||||
import { QqbotMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller';
|
||||
import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard';
|
||||
import { QQBOT_MESSAGE_PUSH_PERMISSION } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator';
|
||||
import {
|
||||
collectControllerRoutes,
|
||||
routeKey,
|
||||
} from '../../../helpers/controller-route.helper';
|
||||
|
||||
const SOURCE_READ_PERMISSIONS = [
|
||||
'QqBot:MessageSubscription:List',
|
||||
'QqBot:MessageSubscription:Create',
|
||||
'QqBot:MessageSubscription:Update',
|
||||
'QqBot:MessageTemplate:List',
|
||||
'QqBot:MessageTemplate:Create',
|
||||
'QqBot:MessageTemplate:Update',
|
||||
'QqBot:MessageTemplate:Preview',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
];
|
||||
|
||||
const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
|
||||
'DELETE /qqbot/accounts/:selfId/message-push/bindings/:id': [
|
||||
'QqBot:Account:MessagePush:Delete',
|
||||
],
|
||||
'DELETE /qqbot/message-push/subscriptions/:id': [
|
||||
'QqBot:MessageSubscription:Delete',
|
||||
],
|
||||
'DELETE /qqbot/message-push/templates/:id': ['QqBot:MessageTemplate:Delete'],
|
||||
'GET /qqbot/accounts/:selfId/message-push/bindings': [
|
||||
'QqBot:Account:MessagePush:List',
|
||||
],
|
||||
'GET /qqbot/accounts/:selfId/message-push/targets': [
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
'GET /qqbot/message-push/sources': SOURCE_READ_PERMISSIONS,
|
||||
'GET /qqbot/message-push/sources/:sourceKey': SOURCE_READ_PERMISSIONS,
|
||||
'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options': [
|
||||
'QqBot:MessageSubscription:Create',
|
||||
'QqBot:MessageSubscription:Update',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
'GET /qqbot/message-push/subscriptions': [
|
||||
'QqBot:MessageSubscription:List',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
'GET /qqbot/message-push/templates': [
|
||||
'QqBot:MessageTemplate:List',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
'POST /qqbot/accounts/:selfId/message-push/bindings': [
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
],
|
||||
'POST /qqbot/message-push/subscriptions': [
|
||||
'QqBot:MessageSubscription:Create',
|
||||
],
|
||||
'POST /qqbot/message-push/templates': ['QqBot:MessageTemplate:Create'],
|
||||
'POST /qqbot/message-push/templates/preview': [
|
||||
'QqBot:MessageTemplate:Preview',
|
||||
],
|
||||
'PUT /qqbot/accounts/:selfId/message-push/bindings/:id': [
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
],
|
||||
'PUT /qqbot/accounts/:selfId/message-push/bindings/:id/enabled': [
|
||||
'QqBot:Account:MessagePush:Toggle',
|
||||
],
|
||||
'PUT /qqbot/message-push/subscriptions/:id': [
|
||||
'QqBot:MessageSubscription:Update',
|
||||
],
|
||||
'PUT /qqbot/message-push/subscriptions/:id/enabled': [
|
||||
'QqBot:MessageSubscription:Toggle',
|
||||
],
|
||||
'PUT /qqbot/message-push/templates/:id': ['QqBot:MessageTemplate:Update'],
|
||||
'PUT /qqbot/message-push/templates/:id/enabled': [
|
||||
'QqBot:MessageTemplate:Toggle',
|
||||
],
|
||||
};
|
||||
|
||||
const STRING_ID = '123456789012345678901234';
|
||||
const SELF_ID = '12345';
|
||||
|
||||
/** Builds one valid strict STUN subscription payload. */
|
||||
const subscriptionBody = () => ({
|
||||
enabled: true,
|
||||
name: 'STUN port changed',
|
||||
remark: 'local test',
|
||||
sourceConfig: {
|
||||
ddnsRecordId: STRING_ID,
|
||||
portForwardId: '1234567890123456789',
|
||||
},
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
});
|
||||
|
||||
/** Builds one valid strict template payload. */
|
||||
const templateBody = () => ({
|
||||
content: 'Endpoint: ${{endpoint}}',
|
||||
enabled: true,
|
||||
name: 'STUN template',
|
||||
remark: 'local test',
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
});
|
||||
|
||||
/** Builds one valid strict account binding payload with a selectable target count. */
|
||||
const bindingBody = (targetCount = 1) => ({
|
||||
enabled: true,
|
||||
subscriptionId: STRING_ID,
|
||||
targets: Array.from({ length: targetCount }, (_, index) => ({
|
||||
targetId: `${10000 + index}`,
|
||||
targetName: `Target ${index}`,
|
||||
targetType: index % 2 === 0 ? 'group' : 'private',
|
||||
})),
|
||||
templateId: '1234567890123456789',
|
||||
});
|
||||
|
||||
/** Returns a complete public source definition plus an internal field to detect leakage. */
|
||||
const sourceDefinition = () =>
|
||||
({
|
||||
credential: 'must-not-leak',
|
||||
description: 'STUN endpoint port change',
|
||||
displayName: 'STUN port changed',
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
subscriptionFields: [
|
||||
{
|
||||
credential: 'must-not-leak',
|
||||
key: 'portForwardId',
|
||||
label: 'Port forward',
|
||||
optionCollection: 'portForwards',
|
||||
required: true,
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
dependsOn: 'portForwardId',
|
||||
key: 'ddnsRecordId',
|
||||
label: 'DDNS',
|
||||
optionCollection: 'ddnsRecords',
|
||||
required: true,
|
||||
type: 'select',
|
||||
},
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
accessToken: 'must-not-leak',
|
||||
description: 'Endpoint',
|
||||
example: 'example.test:10000',
|
||||
key: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
}) as never;
|
||||
|
||||
/** Returns a detached subscription view plus persistence-only fields to detect leakage. */
|
||||
const subscriptionView = () =>
|
||||
({
|
||||
activeKey: 'must-not-leak',
|
||||
createTime: '2026-07-24T00:00:00.000Z',
|
||||
enabled: true,
|
||||
id: STRING_ID,
|
||||
invalidReasonCode: null,
|
||||
isDeleted: false,
|
||||
name: 'STUN port changed',
|
||||
remark: null,
|
||||
sourceConfig: {
|
||||
credential: 'must-not-leak',
|
||||
ddnsRecordId: STRING_ID,
|
||||
portForwardId: '1234567890123456789',
|
||||
},
|
||||
sourceConfigDigest: 'must-not-leak',
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN port changed',
|
||||
sourceSummary: 'summary',
|
||||
updateTime: '2026-07-24T00:00:00.000Z',
|
||||
valid: true,
|
||||
}) as never;
|
||||
|
||||
/** Returns a detached template view plus persistence-only fields to detect leakage. */
|
||||
const templateView = () =>
|
||||
({
|
||||
activeKey: 'must-not-leak',
|
||||
content: 'Endpoint: ${{endpoint}}',
|
||||
createTime: '2026-07-24T00:00:00.000Z',
|
||||
enabled: true,
|
||||
id: STRING_ID,
|
||||
isDeleted: false,
|
||||
name: 'STUN template',
|
||||
referenceCount: 1,
|
||||
remark: null,
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN port changed',
|
||||
updateTime: '2026-07-24T00:00:00.000Z',
|
||||
}) as never;
|
||||
|
||||
/** Returns a detached binding view plus account and active-key fields to detect leakage. */
|
||||
const bindingView = () =>
|
||||
({
|
||||
accountId: 'must-not-leak',
|
||||
activeKey: 'must-not-leak',
|
||||
available: true,
|
||||
createTime: '2026-07-24T00:00:00.000Z',
|
||||
enabled: true,
|
||||
id: STRING_ID,
|
||||
invalidReasonCode: null,
|
||||
selfId: SELF_ID,
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN port changed',
|
||||
subscriptionId: STRING_ID,
|
||||
subscriptionName: 'STUN port changed',
|
||||
targets: [
|
||||
{
|
||||
activeKey: 'must-not-leak',
|
||||
bindingId: STRING_ID,
|
||||
enabled: true,
|
||||
id: '1234567890123456789',
|
||||
targetId: '10000',
|
||||
targetName: 'Target',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
templateId: '1234567890123456789',
|
||||
templateName: 'STUN template',
|
||||
updateTime: '2026-07-24T00:00:00.000Z',
|
||||
}) as never;
|
||||
|
||||
/** Recursively collects response object keys for sensitive-field assertions. */
|
||||
const collectKeys = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) return value.flatMap(collectKeys);
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
return Object.entries(value).flatMap(([key, nested]) => [
|
||||
key,
|
||||
...collectKeys(nested),
|
||||
]);
|
||||
};
|
||||
|
||||
describe('QQBot message-push management controllers', () => {
|
||||
let app: INestApplication;
|
||||
let apiUrl: string;
|
||||
|
||||
const registry = {
|
||||
get: jest.fn(),
|
||||
list: jest.fn(),
|
||||
};
|
||||
const subscriptions = {
|
||||
create: jest.fn(),
|
||||
page: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
setEnabled: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const templates = {
|
||||
create: jest.fn(),
|
||||
page: jest.fn(),
|
||||
preview: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
setEnabled: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const bindings = {
|
||||
createBinding: jest.fn(),
|
||||
listBindings: jest.fn(),
|
||||
removeBinding: jest.fn(),
|
||||
setBindingEnabled: jest.fn(),
|
||||
updateBinding: jest.fn(),
|
||||
};
|
||||
const targets = {
|
||||
listTargetOptions: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [
|
||||
QqbotMessagePushController,
|
||||
QqbotAccountMessagePushController,
|
||||
],
|
||||
providers: [
|
||||
QqbotMessagePushPermissionGuard,
|
||||
{ provide: SystemMessageSourceRegistry, useValue: registry },
|
||||
{ provide: QqbotMessageSubscriptionService, useValue: subscriptions },
|
||||
{ provide: QqbotMessageTemplateService, useValue: templates },
|
||||
{ provide: QqbotAccountMessagePushService, useValue: bindings },
|
||||
{ provide: QqbotMessageTargetOptionsService, useValue: targets },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(QqbotMessagePushPermissionGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.listen(0, '127.0.0.1');
|
||||
apiUrl = await app.getUrl();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
const definition = sourceDefinition();
|
||||
registry.list.mockReturnValue([definition]);
|
||||
registry.get.mockReturnValue({
|
||||
credential: 'must-not-leak',
|
||||
definition,
|
||||
listSubscriptionOptions: jest.fn().mockResolvedValue({
|
||||
credential: 'must-not-leak',
|
||||
ddnsRecords: [
|
||||
{
|
||||
credential: 'must-not-leak',
|
||||
disabledReasonCode: null,
|
||||
eligible: true,
|
||||
fqdn: 'example.test',
|
||||
id: STRING_ID,
|
||||
name: 'DDNS',
|
||||
portForwardId: '1234567890123456789',
|
||||
},
|
||||
],
|
||||
portForwards: [
|
||||
{
|
||||
accessToken: 'must-not-leak',
|
||||
disabledReasonCode: null,
|
||||
eligible: true,
|
||||
externalPort: 10000,
|
||||
id: '1234567890123456789',
|
||||
internalPort: 10000,
|
||||
name: 'STUN',
|
||||
protocol: 'udp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
subscriptions.page.mockResolvedValue({
|
||||
items: [subscriptionView()],
|
||||
total: 1,
|
||||
});
|
||||
subscriptions.create.mockResolvedValue(subscriptionView());
|
||||
subscriptions.update.mockResolvedValue(subscriptionView());
|
||||
subscriptions.setEnabled.mockResolvedValue(subscriptionView());
|
||||
subscriptions.remove.mockResolvedValue(true);
|
||||
templates.page.mockResolvedValue({ items: [templateView()], total: 1 });
|
||||
templates.create.mockResolvedValue(templateView());
|
||||
templates.update.mockResolvedValue(templateView());
|
||||
templates.setEnabled.mockResolvedValue(templateView());
|
||||
templates.remove.mockResolvedValue(true);
|
||||
templates.preview.mockReturnValue({
|
||||
payload: 'must-not-leak',
|
||||
renderedMessage: 'Endpoint: example.test:10000',
|
||||
variables: { endpoint: 'example.test:10000' },
|
||||
});
|
||||
bindings.listBindings.mockResolvedValue([bindingView()]);
|
||||
bindings.createBinding.mockResolvedValue(bindingView());
|
||||
bindings.updateBinding.mockResolvedValue(bindingView());
|
||||
bindings.setBindingEnabled.mockResolvedValue(bindingView());
|
||||
bindings.removeBinding.mockResolvedValue(true);
|
||||
targets.listTargetOptions.mockResolvedValue({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('exposes exactly the approved 20 routes with exact permission metadata', () => {
|
||||
const routes = collectControllerRoutes([
|
||||
QqbotMessagePushController,
|
||||
QqbotAccountMessagePushController,
|
||||
]);
|
||||
const routeKeys = routes.map(routeKey);
|
||||
|
||||
expect(routeKeys).toEqual(Object.keys(EXPECTED_ROUTE_PERMISSIONS).sort());
|
||||
expect(
|
||||
routeKeys.some((route) =>
|
||||
/\/(?:publish|events|deliveries|fanout|retry)(?:\/|$)/.test(route),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
routes.forEach((route) => {
|
||||
const ControllerClass =
|
||||
route.controllerName === QqbotMessagePushController.name
|
||||
? QqbotMessagePushController
|
||||
: QqbotAccountMessagePushController;
|
||||
const handler = ControllerClass.prototype[route.handlerName];
|
||||
expect(
|
||||
Reflect.getMetadata(QQBOT_MESSAGE_PUSH_PERMISSION, handler),
|
||||
).toEqual(EXPECTED_ROUTE_PERMISSIONS[routeKey(route)]);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses page wrappers only for subscription and template pages', async () => {
|
||||
const sourceResponse = await request(apiUrl)
|
||||
.get('/qqbot/message-push/sources')
|
||||
.expect(200);
|
||||
const subscriptionResponse = await request(apiUrl)
|
||||
.get('/qqbot/message-push/subscriptions')
|
||||
.expect(200);
|
||||
const templateResponse = await request(apiUrl)
|
||||
.get('/qqbot/message-push/templates')
|
||||
.expect(200);
|
||||
const bindingResponse = await request(apiUrl)
|
||||
.get('/qqbot/accounts/12345/message-push/bindings')
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(sourceResponse.body.data)).toBe(true);
|
||||
expect(subscriptionResponse.body.data).toMatchObject({ total: 1 });
|
||||
expect(Array.isArray(subscriptionResponse.body.data.items)).toBe(true);
|
||||
expect(templateResponse.body.data).toMatchObject({ total: 1 });
|
||||
expect(Array.isArray(templateResponse.body.data.items)).toBe(true);
|
||||
expect(Array.isArray(bindingResponse.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps unavailable target lookup at HTTP 200 with an exact safe shape', async () => {
|
||||
const response = await request(apiUrl)
|
||||
.get('/qqbot/accounts/12345/message-push/targets')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toEqual({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches both guards and a strict controller-local ValidationPipe', () => {
|
||||
for (const ControllerClass of [
|
||||
QqbotMessagePushController,
|
||||
QqbotAccountMessagePushController,
|
||||
]) {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ControllerClass)).toEqual([
|
||||
JwtAuthGuard,
|
||||
QqbotMessagePushPermissionGuard,
|
||||
]);
|
||||
const pipes = Reflect.getMetadata(PIPES_METADATA, ControllerClass);
|
||||
expect(pipes).toHaveLength(1);
|
||||
expect(pipes[0]).toBeInstanceOf(ValidationPipe);
|
||||
expect(pipes[0].validatorOptions).toMatchObject({
|
||||
forbidNonWhitelisted: true,
|
||||
whitelist: true,
|
||||
});
|
||||
expect(pipes[0].isTransformEnabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns HTTP 200 and a Vben wrapper for every POST route', async () => {
|
||||
const responses = await Promise.all([
|
||||
request(apiUrl)
|
||||
.post('/qqbot/message-push/subscriptions')
|
||||
.send(subscriptionBody())
|
||||
.expect(200),
|
||||
request(apiUrl)
|
||||
.post('/qqbot/message-push/templates')
|
||||
.send(templateBody())
|
||||
.expect(200),
|
||||
request(apiUrl)
|
||||
.post('/qqbot/message-push/templates/preview')
|
||||
.send({
|
||||
content: 'Endpoint: ${{endpoint}}',
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
})
|
||||
.expect(200),
|
||||
request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(bindingBody())
|
||||
.expect(200),
|
||||
]);
|
||||
responses.forEach((response) => {
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({ code: 200, msg: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/qqbot/message-push/subscriptions', subscriptionBody()],
|
||||
['/qqbot/message-push/templates', templateBody()],
|
||||
[
|
||||
'/qqbot/message-push/templates/preview',
|
||||
{
|
||||
content: 'Endpoint: ${{endpoint}}',
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
},
|
||||
],
|
||||
[`/qqbot/accounts/${SELF_ID}/message-push/bindings`, bindingBody()],
|
||||
])('rejects unknown root request keys on POST %s', async (path, body) => {
|
||||
await request(apiUrl)
|
||||
.post(path)
|
||||
.send({ ...body, credential: 'must-reject' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects unknown enabled, sourceConfig, and target fields', async () => {
|
||||
await request(apiUrl)
|
||||
.put(`/qqbot/message-push/subscriptions/${STRING_ID}/enabled`)
|
||||
.send({ enabled: false, secret: 'must-reject' })
|
||||
.expect(400);
|
||||
await request(apiUrl)
|
||||
.post('/qqbot/message-push/subscriptions')
|
||||
.send({
|
||||
...subscriptionBody(),
|
||||
sourceConfig: {
|
||||
...subscriptionBody().sourceConfig,
|
||||
credential: 'must-reject',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
const binding = bindingBody();
|
||||
binding.targets[0] = {
|
||||
...binding.targets[0],
|
||||
accessToken: 'must-reject',
|
||||
} as never;
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(binding)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['null', null],
|
||||
['array', []],
|
||||
])(
|
||||
'rejects %s sourceConfig instead of skipping nested validation',
|
||||
async (_label, sourceConfig) => {
|
||||
const body = subscriptionBody() as Record<string, unknown>;
|
||||
if (sourceConfig === undefined) {
|
||||
delete body.sourceConfig;
|
||||
} else {
|
||||
body.sourceConfig = sourceConfig;
|
||||
}
|
||||
await request(apiUrl)
|
||||
.post('/qqbot/message-push/subscriptions')
|
||||
.send(body)
|
||||
.expect(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects numeric foreign IDs and preserves long decimal strings', async () => {
|
||||
await request(apiUrl)
|
||||
.post('/qqbot/message-push/subscriptions')
|
||||
.send({
|
||||
...subscriptionBody(),
|
||||
sourceConfig: {
|
||||
ddnsRecordId: 123,
|
||||
portForwardId: '1234567890123456789',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send({ ...bindingBody(), subscriptionId: 123 })
|
||||
.expect(400);
|
||||
|
||||
await request(apiUrl)
|
||||
.post('/qqbot/message-push/subscriptions')
|
||||
.send(subscriptionBody())
|
||||
.expect(200);
|
||||
expect(subscriptions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceConfig: expect.objectContaining({ ddnsRecordId: STRING_ID }),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
typeof subscriptions.create.mock.calls[0][0].sourceConfig.ddnsRecordId,
|
||||
).toBe('string');
|
||||
});
|
||||
|
||||
it.each(['0', '-1', '1.5', '1e5', '%20123', '1234567890123456789012345'])(
|
||||
'rejects invalid Snowflake path id %s',
|
||||
async (id) => {
|
||||
await request(apiUrl)
|
||||
.delete(`/qqbot/message-push/subscriptions/${id}`)
|
||||
.expect(400);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['0', '1234', '-12345', '1.2345', '1e9999', '123456789012345678901'])(
|
||||
'rejects invalid selfId %s',
|
||||
async (selfId) => {
|
||||
await request(apiUrl)
|
||||
.get(`/qqbot/accounts/${selfId}/message-push/bindings`)
|
||||
.expect(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('transforms only literal query booleans', async () => {
|
||||
await request(apiUrl)
|
||||
.get('/qqbot/message-push/subscriptions?enabled=false')
|
||||
.expect(200);
|
||||
expect(subscriptions.page).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
|
||||
for (const value of ['0', 'yes', '']) {
|
||||
await request(apiUrl)
|
||||
.get(`/qqbot/message-push/subscriptions?enabled=${value}`)
|
||||
.expect(400);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['target type', { targetType: 'channel' }],
|
||||
['short target id', { targetId: '1234' }],
|
||||
['zero target id', { targetId: '01234' }],
|
||||
['numeric target id', { targetId: 12345 }],
|
||||
])('rejects invalid %s', async (_label, override) => {
|
||||
const body = bindingBody();
|
||||
body.targets[0] = { ...body.targets[0], ...override } as never;
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('enforces target count 1..100 with nested transformation', async () => {
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(bindingBody(0))
|
||||
.expect(400);
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(bindingBody(101))
|
||||
.expect(400);
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(bindingBody(1))
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(bindingBody(100))
|
||||
.expect(200);
|
||||
expect(bindings.createBinding.mock.calls.at(-1)?.[1].targets).toHaveLength(
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('enforces name, remark, target name, and content boundaries', async () => {
|
||||
for (const body of [
|
||||
{ ...subscriptionBody(), name: ' '.repeat(2) },
|
||||
{ ...subscriptionBody(), name: 'x'.repeat(101) },
|
||||
{ ...subscriptionBody(), remark: 'x'.repeat(501) },
|
||||
]) {
|
||||
await request(apiUrl)
|
||||
.post('/qqbot/message-push/subscriptions')
|
||||
.send(body)
|
||||
.expect(400);
|
||||
}
|
||||
await request(apiUrl)
|
||||
.post('/qqbot/message-push/templates')
|
||||
.send({ ...templateBody(), content: '😀'.repeat(2001) })
|
||||
.expect(400);
|
||||
const binding = bindingBody();
|
||||
binding.targets[0].targetName = 'x'.repeat(121);
|
||||
await request(apiUrl)
|
||||
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.send(binding)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('returns only exact public response fields and recursively excludes secrets', async () => {
|
||||
targets.listTargetOptions.mockResolvedValueOnce({
|
||||
accessToken: 'must-not-leak',
|
||||
available: true,
|
||||
options: [
|
||||
{
|
||||
credential: 'must-not-leak',
|
||||
label: 'Target (10000)',
|
||||
targetId: '10000',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
reasonCode: null,
|
||||
});
|
||||
const responses = await Promise.all([
|
||||
request(apiUrl).get('/qqbot/message-push/sources').expect(200),
|
||||
request(apiUrl)
|
||||
.get('/qqbot/message-push/sources/network.stun.mapping-port-changed')
|
||||
.expect(200),
|
||||
request(apiUrl)
|
||||
.get(
|
||||
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
|
||||
)
|
||||
.expect(200),
|
||||
request(apiUrl).get('/qqbot/message-push/subscriptions').expect(200),
|
||||
request(apiUrl).get('/qqbot/message-push/templates').expect(200),
|
||||
request(apiUrl)
|
||||
.post('/qqbot/message-push/templates/preview')
|
||||
.send({
|
||||
content: 'Endpoint: ${{endpoint}}',
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
})
|
||||
.expect(200),
|
||||
request(apiUrl)
|
||||
.get(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
|
||||
.expect(200),
|
||||
request(apiUrl)
|
||||
.get(`/qqbot/accounts/${SELF_ID}/message-push/targets`)
|
||||
.expect(200),
|
||||
]);
|
||||
const [
|
||||
sourceList,
|
||||
sourceDetail,
|
||||
options,
|
||||
subscriptionPage,
|
||||
templatePage,
|
||||
preview,
|
||||
bindingList,
|
||||
targetOptions,
|
||||
] = responses.map((response) => response.body.data);
|
||||
|
||||
expect(Object.keys(sourceList[0]).sort()).toEqual(
|
||||
[
|
||||
'description',
|
||||
'displayName',
|
||||
'sourceKey',
|
||||
'subscriptionFields',
|
||||
'variables',
|
||||
'version',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(sourceDetail).sort()).toEqual(
|
||||
Object.keys(sourceList[0]).sort(),
|
||||
);
|
||||
expect(Object.keys(sourceList[0].subscriptionFields[0]).sort()).toEqual(
|
||||
['key', 'label', 'optionCollection', 'required', 'type'].sort(),
|
||||
);
|
||||
expect(Object.keys(sourceList[0].subscriptionFields[1]).sort()).toEqual(
|
||||
[
|
||||
'dependsOn',
|
||||
'key',
|
||||
'label',
|
||||
'optionCollection',
|
||||
'required',
|
||||
'type',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(sourceList[0].variables[0]).sort()).toEqual(
|
||||
['description', 'example', 'key', 'label', 'type'].sort(),
|
||||
);
|
||||
expect(Object.keys(options).sort()).toEqual([
|
||||
'ddnsRecords',
|
||||
'portForwards',
|
||||
]);
|
||||
expect(Object.keys(options.ddnsRecords[0]).sort()).toEqual(
|
||||
[
|
||||
'disabledReasonCode',
|
||||
'eligible',
|
||||
'fqdn',
|
||||
'id',
|
||||
'name',
|
||||
'portForwardId',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(options.portForwards[0]).sort()).toEqual(
|
||||
[
|
||||
'disabledReasonCode',
|
||||
'eligible',
|
||||
'externalPort',
|
||||
'id',
|
||||
'internalPort',
|
||||
'name',
|
||||
'protocol',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(subscriptionPage.items[0]).sort()).toEqual(
|
||||
[
|
||||
'createTime',
|
||||
'enabled',
|
||||
'id',
|
||||
'invalidReasonCode',
|
||||
'name',
|
||||
'remark',
|
||||
'sourceConfig',
|
||||
'sourceKey',
|
||||
'sourceName',
|
||||
'sourceSummary',
|
||||
'updateTime',
|
||||
'valid',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(subscriptionPage.items[0].sourceConfig).sort()).toEqual([
|
||||
'ddnsRecordId',
|
||||
'portForwardId',
|
||||
]);
|
||||
expect(Object.keys(templatePage.items[0]).sort()).toEqual(
|
||||
[
|
||||
'content',
|
||||
'createTime',
|
||||
'enabled',
|
||||
'id',
|
||||
'name',
|
||||
'referenceCount',
|
||||
'remark',
|
||||
'sourceKey',
|
||||
'sourceName',
|
||||
'updateTime',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(preview).sort()).toEqual([
|
||||
'renderedMessage',
|
||||
'variables',
|
||||
]);
|
||||
expect(Object.keys(bindingList[0]).sort()).toEqual(
|
||||
[
|
||||
'available',
|
||||
'createTime',
|
||||
'enabled',
|
||||
'id',
|
||||
'invalidReasonCode',
|
||||
'sourceKey',
|
||||
'sourceName',
|
||||
'subscriptionId',
|
||||
'subscriptionName',
|
||||
'targets',
|
||||
'templateId',
|
||||
'templateName',
|
||||
'updateTime',
|
||||
].sort(),
|
||||
);
|
||||
expect(Object.keys(bindingList[0].targets[0]).sort()).toEqual(
|
||||
['enabled', 'id', 'targetId', 'targetName', 'targetType'].sort(),
|
||||
);
|
||||
expect(Object.keys(targetOptions).sort()).toEqual(
|
||||
['available', 'options', 'reasonCode'].sort(),
|
||||
);
|
||||
expect(Object.keys(targetOptions.options[0]).sort()).toEqual(
|
||||
['label', 'targetId', 'targetType'].sort(),
|
||||
);
|
||||
|
||||
const forbiddenKeys =
|
||||
/accessToken|credential|secret|password|activeKey|sourceConfigDigest|payload|accountId|selfId|bindingId|isDeleted/i;
|
||||
responses.forEach((response) => {
|
||||
expect(collectKeys(response.body).join('\n')).not.toMatch(forbiddenKeys);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user