Files
Cyrene_v2/internal/services/channelbridge/persona_test.go
T
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

250 lines
9.2 KiB
Go

package channelbridge
import (
"context"
"testing"
"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/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// This file covers design/22 §2/§7-3/§7-4 from the channel side: which role an
// inbound IM turn answers as. The chain asserted is the structural session → the
// resolved SessionEnv the loop is driven with → the role named in it.
// personaHistory is the bridge's history store with one bound session role, so the
// owner-private mirror can be asserted to follow the main session's role.
type personaHistory struct {
*captureHistory
personas map[string]string
}
func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (string, error) {
return p.personas[sessionID], nil
}
// envChatter is a Chatter that records the SessionEnv the bridge resolved for the
// turn, which is where the role travels (the loop reads it from ctx).
type envChatter struct {
env loop.SessionEnv
got bool
}
func (e *envChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
e.env = loop.SessionEnvFrom(ctx)
e.got = true
return &loop.Result{Text: "在呢"}, nil
}
// personaBridge builds a started bridge whose single telegram account is bound to
// channelPersona, over a history store that binds mainSessionPersona to the main
// session ("" = unbound).
func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Bridge, *envChatter) {
t.Helper()
chat := &envChatter{}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"),
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: mainSessionPersona}},
ChannelPersona: func(platform, accountID string) string { return channelPersona },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
return b, chat
}
// TestInboundOwnerPrivateFollowsMainSessionRole is design/22 §7-3: an owner
// private IM chat is mirrored into the main web session, so it must answer as the
// role that session is bound to — not as the global default and not as the
// account's channel-level role.
func TestInboundOwnerPrivateFollowsMainSessionRole(t *testing.T) {
t.Parallel()
b, chat := personaBridge(t, "xilian", "yuki")
b.process(context.Background(), directMessage())
if !chat.got {
t.Fatal("the loop was never driven for the owner private message")
}
if chat.env.PersonaID != "xilian" {
t.Fatalf("resolved persona = %q, want the main session's binding (xilian)", chat.env.PersonaID)
}
// The session binding wins, so the channel binding is never reached here.
if got := b.personaOfAdapter("telegram"); got != "yuki" {
t.Fatalf("cached channel persona = %q, want yuki (resolved at connect)", got)
}
}
// TestInboundChannelPersonaPrecedence pins the three-state precedence of design/22
// §2: the channel-level binding applies only when neither the session nor anything
// stronger resolves a role.
func TestInboundChannelPersonaPrecedence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mainSession string
channel string
want string
}{
{name: "session binding wins", mainSession: "xilian", channel: "yuki", want: "xilian"},
{name: "channel binding when session unbound", mainSession: "", channel: "yuki", want: "yuki"},
{name: "default when neither binds", mainSession: "", channel: "", want: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, chat := personaBridge(t, tc.mainSession, tc.channel)
b.process(context.Background(), directMessage())
if !chat.got {
t.Fatal("the loop was never driven")
}
if chat.env.PersonaID != tc.want {
t.Fatalf("resolved persona = %q, want %q", chat.env.PersonaID, tc.want)
}
})
}
}
// TestInboundGroupForcesDefaultRole is design/22 §7-4: a group conversation — even
// the owner's own messages inside it — keeps the default role even when the account
// binds one, so the isolated group session can never be moved into a role's scope.
func TestInboundGroupForcesDefaultRole(t *testing.T) {
t.Parallel()
chat := &envChatter{}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}},
Owner: testOwnerProfile("telegram", "100"),
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
b.process(context.Background(), adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20",
SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", ContentType: adapter.ContentText,
})
if !chat.got {
t.Fatal("the loop was never driven")
}
if chat.env.PersonaID != "" {
t.Fatalf("group conversation persona = %q, want the default role (empty)", chat.env.PersonaID)
}
// The isolation boundary itself is untouched by role resolution.
if chat.env.MemWritable {
t.Fatalf("env = %+v, want the read-only restricted lane preserved", chat.env)
}
}
// TestResolvePersonaStrangerUsesDefaultRole covers the other restricted lane
// directly: a stranger's isolated private conversation is not the owner's mirror,
// so both the session binding and the channel binding are skipped (design/22 §7-4).
// The pipeline path is asserted for groups above; a stranger message only reaches
// the loop when the participation gate admits it (design/12 §6), which is
// orthogonal to role resolution and asserted elsewhere.
func TestResolvePersonaStrangerUsesDefaultRole(t *testing.T) {
t.Parallel()
b, err := New(Options{
Loop: &envChatter{},
Sessions: &stubSessions{},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
stranger := session.Session{SessionID: "sess-s", Owner: false, MemWritable: false}
target := inboundTargetFor(stranger, "telegram")
if target.private {
t.Fatalf("stranger target = %+v, want an isolated (non-mirrored) session", target)
}
if got := b.resolvePersona(context.Background(), stranger, target, "telegram"); got != "" {
t.Fatalf("stranger persona = %q, want the default role (empty)", got)
}
}
// TestChannelPersonaLookupSplitsAccountName proves the account-level lookup: a
// multi-account platform resolves the role of the account that received the
// message (platform:confId), not of the platform as a whole.
func TestChannelPersonaLookupSplitsAccountName(t *testing.T) {
t.Parallel()
var gotPlatform, gotAccount string
b, err := New(Options{
Loop: &envChatter{},
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
ChannelPersona: func(platform, accountID string) string {
gotPlatform, gotAccount = platform, accountID
if accountID == "conf0" {
return "yuki"
}
return ""
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram:conf0"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
if gotPlatform != "telegram" || gotAccount != "conf0" {
t.Fatalf("lookup = %s/%s, want telegram/conf0", gotPlatform, gotAccount)
}
if got := b.personaOfAdapter("telegram:conf0"); got != "yuki" {
t.Fatalf("cached persona = %q, want yuki", got)
}
if got := b.personaOfAdapter("telegram:conf1"); got != "" {
t.Fatalf("unbound account persona = %q, want empty", got)
}
}
// TestRemovedAccountForgetsChannelPersona locks the reload contract: an account
// dropped by a reload must not keep answering as its old channel role.
func TestRemovedAccountForgetsChannelPersona(t *testing.T) {
t.Parallel()
chat := &envChatter{}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
if got := b.personaOfAdapter("telegram"); got != "yuki" {
t.Fatalf("cached persona = %q, want yuki", got)
}
b.removeAdapter(ad, nil)
if got := b.personaOfAdapter("telegram"); got != "" {
t.Fatalf("cached persona after removal = %q, want empty", got)
}
}