Compare commits
2 Commits
a40a4aa626
...
dcf1e3070e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcf1e3070e | ||
|
|
d081875fce |
@ -40,7 +40,7 @@ func main() {
|
|||||||
ghlAPI := ghl.NewAPIClient()
|
ghlAPI := ghl.NewAPIClient()
|
||||||
oauthHandler := ghl.NewOAuthHandler(cfg.GHLClientID, cfg.GHLClientSecret, cfg.BaseURL, cfg.GHLConversationProviderID, s)
|
oauthHandler := ghl.NewOAuthHandler(cfg.GHLClientID, cfg.GHLClientSecret, cfg.BaseURL, cfg.GHLConversationProviderID, s)
|
||||||
|
|
||||||
webhookHandler, err := ghl.NewWebhookHandler(cfg.GHLWebhookPublicKey, castClient, ghlAPI, oauthHandler)
|
webhookHandler, err := ghl.NewWebhookHandler(cfg.GHLWebhookPublicKey, castClient, ghlAPI, oauthHandler, s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to initialize webhook handler", "err", err)
|
slog.Error("failed to initialize webhook handler", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -56,6 +56,7 @@ func main() {
|
|||||||
r.Get("/install", oauthHandler.HandleInstall)
|
r.Get("/install", oauthHandler.HandleInstall)
|
||||||
r.Get("/oauth-callback", oauthHandler.HandleCallback)
|
r.Get("/oauth-callback", oauthHandler.HandleCallback)
|
||||||
r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook)
|
r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook)
|
||||||
|
r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
|
|||||||
@ -120,6 +120,28 @@ func TestSendSMS_WithoutSenderID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TC5: Failed Cast API key — 401 response is returned as a CastAPIError.
|
||||||
|
func TestSendSMS_Unauthorized(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(SendResponse{Success: false, Error: "invalid api key"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewClient(srv.URL, "cast_badkey", "")
|
||||||
|
_, err := client.SendSMS(context.Background(), "09171234567", "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 401, got nil")
|
||||||
|
}
|
||||||
|
castErr, ok := err.(*CastAPIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected CastAPIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if castErr.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected status 401, got %d", castErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendSMS_RetryOn429(t *testing.T) {
|
func TestSendSMS_RetryOn429(t *testing.T) {
|
||||||
var callCount atomic.Int32
|
var callCount atomic.Int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -32,6 +32,7 @@ type OAuthHandler struct {
|
|||||||
providerID string
|
providerID string
|
||||||
store TokenStore
|
store TokenStore
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
tokenURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, s TokenStore) *OAuthHandler {
|
func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, s TokenStore) *OAuthHandler {
|
||||||
@ -42,6 +43,7 @@ func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, s Token
|
|||||||
providerID: providerID,
|
providerID: providerID,
|
||||||
store: s,
|
store: s,
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
tokenURL: ghlTokenURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +70,12 @@ func (h *OAuthHandler) HandleInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||||
|
slog.Warn("ghl oauth denied by user", "error", errParam)
|
||||||
|
http.Error(w, "authorization denied: "+errParam, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||||
@ -165,7 +173,7 @@ func (h *OAuthHandler) exchangeCode(ctx context.Context, code string) (*TokenRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenResponse, error) {
|
func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenResponse, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghlTokenURL, strings.NewReader(data.Encode()))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.tokenURL, strings.NewReader(data.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,73 @@ func TestGetValidToken_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetValidToken_Expired_RefreshesAutomatically(t *testing.T) {
|
||||||
|
// Mock GHL token endpoint that returns a fresh token on refresh
|
||||||
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.ParseForm()
|
||||||
|
if r.FormValue("grant_type") != "refresh_token" {
|
||||||
|
t.Errorf("expected refresh_token grant, got %s", r.FormValue("grant_type"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(TokenResponse{
|
||||||
|
AccessToken: "new_access_token",
|
||||||
|
RefreshToken: "new_refresh_token",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
LocationID: "loc1",
|
||||||
|
CompanyID: "comp1",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer tokenSrv.Close()
|
||||||
|
|
||||||
|
ms := &inMemStore{
|
||||||
|
token: &store.TokenRecord{
|
||||||
|
LocationID: "loc1",
|
||||||
|
AccessToken: "old_token",
|
||||||
|
RefreshToken: "old_refresh",
|
||||||
|
ExpiresAt: time.Now().Add(2 * time.Minute), // within 5-min refresh window
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
||||||
|
h.tokenURL = tokenSrv.URL
|
||||||
|
|
||||||
|
tok, err := h.GetValidToken(context.Background(), "loc1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tok != "new_access_token" {
|
||||||
|
t.Errorf("expected new_access_token after refresh, got %s", tok)
|
||||||
|
}
|
||||||
|
// Verify store was updated
|
||||||
|
if ms.token.AccessToken != "new_access_token" {
|
||||||
|
t.Errorf("store not updated after refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetValidToken_Expired_RefreshFails(t *testing.T) {
|
||||||
|
// Simulate token endpoint failure during refresh
|
||||||
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":"invalid_grant"}`))
|
||||||
|
}))
|
||||||
|
defer tokenSrv.Close()
|
||||||
|
|
||||||
|
ms := &inMemStore{
|
||||||
|
token: &store.TokenRecord{
|
||||||
|
LocationID: "loc1",
|
||||||
|
AccessToken: "expired_token",
|
||||||
|
RefreshToken: "bad_refresh",
|
||||||
|
ExpiresAt: time.Now().Add(1 * time.Minute), // within 5-min window
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
||||||
|
h.tokenURL = tokenSrv.URL
|
||||||
|
|
||||||
|
_, err := h.GetValidToken(context.Background(), "loc1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when refresh fails, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// inMemStore is a minimal in-memory store for testing
|
// inMemStore is a minimal in-memory store for testing
|
||||||
type inMemStore struct {
|
type inMemStore struct {
|
||||||
token *store.TokenRecord
|
token *store.TokenRecord
|
||||||
|
|||||||
@ -27,6 +27,11 @@ type MessageStatusUpdate struct {
|
|||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UninstallWebhook struct {
|
||||||
|
LocationID string `json:"locationId"`
|
||||||
|
CompanyID string `json:"companyId"`
|
||||||
|
}
|
||||||
|
|
||||||
type InboundMessage struct {
|
type InboundMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|||||||
@ -12,20 +12,30 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
|
castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
|
||||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/phone"
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/phone"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const seenMessageTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
type seenEntry struct {
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type WebhookHandler struct {
|
type WebhookHandler struct {
|
||||||
webhookPubKey *ecdsa.PublicKey
|
webhookPubKey *ecdsa.PublicKey
|
||||||
castClient *castclient.Client
|
castClient *castclient.Client
|
||||||
ghlAPI *APIClient
|
ghlAPI *APIClient
|
||||||
oauthHandler *OAuthHandler
|
oauthHandler *OAuthHandler
|
||||||
|
store TokenStore
|
||||||
|
seenMu sync.Mutex
|
||||||
|
seenMessages map[string]seenEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler) (*WebhookHandler, error) {
|
func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) {
|
||||||
key, err := parseECDSAPublicKey(pubKeyPEM)
|
key, err := parseECDSAPublicKey(pubKeyPEM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse webhook public key: %w", err)
|
return nil, fmt.Errorf("failed to parse webhook public key: %w", err)
|
||||||
@ -35,9 +45,32 @@ func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *
|
|||||||
castClient: castClient,
|
castClient: castClient,
|
||||||
ghlAPI: ghlAPI,
|
ghlAPI: ghlAPI,
|
||||||
oauthHandler: oauth,
|
oauthHandler: oauth,
|
||||||
|
store: store,
|
||||||
|
seenMessages: make(map[string]seenEntry),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markSeen returns true if messageID was already seen within seenMessageTTL (duplicate).
|
||||||
|
// Otherwise records it and returns false.
|
||||||
|
func (h *WebhookHandler) markSeen(messageID string) bool {
|
||||||
|
h.seenMu.Lock()
|
||||||
|
defer h.seenMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// Evict expired entries on every call to avoid unbounded growth.
|
||||||
|
for id, e := range h.seenMessages {
|
||||||
|
if now.Sub(e.at) > seenMessageTTL {
|
||||||
|
delete(h.seenMessages, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := h.seenMessages[messageID]; exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h.seenMessages[messageID] = seenEntry{at: now}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
sigHeader := r.Header.Get("x-wh-signature")
|
sigHeader := r.Header.Get("x-wh-signature")
|
||||||
|
|
||||||
@ -67,6 +100,12 @@ func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.markSeen(webhook.MessageID) {
|
||||||
|
slog.Warn("webhook: duplicate messageId ignored", "message_id", webhook.MessageID)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("webhook: received outbound SMS", "message_id", webhook.MessageID, "location_id", webhook.LocationID)
|
slog.Info("webhook: received outbound SMS", "message_id", webhook.MessageID, "location_id", webhook.LocationID)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
@ -121,6 +160,44 @@ func (h *WebhookHandler) verifySignature(body []byte, signatureB64 string) bool
|
|||||||
return ecdsa.VerifyASN1(h.webhookPubKey, hash[:], sigBytes)
|
return ecdsa.VerifyASN1(h.webhookPubKey, hash[:], sigBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("uninstall: failed to read body", "err", err)
|
||||||
|
http.Error(w, "failed to read request body", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.verifySignature(body, r.Header.Get("x-wh-signature")) {
|
||||||
|
slog.Warn("uninstall: invalid signature")
|
||||||
|
http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload UninstallWebhook
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
slog.Error("uninstall: failed to parse payload", "err", err)
|
||||||
|
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.LocationID == "" {
|
||||||
|
slog.Error("uninstall: missing locationId")
|
||||||
|
http.Error(w, "missing locationId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
if err := h.store.DeleteToken(ctx, payload.LocationID); err != nil {
|
||||||
|
slog.Error("uninstall: failed to delete token", "location_id", payload.LocationID, "err", err)
|
||||||
|
http.Error(w, "failed to process uninstall", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("uninstall: token deleted", "location_id", payload.LocationID)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
|
func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
|
||||||
block, _ := pem.Decode([]byte(pemStr))
|
block, _ := pem.Decode([]byte(pemStr))
|
||||||
if block == nil {
|
if block == nil {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
|
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) {
|
func generateTestKeyPair(t *testing.T) (*ecdsa.PrivateKey, string) {
|
||||||
@ -44,7 +45,7 @@ func newTestHandler(t *testing.T, pubPEM string) *WebhookHandler {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
ms := &inMemStore{}
|
ms := &inMemStore{}
|
||||||
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
||||||
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth)
|
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create handler: %v", err)
|
t.Fatalf("failed to create handler: %v", err)
|
||||||
}
|
}
|
||||||
@ -112,3 +113,99 @@ func TestWebhook_NonSMSType(t *testing.T) {
|
|||||||
t.Errorf("expected 200, got %d", rr.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user