package service import ( "bytes" "context" "encoding/json" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/security" ) // --- Registry Tests --- func TestWebhookProcessorRegistry_RegisterAndGet(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) reg := NewWebhookProcessorRegistry(signer, nil, nil) slackProc, ok := reg.Get(model.HookTypeSlack) assert.True(t, ok, "Slack processor should be registered") assert.Equal(t, model.HookTypeSlack, slackProc.HookType()) shopifyProc, ok := reg.Get(model.HookTypeShopify) assert.True(t, ok, "Shopify processor should be registered") assert.Equal(t, model.HookTypeShopify, shopifyProc.HookType()) linearProc, ok := reg.Get(model.HookTypeLinear) assert.True(t, ok, "Linear processor should be registered") assert.Equal(t, model.HookTypeLinear, linearProc.HookType()) notionProc, ok := reg.Get(model.HookTypeNotion) assert.True(t, ok, "Notion processor should be registered") assert.Equal(t, model.HookTypeNotion, notionProc.HookType()) webhookProc, ok := reg.Get(model.HookTypeWebhook) assert.True(t, ok, "Generic webhook processor should be registered") assert.Equal(t, model.HookTypeWebhook, webhookProc.HookType()) _, ok = reg.Get(model.HookType("unknown")) assert.False(t, ok, "Unknown hook type should not be registered") } func TestWebhookProcessorRegistry_ListTypes(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) reg := NewWebhookProcessorRegistry(signer, nil, nil) types := reg.ListTypes() assert.Len(t, types, 5) } func TestWebhookProcessorRegistry_CustomRegistration(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) reg := &WebhookProcessorRegistry{processors: make(map[model.HookType]WebhookEventProcessor)} reg.Register(NewSlackEventProcessor(nil, nil, signer)) proc, ok := reg.Get(model.HookTypeSlack) assert.True(t, ok) assert.NotNil(t, proc) } // --- Slack Processor Tests --- func TestSlackEventProcessor_URLVerification(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 1, HookType: model.HookTypeSlack, Status: model.HookStatusActive} payload := map[string]interface{}{ "type": "url_verification", "challenge": "test_challenge_token_123", "token": "verification_token", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.Error(t, err, "URL verification should return special error") assert.Contains(t, err.Error(), "slack_url_verification") assert.Contains(t, err.Error(), "test_challenge_token_123") } func TestSlackEventProcessor_MessageEvent(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 2, HookType: model.HookTypeSlack, Status: model.HookStatusActive} payload := map[string]interface{}{ "event": map[string]interface{}{ "type": "message", "user": "U12345", "channel": "C67890", "text": "Hello from Slack!", "ts": "1234567890.123456", }, } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err, "Valid Slack message event should process successfully") } func TestSlackEventProcessor_EmptyMessage(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 3, HookType: model.HookTypeSlack, Status: model.HookStatusActive} payload := map[string]interface{}{ "event": map[string]interface{}{ "type": "message", "user": "U12345", "channel": "C67890", "text": "", }, } err := proc.ProcessEvent(context.Background(), hook, payload) assert.Error(t, err, "Empty Slack message should be rejected") assert.Contains(t, err.Error(), "empty") } func TestSlackEventProcessor_MissingEventField(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 4, HookType: model.HookTypeSlack, Status: model.HookStatusActive} payload := map[string]interface{}{ "type": "event_callback", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.Error(t, err, "Payload without 'event' field should be rejected") assert.Contains(t, err.Error(), "missing 'event' field") } func TestSlackEventProcessor_ChannelJoinLeave(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 5, HookType: model.HookTypeSlack, Status: model.HookStatusActive} for _, eventType := range []string{"channel_join", "channel_leave"} { payload := map[string]interface{}{ "event": map[string]interface{}{ "type": eventType, }, } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err, "Channel join/leave events should be accepted (no action needed)") } } func TestSlackEventProcessor_UnsupportedEventType(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 6, HookType: model.HookTypeSlack, Status: model.HookStatusActive} payload := map[string]interface{}{ "event": map[string]interface{}{ "type": "reaction_added", }, } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err, "Unsupported Slack events should be silently accepted") } // --- Shopify Processor Tests --- func TestShopifyEventProcessor_OrderCreate(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewShopifyEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 10, HookType: model.HookTypeShopify, Status: model.HookStatusActive} for _, topic := range []string{"orders/create", "orders/updated", "orders/paid"} { payload := map[string]interface{}{ "_shopify_topic": topic, "id": float64(12345), "email": "customer@example.com", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err, "Shopify %s should process successfully", topic) } } func TestShopifyEventProcessor_CustomerCreate(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewShopifyEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 11, HookType: model.HookTypeShopify, Status: model.HookStatusActive} payload := map[string]interface{}{ "_shopify_topic": "customers/create", "id": float64(67890), "email": "newcustomer@example.com", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err) } func TestShopifyEventProcessor_UnsupportedTopic(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewShopifyEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 12, HookType: model.HookTypeShopify, Status: model.HookStatusActive} payload := map[string]interface{}{ "_shopify_topic": "products/create", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err, "Unsupported Shopify topics should be silently accepted") } // --- Linear Processor Tests --- func TestLinearEventProcessor_IssueEvent(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewLinearEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 20, HookType: model.HookTypeLinear, Status: model.HookStatusActive} payload := map[string]interface{}{ "action": "create", "data": map[string]interface{}{ "entityType": "Issue", "id": "abc-123", "title": "Bug report: login fails", }, } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err) } func TestLinearEventProcessor_CommentEvent(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewLinearEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 21, HookType: model.HookTypeLinear, Status: model.HookStatusActive} payload := map[string]interface{}{ "action": "create", "data": map[string]interface{}{ "entityType": "Comment", "id": "comment-456", "body": "This is a comment", }, } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err) } func TestLinearEventProcessor_MissingDataField(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewLinearEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 22, HookType: model.HookTypeLinear, Status: model.HookStatusActive} payload := map[string]interface{}{ "action": "create", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.Error(t, err, "Linear payload without 'data' should be rejected") assert.Contains(t, err.Error(), "missing 'data' field") } // --- Notion Processor Tests --- func TestNotionEventProcessor_BasicEvent(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewNotionEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 30, HookType: model.HookTypeNotion, Status: model.HookStatusActive} payload := map[string]interface{}{ "type": "page.created", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err) } // --- Generic Webhook Processor Tests --- func TestGenericWebhookProcessor_BasicEvent(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewGenericWebhookProcessor(nil, signer) hook := &model.IntegrationHook{ID: 40, HookType: model.HookTypeWebhook, Status: model.HookStatusActive, AccessToken: "test_secret_unique_webhook_001"} payload := map[string]interface{}{ "event": "custom_event", "data": "some data", } err := proc.ProcessEvent(context.Background(), hook, payload) assert.NoError(t, err) } // --- ParseWebhookPayload Tests --- func TestParseWebhookPayload_ValidJSON(t *testing.T) { body := []byte(`{"type":"message","text":"hello","user":"U123"}`) payload, err := ParseWebhookPayload(body) require.NoError(t, err) assert.Equal(t, "message", payload["type"]) assert.Equal(t, "hello", payload["text"]) } func TestParseWebhookPayload_InvalidJSON(t *testing.T) { body := []byte(`{invalid json}`) payload, err := ParseWebhookPayload(body) assert.Error(t, err) assert.Nil(t, payload) } func TestParseWebhookPayload_EmptyBody(t *testing.T) { body := []byte(``) payload, err := ParseWebhookPayload(body) assert.Error(t, err) assert.Nil(t, payload) } // --- Signature Verification Integration Tests --- // These tests verify that the VerifySignature method correctly integrates // with the WebhookSignatureService for each channel type. func TestSlackEventProcessor_VerifySignature_InvalidSignature(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewSlackEventProcessor(nil, nil, signer) hook := &model.IntegrationHook{ID: 1, AccessToken: "slack_secret_token_123_unique"} body := []byte(`{"type":"event_callback","event":{"type":"message"}}`) r := httptest.NewRequest("POST", "/webhooks/slack", bytes.NewReader(body)) r.Header.Set("X-Slack-Signature", "v0=invalid_signature_hash") r.Header.Set("X-Slack-Request-Timestamp", "1234567890") err := proc.VerifySignature(r, body, hook) assert.Error(t, err, "Invalid Slack signature should be rejected") } func TestShopifyEventProcessor_VerifySignature_InvalidSignature(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewShopifyEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 2, AccessToken: "shopify_shared_secret_unique_456"} body := []byte(`{"id":12345,"email":"test@example.com"}`) r := httptest.NewRequest("POST", "/webhooks/shopify", bytes.NewReader(body)) r.Header.Set("X-Shopify-Hmac-Sha256", "invalid_base64_signature") err := proc.VerifySignature(r, body, hook) assert.Error(t, err, "Invalid Shopify signature should be rejected") } func TestLinearEventProcessor_VerifySignature_InvalidSignature(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewLinearEventProcessor(nil, signer) hook := &model.IntegrationHook{ID: 3, AccessToken: "linear_webhook_secret_unique_789"} body := []byte(`{"action":"create","data":{"entityType":"Issue"}}`) r := httptest.NewRequest("POST", "/webhooks/linear", bytes.NewReader(body)) r.Header.Set("X-Linear-Signature", "invalid_hex_signature") err := proc.VerifySignature(r, body, hook) assert.Error(t, err, "Invalid Linear signature should be rejected") } func TestGenericWebhookProcessor_VerifySignature_InvalidSignature(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewGenericWebhookProcessor(nil, signer) hook := &model.IntegrationHook{ID: 4, AccessToken: "generic_webhook_secret_unique_012"} body := []byte(`{"event":"custom_event"}`) r := httptest.NewRequest("POST", "/webhooks/generic", bytes.NewReader(body)) r.Header.Set("X-Webhook-Hmac-Signature", "invalid_hmac_hex") err := proc.VerifySignature(r, body, hook) assert.Error(t, err, "Invalid generic webhook signature should be rejected") } func TestGenericWebhookProcessor_VerifySignature_EmptySecret(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewGenericWebhookProcessor(nil, signer) hook := &model.IntegrationHook{ID: 5, AccessToken: ""} body := []byte(`{"event":"test"}`) r := httptest.NewRequest("POST", "/webhooks/generic", bytes.NewReader(body)) r.Header.Set("X-Webhook-Hmac-Signature", "some_signature") err := proc.VerifySignature(r, body, hook) assert.Error(t, err, "Empty secret should be rejected") } func TestGenericWebhookProcessor_VerifySignature_EmptyPayload(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) proc := NewGenericWebhookProcessor(nil, signer) hook := &model.IntegrationHook{ID: 6, AccessToken: "secret_for_empty_payload_test"} body := []byte{} r := httptest.NewRequest("POST", "/webhooks/generic", bytes.NewReader(body)) r.Header.Set("X-Webhook-Hmac-Signature", "some_signature") err := proc.VerifySignature(r, body, hook) assert.Error(t, err, "Empty payload should be rejected") } // --- ProcessWebhookEvent Integration Tests (using IntegrationHookService) --- // These tests verify the full flow: hook lookup → status check → signature verify → event dispatch func TestProcessWebhookEvent_InactiveHook(t *testing.T) { // Inactive hooks should be rejected before signature verification signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) hookRepo := createTestHookRepo(t) appRepo := createTestAppRepo(t) reg := NewWebhookProcessorRegistry(signer, hookRepo, nil) svc := NewIntegrationHookService(hookRepo, appRepo, reg) ctx := context.Background() hook := &model.IntegrationHook{ AccountID: 1, HookType: model.HookTypeSlack, Status: model.HookStatusInactive, AccessToken: "slack_inactive_hook_secret_unique_001", } err := hookRepo.Create(ctx, hook) require.NoError(t, err) body := []byte(`{"type":"url_verification","challenge":"test"}`) r := httptest.NewRequest("POST", "/webhooks/slack", bytes.NewReader(body)) payload := map[string]interface{}{"type": "url_verification", "challenge": "test"} err = svc.ProcessWebhookEvent(ctx, hook.ID, payload, r, body) assert.Error(t, err, "Inactive hook should be rejected") assert.Contains(t, err.Error(), "inactive") } func TestProcessWebhookEvent_UnknownHookType(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) hookRepo := createTestHookRepo(t) appRepo := createTestAppRepo(t) reg := NewWebhookProcessorRegistry(signer, hookRepo, nil) svc := NewIntegrationHookService(hookRepo, appRepo, reg) ctx := context.Background() hook := &model.IntegrationHook{ AccountID: 1, HookType: model.HookType("unknown_type"), Status: model.HookStatusActive, AccessToken: "unknown_hook_secret_unique_002", } err := hookRepo.Create(ctx, hook) require.NoError(t, err) body := []byte(`{"event":"test"}`) r := httptest.NewRequest("POST", "/webhooks/unknown", bytes.NewReader(body)) payload := map[string]interface{}{"event": "test"} err = svc.ProcessWebhookEvent(ctx, hook.ID, payload, r, body) assert.Error(t, err, "Unknown hook type should be rejected") assert.Contains(t, err.Error(), "no processor") } func TestProcessWebhookEvent_HookNotFound(t *testing.T) { signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig()) hookRepo := createTestHookRepo(t) appRepo := createTestAppRepo(t) reg := NewWebhookProcessorRegistry(signer, hookRepo, nil) svc := NewIntegrationHookService(hookRepo, appRepo, reg) ctx := context.Background() body := []byte(`{"event":"test"}`) r := httptest.NewRequest("POST", "/webhooks/999999", bytes.NewReader(body)) payload := map[string]interface{}{"event": "test"} err := svc.ProcessWebhookEvent(ctx, 999999, payload, r, body) assert.Error(t, err, "Non-existent hook ID should be rejected") assert.Contains(t, err.Error(), "not found") } // --- Utility function tests --- func TestPayloadKeys(t *testing.T) { m := map[string]interface{}{ "type": "message", "text": "hello", "user": "U123", } keys := payloadKeys(m) assert.Len(t, keys, 3) assert.Contains(t, keys, "type") assert.Contains(t, keys, "text") assert.Contains(t, keys, "user") } func TestPayloadKeys_Empty(t *testing.T) { keys := payloadKeys(map[string]interface{}{}) assert.Len(t, keys, 0) } // --- WebhookDelivery struct tests --- func TestWebhookDelivery_JSON(t *testing.T) { delivery := WebhookDelivery{ HookID: 1, Status: "success", Attempts: 1, Payload: map[string]interface{}{"event": "test"}, } data, err := json.Marshal(delivery) require.NoError(t, err) assert.Contains(t, string(data), `"hook_id":1`) assert.Contains(t, string(data), `"status":"success"`) var decoded WebhookDelivery err = json.Unmarshal(data, &decoded) require.NoError(t, err) assert.Equal(t, uint(1), decoded.HookID) assert.Equal(t, "success", decoded.Status) } // --- Test helpers --- // NOTE: These use in-memory SQLite DB via the repository layer. // For the webhook processor tests, most tests don't need a DB (processors // accept nil repos for logging-only processing). Only the full integration // tests (ProcessWebhookEvent) need actual repos. func createTestHookRepo(t *testing.T) *repository.IntegrationHookRepo { t.Helper() hookRepo, _ := setupIntegrationHookTestDB(t) return hookRepo } func createTestAppRepo(t *testing.T) *repository.IntegrationAppRepo { t.Helper() _, appRepo := setupIntegrationHookTestDB(t) return appRepo }