package channelbridge // 本文件是**两轴映射与脱敏红线**的验收(design/26 §2/§4.2;切片 E3): // 类别 → 留存档的唯映射(含 self 命中升档与帧级遥测不升档)、类别逐字搬运、 // 摘要净化且绝不夹带平台原文 / URL / OneBot `flag`、当事人角色只认白名单、 // 账号 id 的解析口径。 // // 它与 events_test.go 的分工:那边验「留存这件事真的发生了」(落行 / 计数 / 降级), // 这边验「记进去的东西对不对」(映射、脱敏、字段口径)。 import ( "context" "fmt" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events" ) // TestEventTier_两轴映射_正常:类别 → 留存档的唯映射(§2.1/§2.3/§3),含 // `SelfRelated` 升档与「帧级遥测不升档」两侧。 func TestEventTier_两轴映射_正常(t *testing.T) { t.Parallel() cases := []struct { name string notice adapter.SystemNotice want events.Tier }{ {"心跳是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat}, events.TierT1}, {"生命周期是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle}, events.TierT1}, {"自身回声(无 self 信号)是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1}, {"他人在线状态是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus}, events.TierT1}, {"未知类型是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryUnknown}, events.TierT1}, {"解析失败是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryDecodeError}, events.TierT1}, {"群名片变更是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryCard}, events.TierT2}, {"消息撤回是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryRecall}, events.TierT2}, {"加好友请求是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryFriendRequest}, events.TierT2}, {"全员禁言是 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryBanAll}, events.TierT2}, {"戳一戳是 T3", adapter.SystemNotice{Category: adapter.NoticeCategoryPoke}, events.TierT3}, {"运气王是 T3", adapter.SystemNotice{Category: adapter.NoticeCategoryLuckyKing}, events.TierT3}, {"他人存在性命中自己 ⇒ 升档 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus, SelfRelated: true}, events.TierT2}, {"资源类命中自己 ⇒ 升档 T2", adapter.SystemNotice{Category: adapter.NoticeCategoryCard, SelfRelated: true}, events.TierT2}, {"心跳命中自己 ⇒ 不升档(帧级遥测)", adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat, SelfRelated: true}, events.TierT1}, {"生命周期命中自己 ⇒ 不升档(帧级遥测)", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle, SelfRelated: true}, events.TierT1}, {"自身回声命中自己 ⇒ 升档 T2(出站 id 回声须落行)", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT2}, {"类别缺失按 Kind 兜底:join ⇒ T2", adapter.SystemNotice{Kind: adapter.NoticeJoin}, events.TierT2}, {"类别缺失按 Kind 兜底:request ⇒ T2", adapter.SystemNotice{Kind: adapter.NoticeRequest}, events.TierT2}, {"类别缺失按 Kind 兜底:interaction ⇒ T3", adapter.SystemNotice{Kind: adapter.NoticeInteraction}, events.TierT3}, {"类别缺失按 Kind 兜底:typing ⇒ T1", adapter.SystemNotice{Kind: adapter.NoticeTyping}, events.TierT1}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := eventTier(tc.notice); got != tc.want { t.Fatalf("eventTier = %s, want %s", got, tc.want) } }) } } // TestEventCategory_逐字搬运适配器类别_正常:类别的命名归 adapter(§9.2 第 7 条), // 留存侧只做类型转换、**绝不改名**(改名会让同一件事在适配器与库里各有一个名字)。 // 类别为零值时兜底成 unknown —— 那正是「读不出具体类别」的诚实表达。 func TestEventCategory_逐字搬运适配器类别_正常(t *testing.T) { t.Parallel() cases := []struct { category adapter.NoticeCategory want events.Category }{ {adapter.NoticeCategoryBanAll, events.Category("ban_all")}, {adapter.NoticeCategoryLuckyKing, events.Category("lucky_king")}, {adapter.NoticeCategoryHonor, events.Category("honor")}, {adapter.NoticeCategoryJoin, events.Category("join")}, {adapter.NoticeCategoryUnknown, events.CategoryUnknown}, {adapter.NoticeCategoryDecodeError, events.CategoryDecodeError}, {"", events.CategoryUnknown}, } for _, tc := range cases { tc := tc t.Run(string(tc.want), func(t *testing.T) { t.Parallel() if got := eventCategory(adapter.SystemNotice{Category: tc.category}); got != tc.want { t.Fatalf("eventCategory(%q) = %q, want %q", tc.category, got, tc.want) } }) } } // TestEventSummary_净化且绝不夹带平台原文_正常:§4.2 的红线逐条钉—— // 正文 / 平台动作凭据(OneBot `request.flag`)/ 媒体 URL 绝不入事件(连结构都没有那些 // 字段),摘要必须过 `adapter.SanitizeNoticeText`(唯一净化出处)。 func TestEventSummary_净化且绝不夹带平台原文_正常(t *testing.T) { t.Parallel() ctx := context.Background() const ( secretFlag = "SECRET-FLAG-9f2c" bodyText = "这是群消息正文片段" mediaURL = "https://cdn.example.com/f.mp4?sign=abc" ) raw := []byte(`{"post_type":"request","request_type":"friend","flag":"` + secretFlag + `","comment":"` + bodyText + `","file":{"url":"` + mediaURL + `"}}`) msg := groupNoticeMessage() msg.RawData, msg.Content = raw, bodyText dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07" + strings.Repeat("啊", 300) ad := eventAdapter("qq-obv11", adapter.SystemNotice{ Kind: adapter.NoticeRequest, Category: adapter.NoticeCategoryFriendRequest, PlatformType: "friend", SafeText: dirty, HighValue: true, }) b, _, _, _ := eventBridgeFixture(t, ad, nil) out := b.preEntryReview(ctx, &msg, ad) ev := b.buildEvent(msg, ad, out) // 逐字段取证:把整条事件打成一行文本,验证三个红线取值一个都不在里面。 dump := fmt.Sprintf("%+v", ev) for _, secret := range []string{secretFlag, bodyText, mediaURL} { if strings.Contains(dump, secret) { t.Fatalf("事件里出现了绝不该落的内容 %q:%s", secret, dump) } } // 第二次净化是**本层**的责任(§4.2 的「脱敏唯一出处」),不能靠调用顺序兜底: // 即便 SafeText 未经 preEntryReview(顺序变了、或别的调用方直接问它), // `Event.Summary` 也必须已经净化。未知类型的兜底摘要(纯文本由本层生成)同样如此。 for _, notice := range []adapter.SystemNotice{ {Category: adapter.NoticeCategoryUnknown, PlatformType: "weird\x1b[31m\u200btype"}, {Kind: adapter.NoticeOther, SafeText: dirty}, } { got := eventSummary(notice) if strings.ContainsAny(got, "\u200b\x1b\x07\n") { t.Fatalf("eventSummary 没净化(依赖调用顺序会让这条不变量随时失效):%q", got) } if got == "" { t.Fatalf("eventSummary 生成了空摘要:%+v", notice) } } if strings.ContainsAny(ev.Summary, "\u200b\x1b\x07\n") { t.Fatalf("摘要未净化(含零宽/控制字符):%q", ev.Summary) } if len([]rune(ev.Summary)) > adapter.NoticeSafeTextMaxRunes+1 { t.Fatalf("摘要未按 NoticeSafeTextMaxRunes 截断:%d runes", len([]rune(ev.Summary))) } if !strings.Contains(ev.Summary, "群文件上传") { t.Fatalf("摘要 = %q, want 保留安全文本的可读部分", ev.Summary) } } // TestEventActorRole_只认白名单_正常:事件当事人的角色**只**由白名单判定(与 // session.ResolveActor 同源),绝不看正文、也绝不采信平台上报的 role 字段; // 「不是主人」不等于「是某个群的成员」,故非主人一律如实记 unknown。 func TestEventActorRole_只认白名单_正常(t *testing.T) { t.Parallel() b, _, _, _ := eventBridgeFixture(t, eventAdapter("qq-obv11", adapter.SystemNotice{}), nil) cases := []struct { name string actorID string want string }{ {"白名单命中 ⇒ owner", "1000", events.RoleOwner}, {"不在白名单 ⇒ unknown(判不出来就说判不出来)", "2000", events.RoleUnknown}, {"无当事人 ⇒ system(事件来自平台系统本身)", "", events.RoleSystem}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := b.eventActorRole("qq-obv11", tc.actorID); got != tc.want { t.Fatalf("eventActorRole(%q) = %q, want %q", tc.actorID, got, tc.want) } }) } } // TestAccountIDFromAdapter_多账号与单账号_正常:账号 id 从适配器注册名解析 // (`platform:confID`);单账号与 nil 适配器一律空串 —— 空串在 events 里定义为 // 「单账号 / 未知」,绝不编一个 id 出来。 func TestAccountIDFromAdapter_多账号与单账号_正常(t *testing.T) { t.Parallel() cases := []struct { name string ad adapter.PlatformAdapter want string }{ {"多账号取配置 id", ¬iceAdapter{fakeAdapter: fakeAdapter{name: "qq-obv11:conf0"}}, "conf0"}, {"单账号留空", ¬iceAdapter{fakeAdapter: fakeAdapter{name: "qq-obv11"}}, ""}, {"无适配器留空", nil, ""}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := accountIDFromAdapter(tc.ad); got != tc.want { t.Fatalf("accountIDFromAdapter = %q, want %q", got, tc.want) } }) } } // TestNoticeClassName_可读名_正常:上下文档(NoticeClass)的裁决结果只进日志、不入事件行 // (§9.2 第 2 条),因此它的可读名是日志可读性的唯一依赖。 func TestNoticeClassName_可读名_正常(t *testing.T) { t.Parallel() cases := map[adapter.NoticeClass]string{ adapter.NoticeRegisterOnly: "register_only", adapter.NoticeLimitedInject: "limited_inject", adapter.NoticeEscalate: "escalate", } for class, want := range cases { if got := noticeClassName(class); got != want { t.Fatalf("noticeClassName(%d) = %q, want %q", class, got, want) } } }