Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer afc282c556 fix(channels): remove duplicate EncryptSensitiveFields + log json.Marshal errors
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
Harness Replays / detect-changes (pull_request) Successful in 31s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 34s
qa-review / approved (pull_request) Successful in 38s
security-review / approved (pull_request) Successful in 37s
CI / Detect changes (pull_request) Successful in 1m51s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m41s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m56s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m3s
Harness Replays / Harness Replays (pull_request) Successful in 15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
CI / Python Lint & Test (pull_request) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (pull_request) Failing after 10m50s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m45s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 10m58s
sop-tier-check / tier-check (pull_request) Successful in 41s
CI / Canvas (Next.js) (pull_request) Successful in 20m50s
CI / Platform (Go) (pull_request) Failing after 21m59s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Failing after 14m21s
CI / all-required (pull_request) Failing after 10m32s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
audit-force-merge / audit (pull_request) Has been skipped
- Create: remove the duplicate EncryptSensitiveFields call introduced
  during OFFSEC-010 conflict resolution (commit 58882381). The
  function is idempotent so impact was nil (wasted CPU), but the block
  was dead code.
- Create: add error checks on json.Marshal for config and allowed_users
  (previously j, _ := json.Marshal(...) silently dropped marshal failures).
- Update: add error checks on json.Marshal for config and allowed_users
  (same silent-discard pattern). Also renamed the EncryptSensitiveFields
  error variable to encErr to avoid shadowing the outer err used by
  db.DB.ExecContext.

