diff --git a/internal/cast/client_test.go b/internal/cast/client_test.go index e9cb012..81dd4e9 100644 --- a/internal/cast/client_test.go +++ b/internal/cast/client_test.go @@ -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) { var callCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ghl/oauth.go b/internal/ghl/oauth.go index 767d3bf..a2f15c9 100644 --- a/internal/ghl/oauth.go +++ b/internal/ghl/oauth.go @@ -32,6 +32,7 @@ type OAuthHandler struct { providerID string store TokenStore httpClient *http.Client + tokenURL string } 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, store: s, 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) { - 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 { return nil, err } diff --git a/internal/ghl/oauth_test.go b/internal/ghl/oauth_test.go index 458ab9c..0e52942 100644 --- a/internal/ghl/oauth_test.go +++ b/internal/ghl/oauth_test.go @@ -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 type inMemStore struct { token *store.TokenRecord diff --git a/internal/ghl/webhook_test.go b/internal/ghl/webhook_test.go index d11d29a..39d58dd 100644 --- a/internal/ghl/webhook_test.go +++ b/internal/ghl/webhook_test.go @@ -14,6 +14,7 @@ import ( "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) { @@ -112,3 +113,99 @@ func TestWebhook_NonSMSType(t *testing.T) { 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) + } +}