Why: P2——模型、密钥、Tavily、遥测与运行基础设施属系统受管项;user/project 层须整笔拒绝(含嵌套、点路径与子树覆盖),生效链剥除存量覆盖,管理员经新系统面统一配置。 Evidence: go test ./... 全绿;settings 单测覆盖嵌套/点路径/祖先标量拒绝、系统层生效、读面剥除;server e2e 覆盖 400/系统面读写/掩码;认证档 admin 门测试(普通 403、admin 200、密钥不泄漏)。 Impact: settings.go 受管标记与 mergeScope 拦截、Effective 增系统层、server 新增 GET/PUT /api/settings/system(无认证档本机即管理员)、SecretLookup 系统行回退、插件 schema 受管注入;测试基建 mock seed 与三处用例迁到系统层;svc.sh 播种改走系统面(仅 admin,无管理员时明确跳过)。Core gitlink 升至 9150e6f。 Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2
247 lines
7.8 KiB
Go
247 lines
7.8 KiB
Go
package settings
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/store"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func TestLLMRetriesDefault(t *testing.T) {
|
|
block := SystemDefaults()["runtime"].(map[string]any)
|
|
if block["llmRetries"] != 2 {
|
|
t.Fatalf("llmRetries = %#v", block["llmRetries"])
|
|
}
|
|
var found bool
|
|
for _, desc := range Schema() {
|
|
if desc.Key == "runtime.llmRetries" && desc.Default == 2 {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("schema missing runtime.llmRetries default 2")
|
|
}
|
|
}
|
|
|
|
func TestDeliverableSettingsPresent(t *testing.T) {
|
|
defaults := SystemDefaults()
|
|
block, ok := defaults["deliverables"].(map[string]any)
|
|
if !ok || block["style"] != "modern" || block["accentColor"] != "" || block["templateArtifactId"] != "" {
|
|
t.Fatalf("defaults = %#v", defaults["deliverables"])
|
|
}
|
|
var style, accent, template bool
|
|
for _, desc := range Schema() {
|
|
switch desc.Key {
|
|
case "deliverables.style":
|
|
style = len(desc.Options) == 3 && desc.Options[0] == "modern"
|
|
case "deliverables.accentColor":
|
|
accent = true
|
|
case "deliverables.templateArtifactId":
|
|
template = true
|
|
}
|
|
}
|
|
if !style || !accent || !template {
|
|
t.Fatalf("schema missing deliverable keys: style=%v accent=%v template=%v", style, accent, template)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKeys(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in map[string]any
|
|
want map[string]any
|
|
}{
|
|
{
|
|
name: "dotted only",
|
|
in: map[string]any{"runtime.entry": "squad"},
|
|
want: map[string]any{"runtime": map[string]any{"entry": "squad"}},
|
|
},
|
|
{
|
|
name: "nested wins over dotted",
|
|
in: map[string]any{
|
|
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested"}},
|
|
"tools.tavily.apiKeys": "flat",
|
|
},
|
|
want: map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested"}}},
|
|
},
|
|
{
|
|
name: "dotted key inside a nested object",
|
|
in: map[string]any{"tools": map[string]any{"tavily.apiKeys": "x"}},
|
|
want: map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": "x"}}},
|
|
},
|
|
{
|
|
name: "overlapping dotted keys merge in lexicographic order",
|
|
in: map[string]any{"a.b": 1, "a.b.c": 2, "a.c": 3},
|
|
want: map[string]any{"a": map[string]any{"b": map[string]any{"c": 2}, "c": 3}},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := normalizeKeys(tc.in)
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("got %#v\nwant %#v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
in := map[string]any{"runtime.entry": "squad"}
|
|
_ = normalizeKeys(in)
|
|
if _, ok := in["runtime.entry"]; !ok {
|
|
t.Fatal("normalizeKeys mutated its input")
|
|
}
|
|
}
|
|
|
|
func TestPrepareWriteDropsMask(t *testing.T) {
|
|
got := prepareWrite(map[string]any{
|
|
"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder, "model": "m"}},
|
|
"runtime.entry": "squad",
|
|
})
|
|
want := map[string]any{
|
|
"models": map[string]any{"chat": map[string]any{"model": "m"}},
|
|
"runtime": map[string]any{"entry": "squad"},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestMaskedBySchemaAndLeafName(t *testing.T) {
|
|
values := map[string]any{
|
|
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "tvly-secret"}},
|
|
"models": map[string]any{"chat": map[string]any{"apiKey": "sk-secret"}},
|
|
"plugins": map[string]any{"example": map[string]any{"token": "plugin-secret"}},
|
|
"custom": map[string]any{"apiKeys": "pool-secret"},
|
|
"tools.tavily.apiKeys": "flat-secret",
|
|
}
|
|
schema := []core.SettingDesc{
|
|
{Key: "tools.tavily.apiKeys", Type: core.SettingSecret},
|
|
{Key: "models.chat.apiKey", Type: core.SettingSecret},
|
|
{Key: "plugins.example.token", Type: core.SettingString, Secret: true},
|
|
}
|
|
out := Masked(values, schema)
|
|
blob := stringify(out)
|
|
for _, secret := range []string{"tvly-secret", "sk-secret", "plugin-secret", "pool-secret", "flat-secret"} {
|
|
if strings.Contains(blob, secret) {
|
|
t.Fatalf("secret %q leaked: %#v", secret, out)
|
|
}
|
|
}
|
|
if out["tools"].(map[string]any)["tavily"].(map[string]any)["apiKeys"] != MaskPlaceholder {
|
|
t.Fatal("tavily apiKeys not masked")
|
|
}
|
|
if out["plugins"].(map[string]any)["example"].(map[string]any)["token"] != MaskPlaceholder {
|
|
t.Fatal("plugin token not masked")
|
|
}
|
|
if out["tools.tavily.apiKeys"] != MaskPlaceholder {
|
|
t.Fatal("flat secret key not masked")
|
|
}
|
|
}
|
|
|
|
func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
svc := NewService(st)
|
|
|
|
if err := st.MergeSettingsScope("local", ScopeUser, map[string]any{
|
|
"runtime.entry": "squad",
|
|
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested"}},
|
|
"tools.tavily.apiKeys": "flat",
|
|
"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.MergeSettingsScope("local", ScopeProjectPrefix+"p1", map[string]any{
|
|
"context.maxHistoryMessages": 30,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stats, err := svc.NormalizeStored()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stats.DottedKeys != 3 || stats.Scopes != 2 {
|
|
t.Fatalf("stats = %+v", stats)
|
|
}
|
|
foundMask := false
|
|
for _, p := range stats.MaskPaths {
|
|
if p == "local user models.chat.apiKey" {
|
|
foundMask = true
|
|
}
|
|
}
|
|
if !foundMask {
|
|
t.Fatalf("mask path missing: %v", stats.MaskPaths)
|
|
}
|
|
eff := svc.Effective("local", "p1")
|
|
if eff.GetString("runtime.entry", "") != "squad" {
|
|
t.Fatalf("entry = %q", eff.GetString("runtime.entry", ""))
|
|
}
|
|
// P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。
|
|
if got := eff.GetString("tools.tavily.apiKeys", ""); got != "" {
|
|
t.Fatalf("managed key leaked from user scope: %q", got)
|
|
}
|
|
if eff.GetNumber("context.maxHistoryMessages", 0) != 30 {
|
|
t.Fatalf("history = %v", eff.GetNumber("context.maxHistoryMessages", 0))
|
|
}
|
|
again, err := svc.NormalizeStored()
|
|
if err != nil || again.DottedKeys != 0 {
|
|
t.Fatalf("second pass = %+v err=%v", again, err)
|
|
}
|
|
|
|
if err := svc.MergeUser("local", map[string]any{"runtime.entry": "direct"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if svc.Effective("local", "").GetString("runtime.entry", "") != "direct" {
|
|
t.Fatal("dotted write did not take effect")
|
|
}
|
|
raw, err := svc.UserScope("local")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := raw["runtime.entry"]; ok {
|
|
t.Fatalf("dotted key persisted: %#v", raw)
|
|
}
|
|
// P2 受管:user 层写受管路径(嵌套与点路径形式)都整笔拒绝。
|
|
var mp *ErrManagedPath
|
|
if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); !errors.As(err, &mp) {
|
|
t.Fatalf("nested managed write not rejected: %v", err)
|
|
}
|
|
if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": "sk-real"}); !errors.As(err, &mp) {
|
|
t.Fatalf("dotted managed write not rejected: %v", err)
|
|
}
|
|
if err := svc.MergeUser("local", map[string]any{"models": "smash"}); !errors.As(err, &mp) {
|
|
t.Fatalf("ancestor scalar overwrite not rejected: %v", err)
|
|
}
|
|
// 受管项只能经系统层写入并生效。
|
|
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-sys"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-sys" {
|
|
t.Fatalf("system-layer key not effective: %q", got)
|
|
}
|
|
// 读面剥离:UserScope 不回显受管项的存量值。
|
|
if raw2, _ := svc.UserScope("local"); raw2["models"] != nil {
|
|
t.Fatalf("managed path exposed in user read scope: %#v", raw2["models"])
|
|
}
|
|
}
|
|
|
|
func stringify(v any) string {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case map[string]any:
|
|
out := ""
|
|
for k, val := range x {
|
|
out += k + "=" + stringify(val) + ";"
|
|
}
|
|
return out
|
|
default:
|
|
return ""
|
|
}
|
|
}
|