- 适配器只描述平台身份: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 条/分钟
125 lines
3.1 KiB
Go
125 lines
3.1 KiB
Go
package qq_obv11
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const rlKeyA = "qq-obv11|100|100"
|
|
|
|
func TestRateLimiterAllowWithinWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rl := newRateLimiter(3, 10*time.Second)
|
|
now := time.Now()
|
|
for i := 0; i < 3; i++ {
|
|
if !rl.allow(rlKeyA, now) {
|
|
t.Fatalf("allow #%d denied, want allowed", i)
|
|
}
|
|
}
|
|
if rl.allow(rlKeyA, now) {
|
|
t.Error("4th allow in window accepted, want denied")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterDeniedOverWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rl := newRateLimiter(2, 10*time.Second)
|
|
base := time.Now()
|
|
if !rl.allow(rlKeyA, base) || !rl.allow(rlKeyA, base) {
|
|
t.Fatal("first two sends should be allowed")
|
|
}
|
|
if rl.allow(rlKeyA, base) {
|
|
t.Error("third send within window allowed, want denied")
|
|
}
|
|
// After the window elapses, the budget frees up.
|
|
if !rl.allow(rlKeyA, base.Add(11*time.Second)) {
|
|
t.Error("send after window elapsed denied, want allowed")
|
|
}
|
|
}
|
|
|
|
// TestRateLimiterSessionsIndependent 是裁决 06.4.3 的断言:额度按会话(群聊/私聊)
|
|
// 独立计数,一个群用尽自己的额度不会挤占另一个群,也不影响私聊。
|
|
func TestRateLimiterSessionsIndependent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rl := newRateLimiter(2, 10*time.Second)
|
|
now := time.Now()
|
|
const (
|
|
groupB = "qq-obv11|200|200"
|
|
peer = "qq-obv11|900|900"
|
|
)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if !rl.allow(rlKeyA, now) {
|
|
t.Fatalf("group A send #%d denied, want allowed", i+1)
|
|
}
|
|
}
|
|
if rl.allow(rlKeyA, now) {
|
|
t.Fatal("group A exceeded its own quota, want denied")
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if !rl.allow(groupB, now) {
|
|
t.Fatalf("group B send #%d denied by group A's quota — 会话计数未隔离", i+1)
|
|
}
|
|
if !rl.allow(peer, now) {
|
|
t.Fatalf("peer send #%d denied by another session's quota — 会话计数未隔离", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRateLimiterPrunesIdleSessions 保证长跑进程里会话键不会无界增长:键数超过阈值后,
|
|
// 窗口内已无记录的会话会被回收,新会话照常放行。
|
|
func TestRateLimiterPrunesIdleSessions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rl := newRateLimiter(1, 10*time.Second)
|
|
base := time.Now()
|
|
for i := 0; i < sendSessionsGCThreshold+1; i++ {
|
|
if !rl.allow("sess-"+strconv.Itoa(i), base) {
|
|
t.Fatalf("session %d denied, want allowed", i)
|
|
}
|
|
}
|
|
later := base.Add(time.Minute)
|
|
if !rl.allow("fresh", later) {
|
|
t.Fatal("fresh session denied after window roll")
|
|
}
|
|
rl.mu.Lock()
|
|
keys := len(rl.times)
|
|
rl.mu.Unlock()
|
|
if keys > sendSessionsGCThreshold {
|
|
t.Fatalf("idle sessions not pruned: %d keys remain", keys)
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterDisabledAllowsAll(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rl := newRateLimiter(0, 10*time.Second)
|
|
for i := 0; i < 100; i++ {
|
|
if !rl.allow(rlKeyA, time.Now()) {
|
|
t.Fatalf("disabled limiter denied send #%d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterNilAllows(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var rl *rateLimiter
|
|
if !rl.allow(rlKeyA, time.Now()) {
|
|
t.Error("nil limiter denied send, want allowed")
|
|
}
|
|
}
|
|
|
|
func TestErrRateLimitedSentinel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !errors.Is(ErrRateLimited, ErrRateLimited) {
|
|
t.Error("ErrRateLimited should be its own sentinel")
|
|
}
|
|
}
|