package qq_obv11 // 入站帧脱敏日志(inbound_debug.go)的自证测试。断言分两类: // - **记全了结构**:排障要看的那几个字段一个不少(否则日志等于白留); // - **一个正文/凭据都没记**:正文、access_token、完整媒体 URL(含 raw_message 的 // CQ 码形态)都不得出现。 // // 断言的落点是**渲染后的日志串**(TextHandler 真输出),不是属性表:「没往某个属性里 // 写」离「日志里没有」还差一步——正文只要混进任何一个属性,只有看渲染结果才抓得到。 import ( "bytes" "context" "log/slog" "strings" "testing" ) // logCapture 返回渲染目标与一条把 DEBUG 也打出来的日志器(被测日志级别固定 DEBUG)。 func logCapture() (*bytes.Buffer, *slog.Logger) { buf := &bytes.Buffer{} return buf, slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) } // frameLines 摘出「入站帧」日志行(渲染后的一行行文本)。 func frameLines(buf *bytes.Buffer) []string { var lines []string for _, line := range strings.Split(buf.String(), "\n") { if strings.Contains(line, `msg="inbound frame"`) { lines = append(lines, line) } } return lines } // TestInboundFrameLogIsStructuralOnly 用一条「群里 @ 了机器人」的真实形状事件走完整 // 入站路径:日志必须证明「上游上报了这一帧、帧里 @ 的是 10001」,而正文、access_token、 // 媒体 URL(含 raw_message 里的 CQ 码)一律不得落在日志里。 func TestInboundFrameLogIsStructuralOnly(t *testing.T) { t.Parallel() const ( body = "BODY-SENTINEL-9137" token = "TOKEN-SENTINEL-9137" path = "URL-PATH-SENTINEL-9137" ) raw := []byte(`{ "post_type": "message", "message_type": "group", "sub_type": "normal", "time": 1759600000, "self_id": 10001, "message_id": 7001, "user_id": 20002, "group_id": 1070898847, "group_name": "GROUP-NAME-SENTINEL-9137", "raw_message": "` + body + ` [CQ:at,qq=10001]", "sender": {"user_id": 20002, "nickname": "NICK-SENTINEL-9137", "card": "", "role": "member"}, "message": [ {"type": "at", "data": {"qq": "10001"}}, {"type": "text", "data": {"text": "` + body + `"}}, {"type": "image", "data": {"url": "http://asset.invalid/` + path + `.png?token=` + token + `", "file": "` + path + `.png"}} ] }`) buf, log := logCapture() a := NewAdapter(Config{AccessToken: token, Logger: log}) a.handlePayload(context.Background(), raw) lines := frameLines(buf) if len(lines) != 1 { t.Fatalf("入站帧日志行数 = %d, want 1\n全量日志:\n%s", len(lines), buf.String()) } line := lines[0] for _, want := range []string{ "level=DEBUG", "source=plugins/channels/qq_obv11", "post_type=message", "message_type=group", "sub_type=normal", "self_id=10001", "user_id=20002", "sender_id=20002", "group_id=1070898847", "segments=\"[at text image]\"", "at_targets=[10001]", } { if !strings.Contains(line, want) { t.Errorf("入站帧日志缺少结构字段 %q\n实际: %s", want, line) } } for _, forbidden := range []string{body, token, path, "http", cqPrefix} { if strings.Contains(line, forbidden) { t.Errorf("入站帧日志泄露了 %q\n实际: %s", forbidden, line) } } // 正文与凭据在任何一条日志里都不许出现(含附件内联等旁路日志)。 if all := buf.String(); strings.Contains(all, body) || strings.Contains(all, token) { t.Errorf("正文或 access_token 出现在日志里:\n%s", all) } } // TestInboundFrameLogStaysAtDebugLevel 这条日志必须留在 DEBUG:默认 INFO 下不得出现, // 免得给正常运行加上每帧一条的噪声(要取证时把日志级别调到 debug)。 func TestInboundFrameLogStaysAtDebugLevel(t *testing.T) { t.Parallel() raw := []byte(`{"post_type":"message","message_type":"group","user_id":20002,"group_id":1070898847, "raw_message":"hello","message":[{"type":"text","data":{"text":"hello"}}]}`) buf := &bytes.Buffer{} a := NewAdapter(Config{Logger: slog.New(slog.NewTextHandler(buf, nil))}) a.handlePayload(context.Background(), raw) if strings.Contains(buf.String(), "inbound frame") { t.Errorf("默认级别下不应出现入站帧日志:\n%s", buf.String()) } } // TestInboundFrameLogCoversCQAndNonMessageFrames 覆盖另外两种帧形状:CQ 码形态(只有 // raw_message,没有数组段)取到类型与 at 目标;meta_event 心跳同样留一行——「心跳一直在 // 打、却没有任何 post_type=message 的群帧」正是事故现场要区分的那种证据。 func TestInboundFrameLogCoversCQAndNonMessageFrames(t *testing.T) { t.Parallel() const body = "CQ-BODY-SENTINEL-9137" cqFrame := []byte(`{"post_type":"message","message_type":"group","sub_type":"normal", "self_id":10001,"user_id":30003,"group_id":1070898847, "raw_message":"` + body + `[CQ:at,qq=10001] [CQ:image,file=abc.jpg]"}`) heartbeat := []byte(`{"post_type":"meta_event","meta_event_type":"heartbeat","self_id":10001}`) buf, log := logCapture() a := NewAdapter(Config{Logger: log}) a.handlePayload(context.Background(), cqFrame) a.handlePayload(context.Background(), heartbeat) lines := frameLines(buf) if len(lines) != 2 { t.Fatalf("入站帧日志行数 = %d, want 2\n全量日志:\n%s", len(lines), buf.String()) } for _, want := range []string{"segments=\"[at image]\"", "at_targets=[10001]", "user_id=30003"} { if !strings.Contains(lines[0], want) { t.Errorf("CQ 形态帧日志缺少 %q\n实际: %s", want, lines[0]) } } if strings.Contains(lines[0], body) { t.Errorf("CQ 形态帧日志泄露正文\n实际: %s", lines[0]) } for _, want := range []string{"post_type=meta_event", "self_id=10001", "segments=[]", "at_targets=[]"} { if !strings.Contains(lines[1], want) { t.Errorf("心跳帧日志缺少 %q\n实际: %s", want, lines[1]) } } }