Files
warmbly/internal/app/pipedrive/pipedrive_test.go
T

226 lines
8.1 KiB
Go

package pipedrive
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/integration"
"github.com/warmbly/warmbly/internal/models"
)
func TestVerifyWebhookPassword(t *testing.T) {
s := New(Deps{ClientSecret: "secret", PublicURL: "https://api.example.com"})
conn := uuid.New()
pass := s.hookPassword(conn)
if !s.VerifyWebhook(conn, hookUser, pass) {
t.Fatal("the derived password must verify")
}
if s.VerifyWebhook(uuid.New(), hookUser, pass) {
t.Fatal("a password is only valid for its own connection")
}
if s.VerifyWebhook(conn, "someone", pass) || s.VerifyWebhook(conn, hookUser, "") {
t.Fatal("wrong user or empty password must not verify")
}
if New(Deps{}).VerifyWebhook(conn, hookUser, pass) {
t.Fatal("without a client secret nothing verifies")
}
}
func TestHookURLNeedsHTTPS(t *testing.T) {
conn := uuid.New()
if u := New(Deps{ClientSecret: "x", PublicURL: "http://localhost:8080"}).hookURL(conn); u != "" {
t.Fatalf("a non-HTTPS backend registers no webhook, got %q", u)
}
u := New(Deps{ClientSecret: "x", PublicURL: "https://api.example.com/"}).hookURL(conn)
if u != "https://api.example.com"+WebhookPath+conn.String() {
t.Fatalf("unexpected webhook url %q", u)
}
}
func TestWebhookEventIDs(t *testing.T) {
for _, body := range []string{
`{"meta":{"action":"change","entity":"deal","entity_id":"42"}}`,
`{"meta":{"action":"updated","object":"deal","id":42}}`,
} {
var ev webhookEvent
if err := json.Unmarshal([]byte(body), &ev); err != nil {
t.Fatal(err)
}
got := rawID(ev.Meta.EntityID)
if got == "" {
got = rawID(ev.Meta.ID)
}
if got != "42" || firstNonEmpty(ev.Meta.Entity, ev.Meta.Object) != "deal" {
t.Fatalf("%s: read id %q", body, got)
}
}
if rawID(json.RawMessage(`"abc"`)) != "" || rawID(nil) != "" {
t.Fatal("a non-numeric id is no id")
}
}
func TestDueDates(t *testing.T) {
due := time.Date(2026, 3, 4, 15, 30, 0, 0, time.UTC)
body := map[string]any{}
setDue(body, &due)
if body["due_date"] != "2026-03-04" || body["due_time"] != "15:30" {
t.Fatalf("unexpected due fields %v", body)
}
back := dueAt("2026-03-04", "15:30")
if back == nil || !back.Equal(due) {
t.Fatalf("round trip lost the time: %v", back)
}
allDay := time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)
body = map[string]any{}
setDue(body, &allDay)
if _, ok := body["due_time"]; ok {
t.Fatal("a midnight due date is a whole day, with no time")
}
if parseTime("2026-03-04 10:11:12") == nil || parseTime("2026-03-04T10:11:12Z") == nil || parseTime("") != nil {
t.Fatal("v1 and v2 timestamps must both parse")
}
}
func TestFieldValue(t *testing.T) {
f := &Field{FieldType: "enum", Options: []FieldOption{{ID: 7, Label: "Hot"}, {ID: 8, Label: "Cold"}}}
if fieldValue(float64(7), f) != "Hot" {
t.Fatal("an option id reads as its label")
}
set := &Field{FieldType: "set", Options: f.Options}
if fieldValue([]any{float64(7), float64(8)}, set) != "Hot, Cold" {
t.Fatal("a set reads as its labels")
}
if fieldValue(map[string]any{"value": float64(12.5), "currency": "EUR"}, nil) != "12.5 EUR" {
t.Fatal("money reads with its currency")
}
if fieldValue(nil, nil) != "" || fieldValue(true, nil) != "Yes" {
t.Fatal("empty and boolean values")
}
}
func TestPersonEmailAndLabels(t *testing.T) {
p := &Person{Emails: []ContactPoint{{Value: "other@x.com"}, {Value: " Main@X.com ", Primary: true}}}
if p.Email() != "main@x.com" {
t.Fatalf("the primary address wins, got %q", p.Email())
}
prev := &models.CRMContactRecord{Properties: map[string]string{"label_ids": "1,2"}}
rec := &models.CRMContactRecord{Properties: map[string]string{"label_ids": "2,3"}}
if got := newLabels(prev, rec); len(got) != 1 || got[0] != "3" {
t.Fatalf("only the added label is new, got %v", got)
}
if !isEmailLog(&Activity{Type: "email", Done: true}) || isEmailLog(&Activity{Type: "email"}) || isEmailLog(&Activity{Type: "call", Done: true}) {
t.Fatal("only a done email activity is a log")
}
if websiteDomain("https://www.acme.io/about") != "acme.io" {
t.Fatal("a website reduces to its host")
}
}
func TestClientPagesAndRetries(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if n == 1 {
w.Header().Set("X-RateLimit-Reset", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("cursor") == "" {
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":1,"title":"A"}],"additional_data":{"next_cursor":"c2"}}`))
return
}
_, _ = w.Write([]byte(`{"success":true,"data":[{"id":2,"title":"B"}],"additional_data":{"next_cursor":null}}`))
}))
defer srv.Close()
c := NewClient("test", srv.URL, func(context.Context) (string, error) { return "tok", nil }, nil)
deals, err := c.Deals(context.Background(), url.Values{}, 0)
if err != nil {
t.Fatal(err)
}
if len(deals) != 2 || deals[1].Title != "B" {
t.Fatalf("expected both pages, got %+v", deals)
}
bad := NewClient("test", srv.URL, func(context.Context) (string, error) { return "nope", nil }, nil)
_, err = bad.GetDeal(context.Background(), 1)
ae, ok := AsAPIError(err)
if !ok || !ae.AuthProblem() || ae.Retryable() {
t.Fatalf("a refused token is an auth problem, got %v", err)
}
}
func TestAPIDomainGuard(t *testing.T) {
for _, ok := range []string{"https://acme.pipedrive.com", "https://ACME.pipedrive.com/"} {
if d, err := integration.PipedriveAPIDomain(ok); err != nil || !strings.HasPrefix(d, "https://acme.") {
t.Fatalf("%s should be accepted, got %q %v", ok, d, err)
}
}
for _, bad := range []string{"http://acme.pipedrive.com", "https://evil.com", "https://a.b.pipedrive.com",
"https://pipedrive.com.evil.com", "https://user@acme.pipedrive.com", "https://acme.pipedrive.com:8443"} {
if _, err := integration.PipedriveAPIDomain(bad); err == nil {
t.Fatalf("%s must be refused: the token is sent there", bad)
}
}
}
func TestConfigBelongsTo(t *testing.T) {
legacy := models.DefaultCRMProviderConfig()
if !legacy.BelongsTo(models.CRMProviderHubSpot) || legacy.BelongsTo(models.CRMProviderPipedrive) {
t.Fatal("a config written before Pipedrive is HubSpot's")
}
pd := models.DefaultCRMProviderConfigFor(models.CRMProviderPipedrive)
pd.For = models.CRMProviderPipedrive
if err := pd.Validate(); err != nil {
t.Fatalf("the Pipedrive defaults must validate: %v", err)
}
if pd.FieldMap["company"] != keyOrgName {
t.Fatal("company maps to the person's organization")
}
}
func TestVerifyAppToken(t *testing.T) {
s := New(Deps{ClientSecret: "secret"})
sign := func(method jwt.SigningMethod, key any, claims jwt.MapClaims) string {
tok, err := jwt.NewWithClaims(method, claims).SignedString(key)
if err != nil {
t.Fatal(err)
}
return tok
}
call := AppCall{CompanyID: "77", UserID: "12", Resource: "person", RecordID: "3"}
good := sign(jwt.SigningMethodHS256, []byte("secret"), jwt.MapClaims{"userId": float64(12), "companyId": "77"})
if !s.VerifyAppToken(good, call) {
t.Fatal("a token Pipedrive signed for this user and company verifies")
}
if s.VerifyAppToken(good, AppCall{CompanyID: "77", UserID: "13"}) {
t.Fatal("a token names one user; the query cannot swap in another")
}
if s.VerifyAppToken(sign(jwt.SigningMethodHS256, []byte("other"), jwt.MapClaims{"userId": "12", "companyId": "77"}), call) {
t.Fatal("a token signed with another secret is refused")
}
if s.VerifyAppToken(sign(jwt.SigningMethodHS512, []byte("secret"), jwt.MapClaims{"userId": "12", "companyId": "77"}), call) {
t.Fatal("only HS256 is accepted")
}
expired := sign(jwt.SigningMethodHS256, []byte("secret"), jwt.MapClaims{"userId": "12", "companyId": "77", "exp": float64(time.Now().Add(-time.Hour).Unix())})
if s.VerifyAppToken(expired, call) {
t.Fatal("an expired token is refused")
}
if New(Deps{}).VerifyAppToken(good, call) {
t.Fatal("without a client secret nothing verifies")
}
}