fix(channels): log json.Unmarshal errors + add rows.Err() checks #1109

Open
fullstack-engineer wants to merge 1 commits from fix/channels-json-unmarshal-guard into staging
2 changed files with 76 additions and 4 deletions
+22 -4
View File
@@ -67,7 +67,10 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
var config map[string]interface{}
json.Unmarshal(configJSON, &config)
if err := json.Unmarshal(configJSON, &config); err != nil {
log.Printf("Channels List: json.Unmarshal(config) for channel %s: %v", id, err)
config = map[string]interface{}{}
}
// #319: decrypt sensitive fields first so the mask operates on
// plaintext (first-4 / last-4 of the real token, not the ciphertext
// prefix). Decrypt errors are logged but non-fatal — List must keep
@@ -86,7 +89,10 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
var allowed []string
json.Unmarshal(allowedJSON, &allowed)
if err := json.Unmarshal(allowedJSON, &allowed); err != nil {
log.Printf("Channels List: json.Unmarshal(allowed) for channel %s: %v", id, err)
allowed = []string{}
}
entry := map[string]interface{}{
"id": id,
@@ -104,6 +110,9 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Channels List rows.Err workspace=%s: %v", workspaceID, err)
}
c.JSON(http.StatusOK, result)
}
@@ -496,8 +505,14 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
if err := rows.Scan(&row.ID, &row.WorkspaceID, &row.ChannelType, &configJSON, &row.Enabled, &allowedJSON); err != nil {
continue
}
json.Unmarshal(configJSON, &row.Config)
json.Unmarshal(allowedJSON, &row.AllowedUsers)
if err := json.Unmarshal(configJSON, &row.Config); err != nil {
log.Printf("Channels webhook: json.Unmarshal(config) for row %s: %v", row.ID, err)
row.Config = map[string]interface{}{}
}
if err := json.Unmarshal(allowedJSON, &row.AllowedUsers); err != nil {
log.Printf("Channels webhook: json.Unmarshal(allowed) for row %s: %v", row.ID, err)
row.AllowedUsers = nil
}
if err := channels.DecryptSensitiveFields(row.Config); err != nil {
log.Printf("Channels: decrypt webhook row %s: %v", row.ID, err)
continue
@@ -514,6 +529,9 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels webhook rows.Err channelType=%s: %v", channelType, err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -116,6 +116,60 @@ func TestChannelHandler_List(t *testing.T) {
}
}
func TestChannelHandler_List_InvalidJSONBFallsBackToEmpty(t *testing.T) {
mock := setupTestDB(t)
handler := NewChannelHandler(newTestChannelManager())
// Valid row first
rows := sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config", "enabled",
"allowed_users", "last_message_at", "message_count", "created_at", "updated_at",
}).AddRow(
"ch-valid", "ws-1", "telegram",
[]byte(`{"bot_token":"123:AAA","chat_id":"-100"}`),
true, []byte(`["user-1"]`), nil, 1, nil, nil,
).AddRow(
// Invalid JSONB — causes json.Unmarshal to fail. The handler should
// log the error and fall back to an empty map/array, not crash.
"ch-bad", "ws-1", "telegram",
[]byte(`not valid json at all`),
true, []byte(`also not json`),
nil, 0, nil, nil,
)
mock.ExpectQuery("SELECT .* FROM workspace_channels WHERE workspace_id").
WithArgs("ws-1").
WillReturnRows(rows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/workspaces/ws-1/channels", nil)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
handler.List(c)
if w.Code != 200 {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var result []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &result)
// Both rows should be returned; bad row has empty config/allowed_users
if len(result) != 2 {
t.Fatalf("expected 2 channels, got %d", len(result))
}
for _, ch := range result {
if ch["id"] == "ch-bad" {
// config should be empty map, not nil/null
if cfg := ch["config"]; cfg == nil {
t.Errorf("ch-bad config should be empty map, got nil")
}
// allowed_users should be empty slice, not nil
if allowed := ch["allowed_users"]; allowed == nil {
t.Errorf("ch-bad allowed_users should be empty slice, got nil")
}
}
}
}
// ==================== Create ====================
func TestChannelHandler_Create_Success(t *testing.T) {