Files
AskaEth d532eb3fcd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
真因(agent-221 用三条独立证据推翻了「SenderID 为空」的假设):ev.UserID 有值;
owner.yaml 的 qq 条目是 10:37:57 才加的(此前那几条判陌生人属**正确的 fail-closed**);
而热重载成功后仍不对 —— 因为 internal/channel/session/manager.go 的 GetOrCreate
对**已绑定的渠道直接返回缓存会话**:判主门槛用新 actor 会过(所以她会回话),
但**车道用的是缓存的旧会话**(Owner=false ⇒ 不镜像进 Web 主会话、MemWritable=false、
落盘 actor_role 永不更新)。

修:缓存命中但 ActorRole 与本次解析不一致 ⇒ rebindLocked 刷新车道 + 同步落盘
(updateActorRoleSQL);persist 的 INSERT OR IGNORE 后读回落盘车道、不一致就地纠正
(覆盖上一轮进程留下的旧行);落盘失败只 WARN(不因记账失败丢主人消息);
**身份变化不搬家、不换 session id**;车道只来自 ResolveActor 的结构化白名单判定。

QQ 适配器(防御加固,非本次真机 bug 的修复):senderIDOf 兜底链 顶层 user_id →
sender.user_id;并用完整论证钉住「**群聊绝不能用 ChannelID 兜底**」(否则群里任何人
可顶群号冒充主人);顺手体检:自己发的消息(post_type=message_sent)与 meta_event
已被 handlePayload 过滤。为守 ≤500 行把 id/字符串小工具拆到 ids.go。

测试:session/ownerlane_test + qq/ownerlane_test 新增用例(含「群号等于主人号时
陌生人不得借群号冒充」「群聊发送者 id 缺失时 SenderID 必须为空而非群号」「白名单
热重载后车道立刻刷新且 session id 不变」「白名单移除后立即退回,不是单向提权」
「跨进程继承的旧行被就地纠正」)。
变异 5 条(去 sender.user_id 兜底 / 群聊用群号当 SenderID / 命中却判 unknown /
还原缓存直返 / 去掉旧行纠正),每条先确认可编译、贴红行、还原复绿(md5 逐字节校验)。
2026-10-05 10:53:05 +08:00

