package services import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "strconv" "testing" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/schema" ) // slackTestSigningSecret is the signing secret the test channel is configured with. const slackTestSigningSecret = "test_signing_secret_999" // signSlackPayload builds the X-Slack-Request-Timestamp and X-Slack-Signature // headers Slack would send for this body right now. func signSlackPayload(t *testing.T, secret string, payload []byte) (string, string) { t.Helper() return signSlackPayloadAt(t, secret, payload, time.Now()) } func signSlackPayloadAt(t *testing.T, secret string, payload []byte, at time.Time) (string, string) { t.Helper() timestamp := strconv.FormatInt(at.Unix(), 10) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte("v0:" + timestamp + ":" + string(payload))) return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil)) } func setupSlackTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: "t_", SingularTable: true, }, }) if err != nil { t.Fatalf("open sqlite db: %v", err) } if err := db.AutoMigrate( &models.Channel{}, &models.ChannelMessageOutbox{}, &models.Customer{}, &models.CustomerIdentity{}, &models.CustomerContact{}, &models.Conversation{}, &models.ConversationParticipant{}, &models.ConversationReadState{}, &models.ConversationInterrupt{}, &models.ConversationEventLog{}, &models.Message{}, &models.AIAgent{}, &models.User{}, &models.Role{}, &models.UserRole{}, &models.Permission{}, &models.RolePermission{}, &models.UserPermission{}, ); err != nil { t.Fatalf("migrate slack test tables: %v", err) } sqls.SetDB(db) return db } func TestSlackInboundAndOutbound(t *testing.T) { db := setupSlackTestDB(t) now := time.Now() aiAgent := &models.AIAgent{ Name: "Slack Bot Agent", Status: enums.StatusOk, PublishedRevisionID: 1, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, } if err := db.Create(aiAgent).Error; err != nil { t.Fatalf("create ai agent: %v", err) } slackConfig := dto.SlackChannelConfig{ BotToken: "xoxb-test-bot-token-12345", SigningSecret: slackTestSigningSecret, TeamID: "T0123456789", TeamName: "Acme Corp", DefaultChannel: "C9876543210", } cfgBytes, _ := json.Marshal(slackConfig) channel := &models.Channel{ ChannelType: enums.ChannelTypeSlack, ChannelID: "T0123456789", AIAgentID: aiAgent.ID, AIAgentRolloutPercent: 100, Name: "Slack Support Channel", ConfigJSON: string(cfgBytes), Status: enums.StatusOk, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, } if err := db.Create(channel).Error; err != nil { t.Fatalf("create slack channel: %v", err) } payload := `{ "token": "verification_token", "team_id": "T0123456789", "api_app_id": "A01234567", "type": "event_callback", "event": { "type": "message", "user": "U12345678", "text": "Help with API key generation", "ts": "1725260000.000200", "channel": "C9876543210", "channel_type": "channel" } }` ctx := context.Background() timestamp, signature := signSlackPayload(t, slackTestSigningSecret, []byte(payload)) _, err := SlackInboundService.HandleWebhook(ctx, "", timestamp, signature, []byte(payload)) if err != nil { t.Fatalf("HandleWebhook failed: %v", err) } // Verify customer identity identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd(). Eq("external_source", enums.ExternalSourceSlack). Eq("external_id", "U12345678")) if identity == nil { t.Fatalf("expected customer identity for U12345678") } // Verify conversation conv := repositories.ConversationRepository.FindOne(sqls.DB(), sqls.NewCnd(). Eq("customer_id", identity.CustomerID). Eq("channel_id", channel.ID)) if conv == nil { t.Fatalf("expected conversation to be created") } // Verify message msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd(). Eq("conversation_id", conv.ID). Eq("sender_type", enums.IMSenderTypeCustomer)) if msg == nil { t.Fatalf("expected message to be created") } if msg.Content != "Help with API key generation" { t.Fatalf("expected message content 'Help with API key generation', got %s", msg.Content) } operator := &dto.AuthPrincipal{UserID: 1, Nickname: "Agent Joy"} // Test Outbound enqueue replyMsg, err := MessageService.SendAIMessage(conv.ID, aiAgent.ID, "ai_slack_reply_1", enums.IMMessageTypeText, "You can generate your API key under Settings > API Keys.", "", operator) if err != nil { t.Fatalf("MessageService.SendAIMessage failed: %v", err) } outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeSlack, replyMsg.ID) if outbox == nil { t.Fatalf("expected outbox entry for slack message") } if outbox.ChannelType != enums.ChannelTypeSlack { t.Fatalf("expected outbox channel type 'slack', got %s", outbox.ChannelType) } } func seedSlackChannel(t *testing.T, db *gorm.DB, signingSecret string) *models.Channel { t.Helper() now := time.Now() aiAgent := &models.AIAgent{ Name: "Support AI", Status: enums.StatusOk, PublishedRevisionID: 1, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, } if err := db.Create(aiAgent).Error; err != nil { t.Fatalf("create ai agent: %v", err) } cfgBytes, err := json.Marshal(dto.SlackChannelConfig{ BotToken: "xoxb-test-bot-token-12345", SigningSecret: signingSecret, TeamID: "T0123456789", }) if err != nil { t.Fatalf("marshal slack config: %v", err) } channel := &models.Channel{ ChannelType: enums.ChannelTypeSlack, ChannelID: "slack_sig_channel", AIAgentID: aiAgent.ID, AIAgentRolloutPercent: 100, Name: "Slack Support", ConfigJSON: string(cfgBytes), Status: enums.StatusOk, AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, } if err := db.Create(channel).Error; err != nil { t.Fatalf("create slack channel: %v", err) } return channel } func slackEventPayload(messageID string) []byte { return []byte(`{ "team_id": "T0123456789", "type": "event_callback", "event": { "type": "message", "user": "U_SIG_TEST", "text": "signature probe", "ts": "` + messageID + `", "channel": "C9876543210", "channel_type": "channel" } }`) } func countSlackMessages(t *testing.T, db *gorm.DB, messageTS string) int64 { t.Helper() var count int64 if err := db.Table("t_message").Where("client_msg_id = ?", "slack_C9876543210_"+messageTS).Count(&count).Error; err != nil { t.Fatalf("count messages: %v", err) } return count } // A channel with a signing secret configured must reject anything Slack did not // sign. Accepting an unsigned delivery would let anyone who learns the webhook // URL write into a workspace conversation and trigger paid AI replies. func TestSlackInboundRejectsUnauthenticatedDelivery(t *testing.T) { db := setupSlackTestDB(t) channel := seedSlackChannel(t, db, slackTestSigningSecret) cases := []struct { name string messageTS string timestamp string signature string }{ {"no signature headers at all", "1725260000.000001", "", ""}, {"signature without a timestamp", "1725260000.000002", "", "v0=deadbeef"}, {"timestamp without a signature", "1725260000.000003", "1725260000", ""}, {"wrong signature", "1725260000.000004", "1725260000", "v0=deadbeef"}, {"non-numeric timestamp", "1725260000.000005", "not-a-timestamp", "v0=deadbeef"}, } for _, tc := range cases { payload := slackEventPayload(tc.messageTS) _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, tc.timestamp, tc.signature, payload) if err == nil { t.Errorf("%s: expected the delivery to be rejected", tc.name) continue } if countSlackMessages(t, db, tc.messageTS) != 0 { t.Errorf("%s: a rejected delivery stored a message", tc.name) } } } // Slack signs the timestamp and the body but nothing in the signature expires, so // a captured request replays forever unless the timestamp is checked. Slack's own // guide requires rejecting anything older than five minutes. func TestSlackInboundRejectsReplayedTimestamp(t *testing.T) { db := setupSlackTestDB(t) channel := seedSlackChannel(t, db, slackTestSigningSecret) cases := []struct { name string age time.Duration }{ {"ten minutes old", 10 * time.Minute}, {"one hour old", time.Hour}, {"ten minutes in the future", -10 * time.Minute}, } for i, tc := range cases { messageTS := "1725260000.0000" + strconv.Itoa(10+i) payload := slackEventPayload(messageTS) // Correctly signed for its own timestamp, which is exactly what a replayed // capture looks like on the wire. timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, time.Now().Add(-tc.age)) if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err == nil { t.Errorf("%s: expected a stale timestamp to be rejected", tc.name) } if countSlackMessages(t, db, messageTS) != 0 { t.Errorf("%s: a replayed delivery stored a message", tc.name) } } } // A correctly signed, fresh delivery is still accepted, and one signed just // inside the tolerance window is not rejected for clock drift. func TestSlackInboundAcceptsFreshValidSignature(t *testing.T) { db := setupSlackTestDB(t) channel := seedSlackChannel(t, db, slackTestSigningSecret) cases := []struct { name string age time.Duration }{ {"signed now", 0}, {"signed four minutes ago", 4 * time.Minute}, } for i, tc := range cases { messageTS := "1725260000.0000" + strconv.Itoa(20+i) payload := slackEventPayload(messageTS) timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, time.Now().Add(-tc.age)) if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err != nil { t.Fatalf("%s: HandleWebhook failed: %v", tc.name, err) } if countSlackMessages(t, db, messageTS) != 1 { t.Errorf("%s: expected the signed message to be stored", tc.name) } } } // The url_verification handshake has to be answered before any channel lookup or // signature check, because Slack sends it once while the endpoint is being // configured and will not retry. func TestSlackInboundAnswersURLVerificationChallenge(t *testing.T) { setupSlackTestDB(t) payload := []byte(`{"type":"url_verification","challenge":"challenge_token_abc","token":"verification_token"}`) challenge, err := SlackInboundService.HandleWebhook(context.Background(), "", "", "", payload) if err != nil { t.Fatalf("HandleWebhook failed: %v", err) } if challenge == nil || *challenge != "challenge_token_abc" { t.Fatalf("challenge = %v, want challenge_token_abc", challenge) } } // A channel without its own signing secret inherits the deployment-wide one, // so a single shared Slack app can serve every channel the desk supports. func TestSlackInboundFallsBackToDeploymentSigningSecret(t *testing.T) { db := setupSlackTestDB(t) channel := seedSlackChannel(t, db, "") config.SetCurrent(&config.Config{Slack: config.SlackConfig{SigningSecret: slackTestSigningSecret}}) defer config.SetCurrent(nil) payload := slackEventPayload("1725260000.000300") timestamp, signature := signSlackPayload(t, slackTestSigningSecret, payload) if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err != nil { t.Fatalf("expected the deployment-secret-signed delivery to be accepted: %v", err) } if countSlackMessages(t, db, "1725260000.000300") != 1 { t.Fatal("expected the delivery to be stored") } }