Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- cast/client_test: add TestSendSMS_Unauthorized (401 → CastAPIError) - ghl/webhook_test: add duplicate messageId, 450-char message, HandleUninstall (valid/invalid sig) - ghl/oauth_test: add GetValidToken auto-refresh and refresh-failure tests - ghl/oauth: make tokenURL a struct field (default ghlTokenURL) so tests can inject a mock endpoint Co-Authored-By: SideKx <sidekx.ai@sds.dev>
212 lines
6.8 KiB
Go
212 lines
6.8 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"
|
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
|
)
|
|
|
|
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, ms)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TC9: Duplicate webhook delivery — second delivery of same messageId is silently accepted (200).
|
|
func TestWebhook_DuplicateMessageID(t *testing.T) {
|
|
privKey, pubPEM := generateTestKeyPair(t)
|
|
handler := newTestHandler(t, pubPEM)
|
|
|
|
body := `{"contactId":"c1","locationId":"loc1","messageId":"dup-msg-1","type":"SMS","phone":"+639171234567","message":"hello","attachments":[],"userId":"u1"}`
|
|
sig := signPayload(t, privKey, []byte(body))
|
|
|
|
sendRequest := func() *httptest.ResponseRecorder {
|
|
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)
|
|
return rr
|
|
}
|
|
|
|
rr1 := sendRequest()
|
|
if rr1.Code != http.StatusOK {
|
|
t.Errorf("first delivery: expected 200, got %d", rr1.Code)
|
|
}
|
|
|
|
rr2 := sendRequest()
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("duplicate delivery: expected 200 (idempotent), got %d", rr2.Code)
|
|
}
|
|
}
|
|
|
|
// TC11: 450-char boundary message — webhook accepts and returns 200.
|
|
func TestWebhook_450CharMessage(t *testing.T) {
|
|
privKey, pubPEM := generateTestKeyPair(t)
|
|
handler := newTestHandler(t, pubPEM)
|
|
|
|
msg450 := strings.Repeat("x", 450)
|
|
payload := `{"contactId":"c1","locationId":"loc1","messageId":"msg-450","type":"SMS","phone":"+639171234567","message":"` + msg450 + `","attachments":[],"userId":"u1"}`
|
|
sig := signPayload(t, privKey, []byte(payload))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ghl/v1/webhook/messages", strings.NewReader(payload))
|
|
req.Header.Set("x-wh-signature", sig)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleWebhook(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for 450-char message, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TC12: Uninstall — valid signature deletes token.
|
|
func TestHandleUninstall_ValidSignature(t *testing.T) {
|
|
privKey, pubPEM := generateTestKeyPair(t)
|
|
ms := &inMemStore{
|
|
token: &store.TokenRecord{LocationID: "loc-uninstall"},
|
|
}
|
|
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
|
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
|
if err != nil {
|
|
t.Fatalf("failed to create handler: %v", err)
|
|
}
|
|
|
|
body := `{"locationId":"loc-uninstall","companyId":"comp1"}`
|
|
sig := signPayload(t, privKey, []byte(body))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ghl/v1/uninstall", strings.NewReader(body))
|
|
req.Header.Set("x-wh-signature", sig)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUninstall(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
|
}
|
|
// Verify token was deleted
|
|
if ms.token != nil {
|
|
t.Errorf("expected token to be deleted after uninstall, still present")
|
|
}
|
|
}
|
|
|
|
// TC12: Uninstall — invalid signature returns 401.
|
|
func TestHandleUninstall_InvalidSignature(t *testing.T) {
|
|
_, pubPEM := generateTestKeyPair(t)
|
|
ms := &inMemStore{}
|
|
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
|
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
|
if err != nil {
|
|
t.Fatalf("failed to create handler: %v", err)
|
|
}
|
|
|
|
body := `{"locationId":"loc1","companyId":"comp1"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/ghl/v1/uninstall", strings.NewReader(body))
|
|
req.Header.Set("x-wh-signature", "aW52YWxpZA==")
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUninstall(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|