Files
AskaEth 5139945bfe
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
- 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 侧改追加(不再重启即截断)
2026-10-05 11:55:05 +08:00

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