Compare commits

...

5 Commits

Author SHA1 Message Date
9edc0036a3 channels: add SendAdapter injection + handler test coverage for Test and Send
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
security-review / approved (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas (Next.js) (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m28s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3m16s
CI / Platform (Go) (pull_request) Failing after 5m30s
CI / all-required (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 16s
sop-tier-check / tier-check (pull_request) Successful in 14s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: five-axis-review, no-backwards-compat, memory-consult
audit-force-merge / audit (pull_request) Successful in 18s
- Extract SendAdapter interface (SendMessage only) from ChannelAdapter so
  tests can inject a MockSendAdapter without hitting real Telegram/Slack APIs
- Make GetSendAdapter a package-level var (default: real adapters; tests
  override via SetGetSendAdapter from channels/testing.go)
- Wire GetSendAdapter into Manager.SendOutbound (was GetAdapter → ChannelAdapter)
- Add 4 handler tests in handlers/channels_test.go:
    TestChannelHandler_Test_Success         — full send-outbound success path
    TestChannelHandler_Test_ChannelNotFound — loadChannel error → 500
    TestChannelHandler_Send_Success         — budget pass → send → 200
    TestChannelHandler_Send_ChannelNotFound — loadChannel error → 500

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:30:11 +00:00
b25b4fb6ac Merge pull request '[core-devops-agent] chore: promote main→staging v5 (test panic fix)' (#972) from promote/main-to-staging-v5 into staging
Some checks are pending
qa-review / approved (pull_request) Successful in 35s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
security-review / approved (pull_request) Successful in 33s
CI / Canvas (Next.js) (pull_request) Successful in 13s
audit-force-merge / audit (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 13s
sop-tier-check / tier-check (pull_request) Successful in 38s
CI / Platform (Go) (pull_request) Successful in 24s
sop-checklist / all-items-acked (pull_request) Successful in 43s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m42s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 22s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m51s
CI / all-required (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 31s
CI / Detect changes (pull_request) Successful in 1m54s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 27s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m49s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 8s
CI / Canvas Deploy Reminder (push) Successful in 4s
CI / all-required (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m21s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m45s
gate-check-v3 / gate-check (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m54s
2026-05-14 05:37:47 +00:00
molecule-operator
956c2480d6 chore: promote main→staging v5 (test panic fix + t.Fatal improvements)
Some checks failed
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
CI / Detect changes (pull_request) Successful in 1m29s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m41s
qa-review / approved (pull_request) Successful in 26s
security-review / approved (pull_request) Successful in 25s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m39s
sop-tier-check / tier-check (pull_request) Successful in 24s
gate-check-v3 / gate-check (pull_request) Successful in 21s
audit-force-merge / audit (pull_request) Successful in 24s
sop-checklist / all-items-acked (pull_request) Successful in 18s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Failing after 13m18s
Resolve merge conflict in org_helpers_security_test.go:
- Keep staging t.TempDir() fix for TestResolveInsideRoot_DotDotWithIntermediate
  (a/b/../../c normalizes to c within root — test correctly expects success)
- t.Fatal vs t.Fatalf are equivalent; staging version retained
2026-05-14 05:34:35 +00:00
8026f02050 Merge pull request 'fix(handlers/org_helpers_test): use t.Fatal instead of t.Error in error-path tests' (#970) from fix/org-helpers-test-panic into main
Some checks failed
Handlers Postgres Integration / detect-changes (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 32s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 23s
CI / Python Lint & Test (pull_request) Successful in 3s
security-review / approved (pull_request) Failing after 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
CI / Canvas Deploy Reminder (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m17s
CI / all-required (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 56s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m32s
main-red-watchdog / watchdog (push) Successful in 24s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m31s
gate-check-v3 / gate-check (push) Successful in 8s
gitea-merge-queue / queue (push) Successful in 2s
status-reaper / reap (push) Successful in 1m8s
2026-05-14 05:29:38 +00:00
95c62c6fcd fix(handlers/org_helpers_test): use t.Fatal instead of t.Error in error-path tests
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 57s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m10s
Harness Replays / detect-changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m1s
qa-review / approved (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 29s
security-review / approved (pull_request) Successful in 16s
sop-checklist / all-items-acked (pull_request) Successful in 17s
sop-tier-check / tier-check (pull_request) Successful in 15s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
audit-force-merge / audit (pull_request) Successful in 16s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 7s
Harness Replays / Harness Replays (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m42s
CI / Canvas Deploy Reminder (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Failing after 4m12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m56s
CI / all-required (pull_request) Successful in 5s
Six resolveInsideRoot tests called t.Errorf then continued to err.Error()
on a potentially-nil error — if err was unexpectedly nil, the subsequent
err.Error() call would panic with nil pointer dereference.

Fix: use t.Fatalf/t.Fatal in the nil-error branch so execution stops
before the err.Error() call. Affects:
- TestResolveInsideRoot_EmptyUserPath
- TestResolveInsideRoot_AbsolutePathRejected
- TestResolveInsideRoot_DotDotTraversal
- TestResolveInsideRoot_DotDotWithIntermediate
- TestResolveInsideRoot_NestedDotDotEscapes
- TestResolveInsideRoot_DotdotAtStart

Fixes regression reported in issue #965.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 05:26:38 +00:00
4 changed files with 255 additions and 1 deletions

View File

@ -402,7 +402,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
return err return err
} }
adapter, ok := GetAdapter(ch.ChannelType) adapter, ok := GetSendAdapter(ch.ChannelType)
if !ok { if !ok {
return fmt.Errorf("no adapter for %s", ch.ChannelType) return fmt.Errorf("no adapter for %s", ch.ChannelType)
} }

View File

@ -1,5 +1,7 @@
package channels package channels
import "context"
// Registry of all available channel adapters. // Registry of all available channel adapters.
// To add a new platform: implement ChannelAdapter, register here. // To add a new platform: implement ChannelAdapter, register here.
var adapters = map[string]ChannelAdapter{ var adapters = map[string]ChannelAdapter{
@ -9,6 +11,27 @@ var adapters = map[string]ChannelAdapter{
"discord": &DiscordAdapter{}, "discord": &DiscordAdapter{},
} }
// SendAdapter is the subset of ChannelAdapter needed by SendOutbound.
// Extracted so tests can inject a no-op/mock adapter without hitting real
// platform APIs (Telegram Bot API, Slack API, etc.).
type SendAdapter interface {
SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error
}
// getSendAdapter is the production implementation of GetSendAdapter —
// returns the real registered adapter's SendMessage method.
func getSendAdapter(channelType string) (SendAdapter, bool) {
a, ok := adapters[channelType]
if !ok {
return nil, false
}
return a, true
}
// GetSendAdapter returns the SendAdapter for a channel type.
// Defaults to the real adapter; overridden by SetTestSendAdapter in tests.
var GetSendAdapter = getSendAdapter
// GetAdapter returns the adapter for a channel type. // GetAdapter returns the adapter for a channel type.
func GetAdapter(channelType string) (ChannelAdapter, bool) { func GetAdapter(channelType string) (ChannelAdapter, bool) {
a, ok := adapters[channelType] a, ok := adapters[channelType]

View File

@ -0,0 +1,30 @@
package channels
import "context"
// MockSendAdapter implements SendAdapter for handler tests. It records every
// call and returns a configurable error (nil = success, non-nil = failure).
type MockSendAdapter struct {
Calls int
Err error
SentText string
SentChat string
}
func (m *MockSendAdapter) SendMessage(_ context.Context, _ map[string]interface{}, chatID string, text string) error {
m.Calls++
m.SentText = text
m.SentChat = chatID
return m.Err
}
// SetGetSendAdapter replaces the package-level GetSendAdapter variable.
// Tests MUST call ResetSendAdapters() in their t.Cleanup.
func SetGetSendAdapter(fn func(string) (SendAdapter, bool)) {
GetSendAdapter = fn
}
// ResetSendAdapters restores GetSendAdapter to the production implementation.
func ResetSendAdapters() {
GetSendAdapter = getSendAdapter
}

View File

@ -327,6 +327,207 @@ func TestChannelHandler_Send_EmptyText(t *testing.T) {
} }
} }
// ==================== Test (send outbound) ====================
// TestChannelHandler_Test_Success exercises the /channels/:channelId/test endpoint
// with a mock SendAdapter so the full success path is covered without hitting real
// Telegram/Slack/etc. APIs.
func TestChannelHandler_Test_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
mockAdapter := &channels.MockSendAdapter{Err: nil}
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
if ct == "telegram" {
return mockAdapter, true
}
return channels.GetSendAdapter(ct)
})
t.Cleanup(channels.ResetSendAdapters)
// loadChannel → valid row
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-test-ok").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}).AddRow("ch-test-ok", "ws-1", "telegram",
`{"bot_token":"123:AAA","chat_id":"-100"}`,
true, `[]`))
// UPDATE message_count + last_message_at
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-test-ok/test", nil)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-test-ok"}}
handler.Test(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "ok" {
t.Errorf("expected status 'ok', got %v", resp["status"])
}
if mockAdapter.Calls != 1 {
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
}
if mockAdapter.SentChat != "-100" {
t.Errorf("expected chat_id '-100', got %q", mockAdapter.SentChat)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestChannelHandler_Test_ChannelNotFound verifies that when loadChannel returns
// no rows, the Test handler returns 500 with a "test message failed" error.
func TestChannelHandler_Test_ChannelNotFound(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
// loadChannel → no rows
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-missing").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-missing/test", nil)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-missing"}}
handler.Test(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["error"] != "test message failed" {
t.Errorf("expected error 'test message failed', got %v", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestChannelHandler_Send_Success covers the full outbound send success path:
// budget check passes → loadChannel → mock SendMessage succeeds → UPDATE count → 200.
func TestChannelHandler_Send_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
mockAdapter := &channels.MockSendAdapter{Err: nil}
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
if ct == "telegram" {
return mockAdapter, true
}
return channels.GetSendAdapter(ct)
})
t.Cleanup(channels.ResetSendAdapters)
// Budget check: count=0, no budget limit
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
WithArgs("ch-send-ok").
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
AddRow(0, nil))
// loadChannel → valid row
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-send-ok").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}).AddRow("ch-send-ok", "ws-1", "telegram",
`{"bot_token":"123:AAA","chat_id":"-100"}`,
true, `[]`))
// UPDATE message_count
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
WillReturnResult(sqlmock.NewResult(0, 1))
body, _ := json.Marshal(map[string]string{"text": "hello from test"})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-ok/send", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-ok"}}
handler.Send(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "sent" {
t.Errorf("expected status 'sent', got %v", resp["status"])
}
if mockAdapter.Calls != 1 {
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
}
if mockAdapter.SentText != "hello from test" {
t.Errorf("expected 'hello from test', got %q", mockAdapter.SentText)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestChannelHandler_Send_ChannelNotFound verifies that after the budget check
// passes, a missing channel returns 500 (not 404) with "send failed".
func TestChannelHandler_Send_ChannelNotFound(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewChannelHandler(newTestChannelManager())
// Budget check passes (NULL budget → no limit)
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
WithArgs("ch-send-missing").
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
AddRow(0, nil))
// loadChannel → no rows
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
WithArgs("ch-send-missing").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "channel_type", "channel_config",
"enabled", "allowed_users",
}))
body, _ := json.Marshal(map[string]string{"text": "hello"})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-missing/send", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-missing"}}
handler.Send(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["error"] != "send failed" {
t.Errorf("expected error 'send failed', got %v", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// ==================== Webhook ==================== // ==================== Webhook ====================
func TestChannelHandler_Webhook_UnknownType(t *testing.T) { func TestChannelHandler_Webhook_UnknownType(t *testing.T) {