Files
Cyrene_v2/internal/services/channelbridge/notice_test.go
T
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

381 lines
13 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 channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件覆盖 design/12 §6 策略一补充的「系统通知传入前审查」:归一化 / 分类 /
// fail-safe 三条路径,以及它与审查模型(策略四同一注入点)的复用点。
// fail-safe 的保守方向写死在 noticeFailSafeProceeds(false = 静默),变异验证即把它
// 改成 true —— 下面 TestNoticeReviewFailureFailsSafeSilent、
// TestNoticeReviewerMissingFailsSafeSilent 与 TestPreEntryReviewFailSafeIsSilent
// 会变红。
// noticeAdapter 是最小的 PlatformAdapter + SystemNoticeNormalizer:
// 它按夹具给定的结论回答归一化,并记录收到的原始消息与调用次数。
type noticeAdapter struct {
fakeAdapter
notice adapter.SystemNotice
ok bool
panics bool
calls int
rawSeen []byte
}
func (n *noticeAdapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) {
n.calls++
n.rawSeen = msg.RawData
if n.panics {
panic("malformed platform notice")
}
return n.notice, n.ok
}
// plainAdapter 是**未实现**归一化 seam 的适配器(可选接口缺席时的 fail-safe 路径)。
type plainAdapter struct{ fakeAdapter }
// groupNoticeMessage 是一条带结构化系统事件标记的群通知:ContentType=ContentAction
// 是适配器给出的「非聊天动作信号」标记(判「是否系统」只看这个元数据,绝不看正文)。
func groupNoticeMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "qq-obv11",
ChannelID: "g1",
ChannelType: adapter.ChannelGroup,
GroupID: "g1",
GroupName: "群一",
SenderID: "system",
SenderName: "system",
Sender: adapter.Actor{Role: adapter.RoleSystem, UserID: "system"},
ContentType: adapter.ContentAction,
RawData: []byte(`{"post_type":"notice","notice_type":"group_upload"}`),
}
}
// noticeFixture 组装一条入站通知所需的桥接器:受限群会话 + 捕获历史 +
// 记录调用的循环。review 为 nil 表示未装配审查模型。
func noticeFixture(t *testing.T, ad adapter.PlatformAdapter, review adapter.Reviewer) (*Bridge, *stubChatter, *stubSessions, *captureHistory) {
t.Helper()
chat := &stubChatter{res: nil}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, History: hist,
Owner: testOwnerProfile("qq-obv11"),
Review: review,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, chat, sess, hist
}
// systemLines 返回循环收到的系统角色消息文本。
func systemLines(msgs []model.Message) []string {
var out []string
for _, m := range msgs {
if m.Role == model.RoleSystem {
for _, c := range m.Content {
out = append(out, c.Text)
}
}
}
return out
}
// TestNoticeIgnorableRegisteredOnly 覆盖「可忽略 → 仅登记,不喂 LLM,不驱动 loop」。
func TestNoticeIgnorableRegisteredOnly(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeJoin, SafeText: "有成员加入了群聊", Ignorable: true,
}, ok: true}
b, chat, _, hist := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.sessionID)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("登记的记录数 = %d,want 1(仅登记)", len(recs))
}
if recs[0].Content != "有成员加入了群聊" || recs[0].SessionID != "sess-g1" {
t.Errorf("登记内容 = %+v", recs[0])
}
}
// TestNoticeHighValueEscalatesWithoutReview 覆盖「高价值 → 升级真会话」:
// 它越过审查模型(结构判定优先),进入真会话,并以系统通知行**限定注入**。
func TestNoticeHighValueEscalatesWithoutReview(t *testing.T) {
t.Parallel()
reviewed := 0
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
reviewed++
return nil, nil
}
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeLeave, SafeText: "系统通知:她(机器人账号)被移出了群聊", HighValue: true,
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if reviewed != 0 {
t.Errorf("高价值通知不该走剔噪审查,调用次数 = %d", reviewed)
}
if chat.sessionID != "sess-g1" {
t.Fatalf("高价值通知没进真会话:session=%q", chat.sessionID)
}
lines := systemLines(chat.messages)
if len(lines) != 1 || !strings.HasPrefix(lines[0], noticeSystemPrefix) {
t.Fatalf("限定注入的系统行 = %v", lines)
}
for _, m := range chat.messages {
if m.Role == model.RoleUser {
t.Errorf("系统通知被当成用户消息注入:%v", m.Content)
}
}
}
// TestNoticeLimitedInjectReusesReviewModel 覆盖「其余 → 提取安全文本、剥离异常字段后
// 限定注入」,并证明它复用的是 design/12 §6 策略四的**同一个**审查模型注入点:
// 审查器只看得到剥离后的安全文本,且原始载荷与附件已被剥离。
func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) {
t.Parallel()
var probe adapter.UnifiedMessage
review := func(_ context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
if len(msgs) != 1 {
t.Fatalf("审查器应收到单条消息,收到 %d 条", len(msgs))
}
probe = msgs[0]
return []int{0}, nil
}
dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07超长" + strings.Repeat("啊", 300)
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: dirty}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "sess-g1" {
t.Fatalf("审查保留的通知没进上下文:session=%q", chat.sessionID)
}
if probe.RawData != nil || len(probe.Media) != 0 {
t.Errorf("审查器看到了未剥离字段:raw=%q media=%d", probe.RawData, len(probe.Media))
}
if strings.ContainsAny(probe.Content, "\u200b\x1b\x07\n") {
t.Errorf("审查器看到的安全文本未剥离异常字符:%q", probe.Content)
}
if len([]rune(probe.Content)) > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("安全文本未截断:%d runes", len([]rune(probe.Content)))
}
lines := systemLines(chat.messages)
if len(lines) != 1 || !strings.Contains(lines[0], "群文件上传") {
t.Fatalf("限定注入的系统行 = %v", lines)
}
if got := len([]rune(strings.TrimPrefix(lines[0], noticeSystemPrefix))); got > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("注入文本未截断:%d runes", got)
}
}
// TestNoticeReviewDroppedStaysSilent 覆盖审查模型判「没必要」那一侧:只登记、不进 loop。
func TestNoticeReviewDroppedStaysSilent(t *testing.T) {
t.Parallel()
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) { return nil, nil }
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, hist := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.sessionID)
}
if recs := hist.records(); len(recs) != 1 || recs[0].Content != "群/客户端系统事件" {
t.Errorf("被剔噪后未登记:%+v", recs)
}
}
// TestNoticeReviewFailureFailsSafeSilent 是 fail-safe 的核心用例:
// 审查模型报错 / 超时(这里用 DeadlineExceeded 模拟超时)时**保守侧 = 静默**。
// 变异验证(把 noticeFailSafeProceeds 改成 true)会让本用例变红。
func TestNoticeReviewFailureFailsSafeSilent(t *testing.T) {
t.Parallel()
var hadDeadline bool
review := func(ctx context.Context, _ []adapter.UnifiedMessage) ([]int, error) {
_, hadDeadline = ctx.Deadline()
return nil, context.DeadlineExceeded
}
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if !hadDeadline {
t.Error("审查调用没有超时上限(design/12 §6 实现要点的超时条款)")
}
if chat.sessionID != "" {
t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.sessionID)
}
}
// TestNoticeReviewerMissingFailsSafeSilent 覆盖「未装配审查模型」:
// 与审查失败同侧(静默),绝不放行未审查内容。
func TestNoticeReviewerMissingFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("未装配审查模型时未静默:%q", chat.sessionID)
}
}
// TestNoticeUnparsableFailsSafeSilent 覆盖「格式异常 / 未知 / 解析失败」:
// 归一化 seam 说不认识 ⇒ 只静默,且绝不把原始载荷喂进上下文。
func TestNoticeUnparsableFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{ok: false}
b, chat, _, hist := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.sessionID)
}
for _, m := range chat.messages {
if strings.Contains(m.Content[0].Text, "notice_type") {
t.Errorf("原始载荷进了上下文:%v", m.Content)
}
}
if len(hist.records()) != 0 {
t.Errorf("无安全文本时不该落一条空登记:%+v", hist.records())
}
}
// TestNoticeNormalizerPanicFailsSafeSilent 覆盖畸形载荷把适配器解析器打崩:
// panic 必须折成 fail-safe 静默,绝不牵连常驻后端。
func TestNoticeNormalizerPanicFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{ok: true, panics: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.sessionID)
}
}
// TestNoticeWithoutNormalizerFailsSafeSilent 覆盖适配器未实现归一化 seam 的情况:
// 看不懂平台事件 ⇒ 静默(宁可登记,也不放行)。
func TestNoticeWithoutNormalizerFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &plainAdapter{fakeAdapter{name: "qq-obv11"}}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("无归一化器时未静默:%q", chat.sessionID)
}
}
// TestPreEntryReviewFailSafeIsSilent 直接钉住审查函数本身的 fail-safe 方向
// (未识别 / 空安全文本都不放行)。变异验证会让它变红。
func TestPreEntryReviewFailSafeIsSilent(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ad adapter.PlatformAdapter
msg adapter.UnifiedMessage
}{
{"未识别", &noticeAdapter{ok: false}, groupNoticeMessage()},
{"无归一化器", &plainAdapter{fakeAdapter{name: "qq-obv11"}}, groupNoticeMessage()},
{"安全文本为空", &noticeAdapter{ok: true, notice: adapter.SystemNotice{Kind: adapter.NoticeOther}}, groupNoticeMessage()},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, _, _, _ := noticeFixture(t, tc.ad, nil)
out := b.preEntryReview(context.Background(), &tc.msg, tc.ad)
if !out.Applicable {
t.Fatal("系统事件未被受理")
}
if out.Proceed {
t.Fatalf("fail-safe 方向被反转(应为静默):reason=%q", out.Reason)
}
})
}
}
// TestChatMessageIsNotAReviewSubject 是反向钉:普通聊天消息绝不进传入前审查
// (成本受控:只有系统事件走这条轻审查)。
func TestChatMessageIsNotAReviewSubject(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeJoin, SafeText: "x", Ignorable: true}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
msg := groupNoticeMessage()
msg.ContentType = adapter.ContentText
msg.Content = "大家好"
msg.Sender.Role = adapter.RoleMember
msg.SenderID = "42"
b.processFrom(context.Background(), msg, ad)
if ad.calls != 0 {
t.Errorf("普通聊天消息被送进了归一化 seam:calls=%d", ad.calls)
}
for _, line := range systemLines(chat.messages) {
if strings.HasPrefix(line, noticeSystemPrefix) {
t.Errorf("普通聊天消息被当成系统通知注入:%q", line)
}
}
}
// TestNoticeReviewTimeoutIsBounded 钉住超时常量本身,避免被改成无上限。
func TestNoticeReviewTimeoutIsBounded(t *testing.T) {
t.Parallel()
if noticeReviewTimeout <= 0 || noticeReviewTimeout > 30*time.Second {
t.Fatalf("noticeReviewTimeout = %v,应在 (0, 30s] 内", noticeReviewTimeout)
}
}
// TestNoticeFailSafeDirectionConstant 把保守方向钉在测试里(design/12 §6 策略一补充:
// 审查钩子失败一侧 = 静默)。变异验证改的就是这个常量。
func TestNoticeFailSafeDirectionConstant(t *testing.T) {
t.Parallel()
if noticeFailSafeProceeds {
t.Fatal("fail-safe 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)")
}
}