Files
Cyrene_v2/plugins/channels/qq_obv11/event_test.go
T
AskaEth ba17820040
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
2026-10-05 13:47:27 +08:00

374 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qq_obv11
// 本文件覆盖 design/26 §3.1 / §3.7 的**全枚举投递**:post_type 的每一个取值
// (notice / request / meta_event / message_sent / 从未枚举过的未知值)都必须走到
// 归一化并留痕,**不许静默丢弃**;以及归一化产出的结构化字段(Category /
// PlatformType / Actor / Target / SelfRelated)与脱敏口径。
//
// notice_type 一整张映射表在 notice_test.go,这里只管 post_type 这一层。
import (
"bytes"
"context"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 四类上报各一条真实形状的报文(字段取自 OneBot v11 官方规范与 go-cqhttp 扩展)。
const (
friendRequestEvent = `{"post_type":"request","request_type":"friend","time":1700000100,"self_id":100,"user_id":200,"comment":"我是同事","flag":"FLAG-SENTINEL-1"}`
groupAddRequest = `{"post_type":"request","request_type":"group","sub_type":"add","time":1700000101,"self_id":100,"user_id":200,"group_id":123,"comment":"拉我进群","flag":"FLAG-SENTINEL-2"}`
groupInviteRequest = `{"post_type":"request","request_type":"group","sub_type":"invite","time":1700000102,"self_id":100,"user_id":555,"group_id":123}`
heartbeatEvent = `{"post_type":"meta_event","meta_event_type":"heartbeat","interval":15000,"time":1700000103,"self_id":100,"status":{"online":true,"good":true}}`
lifecycleEnable = `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"enable","time":1700000104,"self_id":100}`
lifecycleDisable = `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"disable","time":1700000105,"self_id":100}`
messageSentPrivate = `{"post_type":"message_sent","message_type":"private","sub_type":"friend","time":1700000106,"self_id":100,"message_id":77,"user_id":100,"target_id":200,"message":[{"type":"text","data":{"text":"她刚发出的回复"}}],"sender":{"user_id":100,"nickname":"bot"}}`
// 既未枚举、也不可能被归一化的假类型:主人裁定的核心反例(design/26 §3.7)。
unenumeratedPostType = "quantum_entanglement"
unenumeratedEvent = `{"post_type":"` + unenumeratedPostType + `","time":1700000107,"self_id":100,"user_id":200,"group_id":123,"some_new_field":{"deep":true}}`
)
// normalizeDelivered 走一遍完整入站路径并归一化投递出来的那一条。
func normalizeDelivered(t *testing.T, raw string) (adapter.UnifiedMessage, adapter.SystemNotice) {
t.Helper()
delivered := dispatchNotices(t, raw)
if len(delivered) != 1 {
t.Fatalf("投递条数 = %d, want 1(上报被丢弃了)", len(delivered))
}
notice, ok := NewAdapter(Config{}).NormalizeSystemNotice(delivered[0])
if !ok {
t.Fatalf("投递出来的上报无法归一化:%+v", delivered[0])
}
return delivered[0], notice
}
// TestUnenumeratedTypeIsNeverSwallowed 是**主人裁定的对照测试**(design/26 §1.1/§3.7):
// 构造一个既未枚举、也没有任何映射分支的假 post_type,断言它绝不会被静默吞掉 ——
// 它必须①被投递、②被归一化成显式的 Unknown 兜底、③原样带着自己的类型名留痕、
// ④可忽略(绝不进她的上下文,fail-safe)、⑤在 DEBUG 日志里看得见。
//
// 变异验证:把 normalizeOneBotEvent 的 default 分支改回 `return ..., false`(改造前的
// 静默丢弃),或让 handlePayload 对非 message 上报直接 return,本测试立刻打红。
func TestUnenumeratedTypeIsNeverSwallowed(t *testing.T) {
t.Parallel()
buf, log := logCapture()
a := NewAdapter(Config{Logger: log})
var delivered []adapter.UnifiedMessage
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
delivered = append(delivered, m)
})
a.handlePayload(context.Background(), []byte(unenumeratedEvent))
if len(delivered) != 1 {
t.Fatalf("未枚举类型的上报被静默吞掉:投递条数 = %d, want 1", len(delivered))
}
msg := delivered[0]
if msg.ContentType != adapter.ContentAction || msg.Sender.Role != adapter.RoleSystem {
t.Errorf("未枚举上报未带系统事件标记:%q/%q", msg.ContentType, msg.Sender.Role)
}
if msg.ChannelType != adapter.ChannelGroup || msg.ChannelID != "123" {
t.Errorf("未枚举上报的会话键 = %q/%q, want group/123", msg.ChannelType, msg.ChannelID)
}
notice, ok := a.NormalizeSystemNotice(msg)
if !ok {
t.Fatal("未枚举类型必须走 Unknown 兜底归一化,绝不是「读不出事件」")
}
if notice.Kind != adapter.NoticeUnknown || notice.Category != adapter.NoticeCategoryUnknown {
t.Errorf("兜底结果 = %q/%q, want unknown/unknown", notice.Kind, notice.Category)
}
if notice.PlatformType != unenumeratedPostType {
t.Errorf("PlatformType = %q, want %q(原生类型名必须原样保留)", notice.PlatformType, unenumeratedPostType)
}
if notice.Classify() != adapter.NoticeRegisterOnly {
t.Errorf("兜底处置 = %v, want 仅登记(unknown 绝不进她的上下文)", notice.Classify())
}
if !strings.Contains(buf.String(), unenumeratedPostType) {
t.Errorf("未枚举类型在日志里留不下痕:\n%s", buf.String())
}
}
// TestRequestEventsAreRequestsNotChat 覆盖此前**整类缺失**的 post_type=request
// (design/26 §3.1 的 request 行):加好友请求 / 加群请求 / 邀请入群都要投递、都要
// 归一成 NoticeRequest 且高价值(她该知道 ⇐ 主人原话),且**绝不自动同意**。
func TestRequestEventsAreRequestsNotChat(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantCategory adapter.NoticeCategory
wantText string
wantActor string
wantChannel string
wantGroup bool
selfRelated bool
}{
{"加好友请求", friendRequestEvent, adapter.NoticeCategoryFriendRequest, "有人请求加她为好友", "200", "200", false, false},
{"加群请求", groupAddRequest, adapter.NoticeCategoryGroupRequest, "有人请求加入群聊", "200", "123", true, false},
{"邀请她入群", groupInviteRequest, adapter.NoticeCategoryGroupRequest, "有人邀请她加入群聊", "555", "123", true, true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msg, notice := normalizeDelivered(t, tc.raw)
if notice.Kind != adapter.NoticeRequest || notice.Category != tc.wantCategory {
t.Errorf("kind/category = %q/%q, want request/%q", notice.Kind, notice.Category, tc.wantCategory)
}
if notice.SafeText != tc.wantText {
t.Errorf("SafeText = %q, want %q", notice.SafeText, tc.wantText)
}
if !notice.HighValue || notice.Classify() != adapter.NoticeEscalate {
t.Errorf("请求类处置 = %v(highValue=%v),want 升级真会话", notice.Classify(), notice.HighValue)
}
if notice.SelfRelated != tc.selfRelated {
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.selfRelated)
}
if notice.Actor.UserID != tc.wantActor {
t.Errorf("Actor = %q, want %q", notice.Actor.UserID, tc.wantActor)
}
if msg.ChannelID != tc.wantChannel || (msg.ChannelType == adapter.ChannelGroup) != tc.wantGroup {
t.Errorf("会话键 = %q/%q, want group=%v/%q", msg.ChannelType, msg.ChannelID, tc.wantGroup, tc.wantChannel)
}
// 请求的**可执行凭据**与验证信息一个字都没留下:本适配器根本没投影这两个字段。
if strings.Contains(notice.SafeText, "FLAG-SENTINEL") || strings.Contains(msg.Content, "FLAG-SENTINEL") {
t.Error("request.flag 泄漏进了结构化字段")
}
if strings.Contains(notice.SafeText, "同事") || strings.Contains(msg.Content, "同事") {
t.Error("request.comment(验证信息)泄漏进了结构化字段")
}
})
}
}
// TestUnknownRequestTypeIsUnknownNotDropped 覆盖 request_type 未枚举的一支:
// 同样不丢,只是降级成 Unknown 兜底。
func TestUnknownRequestTypeIsUnknownNotDropped(t *testing.T) {
t.Parallel()
_, notice := normalizeDelivered(t, `{"post_type":"request","request_type":"telepathy","time":1700000108,"self_id":100,"user_id":200}`)
if notice.Kind != adapter.NoticeUnknown || notice.PlatformType != "telepathy" {
t.Fatalf("归一化结果 = %+v, want unknown/telepathy", notice)
}
if notice.Classify() != adapter.NoticeRegisterOnly {
t.Error("未枚举 request_type 必须仅登记")
}
}
// TestMetaEventDeliveryAndSilence 覆盖 post_type=meta_event(design/26 §3.1 的
// meta_event 行):
// - 心跳 / 生命周期都要投递、要归一化(此前整类被丢),且都**只登记不进上下文**
// (拿每 15s 一帧的遥测去驱动模型回合是荒谬的);
// - 它们**没有会话**:ChannelID 如实留空,绝不编一个会话出来;
// - SafeText 刻意留空:遥测没有要对她说的一句话,留空才不会让登记路径把心跳写进
// 会话历史(design/26 §4.3 明确反对逐帧写库);
// - SelfRelated=true:链路事件天然是**她自己的**账号状态。
func TestMetaEventDeliveryAndSilence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantCategory adapter.NoticeCategory
}{
{"心跳", heartbeatEvent, adapter.NoticeCategoryHeartbeat},
{"生命周期 enable", lifecycleEnable, adapter.NoticeCategoryLifecycle},
{"生命周期 disable", lifecycleDisable, adapter.NoticeCategoryLifecycle},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msg, notice := normalizeDelivered(t, tc.raw)
if notice.Category != tc.wantCategory {
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
}
if notice.Classify() != adapter.NoticeRegisterOnly {
t.Errorf("元事件处置 = %v, want 仅登记", notice.Classify())
}
if !notice.SelfRelated {
t.Error("元事件必须标 SelfRelated(她自己的链路)")
}
if notice.SafeText != "" {
t.Errorf("T1 遥测不该带安全文本(会把心跳写进会话历史):%q", notice.SafeText)
}
if msg.ChannelID != "" || msg.ChannelType != adapter.ChannelDirect {
t.Errorf("无会话的元事件不该编出会话键:%q/%q", msg.ChannelType, msg.ChannelID)
}
})
}
_, unknown := normalizeDelivered(t, `{"post_type":"meta_event","meta_event_type":"teleport","time":1700000109,"self_id":100}`)
if unknown.Kind != adapter.NoticeUnknown || unknown.PlatformType != "teleport" {
t.Fatalf("未枚举 meta_event_type = %+v, want unknown/teleport", unknown)
}
}
// TestMessageSentEchoUsesPeerChannel 覆盖 message_sent(她自己发出消息的回声,
// design/26 §3.1):会话键必须是**对端**(target_id),不是她自己(user_id)——
// 拿 user_id 当键会建出一个「她与自己」的私聊,把她刚发的话再答一遍。
func TestMessageSentEchoUsesPeerChannel(t *testing.T) {
t.Parallel()
msg, notice := normalizeDelivered(t, messageSentPrivate)
if msg.ChannelType != adapter.ChannelDirect || msg.ChannelID != "200" {
t.Fatalf("回声会话键 = %q/%q, want direct/200(对端)", msg.ChannelType, msg.ChannelID)
}
if notice.Category != adapter.NoticeCategoryMessageSent {
t.Errorf("Category = %q, want message_sent", notice.Category)
}
if notice.Classify() != adapter.NoticeRegisterOnly {
t.Errorf("回声处置 = %v, want 仅登记(绝不进上下文)", notice.Classify())
}
if msg.Content != "" {
t.Errorf("回声的平台原文进了正文:%q", msg.Content)
}
}
// TestEventLogsAndStructuresLeakNoPlatformText 是脱敏口径(design/26 §4.2)的对照
// 测试:一条**故意塞满敏感物**的事件走完入站路径后,
// - 投放出来的消息 Content 恒空(平台原文绝不进正文);
// - 归一化产出的结构化字段里没有正文 / 验证信息 / 凭据;
// - 渲染后的**全部日志**(含脱敏结构日志与归一化结果日志)里一个都没有。
//
// 变异验证:把 logNormalizedEvent 改成多打一个原始载荷(如 "payload", string(raw)),
// 本测试立刻打红。
func TestEventLogsAndStructuresLeakNoPlatformText(t *testing.T) {
t.Parallel()
const (
body = "BODY-SENTINEL-9137"
flag = "FLAG-SENTINEL-9137"
cdnURL = "http://cdn.invalid/x.png?sign=SIGN-SENTINEL-9137"
token = "TOKEN-SENTINEL-9137"
)
raw := `{
"post_type": "request",
"request_type": "friend",
"time": 1700000110,
"self_id": 100,
"user_id": 200,
"comment": "` + body + `",
"raw_message": "` + body + `",
"flag": "` + flag + `",
"access_token": "` + token + `",
"file": {"url": "` + cdnURL + `"}
}`
buf, log := logCapture()
a := NewAdapter(Config{AccessToken: token, Logger: log})
var delivered []adapter.UnifiedMessage
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
delivered = append(delivered, m)
})
a.handlePayload(context.Background(), []byte(raw))
if len(delivered) != 1 {
t.Fatalf("投递条数 = %d, want 1", len(delivered))
}
if delivered[0].Content != "" {
t.Errorf("平台原文进了 Content:%q", delivered[0].Content)
}
notice, ok := a.NormalizeSystemNotice(delivered[0])
if !ok {
t.Fatal("加好友请求无法归一化")
}
for name, value := range map[string]string{
"SafeText": notice.SafeText, "PlatformType": notice.PlatformType, "Category": string(notice.Category),
} {
for _, forbidden := range []string{body, flag, token, "SIGN-SENTINEL", "http"} {
if strings.Contains(value, forbidden) {
t.Errorf("%s 泄漏了 %q:%q", name, forbidden, value)
}
}
}
logged := buf.String()
if logged == "" {
t.Fatal("DEBUG 日志一条都没有:取证地基不存在")
}
for _, forbidden := range []string{body, flag, token, "SIGN-SENTINEL", "http"} {
if strings.Contains(logged, forbidden) {
t.Errorf("日志泄漏了 %q:\n%s", forbidden, logged)
}
}
}
// TestNormalizedEventLogStaysAtDebugLevel 归一化结果日志与入站帧日志同级:默认 INFO
// 下一条都不打(免得正常运行每帧两条噪声),要取证时把级别调到 debug。
func TestNormalizedEventLogStaysAtDebugLevel(t *testing.T) {
t.Parallel()
buf := &bytes.Buffer{}
a := NewAdapter(Config{Logger: slog.New(slog.NewTextHandler(buf, nil))})
a.handlePayload(context.Background(), []byte(heartbeatEvent))
if strings.Contains(buf.String(), "system event normalized") {
t.Errorf("默认级别下不该出现归一化结果日志:\n%s", buf.String())
}
}
// TestHTTPReportDeliversNonMessageEventsEndToEnd 是**传输层 → 归一化的端到端**:走
// http+server 这条真实上报路径(OneBot 把事件 POST 到 Cyrene 的监听端口,见
// http_server.go),断言加好友请求 / 心跳 / 未知类型三类上报**都**到达消息处理器且都能
// 归一化——而不是像改造前那样在 handlePayload 的 switch 里掉进虚空(既不投递、也不留痕)。
func TestHTTPReportDeliversNonMessageEventsEndToEnd(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Transport: transportHTTP,
Mode: modeServer,
ListenAddr: "127.0.0.1:0",
AccessToken: "secret-token",
ReportPath: "/onebot/report",
})
got := make(chan adapter.UnifiedMessage, 4)
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m })
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
addr := a.Addr()
if addr == "" {
t.Fatal("Addr() 为空:http+server 端没起来")
}
cases := []struct {
name string
raw string
wantCategory adapter.NoticeCategory
}{
{"加好友请求", friendRequestEvent, adapter.NoticeCategoryFriendRequest},
{"心跳", heartbeatEvent, adapter.NoticeCategoryHeartbeat},
{"未知类型", unenumeratedEvent, adapter.NoticeCategoryUnknown},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if res := postReport(t, "http://"+addr+"/onebot/report", "secret-token", tc.raw); res.status != http.StatusNoContent {
t.Fatalf("上报状态 = %d (body %q), want 204", res.status, res.body)
}
select {
case m := <-got:
notice, ok := a.NormalizeSystemNotice(m)
if !ok {
t.Fatalf("%s 到达了处理器却无法归一化", tc.name)
}
if notice.Category != tc.wantCategory {
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
}
case <-time.After(3 * time.Second):
t.Fatalf("%s 上报从未到达消息处理器(非消息事件被吞)", tc.name)
}
})
}
}