// config_schema_test.go - 插件配置 Schema 验证的单元测试(12.6). // // 覆盖场景: // - ValidatePluginConfig: Required 字段缺失 // - ValidatePluginConfig: 类型检查(number/boolean/string) // - ValidatePluginConfig: Secret 字段脱敏 // - ValidatePluginConfig: 空 schema(no-op) // - maskSecretFields: 脱敏副本 // - pluginConfigStore: Set/Get/GetField 线程安全 // - Host.SetPluginConfig: schema 验证集成 // - Host.GetPluginConfig: 读取配置 // - Host.GetPluginConfigField: 单字段读取 package plugin import ( "encoding/json" "os" "path/filepath" "strings" "sync" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // --- ValidatePluginConfig --- // TestValidatePluginConfig_EmptySchema 空 schema 直接通过 func TestValidatePluginConfig_EmptySchema(t *testing.T) { err := ValidatePluginConfig("test", nil, map[string]string{"foo": "bar"}) if err != nil { t.Errorf("空 schema 应通过,got: %v", err) } } // TestValidatePluginConfig_RequiredPresent Required 字段存在:通过 func TestValidatePluginConfig_RequiredPresent(t *testing.T) { schema := []ConfigFieldDef{ {Key: "api_key", Type: "string", Required: true}, } err := ValidatePluginConfig("test", schema, map[string]string{"api_key": "sk-123"}) if err != nil { t.Errorf("Required 字段存在时应通过,got: %v", err) } } // TestValidatePluginConfig_RequiredMissing Required 字段缺失:报错 func TestValidatePluginConfig_RequiredMissing(t *testing.T) { schema := []ConfigFieldDef{ {Key: "api_key", Type: "string", Required: true}, } err := ValidatePluginConfig("test-plugin", schema, map[string]string{}) if err == nil { t.Fatal("Required 字段缺失时应报错") } pe, ok := err.(PluginError) if !ok { t.Fatalf("错误类型应为 PluginError,got %T", err) } if pe.Code != ErrConfigValidation { t.Errorf("Code 应为 ErrConfigValidation,got %d", pe.Code) } if !strings.Contains(pe.Message, "api_key") { t.Errorf("错误消息应包含字段名,got: %q", pe.Message) } } // TestValidatePluginConfig_RequiredEmpty Required 字段值为空字符串:报错 func TestValidatePluginConfig_RequiredEmpty(t *testing.T) { schema := []ConfigFieldDef{ {Key: "token", Type: "string", Required: true}, } err := ValidatePluginConfig("test", schema, map[string]string{"token": ""}) if err == nil { t.Fatal("Required 字段为空字符串时应报错") } } // TestValidatePluginConfig_MultipleRequired 多个 Required 字段,全部缺失:错误消息包含所有字段 func TestValidatePluginConfig_MultipleRequired(t *testing.T) { schema := []ConfigFieldDef{ {Key: "key1", Type: "string", Required: true}, {Key: "key2", Type: "string", Required: true}, } err := ValidatePluginConfig("test", schema, map[string]string{}) if err == nil { t.Fatal("应报错") } msg := err.Error() if !strings.Contains(msg, "key1") || !strings.Contains(msg, "key2") { t.Errorf("错误消息应包含所有缺失字段,got: %q", msg) } } // TestValidatePluginConfig_TypeNumber_Valid 有效数字通过 func TestValidatePluginConfig_TypeNumber_Valid(t *testing.T) { schema := []ConfigFieldDef{ {Key: "timeout", Type: "number"}, } err := ValidatePluginConfig("test", schema, map[string]string{"timeout": "30"}) if err != nil { t.Errorf("有效数字应通过,got: %v", err) } } // TestValidatePluginConfig_TypeNumber_Invalid 无效数字报错 func TestValidatePluginConfig_TypeNumber_Invalid(t *testing.T) { schema := []ConfigFieldDef{ {Key: "timeout", Type: "number"}, } err := ValidatePluginConfig("test", schema, map[string]string{"timeout": "thirty"}) if err == nil { t.Fatal("无效数字应报错") } if !strings.Contains(err.Error(), "timeout") { t.Errorf("错误消息应包含字段名,got: %q", err.Error()) } } // TestValidatePluginConfig_TypeBoolean_Valid 有效布尔值通过 func TestValidatePluginConfig_TypeBoolean_Valid(t *testing.T) { schema := []ConfigFieldDef{ {Key: "debug", Type: "boolean"}, } for _, val := range []string{"true", "false", "True", "FALSE", "1", "0"} { err := ValidatePluginConfig("test", schema, map[string]string{"debug": val}) if err != nil { t.Errorf("有效布尔值 %q 应通过,got: %v", val, err) } } } // TestValidatePluginConfig_TypeBoolean_Invalid 无效布尔值报错 func TestValidatePluginConfig_TypeBoolean_Invalid(t *testing.T) { schema := []ConfigFieldDef{ {Key: "debug", Type: "boolean"}, } err := ValidatePluginConfig("test", schema, map[string]string{"debug": "yes"}) if err == nil { t.Fatal("无效布尔值应报错") } } // TestValidatePluginConfig_SecretMasked Secret 字段值在错误消息中脱敏 func TestValidatePluginConfig_SecretMasked(t *testing.T) { schema := []ConfigFieldDef{ {Key: "api_key", Type: "number", Secret: true}, // 声明 number 但给字符串,触发类型错误 } err := ValidatePluginConfig("test", schema, map[string]string{"api_key": "sk-secret-value"}) if err == nil { t.Fatal("类型不匹配应报错") } if strings.Contains(err.Error(), "sk-secret-value") { t.Errorf("Secret 字段值不应出现在错误消息中,got: %q", err.Error()) } if !strings.Contains(err.Error(), "***") { t.Errorf("Secret 字段值应脱敏为 ***,got: %q", err.Error()) } } // TestValidatePluginConfig_NilConfig nil 配置视为空 map func TestValidatePluginConfig_NilConfig(t *testing.T) { schema := []ConfigFieldDef{ {Key: "api_key", Type: "string", Required: true}, } err := ValidatePluginConfig("test", schema, nil) if err == nil { t.Fatal("nil config + Required 字段应报错") } } // TestValidatePluginConfig_OptionalMissing 非必填字段缺失:通过 func TestValidatePluginConfig_OptionalMissing(t *testing.T) { schema := []ConfigFieldDef{ {Key: "debug", Type: "boolean", Required: false, Default: "false"}, } err := ValidatePluginConfig("test", schema, map[string]string{}) if err != nil { t.Errorf("非必填字段缺失时应通过,got: %v", err) } } // TestValidatePluginConfig_UnknownType 未知类型:跳过类型校验(向前兼容) func TestValidatePluginConfig_UnknownType(t *testing.T) { schema := []ConfigFieldDef{ {Key: "data", Type: "array"}, // 未知类型 } err := ValidatePluginConfig("test", schema, map[string]string{"data": "[1,2,3]"}) if err != nil { t.Errorf("未知类型应跳过校验,got: %v", err) } } // --- maskSecretFields --- // TestMaskSecretFields_MasksSecrets Secret 字段被脱敏 func TestMaskSecretFields_MasksSecrets(t *testing.T) { schema := []ConfigFieldDef{ {Key: "api_key", Secret: true}, {Key: "username", Secret: false}, } config := map[string]string{ "api_key": "sk-real-key", "username": "alice", } masked := maskSecretFields(schema, config) if masked["api_key"] != "***" { t.Errorf("Secret 字段应脱敏为 ***,got %q", masked["api_key"]) } if masked["username"] != "alice" { t.Errorf("非 Secret 字段不应脱敏,got %q", masked["username"]) } // 原 map 不被修改 if config["api_key"] != "sk-real-key" { t.Error("原 config map 不应被修改") } } // TestMaskSecretFields_NoSecrets 无 Secret 字段:返回原 map func TestMaskSecretFields_NoSecrets(t *testing.T) { schema := []ConfigFieldDef{ {Key: "username", Secret: false}, } config := map[string]string{"username": "bob"} masked := maskSecretFields(schema, config) if masked["username"] != "bob" { t.Errorf("got %q", masked["username"]) } } // --- pluginConfigStore --- // TestPluginConfigStore_SetGet Set 后 Get 返回副本 func TestPluginConfigStore_SetGet(t *testing.T) { s := newPluginConfigStore() s.Set("plugin-a", map[string]string{"key": "value"}) cfg := s.Get("plugin-a") if cfg["key"] != "value" { t.Errorf("got %q", cfg["key"]) } // 修改返回值不影响存储 cfg["key"] = "modified" cfg2 := s.Get("plugin-a") if cfg2["key"] != "value" { t.Error("修改返回值不应影响存储内部状态") } } // TestPluginConfigStore_NotFound 未设置配置时返回空 map func TestPluginConfigStore_NotFound(t *testing.T) { s := newPluginConfigStore() cfg := s.Get("nonexistent") if cfg == nil { t.Error("未设置时应返回空 map,不应返回 nil") } if len(cfg) != 0 { t.Errorf("未设置时应返回空 map,got len=%d", len(cfg)) } } // TestPluginConfigStore_GetField 单字段读取 func TestPluginConfigStore_GetField(t *testing.T) { s := newPluginConfigStore() s.Set("p", map[string]string{"token": "abc"}) v, ok := s.GetField("p", "token") if !ok || v != "abc" { t.Errorf("GetField 应返回 (abc, true),got (%q, %v)", v, ok) } _, ok = s.GetField("p", "missing") if ok { t.Error("不存在的 key 应返回 (_, false)") } _, ok = s.GetField("missing-plugin", "key") if ok { t.Error("不存在的插件应返回 (_, false)") } } // TestPluginConfigStore_SetNil Set nil 等同于删除 func TestPluginConfigStore_SetNil(t *testing.T) { s := newPluginConfigStore() s.Set("p", map[string]string{"key": "val"}) s.Set("p", nil) // 删除 cfg := s.Get("p") if len(cfg) != 0 { t.Errorf("Set nil 后应无配置,got: %v", cfg) } } // TestPluginConfigStore_Concurrent 并发读写不 panic func TestPluginConfigStore_Concurrent(t *testing.T) { s := newPluginConfigStore() var wg sync.WaitGroup for i := 0; i < 20; i++ { wg.Add(2) go func() { defer wg.Done() s.Set("p", map[string]string{"k": "v"}) }() go func() { defer wg.Done() s.Get("p") }() } wg.Wait() } // --- Host.SetPluginConfig / GetPluginConfig 集成测试 --- // TestHost_SetPluginConfig_NoSchema 无 schema 插件可随意配置 func TestHost_SetPluginConfig_NoSchema(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) h.Load(Definition{Name: "my-plugin"}) err := h.SetPluginConfig("my-plugin", map[string]string{"any": "value"}) if err != nil { t.Errorf("无 schema 时应无限制,got: %v", err) } cfg := h.GetPluginConfig("my-plugin") if cfg["any"] != "value" { t.Errorf("GetPluginConfig 应返回设置的值,got: %v", cfg) } } // TestHost_SetPluginConfig_WithSchema_Valid 有 schema 且验证通过 func TestHost_SetPluginConfig_WithSchema_Valid(t *testing.T) { dir := t.TempDir() manifest := map[string]any{ "name": "schema-plugin", "version": "1.0.0", "config_schema": []map[string]any{ {"key": "api_key", "type": "string", "required": true}, }, } data, _ := json.MarshalIndent(manifest, "", " ") os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0644) h := NewHost(execenv.DefaultExecutor{}) if err := h.LoadFromDir(dir); err != nil { t.Fatalf("加载插件失败: %v", err) } err := h.SetPluginConfig("schema-plugin", map[string]string{"api_key": "sk-123"}) if err != nil { t.Errorf("满足 Required 约束时应通过,got: %v", err) } } // TestHost_SetPluginConfig_WithSchema_MissingRequired Required 字段缺失报错 func TestHost_SetPluginConfig_WithSchema_MissingRequired(t *testing.T) { dir := t.TempDir() manifest := map[string]any{ "name": "strict-plugin", "version": "1.0.0", "config_schema": []map[string]any{ {"key": "api_key", "type": "string", "required": true}, }, } data, _ := json.MarshalIndent(manifest, "", " ") os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0644) h := NewHost(execenv.DefaultExecutor{}) if err := h.LoadFromDir(dir); err != nil { t.Fatalf("加载插件失败: %v", err) } // 不提供必填字段 err := h.SetPluginConfig("strict-plugin", map[string]string{}) if err == nil { t.Fatal("缺少 Required 字段时应报错") } pe, ok := err.(PluginError) if !ok { t.Fatalf("应为 PluginError,got %T", err) } if pe.Code != ErrConfigValidation { t.Errorf("Code 应为 ErrConfigValidation,got %d", pe.Code) } } // TestHost_GetPluginConfigField 单字段读取 func TestHost_GetPluginConfigField(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) h.Load(Definition{Name: "p"}) h.SetPluginConfig("p", map[string]string{"endpoint": "https://api.example.com"}) v, ok := h.GetPluginConfigField("p", "endpoint") if !ok || v != "https://api.example.com" { t.Errorf("got (%q, %v)", v, ok) } _, ok = h.GetPluginConfigField("p", "missing") if ok { t.Error("不存在的 key 应返回 false") } } // TestHost_SetPluginConfig_NotLoaded 未加载的插件可预配置(延迟加载场景) func TestHost_SetPluginConfig_NotLoaded(t *testing.T) { h := NewHost(execenv.DefaultExecutor{}) // 插件尚未加载,预先设置配置(无 schema 校验,直接存入) err := h.SetPluginConfig("future-plugin", map[string]string{"key": "val"}) if err != nil { t.Errorf("未加载插件预配置应允许,got: %v", err) } cfg := h.GetPluginConfig("future-plugin") if cfg["key"] != "val" { t.Errorf("预配置应保留,got: %v", cfg) } } // TestErrConfigValidation_Registered 错误码在 codeName 中注册 func TestErrConfigValidation_Registered(t *testing.T) { pe := PluginError{Code: ErrConfigValidation, PluginName: "test", Message: "test error"} if !strings.Contains(pe.Error(), "config_validation") { t.Errorf("codeName 应包含 config_validation,got: %q", pe.Error()) } }