Files
dingtalk-workspace-cli/internal/helpers/connect_role_test.go
修雨 8b39dac5db feat(connect): wire role config into the connector runtime
Sprint 2 item 2: a digital-employee role config (connect_role.go) was a
defined-but-unused schema. Wire it into the connector so a single
--role-config <file> supplies the bot's owner, persona and knowledge
sources instead of scattering them across flags.

- add --role-config / DWS_ROLE_CONFIG; load + validate in launchConnector
  with a client_id-must-match-this-bot guard (one role == one bot)
- applyRoleConfig merges role -> options with explicit-flag-wins semantics
  (a flag/env always overrides the role; knowledge sources are additive)
- owner feeds the approval gate; persona is prepended to every forwarded
  prompt; role knowledge sources load and merge into the same retriever
- AllowedScopes / ConfirmPolicy are parsed and logged but not yet enforced
  (scope gating + auto/remember strategies are a later slice; no dead opts
  fields carried for them)
- tests: applyRoleConfig flag-wins / fill-empty / nil-role

Scope enforcement and confirm-policy behavior intentionally deferred.
2026-06-14 10:41:37 +08:00

283 lines
8.4 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 (
"os"
"path/filepath"
"strings"
"testing"
)
func writeRoleFile(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return p
}
func TestLoadRoleConfig_Full(t *testing.T) {
dir := t.TempDir()
body := `
name: 人事助理
client_id: dinghr0001
owner_user_id: "012345678901"
confirm_policy: remember
persona: |
You are the HR assistant.
knowledge_sources:
- ./knowledge/hr
- wiki:1234567890
- doc:abcDEF
allowed_scopes:
- attendance
- approval
extra:
team: people-ops
`
p := writeRoleFile(t, dir, "hr.yaml", body)
cfg, err := LoadRoleConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Name != "人事助理" {
t.Errorf("name = %q", cfg.Name)
}
if cfg.ClientID != "dinghr0001" {
t.Errorf("client_id = %q", cfg.ClientID)
}
if cfg.OwnerUserID != "012345678901" {
t.Errorf("owner_user_id = %q", cfg.OwnerUserID)
}
if cfg.ConfirmPolicy != ConfirmRemember {
t.Errorf("confirm_policy = %q", cfg.ConfirmPolicy)
}
if !strings.Contains(cfg.Persona, "HR assistant") {
t.Errorf("persona = %q", cfg.Persona)
}
if len(cfg.KnowledgeSources) != 3 {
t.Fatalf("knowledge_sources = %v", cfg.KnowledgeSources)
}
if len(cfg.AllowedScopes) != 2 || cfg.AllowedScopes[0] != "attendance" {
t.Errorf("allowed_scopes = %v", cfg.AllowedScopes)
}
if cfg.Extra["team"] != "people-ops" {
t.Errorf("extra = %v", cfg.Extra)
}
}
func TestLoadRoleConfig_DefaultConfirmPolicy(t *testing.T) {
dir := t.TempDir()
body := `
name: 前端分身
client_id: dingfe0001
owner_user_id: "999"
`
p := writeRoleFile(t, dir, "fe.yaml", body)
cfg, err := LoadRoleConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.ConfirmPolicy != ConfirmManual {
t.Errorf("default confirm_policy = %q, want manual", cfg.ConfirmPolicy)
}
}
func TestLoadRoleConfig_Errors(t *testing.T) {
cases := []struct {
name string
body string
wantSub string
}{
{
name: "missing name",
body: "client_id: a\nowner_user_id: b\n",
wantSub: `"name" is required`,
},
{
name: "missing client_id",
body: "name: x\nowner_user_id: b\n",
wantSub: `"client_id" is required`,
},
{
name: "missing owner_user_id",
body: "name: x\nclient_id: a\n",
wantSub: `"owner_user_id" is required`,
},
{
name: "bad confirm_policy",
body: "name: x\nclient_id: a\nowner_user_id: b\nconfirm_policy: sometimes\n",
wantSub: `"confirm_policy" must be one of`,
},
{
name: "empty knowledge source",
body: "name: x\nclient_id: a\nowner_user_id: b\nknowledge_sources:\n - \"\"\n",
wantSub: "knowledge_sources[0] is empty",
},
{
name: "bad wiki source",
body: "name: x\nclient_id: a\nowner_user_id: b\nknowledge_sources:\n - \"wiki:\"\n",
wantSub: "missing a spaceId",
},
{
name: "malformed yaml",
body: "name: [unterminated\n",
wantSub: "parse role config",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
p := writeRoleFile(t, dir, "r.yaml", tc.body)
_, err := LoadRoleConfig(p)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantSub)
}
if !strings.Contains(err.Error(), tc.wantSub) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub)
}
})
}
}
func TestLoadRoleConfig_MissingFile(t *testing.T) {
_, err := LoadRoleConfig(filepath.Join(t.TempDir(), "nope.yaml"))
if err == nil || !strings.Contains(err.Error(), "read role config") {
t.Fatalf("expected read error, got %v", err)
}
}
func TestRoleConfigExample_Parses(t *testing.T) {
dir := t.TempDir()
p := writeRoleFile(t, dir, "example.yaml", RoleConfigExample)
cfg, err := LoadRoleConfig(p)
if err != nil {
t.Fatalf("example YAML must be valid: %v", err)
}
if cfg.ConfirmPolicy != ConfirmManual {
t.Errorf("example confirm_policy = %q", cfg.ConfirmPolicy)
}
}
func TestLoadRoleConfigs_IndexByClientID(t *testing.T) {
dir := t.TempDir()
writeRoleFile(t, dir, "hr.yaml", "name: HR\nclient_id: dinghr\nowner_user_id: o1\n")
writeRoleFile(t, dir, "fin.yml", "name: Finance\nclient_id: dingfin\nowner_user_id: o2\n")
// Non-yaml and subdirectory entries must be ignored.
writeRoleFile(t, dir, "notes.txt", "ignore me")
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
roles, err := LoadRoleConfigs(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(roles) != 2 {
t.Fatalf("got %d roles, want 2", len(roles))
}
if roles["dinghr"].Name != "HR" {
t.Errorf("dinghr -> %q", roles["dinghr"].Name)
}
if roles["dingfin"].Name != "Finance" {
t.Errorf("dingfin -> %q", roles["dingfin"].Name)
}
}
func TestLoadRoleConfigs_DuplicateClientID(t *testing.T) {
dir := t.TempDir()
writeRoleFile(t, dir, "a.yaml", "name: A\nclient_id: dingdup\nowner_user_id: o1\n")
writeRoleFile(t, dir, "b.yaml", "name: B\nclient_id: dingdup\nowner_user_id: o2\n")
_, err := LoadRoleConfigs(dir)
if err == nil || !strings.Contains(err.Error(), "duplicate client_id") {
t.Fatalf("expected duplicate client_id error, got %v", err)
}
}
func TestLoadRoleConfigs_Empty(t *testing.T) {
_, err := LoadRoleConfigs(t.TempDir())
if err == nil || !strings.Contains(err.Error(), "no role config") {
t.Fatalf("expected empty-dir error, got %v", err)
}
}
func TestLoadRoleConfigs_MissingDir(t *testing.T) {
_, err := LoadRoleConfigs(filepath.Join(t.TempDir(), "nope"))
if err == nil || !strings.Contains(err.Error(), "read role config dir") {
t.Fatalf("expected dir read error, got %v", err)
}
}
// TestApplyRoleConfig_FillsEmptyOptions verifies the role fills options that
// were not set explicitly: owner, persona, and (additive) knowledge sources.
func TestApplyRoleConfig_FillsEmptyOptions(t *testing.T) {
role := &RoleConfig{
Name: "人事助理",
ClientID: "dingbot1",
OwnerUserID: "owner-from-role",
Persona: " You are HR. ",
KnowledgeSources: []string{"wiki:1", "./kb/hr"},
AllowedScopes: []string{"todo", "approval"},
}
got := applyRoleConfig(connectAgentOptions{}, role)
if got.OwnerUserID != "owner-from-role" {
t.Fatalf("owner = %q, want filled from role", got.OwnerUserID)
}
if got.Persona != "You are HR." { // trimmed
t.Fatalf("persona = %q, want trimmed role persona", got.Persona)
}
if strings.Join(got.KnowledgeSources, ",") != "wiki:1,./kb/hr" {
t.Fatalf("knowledge sources = %v, want role's two", got.KnowledgeSources)
}
}
// TestApplyRoleConfig_ExplicitFlagsWin is the headline contract: an option set
// explicitly (by flag/env) is never overwritten by the role, and role knowledge
// sources are appended to — not replacing — any already configured.
func TestApplyRoleConfig_ExplicitFlagsWin(t *testing.T) {
role := &RoleConfig{
ClientID: "dingbot1",
OwnerUserID: "owner-from-role",
Persona: "role persona",
KnowledgeSources: []string{"wiki:role"},
}
opts := connectAgentOptions{
OwnerUserID: "owner-from-flag",
Persona: "flag persona",
KnowledgeSources: []string{"wiki:flag"},
}
got := applyRoleConfig(opts, role)
if got.OwnerUserID != "owner-from-flag" {
t.Fatalf("owner = %q, want explicit flag to win", got.OwnerUserID)
}
if got.Persona != "flag persona" {
t.Fatalf("persona = %q, want explicit flag to win", got.Persona)
}
if strings.Join(got.KnowledgeSources, ",") != "wiki:flag,wiki:role" {
t.Fatalf("knowledge sources = %v, want flag then role appended", got.KnowledgeSources)
}
}
// TestApplyRoleConfig_NilRole is a defensive no-op guard.
func TestApplyRoleConfig_NilRole(t *testing.T) {
opts := connectAgentOptions{OwnerUserID: "x"}
if got := applyRoleConfig(opts, nil); got.OwnerUserID != "x" {
t.Fatalf("nil role must be a no-op, got %+v", got)
}
}