diff --git a/workspace-server/internal/handlers/channels.go b/workspace-server/internal/handlers/channels.go index 183c01bb9..736128587 100644 --- a/workspace-server/internal/handlers/channels.go +++ b/workspace-server/internal/handlers/channels.go @@ -154,16 +154,7 @@ func (h *ChannelHandler) Create(c *gin.Context) { } // #319: encrypt sensitive fields (bot_token, webhook_secret) before - // persisting so a DB read/backup leak can't recover the credentials. - // Validation above ran against plaintext; storage is ciphertext. - if err := channels.EncryptSensitiveFields(body.Config); err != nil { - log.Printf("Channels: encrypt config failed for workspace %s: %v", workspaceID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) - return - } - - // #319: encrypt sensitive fields (bot_token, webhook_secret) before - // persisting so a DB read/backup leak can't recover the credentials. + // persisting. Exactly one call here; duplicate removed in this PR. // Validation above ran against plaintext; storage is ciphertext. if err := channels.EncryptSensitiveFields(body.Config); err != nil { log.Printf("Channels: encrypt config failed for workspace %s: %v", workspaceID, err) diff --git a/workspace-server/internal/handlers/channels_test.go b/workspace-server/internal/handlers/channels_test.go index 95144c2f2..a8e9dacea 100644 --- a/workspace-server/internal/handlers/channels_test.go +++ b/workspace-server/internal/handlers/channels_test.go @@ -5,16 +5,21 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "database/sql/driver" + "encoding/base64" "encoding/hex" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" + "os" "strings" "testing" sqlmock "github.com/DATA-DOG/go-sqlmock" "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/channels" + channels_crypto "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto" "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" "github.com/gin-gonic/gin" ) @@ -166,6 +171,42 @@ func TestChannelHandler_List_InvalidJSON_FallsBack(t *testing.T) { } } +func TestChannelHandler_List_RowsErr_LogsError(t *testing.T) { + mock := setupTestDB(t) + handler := NewChannelHandler(newTestChannelManager()) + + 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-1", "ws-1", "telegram", + []byte(`{"bot_token":"123:ABCDEFGHIJ","chat_id":"-100"}`), + true, []byte(`["user-1"]`), nil, 5, nil, nil, + ).RowError(1, errors.New("storage engine fault")) + 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) + + // rows.Err() is non-fatal — the handler logs and still returns the row + // that was successfully scanned before the iteration error. + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + + var result []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &result) + if len(result) != 1 { + t.Fatalf("expected 1 channel despite rows.Err, got %d", len(result)) + } +} + // ==================== Create ==================== func TestChannelHandler_Create_Success(t *testing.T) { @@ -203,6 +244,66 @@ func TestChannelHandler_Create_Success(t *testing.T) { } } +// encryptedConfigArg matches INSERT args where bot_token has the ec1: prefix. +type encryptedConfigArg struct{} + +func (a encryptedConfigArg) Match(v driver.Value) bool { + s, ok := v.(string) + if !ok { + return false + } + var cfg map[string]interface{} + if err := json.Unmarshal([]byte(s), &cfg); err != nil { + return false + } + token, ok := cfg["bot_token"].(string) + if !ok { + return false + } + // #319: bot_token must be encrypted (ciphertextPrefix "ec1:") + // before persistence, NOT stored plaintext. + return strings.HasPrefix(token, "ec1:") +} + +func TestChannelHandler_Create_EncryptsSensitiveFields(t *testing.T) { + // Enable encryption for this test so EncryptSensitiveFields actually transforms. + os.Setenv("SECRETS_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) + channels_crypto.ResetForTesting() + channels_crypto.Init() + defer func() { + os.Unsetenv("SECRETS_ENCRYPTION_KEY") + channels_crypto.ResetForTesting() + }() + + mock := setupTestDB(t) + handler := NewChannelHandler(newTestChannelManager()) + + mock.ExpectQuery("INSERT INTO workspace_channels"). + WithArgs("ws-1", "telegram", encryptedConfigArg{}, true, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-ch-id")) + // Reload query + mock.ExpectQuery("SELECT .* FROM workspace_channels"). + WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "channel_type", "channel_config", "enabled", "allowed_users"})) + + body, _ := json.Marshal(map[string]interface{}{ + "channel_type": "telegram", + "config": map[string]interface{}{"bot_token": "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "chat_id": "-100"}, + "allowed_users": []string{"user-1"}, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + + handler.Create(c) + + if w.Code != 201 { + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) + } +} + func TestChannelHandler_Create_MissingType(t *testing.T) { handler := NewChannelHandler(newTestChannelManager())