Files
2026-07-13 13:00:08 +08:00

502 lines
18 KiB
Go

package cli
import (
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
func TestRememberBotRemoteStoresIncomingChatID(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
msg := bot.InboundMessage{
Platform: bot.PlatformWeixin,
ChatType: bot.ChatDM,
ChatID: "wx-chat-1",
UserID: "wx-user-1",
}
if err := botruntime.RememberInbound(msg); err != nil {
t.Fatalf("rememberBotInbound: %v", err)
}
if err := botruntime.RememberInbound(msg); err != nil {
t.Fatalf("rememberBotRemote duplicate: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if len(got.Bot.Connections) != 2 {
t.Fatalf("connections = %d, want 2", len(got.Bot.Connections))
}
var wx config.BotConnectionConfig
var fs config.BotConnectionConfig
for _, conn := range got.Bot.Connections {
switch conn.ID {
case "weixin-weixin":
wx = conn
case "feishu-feishu":
fs = conn
}
}
if len(fs.SessionMappings) != 0 {
t.Fatalf("feishu mappings = %+v, want none", fs.SessionMappings)
}
if len(wx.SessionMappings) != 1 {
t.Fatalf("weixin mappings = %+v, want one", wx.SessionMappings)
}
if m := wx.SessionMappings[0]; m.RemoteID != "wx-chat-1" || m.Scope != "global" || m.WorkspaceRoot != "" || m.UpdatedAt == "" {
t.Fatalf("weixin mapping = %+v, want global wx-chat-1 with timestamp", m)
}
if got := got.Bot.Allowlist.WeixinUsers; len(got) != 1 || got[0] != "wx-user-1" {
t.Fatalf("weixin users = %+v, want wx-user-1", got)
}
}
func TestRememberBotRemoteKeepsProjectScopedConnection(t *testing.T) {
isolateBotUserConfig(t)
workspace := filepath.Join(t.TempDir(), "project")
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-project",
Provider: "feishu",
Domain: "feishu",
Label: "飞书",
Enabled: true,
Status: "connected",
WorkspaceRoot: workspace,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if err := botruntime.RememberInbound(bot.InboundMessage{
Platform: bot.PlatformFeishu,
ChatType: bot.ChatDM,
ChatID: "oc-chat-1",
UserID: "ou-user-1",
}); err != nil {
t.Fatalf("rememberBotInbound: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if len(got.Bot.Connections) != 1 || len(got.Bot.Connections[0].SessionMappings) != 1 {
t.Fatalf("connections = %+v, want one project mapping", got.Bot.Connections)
}
if m := got.Bot.Connections[0].SessionMappings[0]; m.RemoteID != "oc-chat-1" || m.Scope != "project" || m.WorkspaceRoot != workspace {
t.Fatalf("mapping = %+v, want project scoped remote", m)
}
if got := got.Bot.Allowlist.FeishuUsers; len(got) != 1 || got[0] != "ou-user-1" {
t.Fatalf("feishu users = %+v, want ou-user-1", got)
}
}
func TestRememberBotInboundStoresGroupAllowlist(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
msg := bot.InboundMessage{
Platform: bot.PlatformFeishu,
ChatType: bot.ChatGroup,
ChatID: "oc-group-1",
UserID: "ou-user-1",
}
if err := botruntime.RememberInbound(msg); err != nil {
t.Fatalf("rememberBotInbound: %v", err)
}
if err := botruntime.RememberInbound(msg); err != nil {
t.Fatalf("rememberBotInbound duplicate: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
if users := got.Bot.Allowlist.FeishuUsers; len(users) != 1 || users[0] != "ou-user-1" {
t.Fatalf("feishu users = %+v, want one ou-user-1", users)
}
if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
t.Fatalf("feishu groups = %+v, want one oc-group-1", groups)
}
}
func TestBotDoctorReportsSessionMappingCounts(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
}
cfg.Bot.Connections[0].SessionMappings = []config.BotConnectionSessionMapping{{RemoteID: "oc-chat-1", Scope: "global"}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
out := captureStdout(t, func() {
if rc := botDoctor([]string{"--json"}); rc != 0 {
t.Fatalf("botDoctor rc = %d, want 0", rc)
}
})
for _, want := range []string{
`"name":"bot.connections","status":"ok","detail":"enabled=2 total=2"`,
`"name":"bot.connection.feishu-feishu.session_mappings","status":"ok","detail":"provider=feishu mappings=1"`,
`"name":"bot.connection.weixin-weixin.session_mappings","status":"missing","detail":"provider=weixin mappings=0"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
}
}
}
func TestBotDoctorDeepReportsPairingAndRoles(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Pairing.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
cfg.Bot.Allowlist.FeishuApprovers = []string{"ou-approver"}
cfg.Bot.Allowlist.FeishuAdmins = []string{"ou-admin"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if _, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
Platform: bot.PlatformFeishu,
ConnectionID: "feishu-feishu",
ChatType: bot.ChatDM,
ChatID: "chat",
UserID: "pending-user",
}, bot.PairingConfig{Enabled: true}); err != nil {
t.Fatalf("create pairing: %v", err)
}
out := captureStdout(t, func() {
if rc := botDoctor([]string{"--json", "--deep"}); rc != 0 {
t.Fatalf("botDoctor rc = %d, want 0", rc)
}
})
for _, want := range []string{
`"name":"bot.pairing.pending","status":"ok","detail":"1 pending"`,
`"name":"bot.roles","status":"ok","detail":"approvers=1 admins=1"`,
`"name":"bot.config.user","status":"ok"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("bot doctor deep output missing %s:\n%s", want, out)
}
}
}
func TestBotPairingApproveAddsAllowlistAndFirstAdmin(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
Platform: bot.PlatformWeixin,
ChatType: bot.ChatDM,
ChatID: "wx-chat",
UserID: "wx-user",
}, bot.PairingConfig{Enabled: true})
if err != nil {
t.Fatalf("create pairing: %v", err)
}
if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
t.Fatalf("botPairing approve rc = %d, want 0", rc)
}
got := config.LoadForEdit(config.UserConfigPath())
if users := got.Bot.Allowlist.WeixinUsers; len(users) != 1 || users[0] != "wx-user" {
t.Fatalf("weixin users = %+v, want wx-user", users)
}
if admins := got.Bot.Allowlist.WeixinAdmins; len(admins) != 1 || admins[0] != "wx-user" {
t.Fatalf("weixin admins = %+v, want first paired admin", admins)
}
if approvers := got.Bot.Allowlist.WeixinApprovers; len(approvers) != 1 || approvers[0] != "wx-user" {
t.Fatalf("weixin approvers = %+v, want first paired approver", approvers)
}
}
func TestBotPairingApproveAddsUserToConnectionAccess(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "Lark",
Enabled: true,
Status: "connected",
Access: config.BotAccessConfig{
Enabled: true,
PairingEnabled: true,
Users: []string{"ou-existing"},
},
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
Platform: bot.PlatformFeishu,
ConnectionID: "feishu-lark",
Domain: "lark",
ChatType: bot.ChatDM,
ChatID: "oc-chat",
UserID: "ou-new",
}, bot.PairingConfig{Enabled: true})
if err != nil {
t.Fatalf("create pairing: %v", err)
}
if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
t.Fatalf("botPairing approve rc = %d, want 0", rc)
}
got := config.LoadForEdit(config.UserConfigPath())
if users := got.Bot.Allowlist.FeishuUsers; len(users) != 0 {
t.Fatalf("global feishu users = %+v, want unchanged global allowlist", users)
}
if len(got.Bot.Connections) != 1 {
t.Fatalf("connections = %+v, want one connection", got.Bot.Connections)
}
access := got.Bot.Connections[0].Access
if !access.Enabled {
t.Fatal("connection access disabled after approval, want enabled")
}
for _, want := range []string{"ou-existing", "ou-new"} {
if !hasTestString(access.Users, want) {
t.Fatalf("connection users = %+v, want %s", access.Users, want)
}
}
}
func TestBotDoctorPrefersUserBotSettingsOverProjectBotConfig(t *testing.T) {
isolateBotUserConfig(t)
userCfg := config.Default()
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
userCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = false
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
t.Chdir(project)
out := captureStdout(t, func() {
if rc := botDoctor([]string{"--json"}); rc != 0 {
t.Fatalf("botDoctor rc = %d, want 0", rc)
}
})
for _, want := range []string{
`"name":"bot.enabled","status":"ok"`,
`"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
`"name":"bot.connection.feishu-lark.session_mappings","status":"missing","detail":"provider=feishu mappings=0"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
}
}
}
func TestBotDoctorUsesProjectBotConfigWhenUserBotIsUnconfigured(t *testing.T) {
isolateBotUserConfig(t)
projectCfg := config.Default()
projectCfg.Bot.Enabled = true
projectCfg.Bot.Allowlist.AllowAll = true
projectCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
}
if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
t.Fatalf("save project config: %v", err)
}
out := captureStdout(t, func() {
if rc := botDoctor([]string{"--json"}); rc != 0 {
t.Fatalf("botDoctor rc = %d, want 0", rc)
}
})
for _, want := range []string{
`"name":"bot.enabled","status":"ok"`,
`"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
`"name":"bot.allowlist","status":"open"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
}
}
}
func TestBotDoctorUsesProjectBotConfigWhenUserConfigOnlyHasBotDefaults(t *testing.T) {
isolateBotUserConfig(t)
userCfg := config.Default()
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
projectCfg := config.Default()
projectCfg.Bot.Enabled = true
projectCfg.Bot.Allowlist.AllowAll = true
projectCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
}
if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
t.Fatalf("save project config: %v", err)
}
out := captureStdout(t, func() {
if rc := botDoctor([]string{"--json"}); rc != 0 {
t.Fatalf("botDoctor rc = %d, want 0", rc)
}
})
for _, want := range []string{
`"name":"bot.enabled","status":"ok"`,
`"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
`"name":"bot.allowlist","status":"open"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("bot doctor output missing %s:\n%s", want, out)
}
}
}
func TestBotConnectionChannelConfigsKeepFeishuAndLarkSeparate(t *testing.T) {
connections := []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Model: "feishu-model", WorkspaceRoot: "/feishu"},
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Model: "lark-model", WorkspaceRoot: "/lark"},
}
channels := botruntime.ConnectionChannelConfigs(connections, true, true)
if channels["feishu-feishu"].Model != "feishu-model" || channels["feishu-feishu"].WorkspaceRoot != "/feishu" {
t.Fatalf("feishu channel = %+v, want feishu override", channels["feishu-feishu"])
}
if channels["feishu-lark"].Model != "lark-model" || channels["feishu-lark"].WorkspaceRoot != "/lark" {
t.Fatalf("lark channel = %+v, want lark override", channels["feishu-lark"])
}
}
func TestBotAdapterBindingsCreateSeparateFeishuAndLarkInstances(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Credential: config.BotConnectionCredential{AccountID: "wx-account", TokenEnv: "WEIXIN_BOT_TOKEN"}},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
bindings := botruntime.AdapterBindings(cfg, map[bot.Platform]bool{bot.PlatformFeishu: true, bot.PlatformWeixin: true}, nil, logger)
got := map[string]bot.AdapterBinding{}
for _, binding := range bindings {
got[binding.ID] = binding
}
for _, id := range []string{"feishu-feishu", "feishu-lark", "weixin-weixin"} {
if got[id].Adapter == nil {
t.Fatalf("binding %s missing from %+v", id, bindings)
}
}
if got["feishu-feishu"].Domain != "feishu" || got["feishu-lark"].Domain != "lark" {
t.Fatalf("domains = feishu:%q lark:%q, want separate domains", got["feishu-feishu"].Domain, got["feishu-lark"].Domain)
}
}
func TestBotAdapterBindingsIsolateRequestedFeishuDomain(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
enabled := map[bot.Platform]bool{bot.PlatformFeishu: true}
larkOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"lark"}), logger)
if len(larkOnly) != 1 || larkOnly[0].ID != "feishu-lark" {
t.Fatalf("--channels lark bindings = %+v, want only feishu-lark", larkOnly)
}
feishuOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"feishu"}), logger)
if len(feishuOnly) != 1 || feishuOnly[0].ID != "feishu-feishu" {
t.Fatalf("--channels feishu bindings = %+v, want only feishu-feishu", feishuOnly)
}
}
func TestRememberBotInboundUsesConnectionID(t *testing.T) {
isolateBotUserConfig(t)
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if err := botruntime.RememberInbound(bot.InboundMessage{
Platform: bot.PlatformFeishu,
ConnectionID: "feishu-lark",
Domain: "lark",
ChatType: bot.ChatDM,
ChatID: "oc-lark-chat",
UserID: "ou-lark-user",
}); err != nil {
t.Fatalf("rememberBotInbound: %v", err)
}
got := config.LoadForEdit(config.UserConfigPath())
var feishuConn, larkConn config.BotConnectionConfig
for _, conn := range got.Bot.Connections {
switch conn.ID {
case "feishu-feishu":
feishuConn = conn
case "feishu-lark":
larkConn = conn
}
}
if len(feishuConn.SessionMappings) != 0 {
t.Fatalf("feishu mappings = %+v, want none", feishuConn.SessionMappings)
}
if len(larkConn.SessionMappings) != 1 || larkConn.SessionMappings[0].RemoteID != "oc-lark-chat" {
t.Fatalf("lark mappings = %+v, want lark chat only", larkConn.SessionMappings)
}
}
func isolateBotUserConfig(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
t.Chdir(t.TempDir())
}
func hasTestString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}