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) } }) } }