mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 00:02:15 +00:00
226 lines
8.1 KiB
Go
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")
|
|
}
|
|
}
|