package handlers import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" sqlmock "github.com/DATA-DOG/go-sqlmock" "github.com/Molecule-AI/molecule-monorepo/platform/internal/channels" "github.com/gin-gonic/gin" ) type stubProxy struct { statusCode int respBody []byte err error } func (s *stubProxy) ProxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) { return s.statusCode, s.respBody, s.err } type stubBroadcaster struct{} func (s *stubBroadcaster) RecordAndBroadcast(ctx context.Context, eventType, workspaceID string, data interface{}) error { return nil } func newTestChannelManager() *channels.Manager { return channels.NewManager(&stubProxy{statusCode: 200}, &stubBroadcaster{}) } // ==================== ListAdapters ==================== func TestChannelHandler_ListAdapters(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("GET", "/channels/adapters", nil) handler.ListAdapters(c) if w.Code != 200 { t.Errorf("expected 200, got %d", w.Code) } var result []map[string]string json.Unmarshal(w.Body.Bytes(), &result) if len(result) == 0 { t.Error("expected at least 1 adapter") } found := false for _, a := range result { if a["type"] == "telegram" { found = true } } if !found { t.Error("telegram not in adapter list") } } // ==================== List ==================== func TestChannelHandler_List(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, ) 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", w.Code) } var result []map[string]interface{} json.Unmarshal(w.Body.Bytes(), &result) if len(result) != 1 { t.Fatalf("expected 1 channel, got %d", len(result)) } // Verify bot_token is masked config := result[0]["config"].(map[string]interface{}) token := config["bot_token"].(string) if token == "123:ABCDEFGHIJ" { t.Error("bot_token should be masked in list response") } if token != "123:...GHIJ" { t.Errorf("expected masked token '123:...GHIJ', got %q", token) } } // ==================== Create ==================== func TestChannelHandler_Create_Success(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) mock.ExpectQuery("INSERT INTO workspace_channels"). 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()) } var result map[string]interface{} json.Unmarshal(w.Body.Bytes(), &result) if result["id"] != "new-ch-id" { t.Errorf("expected id 'new-ch-id', got %v", result["id"]) } } func TestChannelHandler_Create_MissingType(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{ "config": map[string]interface{}{"bot_token": "123"}, }) 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 != 400 { t.Errorf("expected 400 for missing channel_type, got %d", w.Code) } } func TestChannelHandler_Create_UnsupportedType(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{ "channel_type": "whatsapp", "config": map[string]interface{}{}, }) 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 != 400 { t.Errorf("expected 400 for unsupported type, got %d", w.Code) } } func TestChannelHandler_Create_InvalidConfig(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{ "channel_type": "telegram", "config": map[string]interface{}{}, // missing bot_token + chat_id }) 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 != 400 { t.Errorf("expected 400 for invalid config, got %d", w.Code) } } // ==================== Update ==================== func TestChannelHandler_Update_Success(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) mock.ExpectExec("UPDATE workspace_channels"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT .* FROM workspace_channels"). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "channel_type", "channel_config", "enabled", "allowed_users"})) enabled := false body, _ := json.Marshal(map[string]interface{}{ "enabled": enabled, }) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("PATCH", "/workspaces/ws-1/channels/ch-1", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-1"}} handler.Update(c) if w.Code != 200 { t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) } } func TestChannelHandler_Update_NotFound(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) mock.ExpectExec("UPDATE workspace_channels"). WillReturnResult(sqlmock.NewResult(0, 0)) body, _ := json.Marshal(map[string]interface{}{"enabled": false}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("PATCH", "/workspaces/ws-1/channels/ch-999", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-999"}} handler.Update(c) if w.Code != 404 { t.Errorf("expected 404, got %d", w.Code) } } // ==================== Delete ==================== func TestChannelHandler_Delete_Success(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) mock.ExpectExec("DELETE FROM workspace_channels"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT .* FROM workspace_channels"). WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "channel_type", "channel_config", "enabled", "allowed_users"})) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("DELETE", "/workspaces/ws-1/channels/ch-1", nil) c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-1"}} handler.Delete(c) if w.Code != 200 { t.Errorf("expected 200, got %d", w.Code) } } func TestChannelHandler_Delete_NotFound(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) mock.ExpectExec("DELETE FROM workspace_channels"). WillReturnResult(sqlmock.NewResult(0, 0)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("DELETE", "/workspaces/ws-1/channels/ch-999", nil) c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-999"}} handler.Delete(c) if w.Code != 404 { t.Errorf("expected 404, got %d", w.Code) } } // ==================== Send ==================== func TestChannelHandler_Send_EmptyText(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{"text": ""}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels/ch-1/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-1"}} handler.Send(c) if w.Code != 400 { t.Errorf("expected 400 for empty text, got %d", w.Code) } } // ==================== Webhook ==================== func TestChannelHandler_Webhook_UnknownType(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/webhooks/whatsapp", nil) c.Params = gin.Params{{Key: "type", Value: "whatsapp"}} handler.Webhook(c) if w.Code != 404 { t.Errorf("expected 404 for unknown type, got %d", w.Code) } } // ==================== Discover ==================== func TestChannelHandler_Discover_MissingToken(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{"channel_type": "telegram"}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/channels/discover", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") handler.Discover(c) if w.Code != 400 { t.Errorf("expected 400 for missing token, got %d", w.Code) } } func TestChannelHandler_Discover_UnsupportedType(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) // #329: workspace_id required — include so we actually reach the // unsupported-type check instead of bouncing at the new scope gate. body, _ := json.Marshal(map[string]interface{}{ "channel_type": "whatsapp", "bot_token": "fake", "workspace_id": "ws-test", }) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/channels/discover", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") handler.Discover(c) if w.Code != 400 { t.Errorf("expected 400 for unsupported type, got %d", w.Code) } } func TestChannelHandler_Discover_InvalidBotToken(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{ "channel_type": "telegram", "bot_token": "clearly-not-a-real-token", "workspace_id": "ws-test", }) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/channels/discover", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") handler.Discover(c) if w.Code != 400 { t.Errorf("expected 400 for invalid token, got %d", w.Code) } // Verify error is user-friendly (not a raw tgbotapi error) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) errMsg, _ := resp["error"].(string) if errMsg == "" { t.Error("expected error field in response") } } // #329: workspace_id is now required. Without it, Discover must 400 // *before* issuing the unscoped DB query that would decrypt every // tenant's bot tokens. func TestChannelHandler_Discover_329_RequiresWorkspaceID(t *testing.T) { handler := NewChannelHandler(newTestChannelManager()) body, _ := json.Marshal(map[string]interface{}{ "channel_type": "telegram", "bot_token": "any-non-empty-token", // workspace_id intentionally omitted }) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/channels/discover", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") handler.Discover(c) if w.Code != 400 { t.Errorf("expected 400 when workspace_id missing, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) if errMsg, _ := resp["error"].(string); errMsg != "workspace_id is required" { t.Errorf("expected workspace_id error, got %q", errMsg) } } // ==================== System Caller Prefix ==================== func TestSystemCallerPrefix_ChannelIncluded(t *testing.T) { if !isSystemCaller("channel:telegram") { t.Error("channel:telegram should be recognized as system caller") } if !isSystemCaller("channel:slack") { t.Error("channel:slack should be recognized as system caller") } if isSystemCaller("user:someone") { t.Error("user:someone should NOT be a system caller") } } // ==================== Per-channel budget (#368) ==================== // TestChannelHandler_Send_BudgetExceeded verifies that when message_count // equals channel_budget, the Send handler returns 429 {"error":"channel budget exceeded"} // and does NOT call SendOutbound. func TestChannelHandler_Send_BudgetExceeded_Returns429(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) // Budget = 10, message_count = 10 → at the ceiling → 429. mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id"). WithArgs("ch-budget-hit"). WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}). AddRow(10, 10)) body, _ := json.Marshal(map[string]string{"text": "hello"}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels/ch-budget-hit/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-budget-hit"}} handler.Send(c) if w.Code != http.StatusTooManyRequests { t.Errorf("expected 429 when budget exceeded, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) if resp["error"] != "channel budget exceeded" { t.Errorf("expected error 'channel budget exceeded', got %v", resp["error"]) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("sqlmock expectations not met: %v", err) } } // TestChannelHandler_Send_BudgetExceeded_AboveLimit verifies that when // message_count exceeds channel_budget (not just equals it), 429 is returned. func TestChannelHandler_Send_BudgetExceeded_AboveLimit_Returns429(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) // Budget = 5, message_count = 99 → well above limit. mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id"). WithArgs("ch-over"). WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}). AddRow(99, 5)) body, _ := json.Marshal(map[string]string{"text": "hi"}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels/ch-over/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-over"}} handler.Send(c) if w.Code != http.StatusTooManyRequests { t.Errorf("expected 429 for over-limit, got %d: %s", w.Code, w.Body.String()) } } // TestChannelHandler_Send_NoBudget verifies that when channel_budget IS NULL // (no limit), the send proceeds past the budget check (no 429 returned). // The eventual 500 comes from loadChannel not finding the mock channel — // the important assertion is NOT 429. func TestChannelHandler_Send_NoBudget_PassesThrough(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) // NULL budget → no restriction. mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id"). WithArgs("ch-unlimited"). WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}). AddRow(9999, nil)) // SendOutbound → loadChannel SELECT — channel not found → error. mock.ExpectQuery("SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users"). WithArgs("ch-unlimited"). WillReturnRows(sqlmock.NewRows([]string{})) body, _ := json.Marshal(map[string]string{"text": "unlimited send"}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels/ch-unlimited/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-unlimited"}} handler.Send(c) if w.Code == http.StatusTooManyRequests { t.Errorf("expected budget check to pass (NULL budget), but got 429") } } // TestChannelHandler_Send_BudgetNotYetReached verifies that when // message_count < channel_budget, the send proceeds past the budget check. func TestChannelHandler_Send_BudgetNotYetReached_PassesThrough(t *testing.T) { mock := setupTestDB(t) handler := NewChannelHandler(newTestChannelManager()) // Budget = 100, message_count = 9 → still under limit. mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id"). WithArgs("ch-under"). WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}). AddRow(9, 100)) // SendOutbound → loadChannel SELECT — channel not found → error. mock.ExpectQuery("SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users"). WithArgs("ch-under"). WillReturnRows(sqlmock.NewRows([]string{})) body, _ := json.Marshal(map[string]string{"text": "still under budget"}) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels/ch-under/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-under"}} handler.Send(c) if w.Code == http.StatusTooManyRequests { t.Errorf("expected budget check to pass (under limit), but got 429") } } // ==================== Discord Ed25519 signature verification ==================== // // These tests cover verifyDiscordSignature and the Discord signature gate in // the Webhook handler. They use real Ed25519 key pairs generated in-process so // the cryptographic assertions are load-bearing (not hand-crafted hex strings). // genDiscordKey generates a fresh Ed25519 key pair for tests. // Returns (pubKeyHex, privKey). func genDiscordKey(t *testing.T) (string, ed25519.PrivateKey) { t.Helper() pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("ed25519.GenerateKey: %v", err) } return hex.EncodeToString(pub), priv } // discordSignedRequest builds an *http.Request with the correct Discord // Ed25519 headers signed by privKey. func discordSignedRequest(t *testing.T, body string, ts string, privKey ed25519.PrivateKey) *http.Request { t.Helper() msg := append([]byte(ts), []byte(body)...) sig := ed25519.Sign(privKey, msg) req := httptest.NewRequest(http.MethodPost, "/webhooks/discord", strings.NewReader(body)) req.Header.Set("X-Signature-Ed25519", hex.EncodeToString(sig)) req.Header.Set("X-Signature-Timestamp", ts) return req } // TestVerifyDiscordSignature_Valid asserts that a correctly signed request // passes verification. func TestVerifyDiscordSignature_Valid(t *testing.T) { pubHex, priv := genDiscordKey(t) body := `{"type":1}` req := discordSignedRequest(t, body, "1700000000", priv) if !verifyDiscordSignature(req, pubHex) { t.Error("expected true for valid Discord signature, got false") } // Body must be restored so subsequent reads still work. restored, _ := io.ReadAll(req.Body) if string(restored) != body { t.Errorf("body not restored: got %q, want %q", restored, body) } } // TestVerifyDiscordSignature_WrongKey asserts that a signature verified with // a different public key returns false. func TestVerifyDiscordSignature_WrongKey(t *testing.T) { _, priv := genDiscordKey(t) wrongPubHex, _ := genDiscordKey(t) // different key pair req := discordSignedRequest(t, `{"type":1}`, "1700000000", priv) if verifyDiscordSignature(req, wrongPubHex) { t.Error("expected false for signature verified with wrong public key") } } // TestVerifyDiscordSignature_TamperedBody asserts that modifying the body // after signing invalidates the signature. func TestVerifyDiscordSignature_TamperedBody(t *testing.T) { pubHex, priv := genDiscordKey(t) req := discordSignedRequest(t, `{"type":1}`, "1700000000", priv) // Replace the body with different content after signing. req.Body = io.NopCloser(strings.NewReader(`{"type":2,"tampered":true}`)) if verifyDiscordSignature(req, pubHex) { t.Error("expected false for tampered body, got true") } } // TestVerifyDiscordSignature_MissingTimestamp asserts that a missing // X-Signature-Timestamp header returns false. func TestVerifyDiscordSignature_MissingTimestamp(t *testing.T) { pubHex, priv := genDiscordKey(t) req := discordSignedRequest(t, `{"type":1}`, "1700000000", priv) req.Header.Del("X-Signature-Timestamp") if verifyDiscordSignature(req, pubHex) { t.Error("expected false for missing X-Signature-Timestamp") } } // TestVerifyDiscordSignature_MissingSignature asserts that a missing // X-Signature-Ed25519 header returns false. func TestVerifyDiscordSignature_MissingSignature(t *testing.T) { pubHex, priv := genDiscordKey(t) req := discordSignedRequest(t, `{"type":1}`, "1700000000", priv) req.Header.Del("X-Signature-Ed25519") if verifyDiscordSignature(req, pubHex) { t.Error("expected false for missing X-Signature-Ed25519") } } // TestVerifyDiscordSignature_InvalidHexSignature asserts that a non-hex // signature returns false. func TestVerifyDiscordSignature_InvalidHexSignature(t *testing.T) { pubHex, _ := genDiscordKey(t) req := httptest.NewRequest(http.MethodPost, "/webhooks/discord", strings.NewReader(`{}`)) req.Header.Set("X-Signature-Ed25519", "not-valid-hex!!!") req.Header.Set("X-Signature-Timestamp", "1700000000") if verifyDiscordSignature(req, pubHex) { t.Error("expected false for invalid hex signature") } } // TestVerifyDiscordSignature_InvalidHexPubKey asserts that a non-hex public // key returns false. func TestVerifyDiscordSignature_InvalidHexPubKey(t *testing.T) { _, priv := genDiscordKey(t) req := discordSignedRequest(t, `{}`, "1700000000", priv) if verifyDiscordSignature(req, "not-hex-at-all!!!") { t.Error("expected false for non-hex public key") } } // TestVerifyDiscordSignature_WrongLengthPubKey asserts that a hex-encoded // byte slice that is not 32 bytes returns false. func TestVerifyDiscordSignature_WrongLengthPubKey(t *testing.T) { _, priv := genDiscordKey(t) req := discordSignedRequest(t, `{}`, "1700000000", priv) // 16 bytes — too short for Ed25519. shortKey := hex.EncodeToString(make([]byte, 16)) if verifyDiscordSignature(req, shortKey) { t.Error("expected false for short public key") } } // TestChannelHandler_Webhook_Discord_NoKey_Returns401 verifies that a Discord // webhook request is rejected with 401 when no public key is configured in the // DB and DISCORD_APP_PUBLIC_KEY env var is not set. func TestChannelHandler_Webhook_Discord_NoKey_Returns401(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) handler := NewChannelHandler(newTestChannelManager()) // discordPublicKey: DB returns no rows (no Discord channels with app_public_key). mock.ExpectQuery(`SELECT COALESCE\(channel_config->>'app_public_key'`). WillReturnRows(sqlmock.NewRows([]string{"pubkey"})) // Ensure env var is not set. t.Setenv("DISCORD_APP_PUBLIC_KEY", "") w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/webhooks/discord", strings.NewReader(`{"type":1}`)) c.Request.Header.Set("X-Signature-Ed25519", "aabbcc") c.Request.Header.Set("X-Signature-Timestamp", "1700000000") c.Params = gin.Params{{Key: "type", Value: "discord"}} handler.Webhook(c) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 (no public key), got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sqlmock expectations: %v", err) } } // TestChannelHandler_Webhook_Discord_InvalidSig_Returns401 verifies that a // Discord webhook with an invalid signature is rejected with 401, even when a // valid public key is configured. func TestChannelHandler_Webhook_Discord_InvalidSig_Returns401(t *testing.T) { pubHex, _ := genDiscordKey(t) // generate key but sign with a DIFFERENT key _, wrongPriv := genDiscordKey(t) mock := setupTestDB(t) setupTestRedis(t) handler := NewChannelHandler(newTestChannelManager()) // discordPublicKey: DB returns the correct pubHex. mock.ExpectQuery(`SELECT COALESCE\(channel_config->>'app_public_key'`). WillReturnRows(sqlmock.NewRows([]string{"pubkey"}).AddRow(pubHex)) // Build a request signed with the wrong private key. req := discordSignedRequest(t, `{"type":1}`, "1700000000", wrongPriv) req.URL.Path = "/webhooks/discord" w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req c.Params = gin.Params{{Key: "type", Value: "discord"}} handler.Webhook(c) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 (invalid sig), got %d: %s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sqlmock expectations: %v", err) } } // TestChannelHandler_Webhook_Discord_ValidSig_PingAccepted verifies that a // correctly signed Discord PING (type=1) passes the signature gate and the // handler returns 200 (PING returns nil msg → "ignored" status). func TestChannelHandler_Webhook_Discord_ValidSig_PingAccepted(t *testing.T) { pubHex, priv := genDiscordKey(t) mock := setupTestDB(t) setupTestRedis(t) handler := NewChannelHandler(newTestChannelManager()) // discordPublicKey: DB returns pubHex. mock.ExpectQuery(`SELECT COALESCE\(channel_config->>'app_public_key'`). WillReturnRows(sqlmock.NewRows([]string{"pubkey"}).AddRow(pubHex)) body := `{"type":1}` req := discordSignedRequest(t, body, "1700000000", priv) req.URL.Path = "/webhooks/discord" w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req c.Params = gin.Params{{Key: "type", Value: "discord"}} handler.Webhook(c) // Discord PING → ParseWebhook returns nil, nil → handler responds "ignored" if w.Code != http.StatusOK { t.Errorf("expected 200 for valid PING, got %d: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "ignored") { t.Errorf("expected body to contain 'ignored', got: %s", w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sqlmock expectations: %v", err) } }