This PR complements #1109 (merge-queue: json.Unmarshal + rows.Err) and
#1110 (duplicate EncryptSensitiveFields removal) — those two cover the
same channels.go surface but neither adds json.Marshal error checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:18:09 +00:00
4 changed files with 10 additions and 139 deletions
+10 -28
View File
@@ -67,10 +67,7 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
var config map[string]interface{}
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{}{}
}
json.Unmarshal(configJSON, &config)
// #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
@@ -89,10 +86,7 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
var allowed []string
if err := json.Unmarshal(allowedJSON, &allowed); err != nil {
log.Printf("Channels List: json.Unmarshal(allowed) for channel %s: %v", id, err)
allowed = []string{}
}
json.Unmarshal(allowedJSON, &allowed)
entry := map[string]interface{}{
"id": id,
@@ -110,9 +104,6 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Channels List rows.Err: %v", err)
}
c.JSON(http.StatusOK, result)
}
@@ -158,15 +149,15 @@ func (h *ChannelHandler) Create(c *gin.Context) {
return
}
configJSON, err := json.Marshal(body.Config)
if err != nil {
log.Printf("Channels: marshal config for workspace %s: %v", workspaceID, err)
configJSON, mErr := json.Marshal(body.Config)
if mErr != nil {
log.Printf("Channels Create: marshal config for workspace %s: %v", workspaceID, mErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "marshal config failed"})
return
}
allowedJSON, err := json.Marshal(body.AllowedUsers)
if err != nil {
log.Printf("Channels: marshal allowed_users for workspace %s: %v", workspaceID, err)
allowedJSON, mErr := json.Marshal(body.AllowedUsers)
if mErr != nil {
log.Printf("Channels Create: marshal allowed_users for workspace %s: %v", workspaceID, mErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "marshal allowed_users failed"})
return
}
@@ -516,14 +507,8 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
if err := rows.Scan(&row.ID, &row.WorkspaceID, &row.ChannelType, &configJSON, &row.Enabled, &allowedJSON); err != nil {
continue
}
if err := json.Unmarshal(configJSON, &row.Config); err != nil {
log.Printf("Channels Webhook: json.Unmarshal(config) for row %s: %v", row.ID, err)
continue
}
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 = []string{}
}
json.Unmarshal(configJSON, &row.Config)
json.Unmarshal(allowedJSON, &row.AllowedUsers)
if err := channels.DecryptSensitiveFields(row.Config); err != nil {
log.Printf("Channels: decrypt webhook row %s: %v", row.ID, err)
continue
@@ -540,9 +525,6 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels Webhook rows.Err: %v", err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -72,74 +72,6 @@ func TestChannelHandler_ListAdapters(t *testing.T) {
// ==================== List ====================
// TestChannelHandler_List_InvalidJSONBFallsBackToEmpty exercises the graceful-degradation
// path when the channel_config or allowed_users column contains bytes that are not valid
// JSON. Previously json.Unmarshal errors were silently discarded, leaving config as nil (map
// interface{}) and allowed as nil (slice interface{}). The handler now logs the error and
// falls back to an empty map/array so the rest of the channel list is still returned.
func TestChannelHandler_List_InvalidJSONBFallsBackToEmpty(t *testing.T) {
mock := setupTestDB(t)
handler := NewChannelHandler(newTestChannelManager())
// First row: valid config, invalid allowed_users
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-bad-allowed", "ws-1", "telegram",
[]byte(`{"bot_token":"123:ABC","chat_id":"-100"}`),
true, []byte(`{not json}`), nil, 1, nil, nil,
).AddRow(
// Second row: invalid config, valid allowed_users
"ch-bad-config", "ws-1", "telegram",
[]byte(`also not json`),
true, []byte(`["user-2"]`), nil, 2, nil, nil,
).AddRow(
// Third row: both valid — ensures partial corruption doesn't block the list
"ch-good", "ws-1", "telegram",
[]byte(`{"bot_token":"456:DEF","chat_id":"-200"}`),
true, []byte(`["user-3"]`), nil, 3, 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)
if len(result) != 3 {
t.Errorf("expected 3 channels (graceful degradation), got %d", len(result))
}
// Valid row must appear with correct id
foundGood := false
for _, ch := range result {
if ch["id"] == "ch-good" {
foundGood = true
// config should be a non-empty map
cfg, ok := ch["config"].(map[string]interface{})
if !ok || cfg == nil {
t.Error("ch-good config should be a non-nil map")
}
allowed, ok := ch["allowed_users"].([]interface{})
if !ok || allowed == nil {
t.Error("ch-good allowed_users should be a non-nil array")
}
}
}
if !foundGood {
t.Error("valid channel 'ch-good' should be in the list despite other rows having bad JSON")
}
}
func TestChannelHandler_List(t *testing.T) {
mock := setupTestDB(t)
handler := NewChannelHandler(newTestChannelManager())
@@ -54,9 +54,6 @@ func (h *MemoryHandler) List(c *gin.Context) {
entry.Value = json.RawMessage(value)
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Memory List rows.Err: %v", err)
}
c.JSON(http.StatusOK, entries)
}
@@ -57,46 +57,6 @@ func TestMemoryList_Success(t *testing.T) {
}
}
// TestMemoryList_RowsErrLogged covers the rows.Err() check added after the
// rows.Next() loop in List. When the query returns rows but a subsequent
// database error occurs (e.g. connection drops mid-stream), rows.Err() catches
// it and the handler returns what it collected so far with a log entry.
func TestMemoryList_RowsErrLogged(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewMemoryHandler()
now := time.Now()
// Return one good row, then simulate a DB-level error after the last row.
rows := sqlmock.NewRows([]string{"key", "value", "version", "expires_at", "updated_at"}).
AddRow("good-key", []byte(`"ok"`), int64(1), nil, now).
RowError(0, sql.ErrConnDone)
mock.ExpectQuery("SELECT key, value, version").
WithArgs("ws-rows-err").
WillReturnRows(rows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-rows-err"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-rows-err/memory", nil)
handler.List(c)
// rows.Err() is logged but non-fatal — partial results are still returned.
if w.Code != http.StatusOK {
t.Errorf("expected 200 (partial results), got %d: %s", w.Code, w.Body.String())
}
var resp []MemoryEntry
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response should be valid JSON even after rows.Err: %v", err)
}
// The one successfully scanned row must still be returned.
if len(resp) != 1 || resp[0].Key != "good-key" {
t.Errorf("expected 1 entry with key 'good-key', got %d entries", len(resp))
}
}
func TestMemoryList_DBError(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)