diff --git a/workspace-server/internal/handlers/channels.go b/workspace-server/internal/handlers/channels.go index 6d9008bf5..c171e6f96 100644 --- a/workspace-server/internal/handlers/channels.go +++ b/workspace-server/internal/handlers/channels.go @@ -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) diff --git a/workspace-server/internal/handlers/channels_test.go b/workspace-server/internal/handlers/channels_test.go index 7c3454c12..8d07cd37e 100644 --- a/workspace-server/internal/handlers/channels_test.go +++ b/workspace-server/internal/handlers/channels_test.go @@ -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) {