fix(channels): remove duplicate EncryptSensitiveFields + add rows.Err test (#1221) #2413
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user