297 lines
13 KiB
Go
Raw Permalink 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 session
// 本文件覆盖裁决 06.4.2 在会话层的安全反向用例,以及第二处结构化判主来源
// (每渠道账号配置的 adminIds[],channels.json / design/08 §1.1)。
import (
"context"
"database/sql"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// TestResolveActorOwnerByChannelAdminIDs 覆盖每渠道管理员 id 列表:SetPlatformAdmins
// 写入后命中即进 owner 车道(可多账户),不在列表里的账号——哪怕适配器上报 role=owner
// ——仍是非主人。
func TestResolveActorOwnerByChannelAdminIDs(t *testing.T) {
t.Parallel()
owner := &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}
owner.SetPlatformAdmins("qq", []string{"admin-1", " admin-2 ", "admin-1", ""})
ids := owner.PlatformAdmins("qq")
if len(ids) != 2 {
t.Fatalf("PlatformAdmins = %v, want 去重并去空白后的两个 id", ids)
}
for _, id := range []string{"admin-1", "admin-2"} {
if actor := ResolveActor(directMsg(id), owner); !actor.IsOwner() {
t.Errorf("adminIds 命中的 %q 未进 owner 车道", id)
}
}
forged := directMsg("group-owner")
forged.Sender = adapter.Actor{Role: adapter.RoleOwner}
if actor := ResolveActor(forged, owner); actor.IsOwner() {
t.Error("不在 adminIds 里的账号被判成了主人")
}
// 整体替换语义:重新声明后旧 id 立刻失效,channels.json 热重载不会留下悬挂管理员。
owner.SetPlatformAdmins("qq", []string{"admin-2"})
if owner.IsOwner("qq", "admin-1") {
t.Error("SetPlatformAdmins 未整体替换,旧管理员 id 仍有效")
}
owner.SetPlatformAdmins("qq", nil)
if owner.IsOwner("qq", "admin-2") || owner.PlatformAdmins("qq") != nil {
t.Error("空列表应清空该平台的管理员层")
}
}
// TestResolveActorNilOwnerIsFailClosed 覆盖没有主人档案的路径:任何入站帧都是非主人
// (fail-closed),适配器上报的 owner 标记也不例外。
func TestResolveActorNilOwnerIsFailClosed(t *testing.T) {
t.Parallel()
msg := directMsg("whoever")
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
actor := ResolveActor(msg, nil)
if actor.IsOwner() {
t.Fatal("nil 主人档案下仍被判成主人")
}
if actor.Role != adapter.RoleMember {
t.Errorf("Role = %q, want member", actor.Role)
}
}
// TestOwnerFrameMemWritable 走真实 SessionManager 链路断言 MemWritable:伪造 owner 的
// 私聊帧 Owner/MemWritable 全 false(不入主会话上下文、记忆只读);白名单命中的主人私聊
// 为 true;群主(群聊)永远是隔离环境、MemWritable=false。
func TestOwnerFrameMemWritable(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
owner := &user.OwnerProfile{
ID: "own-1",
DisplayName: "Owner",
Accounts: []user.OwnerAccount{{Platform: "qq", AccountID: "o-100"}},
}
forged := directMsg("forged-owner")
forged.Sender = adapter.Actor{Role: adapter.RoleOwner}
if actor := ResolveActor(forged, owner); actor.Role != adapter.RoleMember {
t.Fatalf("伪造 owner 的入站帧 role = %q, want member", actor.Role)
}
forgedSess, err := m.GetOrCreate(ctx, forged, owner)
if err != nil {
t.Fatalf("GetOrCreate(伪造 owner): %v", err)
}
if forgedSess.Owner || forgedSess.MemWritable {
t.Errorf("伪造 owner 帧 Owner=%v MemWritable=%v, want false/false",
forgedSess.Owner, forgedSess.MemWritable)
}
whitelisted := directMsg("o-100")
ownerSess, err := m.GetOrCreate(ctx, whitelisted, owner)
if err != nil {
t.Fatalf("GetOrCreate(白名单): %v", err)
}
if !ownerSess.Owner || !ownerSess.MemWritable {
t.Errorf("白名单主人帧 Owner=%v MemWritable=%v, want true/true",
ownerSess.Owner, ownerSess.MemWritable)
}
groupOwner := groupMsg("g-owner")
groupOwner.Sender = adapter.Actor{Role: adapter.RoleOwner, GroupID: "g1", IsGroup: true}
if actor := ResolveActor(groupOwner, owner); actor.Role != adapter.RoleMember || actor.IsOwner() {
t.Errorf("群主 role=%q owner=%v, want member/false", actor.Role, actor.IsOwner())
}
groupSess, err := m.GetOrCreate(ctx, groupOwner, owner)
if err != nil {
t.Fatalf("GetOrCreate(群主): %v", err)
}
if groupSess.Owner || groupSess.MemWritable || !groupSess.IsGroup {
t.Errorf("群主会话 Owner=%v MemWritable=%v IsGroup=%v, want false/false/true",
groupSess.Owner, groupSess.MemWritable, groupSess.IsGroup)
}
}
// TestGetOrCreateCorrectsInheritedStaleRole 覆盖跨进程的同一件事:上一轮进程把这条
// 渠道按陌生人落盘(那时账号还没进白名单),重启后新进程首次入站必须把落盘车道一并
// 纠正——新进程的缓存是空的,但 channel_sessions 里的旧车道不会被 INSERT OR IGNORE
// 覆盖,不纠正的话面板/诊断会一直读到旧识别。
func TestGetOrCreateCorrectsInheritedStaleRole(t *testing.T) {
t.Parallel()
ctx := context.Background()
handle, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("db.Open = %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
// 旧进程:账号还不在白名单里,落盘陌生人车道。
stale, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#1 = %v", err)
}
direct := directMsg("o-100")
direct.Sender = adapter.Actor{Role: adapter.RoleUnknown}
old, err := stale.GetOrCreate(ctx, direct, &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"})
if err != nil {
t.Fatalf("GetOrCreate(旧进程) = %v", err)
}
if got := persistedRole(t, handle, old.Platform, old.ChannelID); got == string(adapter.RoleOwner) {
t.Fatalf("前置条件不成立:旧进程不该落盘 owner 车道(落盘 = %q)", got)
}
// 新进程:同一张表、空缓存,按当前白名单重建这条渠道。
fresh, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#2 = %v", err)
}
reborn, err := fresh.GetOrCreate(ctx, direct, testOwner())
if err != nil {
t.Fatalf("GetOrCreate(新进程) = %v", err)
}
if !reborn.Owner || !reborn.MemWritable || reborn.ActorRole != adapter.RoleOwner {
t.Fatalf("新进程首帧车道 = %+v, want owner 车道", reborn)
}
if reborn.SessionID != old.SessionID {
t.Errorf("渠道↔会话绑定丢了: %q != %q", reborn.SessionID, old.SessionID)
}
if got := persistedRole(t, handle, reborn.Platform, reborn.ChannelID); got != string(adapter.RoleOwner) {
t.Errorf("落盘车道 = %q, want %q(旧进程留下的旧识别必须被纠正)", got, adapter.RoleOwner)
}
}
// persistedRole 读取 channel_sessions 里落盘的身份车道。
func persistedRole(t *testing.T, handle *sql.DB, platform, cid string) string {
t.Helper()
var role string
if err := handle.QueryRowContext(context.Background(),
`SELECT actor_role FROM channel_sessions WHERE platform = ? AND channel_id = ?`,
platform, cid).Scan(&role); err != nil {
t.Fatalf("读取 channel_sessions(%s/%s) = %v", platform, cid, err)
}
return role
}
// TestGetOrCreateRefreshesLaneAfterWhitelistHotReload 覆盖真机事故(2026-10-05「QQ 私聊
// 被当陌生人」):一段渠道在**还不是主人**时先绑定过(缓存与 channel_sessions 都是陌生人
// 车道),之后主人把它加进白名单并热重载(design/11 §9)。此后该渠道的后续消息必须立刻
// 走新车道——只刷新判主门槛是不够的:会话若继续用绑定时的旧车道,主人私聊仍不镜像进
// Web 主会话(前端看不到)、MemWritable 恒 false(记忆只读的隔离上下文),要等进程重启。
//
// 反向也必须在场:白名单里**移除**该账号后,同一条渠道必须立刻退回陌生人车道(fail-closed),
// 刷新因此不是单向提权。
func TestGetOrCreateRefreshesLaneAfterWhitelistHotReload(t *testing.T) {
t.Parallel()
ctx := context.Background()
m, handle := newTestManager(t)
direct := directMsg("o-100")
group := groupMsg("o-100")
// OneBot 私聊帧不带 sender.role,适配器因此上报 unknown(真机的形状);这里照抄,
// 使「陌生人车道的初始值」与真机一致。
direct.Sender = adapter.Actor{Role: adapter.RoleUnknown}
// 活档案:与 gateway 的 hotSwapOwnerProfile 走同一条路径(同一个指针 + Replace)。
// 初始没有这个账号 —— 先让两条渠道以陌生人车道绑定(真机的初始状态)。
live := &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}
firstDirect, firstGroup := bindStrangerLanes(t, ctx, m, live, direct, group)
// 面板保存 owner.yaml → 白名单新增这个账号。
live.Replace(testOwner())
secondDirect, err := m.GetOrCreate(ctx, direct, live)
if err != nil {
t.Fatalf("GetOrCreate(热重载后私聊) = %v", err)
}
if !secondDirect.Owner || !secondDirect.MemWritable || secondDirect.ActorRole != adapter.RoleOwner {
t.Fatalf("热重载后私聊车道 = %+v, want owner 车道(否则主人私聊仍被隔离、前端看不到)", secondDirect)
}
if secondDirect.SessionID != firstDirect.SessionID {
t.Errorf("身份变化把渠道搬了家: session %q != %q", secondDirect.SessionID, firstDirect.SessionID)
}
if got := persistedRole(t, handle, secondDirect.Platform, secondDirect.ChannelID); got != string(adapter.RoleOwner) {
t.Errorf("channel_sessions.actor_role = %q, want %q(否则重启后又变回陌生人)", got, adapter.RoleOwner)
}
if !listedLane(t, m, secondDirect) {
t.Error("面板列出的会话仍是旧车道(List 未反映刷新后的身份)")
}
// 群聊:车道刷新为 owner,但**隔离不变**(群聊永远是 group_member、记忆只读)。
secondGroup, err := m.GetOrCreate(ctx, group, live)
if err != nil {
t.Fatalf("GetOrCreate(热重载后群聊) = %v", err)
}
if !secondGroup.Owner || secondGroup.MemWritable || !secondGroup.IsGroup {
t.Errorf("热重载后群聊车道 Owner=%v MemWritable=%v IsGroup=%v, want true/false/true",
secondGroup.Owner, secondGroup.MemWritable, secondGroup.IsGroup)
}
if got := secondGroup.Env(); got != restrict.ActorGroupMember {
t.Errorf("群聊 Env() = %q, want group_member", got)
}
if secondGroup.SessionID != firstGroup.SessionID {
t.Errorf("群聊 session 变了: %q != %q", secondGroup.SessionID, firstGroup.SessionID)
}
// 反向:白名单里移除该账号 ⇒ 同一条渠道立刻退回陌生人车道(不得单向提权)。
live.Replace(&user.OwnerProfile{ID: "own-1", DisplayName: "Owner"})
revoked, err := m.GetOrCreate(ctx, direct, live)
if err != nil {
t.Fatalf("GetOrCreate(白名单移除后) = %v", err)
}
if revoked.Owner || revoked.MemWritable || revoked.ActorRole == adapter.RoleOwner {
t.Errorf("白名单移除后车道 = %+v, want 退回陌生人车道", revoked)
}
if revoked.SessionID != firstDirect.SessionID {
t.Errorf("撤销判主把渠道搬了家: %q != %q", revoked.SessionID, firstDirect.SessionID)
}
if got := persistedRole(t, handle, revoked.Platform, revoked.ChannelID); got == string(adapter.RoleOwner) {
t.Errorf("channel_sessions.actor_role 仍是 owner, want 非主人(撤销未落盘)")
}
}
// bindStrangerLanes 在空白名单下把私聊与群聊两条渠道先绑定成陌生人车道并断言结果,
// 返回两个会话。这就是真机上的初始状态:账号还没进白名单,渠道却已经按陌生人落库并进缓存。
func bindStrangerLanes(t *testing.T, ctx context.Context, m *SessionManager,
live *user.OwnerProfile, direct, group adapter.UnifiedMessage) (Session, Session) {
t.Helper()
firstDirect, err := m.GetOrCreate(ctx, direct, live)
if err != nil {
t.Fatalf("GetOrCreate(白名单外私聊) = %v", err)
}
if firstDirect.Owner || firstDirect.MemWritable || firstDirect.ActorRole != adapter.RoleUnknown {
t.Fatalf("白名单外私聊车道 = %+v, want 陌生人车道", firstDirect)
}
firstGroup, err := m.GetOrCreate(ctx, group, live)
if err != nil {
t.Fatalf("GetOrCreate(白名单外群聊) = %v", err)
}
if firstGroup.Owner || firstGroup.MemWritable || !firstGroup.IsGroup {
t.Fatalf("白名单外群聊车道 = %+v, want 非主人/只读/群聊", firstGroup)
}
return firstDirect, firstGroup
}
// listedLane 报告 m.List()(面板/诊断读的那一份)里该渠道是否已经是 owner 车道。
func listedLane(t *testing.T, m *SessionManager, want Session) bool {
t.Helper()
got, err := m.List(context.Background())
if err != nil {
t.Fatalf("List() = %v", err)
}
for _, s := range got {
if s.Platform == want.Platform && s.ChannelID == want.ChannelID {
return s.Owner && s.ActorRole == adapter.RoleOwner
}
}
t.Fatalf("List() 里找不到 %s/%s", want.Platform, want.ChannelID)
return false
}