- 缺口:后端解析与面板都已就绪,但 gateway 没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」重置为 0=保护性默认 6 条/分 - 5 处:AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf 映射 / 微信扫码重建账号 - 语义零改动(只透传);变异实测:删拷贝 ⇒ channels.json 变 0(want -1)、删微信重建拷贝 ⇒ 同红、删 GET 映射 ⇒ 回显缺失 - 顺带登记一条负载诱发 flake(capability/plugin 的 TestManagerInstall_ConnectFailureDoesNotPersist,靠「端口被拒」断言,满载时端口被抢)⇒ 非本切片引入,已进 00-索引 §四
160 lines
6.5 KiB
Go
160 lines
6.5 KiB
Go
package gateway
|
||
|
||
// 本文件覆盖渠道账号**顶层限流字段 `directMsgPerMin`** 的写/读往返 ——
|
||
// 「已关闭的流控被静默重置」回归(写入见 admin_write_channel_config.go 的
|
||
// DTO / 请求体 / config.ChannelAccount 拷贝 / adminChannelAccountOf,
|
||
// 以及 admin_channels_weixin.go 的扫码重建账号)。
|
||
//
|
||
// 语义(与 internal/services/channelbridge/flowgate.go 的 directLimit 逐字同口径):
|
||
//
|
||
// 键缺失 / 0 ⇒ 保护性默认 6 条/分(陌生人必须有流控,缺省绝不解释成「关闭」)
|
||
// 正值 ⇒ 该值
|
||
// 负值 ⇒ 关闭该限制(主人的显式选择,桥会留一条带 source 的 WARN)
|
||
//
|
||
// 它在线上是**账号顶层**字段(不在 config 里),面板回显后把该值原样写回,
|
||
// 因此两端任一处漏掉它,「已关闭」都会被静默变回「保护性默认 6 条/分」——
|
||
// 面板保存一次账号卡 = 主人明确关掉的限制被系统悄悄打开。
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// TestAdminSaveChannelConfigRoundTripsDirectMsgPerMin 是主回归:PUT 里给出的
|
||
// directMsgPerMin 必须**逐字**落到 channels.json(负值尤其不许被吞成 0),
|
||
// 且 GET /admin/channels 把它如实回显给面板 —— 面板正是靠这次回显才知道
|
||
// 该字段「已关闭」,进而在下一次保存时把 -1 原样送回。
|
||
func TestAdminSaveChannelConfigRoundTripsDirectMsgPerMin(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range []struct {
|
||
name string
|
||
baseline int
|
||
body string
|
||
want int
|
||
}{
|
||
{
|
||
name: "已关闭(-1)再保存一次仍是 -1",
|
||
baseline: -1,
|
||
body: `{"id":"conf0","token":"bt","directMsgPerMin":-1}`,
|
||
want: -1,
|
||
},
|
||
{
|
||
name: "原本是正值 5,改成 -1 关掉",
|
||
baseline: 5,
|
||
body: `{"id":"conf0","token":"bt","directMsgPerMin":-1}`,
|
||
want: -1,
|
||
},
|
||
{
|
||
name: "原本已关闭,改成正值 4 重新打开",
|
||
baseline: -1,
|
||
body: `{"id":"conf0","token":"bt","directMsgPerMin":4}`,
|
||
want: 4,
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"kook": {{ID: "conf0", Config: map[string]any{"bot_token": "bt"}, DirectMsgPerMin: tc.baseline}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
if resp, body := putChannelConfigRaw(t, ts, "kook", tc.body, tok); resp.StatusCode != http.StatusCreated {
|
||
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
|
||
// 盘上:channels.json 的账号顶层字段,就是请求体给的值。
|
||
acct, ok := storedChannelAccountOrNil(t, path, "kook", "conf0")
|
||
if !ok {
|
||
t.Fatal("kook/conf0 vanished from channels.json")
|
||
}
|
||
if acct.DirectMsgPerMin != tc.want {
|
||
t.Fatalf("channels.json directMsgPerMin = %d, want %d —— 被吞成 0 就等于把关掉的限制"+
|
||
"静默重置回保护性默认 6 条/分", acct.DirectMsgPerMin, tc.want)
|
||
}
|
||
// 内存里的账号(热重载后适配器读的就是它)。
|
||
for _, live := range store.accounts["kook"] {
|
||
if live.ID == "conf0" && live.DirectMsgPerMin != tc.want {
|
||
t.Fatalf("in-memory directMsgPerMin = %d, want %d", live.DirectMsgPerMin, tc.want)
|
||
}
|
||
}
|
||
// GET 如实回显(否则面板把它显示成「未设置」,下一次保存就把它抹成 0)。
|
||
_, listBody := adminChannelsList(t, ts, tok)
|
||
if !strings.Contains(string(listBody), `"directMsgPerMin":`+strconv.Itoa(tc.want)) {
|
||
t.Fatalf("GET /admin/channels did not echo directMsgPerMin=%d: %s", tc.want, listBody)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminChannelsListOmitsUnsetDirectMsgPerMin 钉住 omitempty 约定:账号未设
|
||
// 该字段时键**缺席**(0 与「未设置」在线上同形是既有约定,不改)。对照组断言
|
||
// 该账号元素确实出现在响应里,使「不含该键」不是一句空话。
|
||
func TestAdminChannelsListOmitsUnsetDirectMsgPerMin(t *testing.T) {
|
||
t.Parallel()
|
||
store := &stubChannelConfigStore{
|
||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||
accounts: map[string][]config.ChannelAccount{
|
||
"kook": {{ID: "conf0", Config: map[string]any{"bot_token": "bt"}, GroupMsgPerMin: 5}},
|
||
},
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
items, listBody := adminChannelsList(t, ts, tok)
|
||
if len(items) != 1 || len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "conf0" {
|
||
t.Fatalf("items = %+v, want a single kook/conf0 account", items)
|
||
}
|
||
if !strings.Contains(string(listBody), `"groupMsgPerMin":5`) {
|
||
t.Fatalf("control field missing, the absence assertion would be vacuous: %s", listBody)
|
||
}
|
||
if strings.Contains(string(listBody), "directMsgPerMin") {
|
||
t.Fatalf("未设置时该键必须缺席(omitempty 约定;0 与未设置线上同形): %s", listBody)
|
||
}
|
||
}
|
||
|
||
// TestPersistWeixinConfirmedKeepsDirectMsgPerMin 覆盖第二条重建路径:微信扫码
|
||
// 确认后 handler 会把账号整体重建为 config.ChannelAccount 再 UpsertChannel,
|
||
// 因此它必须把账号顶层的 directMsgPerMin(含 -1 = 关闭)一并拷过去 ——
|
||
// 否则一次扫码保存会把主人关掉的陌生人私聊限制重新打开。
|
||
func TestPersistWeixinConfirmedKeepsDirectMsgPerMin(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
chs := &fakeWeixinChannels{
|
||
path: path,
|
||
data: map[string][]config.ChannelAccount{
|
||
"weixin-ilink": {{
|
||
ID: "conf0",
|
||
Config: map[string]any{"base_url": "https://base"},
|
||
DirectMsgPerMin: -1,
|
||
}},
|
||
},
|
||
}
|
||
s := &Server{channels: chs, log: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||
|
||
if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-x", "bot-x", "o-1"); err != nil {
|
||
t.Fatalf("persistWeixinConfirmed: %v", err)
|
||
}
|
||
|
||
acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "conf0")
|
||
if !ok {
|
||
t.Fatal("weixin-ilink/conf0 vanished from channels.json after the scan save")
|
||
}
|
||
if acct.DirectMsgPerMin != -1 {
|
||
t.Fatalf("扫码重建账号把 directMsgPerMin 写成 %d, want -1(关掉的限制不许被重置)", acct.DirectMsgPerMin)
|
||
}
|
||
if acct.Config["bot_token"] != "tok-x" {
|
||
t.Fatalf("config[bot_token] = %#v, want the scanned token persisted", acct.Config["bot_token"])
|
||
}
|
||
}
|