- notice:adapter 归一化 seam + 结构化分类 + 净化(ANSI/控制/零宽/双向字符、200 runes 截断) + fail-safe 写死为「静默」(未装配审查/超时/失败 ⇒ 仅登记、不驱动 loop) - 身份面零改动:notice 发送者恒 RoleSystem + SenderID=system - 复用策略四的同一个审查模型(Options.Review),未另造第二套 - 入站帧脱敏 DEBUG(post_type/message_type/sub_type/self_id/user_id/sender_id/group_id/段类型/at 目标) - ctrl.bat 启动前归档轮转(保留 20 份)+ bash 侧改追加(不再重启即截断)
160 lines
5.8 KiB
Go
160 lines
5.8 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|