Files
dingtalk-workspace-cli/internal/helpers/connect_acl_test.go
修雨 c151756168 feat(connect): Q&A-bot hardening for the stream bridge
Five operational gaps that bite when a connector-backed bot answers
questions in real groups:

- Single-instance lock: one connector per robot per machine (pid file,
  stale-lock takeover). Duplicate Stream connections on one clientId get
  messages load-balanced between them, so the bot answers intermittently
  (verified live).
- Access policy: --allowed-users / --allowed-groups allowlists plus a
  per-sender rate limit (--user-rate-limit, default 20/min). Every
  message is an LLM call; without this anyone who can reach the bot can
  spend on its behalf.
- Per-conversation FIFO queue: same-chat messages run in arrival order
  (follow-ups need the previous turn's --resume session; parallel CLIs
  racing one session corrupt it); different chats stay parallel.
- Picture messages: resolve the callback downloadCode via robot
  messageFiles/download to a local temp file and hand the path to the
  agent CLI — error screenshots are the top Q&A inbound and were
  previously dropped silently. The claudecode persona now permits
  reading attached files.
- --knowledge-dir: lightweight local retrieval (markdown chunking,
  latin-token + CJK-bigram scoring) prepends top-k matching chunks to
  each question while the agent keeps running from the clean scratch
  dir, so replies stay inside DingTalk's AI-assistant response window.

All knobs have env mirrors (DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS /
DWS_USER_RATE_LIMIT / DWS_KNOWLEDGE_DIR) for service-style runs.

go build ./... clean; go test ./internal/helpers -race passes with new
tests for lock/gate/queue/knowledge/media. test/cli_compat, test/scripts
and test/unit fail identically on the unmodified base (pre-existing
fixture issues).
2026-06-11 12:18:25 +08:00

98 lines
3.2 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"testing"
"time"
)
func TestConnectGateAllowlists(t *testing.T) {
cases := []struct {
name string
users []string
groups []string
staff string
convType string
convID string
wantOK bool
wantReason string
}{
{"no policy allows everyone", nil, nil, "u1", "1", "", true, ""},
{"user in list", []string{"u1", "u2"}, nil, "u1", "1", "", true, ""},
{"user not in list", []string{"u1"}, nil, "u9", "1", "", false, "user-not-allowed"},
{"group in list", nil, []string{"cid-a"}, "u9", "2", "cid-a", true, ""},
{"group not in list", nil, []string{"cid-a"}, "u9", "2", "cid-b", false, "group-not-allowed"},
{"group list does not gate DMs", nil, []string{"cid-a"}, "u9", "1", "", true, ""},
{"both lists: group ok user not", []string{"u1"}, []string{"cid-a"}, "u9", "2", "cid-a", false, "user-not-allowed"},
{"both lists: both ok", []string{"u1"}, []string{"cid-a"}, "u1", "2", "cid-a", true, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
g := newConnectGate(tc.users, tc.groups, 0)
ok, reason := g.allow(tc.staff, tc.convType, tc.convID)
if ok != tc.wantOK || reason != tc.wantReason {
t.Fatalf("allow = (%v, %q), want (%v, %q)", ok, reason, tc.wantOK, tc.wantReason)
}
})
}
}
func TestConnectGateRateLimit(t *testing.T) {
g := newConnectGate(nil, nil, 2)
now := time.Unix(1_700_000_000, 0)
g.now = func() time.Time { return now }
for i := 0; i < 2; i++ {
if ok, _ := g.allow("u1", "1", ""); !ok {
t.Fatalf("message %d should pass", i+1)
}
}
if ok, reason := g.allow("u1", "1", ""); ok || reason != "rate-limited" {
t.Fatalf("3rd message = (%v, %q), want rate-limited", ok, reason)
}
// Another sender is unaffected.
if ok, _ := g.allow("u2", "1", ""); !ok {
t.Fatal("other sender should pass")
}
// The window slides: a minute later the sender passes again.
now = now.Add(61 * time.Second)
if ok, _ := g.allow("u1", "1", ""); !ok {
t.Fatal("after window slides the sender should pass")
}
}
func TestConnectGateDisabled(t *testing.T) {
var nilGate *connectGate
if nilGate.enabled() {
t.Fatal("nil gate must report disabled")
}
if ok, _ := nilGate.allow("u", "1", ""); !ok {
t.Fatal("nil gate must allow")
}
if newConnectGate(nil, nil, 0).enabled() {
t.Fatal("empty gate must report disabled")
}
}
func TestSplitCommaList(t *testing.T) {
got := splitCommaList(" a, b ,, c ")
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
t.Fatalf("splitCommaList = %v", got)
}
if splitCommaList("") != nil {
t.Fatal("empty input should yield nil")
}
}