cast-ghl-plugin/internal/ghl/webhook_test.go
Head of Product & Engineering a40a4aa626
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: initial implementation of Cast GHL Provider
Complete MVP implementation of the Cast GHL Conversation Provider bridge:
- Go module setup with chi router and mongo-driver dependencies
- Config loading with env var validation and defaults
- MongoDB token store with upsert, get, update, delete operations
- Cast.ph SMS client with 429 retry logic and typed errors
- Phone number normalization (E.164 ↔ Philippine local format)
- GHL OAuth 2.0 install/callback/refresh flow
- GHL webhook handler with ECDSA signature verification (async dispatch)
- GHL API client for message status updates and inbound message stubs
- Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline
- Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-04 17:27:05 +02:00

115 lines
3.3 KiB
Go

package ghl
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"net/http"
"net/http/httptest"
"strings"
"testing"
castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
)
func generateTestKeyPair(t *testing.T) (*ecdsa.PrivateKey, string) {
t.Helper()
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("failed to generate key: %v", err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
if err != nil {
t.Fatalf("failed to marshal public key: %v", err)
}
pemBlock := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
return privKey, string(pemBlock)
}
func signPayload(t *testing.T, privKey *ecdsa.PrivateKey, body []byte) string {
t.Helper()
hash := sha256.Sum256(body)
sig, err := ecdsa.SignASN1(rand.Reader, privKey, hash[:])
if err != nil {
t.Fatalf("failed to sign: %v", err)
}
return base64.StdEncoding.EncodeToString(sig)
}
func newTestHandler(t *testing.T, pubPEM string) *WebhookHandler {
t.Helper()
ms := &inMemStore{}
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth)
if err != nil {
t.Fatalf("failed to create handler: %v", err)
}
return handler
}
func TestWebhook_ValidSignature_SMS(t *testing.T) {
privKey, pubPEM := generateTestKeyPair(t)
handler := newTestHandler(t, pubPEM)
body := `{"contactId":"c1","locationId":"loc1","messageId":"msg1","type":"SMS","phone":"+639171234567","message":"hello","attachments":[],"userId":"u1"}`
sig := signPayload(t, privKey, []byte(body))
req := httptest.NewRequest(http.MethodPost, "/api/ghl/v1/webhook/messages", strings.NewReader(body))
req.Header.Set("x-wh-signature", sig)
rr := httptest.NewRecorder()
handler.HandleWebhook(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}
func TestWebhook_InvalidSignature(t *testing.T) {
_, pubPEM := generateTestKeyPair(t)
handler := newTestHandler(t, pubPEM)
body := `{"type":"SMS","phone":"+639171234567","message":"test"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("x-wh-signature", "aW52YWxpZA==")
rr := httptest.NewRecorder()
handler.HandleWebhook(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
func TestWebhook_MissingSignature(t *testing.T) {
_, pubPEM := generateTestKeyPair(t)
handler := newTestHandler(t, pubPEM)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"type":"SMS"}`))
rr := httptest.NewRecorder()
handler.HandleWebhook(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
func TestWebhook_NonSMSType(t *testing.T) {
privKey, pubPEM := generateTestKeyPair(t)
handler := newTestHandler(t, pubPEM)
body := `{"type":"Email","phone":"+639171234567","message":"test"}`
sig := signPayload(t, privKey, []byte(body))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("x-wh-signature", sig)
rr := httptest.NewRecorder()
handler.HandleWebhook(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}