- E3:channelbridge/events.go 唯一插入点 recordEvent(两轴映射 Category→Tier、摘要恒过 SanitizeNoticeText、角色/账号只认白名单)+ Options.EventStore 注入 - 未接线 store=nil ⇒ 内存计数 + 首次 DEBUG,绝不中断入站(变异实测:改成直接调 Record ⇒ panic,测试红) - 普通聊天消息零副作用(变异:去掉 Applicable 早退 ⇒ rows=1 红) - T1 遥测只计数不落行(变异:心跳误判 T2 ⇒ 「逐条落行 1 行,want 0」红) - E4:wire_channel_events.go(启动期清扫 + 双 ticker + 退出 flush,逐字对齐 wire_document_gc 的三条取舍)、wire_channels 注入、main.go 加一行 startChannelEvents - internal/config/duration_env.go:新 env 键的读取口径(进程环境 > .env > 默认;「没写」与「写了空」都当没配;非法值回落 + 报错)——避免「只写在 .env 里被静默忽略」 - 三个新 env 键:CHANNEL_EVENT_RETENTION / GC_INTERVAL / COUNTER_FLUSH(模板同批更新,测试钉住键名一致) - 插入点比设计示意图晚一步(放 preEntryReview 之后,只归一化一次且 NoticeClass 已有结论),「留存无条件、在任何早退之前」这条性质不变
231 lines
10 KiB
Go
231 lines
10 KiB
Go
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},
|
||
{"自身回声是 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},
|
||
{"自身回声命中自己 ⇒ 不升档(帧级遥测)",
|
||
adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT1},
|
||
{"类别缺失按 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)
|
||
}
|
||
}
|
||
}
|