fix: save Discord token updates from channel settings
- preserve secret fields from PUT/PATCH /api/config payloads via setters - include _token edit fields in channel save payload construction - add regression test for Discord token patch flow (issue #2005)
This commit is contained in:
parent
27f638e909
commit
abee8cd992
3 changed files with 194 additions and 11 deletions
|
|
@ -52,6 +52,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err = json.Unmarshal(body, &raw); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if execAllowRemoteOmitted(body) {
|
if execAllowRemoteOmitted(body) {
|
||||||
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +68,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applyConfigSecretsFromMap(&cfg, raw)
|
||||||
|
|
||||||
if errs := validateConfig(&cfg); len(errs) > 0 {
|
if errs := validateConfig(&cfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -159,6 +165,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applyConfigSecretsFromMap(&newCfg, base)
|
||||||
|
|
||||||
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -325,3 +332,140 @@ func mergeMap(dst, src map[string]any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func asMapField(value map[string]any, key string) (map[string]any, bool) {
|
||||||
|
raw, ok := value[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
m, ok := raw.(map[string]any)
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSecretString(m map[string]any, key string) (string, bool) {
|
||||||
|
if raw, ok := m[key]; ok {
|
||||||
|
s, ok := raw.(string)
|
||||||
|
if ok {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := m["_"+key]; ok {
|
||||||
|
s, ok := raw.(string)
|
||||||
|
if ok {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
|
||||||
|
channels, ok := asMapField(raw, "channels")
|
||||||
|
if ok {
|
||||||
|
if telegram, ok := asMapField(channels, "telegram"); ok {
|
||||||
|
if token, ok := getSecretString(telegram, "token"); ok {
|
||||||
|
cfg.Channels.Telegram.SetToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if feishu, ok := asMapField(channels, "feishu"); ok {
|
||||||
|
if appSecret, ok := getSecretString(feishu, "app_secret"); ok {
|
||||||
|
cfg.Channels.Feishu.SetAppSecret(appSecret)
|
||||||
|
}
|
||||||
|
if encryptKey, ok := getSecretString(feishu, "encrypt_key"); ok {
|
||||||
|
cfg.Channels.Feishu.SetEncryptKey(encryptKey)
|
||||||
|
}
|
||||||
|
if verificationToken, ok := getSecretString(feishu, "verification_token"); ok {
|
||||||
|
cfg.Channels.Feishu.SetVerificationToken(verificationToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if discord, ok := asMapField(channels, "discord"); ok {
|
||||||
|
if token, ok := getSecretString(discord, "token"); ok {
|
||||||
|
cfg.Channels.Discord.SetToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if weixin, ok := asMapField(channels, "weixin"); ok {
|
||||||
|
if token, ok := getSecretString(weixin, "token"); ok {
|
||||||
|
cfg.Channels.Weixin.SetToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if qq, ok := asMapField(channels, "qq"); ok {
|
||||||
|
if appSecret, ok := getSecretString(qq, "app_secret"); ok {
|
||||||
|
cfg.Channels.QQ.SetAppSecret(appSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dingtalk, ok := asMapField(channels, "dingtalk"); ok {
|
||||||
|
if clientSecret, ok := getSecretString(dingtalk, "client_secret"); ok {
|
||||||
|
cfg.Channels.DingTalk.SetClientSecret(clientSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slack, ok := asMapField(channels, "slack"); ok {
|
||||||
|
if botToken, ok := getSecretString(slack, "bot_token"); ok {
|
||||||
|
cfg.Channels.Slack.SetBotToken(botToken)
|
||||||
|
}
|
||||||
|
if appToken, ok := getSecretString(slack, "app_token"); ok {
|
||||||
|
cfg.Channels.Slack.SetAppToken(appToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matrix, ok := asMapField(channels, "matrix"); ok {
|
||||||
|
if accessToken, ok := getSecretString(matrix, "access_token"); ok {
|
||||||
|
cfg.Channels.Matrix.SetAccessToken(accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if line, ok := asMapField(channels, "line"); ok {
|
||||||
|
if channelSecret, ok := getSecretString(line, "channel_secret"); ok {
|
||||||
|
cfg.Channels.LINE.SetChannelSecret(channelSecret)
|
||||||
|
}
|
||||||
|
if channelAccessToken, ok := getSecretString(line, "channel_access_token"); ok {
|
||||||
|
cfg.Channels.LINE.SetChannelAccessToken(channelAccessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onebot, ok := asMapField(channels, "onebot"); ok {
|
||||||
|
if accessToken, ok := getSecretString(onebot, "access_token"); ok {
|
||||||
|
cfg.Channels.OneBot.SetAccessToken(accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wecom, ok := asMapField(channels, "wecom"); ok {
|
||||||
|
if secret, ok := getSecretString(wecom, "secret"); ok {
|
||||||
|
cfg.Channels.WeCom.SetSecret(secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pico, ok := asMapField(channels, "pico"); ok {
|
||||||
|
if token, ok := getSecretString(pico, "token"); ok {
|
||||||
|
cfg.Channels.Pico.SetToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if irc, ok := asMapField(channels, "irc"); ok {
|
||||||
|
if password, ok := getSecretString(irc, "password"); ok {
|
||||||
|
cfg.Channels.IRC.SetPassword(password)
|
||||||
|
}
|
||||||
|
if nickservPassword, ok := getSecretString(irc, "nickserv_password"); ok {
|
||||||
|
cfg.Channels.IRC.SetNickServPassword(nickservPassword)
|
||||||
|
}
|
||||||
|
if saslPassword, ok := getSecretString(irc, "sasl_password"); ok {
|
||||||
|
cfg.Channels.IRC.SetSASLPassword(saslPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, ok := asMapField(raw, "tools")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
skills, ok := asMapField(tools, "skills")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if github, ok := asMapField(skills, "github"); ok {
|
||||||
|
if token, ok := getSecretString(github, "token"); ok {
|
||||||
|
cfg.Tools.Skills.Github.SetToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registries, ok := asMapField(skills, "registries")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clawHub, ok := asMapField(registries, "clawhub"); ok {
|
||||||
|
if authToken, ok := getSecretString(clawHub, "auth_token"); ok {
|
||||||
|
cfg.Tools.Skills.Registries.ClawHub.SetAuthToken(authToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,42 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "discord-test-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Channels.Discord.Enabled {
|
||||||
|
t.Fatal("discord should be enabled after PATCH")
|
||||||
|
}
|
||||||
|
if got := cfg.Channels.Discord.Token(); got != "discord-test-token" {
|
||||||
|
t.Fatalf("discord token = %q, want %q", got, "discord-test-token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,8 @@ function asBool(value: unknown): boolean {
|
||||||
|
|
||||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
||||||
const edit: ChannelConfig = { ...config }
|
const edit: ChannelConfig = { ...config }
|
||||||
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
|
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
|
||||||
if (secretKey in config) {
|
edit[editKey] = ""
|
||||||
edit[SECRET_FIELD_MAP[secretKey]] = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return edit
|
return edit
|
||||||
}
|
}
|
||||||
|
|
@ -94,17 +92,22 @@ function buildSavePayload(
|
||||||
for (const [key, value] of Object.entries(editConfig)) {
|
for (const [key, value] of Object.entries(editConfig)) {
|
||||||
if (key.startsWith("_")) continue
|
if (key.startsWith("_")) continue
|
||||||
if (key === "enabled") continue
|
if (key === "enabled") continue
|
||||||
|
if (key in SECRET_FIELD_MAP) continue
|
||||||
if (key in SECRET_FIELD_MAP) {
|
|
||||||
const editKey = SECRET_FIELD_MAP[key]
|
|
||||||
const incoming = asString(editConfig[editKey])
|
|
||||||
payload[key] = incoming !== "" ? incoming : value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
|
||||||
|
const incoming = asString(editConfig[editKey])
|
||||||
|
if (incoming !== "") {
|
||||||
|
payload[secretKey] = incoming
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (secretKey in editConfig) {
|
||||||
|
payload[secretKey] = editConfig[secretKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (channel.name === "whatsapp_native") {
|
if (channel.name === "whatsapp_native") {
|
||||||
payload.use_native = true
|
payload.use_native = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue