fix(channels): remove duplicate EncryptSensitiveFields + add rows.Err test (#1221) #2413

Merged
agent-dev-a merged 4 commits from fix/1221-channels-rowserr-dedup-encrypt into main 2026-06-08 05:21:36 +00:00
2 changed files with 102 additions and 10 deletions
+1 -10
View File
@@ -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)
@@ -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())