masked sensitive
improved schema
This commit is contained in:
parent
84815818f7
commit
83682e203d
3 changed files with 284 additions and 16 deletions
|
|
@ -93,6 +93,7 @@
|
||||||
"ChannelsConfig": {
|
"ChannelsConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Channel (messenger) integrations. Enable the channels you want to receive messages from.",
|
||||||
"required": [
|
"required": [
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"telegram",
|
"telegram",
|
||||||
|
|
@ -123,6 +124,7 @@
|
||||||
"WhatsAppConfig": {
|
"WhatsAppConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "WhatsApp channel integration (via a bridge service).",
|
||||||
"required": ["enabled", "bridge_url", "allow_from"],
|
"required": ["enabled", "bridge_url", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -133,6 +135,7 @@
|
||||||
"TelegramConfig": {
|
"TelegramConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Telegram bot channel integration.",
|
||||||
"required": ["enabled", "token", "proxy", "allow_from"],
|
"required": ["enabled", "token", "proxy", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -144,6 +147,7 @@
|
||||||
"FeishuConfig": {
|
"FeishuConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Feishu / Lark (飞书 / Lark) bot channel integration.",
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"app_id",
|
"app_id",
|
||||||
|
|
@ -164,6 +168,7 @@
|
||||||
"DiscordConfig": {
|
"DiscordConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Discord bot channel integration.",
|
||||||
"required": ["enabled", "token", "allow_from"],
|
"required": ["enabled", "token", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -174,6 +179,7 @@
|
||||||
"MaixCamConfig": {
|
"MaixCamConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Sipeed MaixCam device channel integration.",
|
||||||
"required": ["enabled", "host", "port", "allow_from"],
|
"required": ["enabled", "host", "port", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -190,6 +196,7 @@
|
||||||
"QQConfig": {
|
"QQConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Tencent QQ (腾讯 QQ) bot/channel integration.",
|
||||||
"required": ["enabled", "app_id", "app_secret", "allow_from"],
|
"required": ["enabled", "app_id", "app_secret", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -201,6 +208,7 @@
|
||||||
"DingTalkConfig": {
|
"DingTalkConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "DingTalk / DingDing (钉钉) bot/channel integration.",
|
||||||
"required": ["enabled", "client_id", "client_secret", "allow_from"],
|
"required": ["enabled", "client_id", "client_secret", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -212,6 +220,7 @@
|
||||||
"SlackConfig": {
|
"SlackConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Slack bot channel integration.",
|
||||||
"required": ["enabled", "bot_token", "app_token", "allow_from"],
|
"required": ["enabled", "bot_token", "app_token", "allow_from"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": false },
|
"enabled": { "type": "boolean", "default": false },
|
||||||
|
|
@ -223,6 +232,7 @@
|
||||||
"LINEConfig": {
|
"LINEConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "LINE Messaging API channel integration.",
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"channel_secret",
|
"channel_secret",
|
||||||
|
|
@ -250,6 +260,7 @@
|
||||||
"OneBotConfig": {
|
"OneBotConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "OneBot (CQHTTP) compatible channel integration (often used for QQ bots via external bridges).",
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"ws_url",
|
"ws_url",
|
||||||
|
|
@ -274,6 +285,7 @@
|
||||||
"WebUIConfig": {
|
"WebUIConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Built-in web UI channel. Serves the browser chat UI and /admin config editor over HTTP.",
|
||||||
"required": ["enabled"],
|
"required": ["enabled"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": { "type": "boolean", "default": true }
|
"enabled": { "type": "boolean", "default": true }
|
||||||
|
|
@ -282,6 +294,7 @@
|
||||||
"ProvidersConfig": {
|
"ProvidersConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Model provider configuration. Each key enables and configures a provider integration (API key, base URL overrides, etc).",
|
||||||
"required": [
|
"required": [
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"openai",
|
"openai",
|
||||||
|
|
@ -297,18 +310,54 @@
|
||||||
"github_copilot"
|
"github_copilot"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"anthropic": { "$ref": "#/$defs/ProviderConfig" },
|
"anthropic": {
|
||||||
"openai": { "$ref": "#/$defs/ProviderConfig" },
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
"openrouter": { "$ref": "#/$defs/ProviderConfig" },
|
"description": "Anthropic (Claude) API provider."
|
||||||
"groq": { "$ref": "#/$defs/ProviderConfig" },
|
},
|
||||||
"zhipu": { "$ref": "#/$defs/ProviderConfig" },
|
"openai": {
|
||||||
"vllm": { "$ref": "#/$defs/ProviderConfig" },
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
"gemini": { "$ref": "#/$defs/ProviderConfig" },
|
"description": "OpenAI API provider (GPT models)."
|
||||||
"nvidia": { "$ref": "#/$defs/ProviderConfig" },
|
},
|
||||||
"moonshot": { "$ref": "#/$defs/ProviderConfig" },
|
"openrouter": {
|
||||||
"shengsuanyun": { "$ref": "#/$defs/ProviderConfig" },
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
"deepseek": { "$ref": "#/$defs/ProviderConfig" },
|
"description": "OpenRouter aggregator provider (routes requests to many different model backends)."
|
||||||
"github_copilot": { "$ref": "#/$defs/ProviderConfig" }
|
},
|
||||||
|
"groq": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "Groq hosted inference provider (fast LLM serving)."
|
||||||
|
},
|
||||||
|
"zhipu": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "Zhipu AI (智谱AI) provider (GLM model family)."
|
||||||
|
},
|
||||||
|
"vllm": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "vLLM-compatible OpenAI-style endpoint (typically self-hosted)."
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "Google Gemini API provider."
|
||||||
|
},
|
||||||
|
"nvidia": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "NVIDIA hosted model API provider (where supported)."
|
||||||
|
},
|
||||||
|
"moonshot": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "Moonshot AI provider (Kimi)."
|
||||||
|
},
|
||||||
|
"shengsuanyun": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "Shengsuanyun (生数云) provider (where supported)."
|
||||||
|
},
|
||||||
|
"deepseek": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "DeepSeek API provider."
|
||||||
|
},
|
||||||
|
"github_copilot": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||||
|
"description": "GitHub Copilot provider (where supported by the integration)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ProviderConfig": {
|
"ProviderConfig": {
|
||||||
|
|
@ -397,10 +446,17 @@
|
||||||
"WebToolsConfig": {
|
"WebToolsConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Web search providers used by the web tool.",
|
||||||
"required": ["brave", "duckduckgo"],
|
"required": ["brave", "duckduckgo"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"brave": { "$ref": "#/$defs/BraveConfig" },
|
"brave": {
|
||||||
"duckduckgo": { "$ref": "#/$defs/DuckDuckGoConfig" }
|
"allOf": [{ "$ref": "#/$defs/BraveConfig" }],
|
||||||
|
"description": "Brave Search API provider. Requires an API key."
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"allOf": [{ "$ref": "#/$defs/DuckDuckGoConfig" }],
|
||||||
|
"description": "DuckDuckGo search provider. No API key required."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"BraveConfig": {
|
"BraveConfig": {
|
||||||
|
|
|
||||||
|
|
@ -317,9 +317,14 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
masked, err := maskConfigJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to mask config", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(raw)
|
w.Write(masked)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +341,18 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
||||||
http.Error(w, "Empty body", http.StatusBadRequest)
|
http.Error(w, "Empty body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := c.configUpdateFn(body); err != nil {
|
|
||||||
|
mergedRaw := body
|
||||||
|
if c.configReadFn != nil {
|
||||||
|
oldRaw, rerr := c.configReadFn()
|
||||||
|
if rerr == nil {
|
||||||
|
if out, merr := mergePreserveMaskedSecrets(oldRaw, body); merr == nil {
|
||||||
|
mergedRaw = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.configUpdateFn(mergedRaw); err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
lower := strings.ToLower(msg)
|
lower := strings.ToLower(msg)
|
||||||
if strings.Contains(lower, "config is not writable") {
|
if strings.Contains(lower, "config is not writable") {
|
||||||
|
|
@ -350,6 +366,106 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maskedSentinel = "********"
|
||||||
|
|
||||||
|
func maskStringPrefix8(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
n := 8
|
||||||
|
if len(s) < n {
|
||||||
|
n = len(s)
|
||||||
|
}
|
||||||
|
return s[:n] + maskedSentinel
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitiveKey(k string) bool {
|
||||||
|
switch strings.ToLower(k) {
|
||||||
|
case "api_key", "token", "admin_token", "app_secret", "client_secret", "channel_secret", "channel_access_token", "access_token", "bot_token", "app_token", "encrypt_key", "verification_token":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskConfigJSON(raw []byte) ([]byte, error) {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maskAny(v)
|
||||||
|
return json.MarshalIndent(v, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAny(v any) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for k, vv := range x {
|
||||||
|
if isSensitiveKey(k) {
|
||||||
|
if s, ok := vv.(string); ok && s != "" {
|
||||||
|
x[k] = maskStringPrefix8(s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maskAny(vv)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for i := range x {
|
||||||
|
maskAny(x[i])
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergePreserveMaskedSecrets(oldRaw, newRaw []byte) ([]byte, error) {
|
||||||
|
var oldV any
|
||||||
|
var newV any
|
||||||
|
if err := json.Unmarshal(oldRaw, &oldV); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(newRaw, &newV); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mergeAny(oldV, newV)
|
||||||
|
return json.MarshalIndent(newV, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAny(oldV any, newV any) {
|
||||||
|
oldMap, okOld := oldV.(map[string]any)
|
||||||
|
newMap, okNew := newV.(map[string]any)
|
||||||
|
if okOld && okNew {
|
||||||
|
for k, nv := range newMap {
|
||||||
|
ov, ok := oldMap[k]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isSensitiveKey(k) {
|
||||||
|
if s, ok := nv.(string); ok && strings.Contains(s, maskedSentinel) {
|
||||||
|
if os, ok := ov.(string); ok {
|
||||||
|
newMap[k] = os
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergeAny(ov, nv)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldArr, okOldArr := oldV.([]any)
|
||||||
|
newArr, okNewArr := newV.([]any)
|
||||||
|
if okOldArr && okNewArr {
|
||||||
|
min := len(oldArr)
|
||||||
|
if len(newArr) < min {
|
||||||
|
min = len(newArr)
|
||||||
|
}
|
||||||
|
for i := 0; i < min; i++ {
|
||||||
|
mergeAny(oldArr[i], newArr[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WebUIChannel) handleAdminSchema(w http.ResponseWriter, r *http.Request) {
|
func (c *WebUIChannel) handleAdminSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
w.Header().Set("Allow", http.MethodGet)
|
w.Header().Set("Allow", http.MethodGet)
|
||||||
|
|
|
||||||
96
pkg/channels/webui_admin_config_test.go
Normal file
96
pkg/channels/webui_admin_config_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMaskConfigJSON_MasksSensitiveValuesPrefix8(t *testing.T) {
|
||||||
|
in := []byte(`{
|
||||||
|
"gateway": {
|
||||||
|
"token": "12345678ABCDEFGH",
|
||||||
|
"admin_token": "ABCDEFGH12345678"
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"openai": {"api_key": "sk-12345678ZZZZ", "api_base": ""}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": {"token": "99999999TTTT"}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := maskConfigJSON(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("maskConfigJSON error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var v map[string]any
|
||||||
|
if err := json.Unmarshal(out, &v); err != nil {
|
||||||
|
t.Fatalf("unmarshal masked output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway := v["gateway"].(map[string]any)
|
||||||
|
if got := gateway["token"].(string); got != "12345678********" {
|
||||||
|
t.Fatalf("gateway.token = %q, want %q", got, "12345678********")
|
||||||
|
}
|
||||||
|
if got := gateway["admin_token"].(string); got != "ABCDEFGH********" {
|
||||||
|
t.Fatalf("gateway.admin_token = %q, want %q", got, "ABCDEFGH********")
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := v["providers"].(map[string]any)
|
||||||
|
openai := providers["openai"].(map[string]any)
|
||||||
|
if got := openai["api_key"].(string); got != "sk-12345********" {
|
||||||
|
t.Fatalf("providers.openai.api_key = %q, want %q", got, "sk-12345********")
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := v["channels"].(map[string]any)
|
||||||
|
tg := channels["telegram"].(map[string]any)
|
||||||
|
if got := tg["token"].(string); got != "99999999********" {
|
||||||
|
t.Fatalf("channels.telegram.token = %q, want %q", got, "99999999********")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergePreserveMaskedSecrets_PreservesOnlyMaskedNotEmpty(t *testing.T) {
|
||||||
|
oldRaw := []byte(`{
|
||||||
|
"gateway": {"token": "TOK_OLD_1234567890", "admin_token": "ADM_OLD_1234567890"},
|
||||||
|
"providers": {"openai": {"api_key": "sk-OLD-1234567890", "api_base": ""}},
|
||||||
|
"channels": {"telegram": {"token": "TG_OLD_1234567890"}}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
newRaw := []byte(`{
|
||||||
|
"gateway": {"token": "TOK_OLD_********", "admin_token": ""},
|
||||||
|
"providers": {"openai": {"api_key": "sk-OLD-1********", "api_base": ""}},
|
||||||
|
"channels": {"telegram": {"token": "TG_NEW_123"}}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
merged, err := mergePreserveMaskedSecrets(oldRaw, newRaw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mergePreserveMaskedSecrets error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var v map[string]any
|
||||||
|
if err := json.Unmarshal(merged, &v); err != nil {
|
||||||
|
t.Fatalf("unmarshal merged: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway := v["gateway"].(map[string]any)
|
||||||
|
if got := gateway["token"].(string); got != "TOK_OLD_1234567890" {
|
||||||
|
t.Fatalf("gateway.token = %q, want old preserved", got)
|
||||||
|
}
|
||||||
|
// empty string should not be treated as "keep"; it should remain empty (delete)
|
||||||
|
if got := gateway["admin_token"].(string); got != "" {
|
||||||
|
t.Fatalf("gateway.admin_token = %q, want empty string", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := v["providers"].(map[string]any)
|
||||||
|
openai := providers["openai"].(map[string]any)
|
||||||
|
if got := openai["api_key"].(string); got != "sk-OLD-1234567890" {
|
||||||
|
t.Fatalf("providers.openai.api_key = %q, want old preserved", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := v["channels"].(map[string]any)
|
||||||
|
tg := channels["telegram"].(map[string]any)
|
||||||
|
if got := tg["token"].(string); got != "TG_NEW_123" {
|
||||||
|
t.Fatalf("channels.telegram.token = %q, want new value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue