test: expand test coverage — uninstall, dedup, 401, token refresh
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>
This commit is contained in:
Head of Product & Engineering 2026-04-05 00:36:45 +02:00
parent d081875fce
commit dcf1e3070e
4 changed files with 189 additions and 1 deletions

View File

@ -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) {

View File

@ -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,
} }
} }
@ -171,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
} }

View File

@ -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

View File

@ -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) {
@ -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)
}
}