Files
agent-desk-ng/internal/handlers/third/slack_handler_test.go

154 lines
4.9 KiB
Go

package third
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/sqls"
)
const slackHandlerTestSigningSecret = "test_signing_secret"
// signSlackHandlerPayload builds the X-Slack-Request-Timestamp and
// X-Slack-Signature headers Slack sends for this body right now.
func signSlackHandlerPayload(payload []byte) (string, string) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, []byte(slackHandlerTestSigningSecret))
mac.Write([]byte("v0:" + timestamp + ":" + string(payload)))
return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil))
}
func TestSlackWebhook_Handler(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupThirdHandlerTestDB(t)
now := time.Now()
agent := &models.AIAgent{
Name: "Slack Agent",
ServiceMode: enums.IMConversationServiceModeAIFirst,
PublishedRevisionID: 1,
WelcomeMessage: "Hello Slack User!",
Status: enums.StatusOk,
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create ai agent: %v", err)
}
slackConfig, err := json.Marshal(dto.SlackChannelConfig{
BotToken: "xoxb-test-token",
SigningSecret: slackHandlerTestSigningSecret,
TeamID: "T_SLACK_100",
DefaultChannel: "C_GENERAL",
})
if err != nil {
t.Fatalf("marshal slack config: %v", err)
}
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
Name: "Slack Channel",
ChannelType: enums.ChannelTypeSlack,
AIAgentID: agent.ID,
AIAgentRolloutPercent: 100,
ConfigJSON: string(slackConfig),
Status: int(enums.StatusOk),
}, operator)
if err != nil {
t.Fatalf("CreateChannel failed: %v", err)
}
router := gin.New()
router.POST("/api/third/slack/webhook/:channel_id", SlackPostWebhook)
router.POST("/api/third/slack/webhook", SlackPostWebhook)
post := func(path string, payload []byte, timestamp, signature string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(http.MethodPost, path, bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
if timestamp != "" {
req.Header.Set("X-Slack-Request-Timestamp", timestamp)
}
if signature != "" {
req.Header.Set("X-Slack-Signature", signature)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
webhookPath := "/api/third/slack/webhook/" + channel.ChannelID
// 1. The url_verification handshake is answered without a signature, because
// Slack sends it once while the endpoint is being configured.
challengePayload := []byte(`{
"token": "token123",
"challenge": "slack_challenge_string_999",
"type": "url_verification"
}`)
recChallenge := post(webhookPath, challengePayload, "", "")
if recChallenge.Code != http.StatusOK {
t.Fatalf("expected 200 OK for challenge, got: %d", recChallenge.Code)
}
var challengeResp map[string]any
if err := json.Unmarshal(recChallenge.Body.Bytes(), &challengeResp); err != nil {
t.Fatalf("unmarshal challenge response: %v", err)
}
if challengeResp["challenge"] != "slack_challenge_string_999" {
t.Fatalf("expected challenge in body, got: %+v", challengeResp)
}
// 2. An event the channel's signing secret did not produce is rejected, and
// must not create a customer identity.
eventPayload := []byte(`{
"token": "token123",
"team_id": "T_SLACK_100",
"type": "event_callback",
"event": {
"type": "message",
"user": "U_USER_777",
"text": "Hello support team on Slack!",
"ts": "1725260000.000100",
"channel": "C_GENERAL"
}
}`)
recUnsigned := post(webhookPath, eventPayload, "", "")
if recUnsigned.Code != http.StatusOK {
t.Fatalf("expected the handler to answer 200 with an error body, got: %d", recUnsigned.Code)
}
if repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
Eq("external_source", enums.ExternalSourceSlack).
Eq("external_id", "U_USER_777")) != nil {
t.Fatalf("an unsigned event created a customer identity")
}
// 3. A correctly signed event is accepted and resolves the sender.
timestamp, signature := signSlackHandlerPayload(eventPayload)
recEvent := post(webhookPath, eventPayload, timestamp, signature)
if recEvent.Code != http.StatusOK {
t.Fatalf("expected 200 OK for event, got: %d", recEvent.Code)
}
identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
Eq("external_source", enums.ExternalSourceSlack).
Eq("external_id", "U_USER_777"))
if identity == nil {
t.Fatalf("expected customer identity for U_USER_777")
}
}