Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func main() {
go (integrations.SlackWorker{DB: db}).Start(workerCtx)
go (integrations.MicrosoftTeamsWorker{DB: db}).Start(workerCtx)
go (integrations.SentryWorker{DB: db}).Start(workerCtx)
go (integrations.ZendeskWorker{DB: db}).Start(workerCtx)

server := &http.Server{
Addr: cfg.Addr,
Expand Down
1 change: 1 addition & 0 deletions apps/api/internal/demo/demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ func resetTx(ctx context.Context, tx pgx.Tx) error {
`delete from provider_job where workspace_id=$1::uuid`,
`delete from provider_credential where workspace_integration_id in (select id from workspace_integration where workspace_id=$1::uuid)`,
`delete from integration_thread_link where workspace_id=$1::uuid`,
`delete from zendesk_ticket_link where workspace_id=$1::uuid`,
`delete from team_notification_integration where team_id in (select id from team where workspace_id=$1::uuid)`,
`delete from workspace_integration where workspace_id=$1::uuid`,
`delete from personal_access_token_audit_log where workspace_id=$1::uuid`,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ func mountAPIRoutes(r chi.Router, prefix string, db *pgxpool.Pool, emailSender e
publicProvider.Post("/integrations/sentry/issues/search", integrationsHandler.SentryIssueSearch)
publicProvider.Post("/integrations/sentry/issues/link", integrationsHandler.SentryIssueLink)
publicProvider.Post("/integrations/sentry/issues/create", integrationsHandler.SentryIssueCreate)
publicProvider.Post("/integrations/zendesk/tickets/search", integrationsHandler.ZendeskTicketSearch)
publicProvider.Post("/integrations/zendesk/tickets/link", integrationsHandler.ZendeskTicketLink)
publicProvider.Post("/integrations/zendesk/tickets/create", integrationsHandler.ZendeskTicketCreate)
publicProvider.Post("/integrations/zendesk/tickets/status", integrationsHandler.ZendeskTicketStatus)
publicProvider.Post("/integrations/microsoft-teams/activities", integrationsHandler.MicrosoftTeamsActivities)
publicProvider.Post("/integrations/slack/events", integrationsHandler.SlackEvents)
publicProvider.Post("/integrations/gitlab/webhook/{integrationID}", integrationsHandler.GitLabWebhook)
Expand Down
2 changes: 2 additions & 0 deletions apps/api/internal/integrations/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ func (h Handler) Routes() chi.Router {
r.Post("/microsoft-teams/disconnect", h.MicrosoftTeamsDisconnect)
r.Post("/sentry/connect", h.SentryConnect)
r.Post("/sentry/disconnect", h.SentryDisconnect)
r.Post("/zendesk/setup", h.ZendeskSetup)
r.Post("/zendesk/disconnect", h.ZendeskDisconnect)
r.Post("/gong/connect", h.GongConnect)
r.Post("/gong/disconnect", h.GongDisconnect)
r.Post("/slack/disconnect", h.SlackDisconnect)
Expand Down
149 changes: 149 additions & 0 deletions apps/api/internal/integrations/handler_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package integrations

import (
"context"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
Expand All @@ -13,6 +14,9 @@ import (
"strings"
"testing"
"time"

"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)

func TestSetupRequirement(t *testing.T) {
Expand Down Expand Up @@ -901,3 +905,148 @@ func TestSentryIssueActionFromPayload(t *testing.T) {
t.Fatalf("parsed Sentry issue = %#v", got)
}
}

func TestZendeskSetupRequirementAndSubdomain(t *testing.T) {
if got := setupRequirement("zendesk"); got != nil {
t.Fatalf("zendesk should be configurable from admin setup, got %#v", got)
}
subdomain, accountURL, err := normalizeZendeskSubdomain("https://Acme.zendesk.com/")
if err != nil {
t.Fatal(err)
}
if subdomain != "acme" || accountURL != "https://acme.zendesk.com" {
t.Fatalf("normalized = %q %q", subdomain, accountURL)
}
if _, _, err := normalizeZendeskSubdomain("https://acme.zendesk.com/path"); err == nil {
t.Fatal("Zendesk origin with path was accepted")
}
}

func TestZendeskSignatureVerification(t *testing.T) {
body := []byte(`{"ticket":{"id":"42"}}`)
mac := hmac.New(sha256.New, []byte("secret"))
_, _ = mac.Write(body)
signature := hex.EncodeToString(mac.Sum(nil))
if !verifyZendeskSignature("secret", signature, body) {
t.Fatal("valid Zendesk signature was rejected")
}
if !verifyZendeskSignature("secret", "sha256="+signature, body) {
t.Fatal("prefixed Zendesk signature was rejected")
}
if verifyZendeskSignature("secret", signature, []byte(`{"ticket":{"id":"43"}}`)) {
t.Fatal("tampered Zendesk body was accepted")
}
}

func TestZendeskTicketActionFromPayload(t *testing.T) {
payload := map[string]any{
"query": "ENG",
"teamKey": "ENG",
"issueIdentifier": "ENG-42",
"ticket": map[string]any{
"id": "123",
"url": "https://acme.zendesk.com/agent/tickets/123",
"subject": "Customer cannot export",
"description": "Export fails",
"status": "open",
"requester": map[string]any{"id": "9", "name": "Ada", "email": "ADA@EXAMPLE.COM"},
"organization": map[string]any{"id": "7", "name": "Acme"},
},
}
got := zendeskTicketActionFromPayload(payload)
if got.Query != "ENG" || got.TeamKey != "ENG" || got.IssueID != "ENG-42" {
t.Fatalf("parsed action = %#v", got)
}
if got.Subdomain != "acme" || got.Ticket.ID != "123" || got.Ticket.Requester.Email != "ada@example.com" || got.Ticket.Organization.Name != "Acme" {
t.Fatalf("parsed ticket = %#v", got.Ticket)
}
}

func TestUpdateZendeskTicket(t *testing.T) {
var got map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/tickets/123.json" {
t.Fatalf("path = %s", r.URL.Path)
}
username, password, ok := r.BasicAuth()
if !ok || username != "admin@example.com/token" || password != "token" {
t.Fatalf("basic auth = %q %q %v", username, password, ok)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"ticket":{"id":123}}`))
}))
defer server.Close()
t.Setenv("ZENDESK_API_BASE_URL", server.URL)

err := updateZendeskTicket(t.Context(), server.Client(), zendeskCredential{Subdomain: "acme", AccountURL: "https://acme.zendesk.com", Email: "admin@example.com", APIToken: "token"}, "123", "Done")
if err != nil {
t.Fatal(err)
}
ticket := got["ticket"].(map[string]any)
comment := ticket["comment"].(map[string]any)
if comment["body"] != "Done" || comment["public"] != false || ticket["status"] != "open" {
t.Fatalf("payload = %#v", got)
}
}

type fakeZendeskLinkExecutor struct {
queries []string
args [][]any
}

func (f *fakeZendeskLinkExecutor) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
f.queries = append(f.queries, sql)
f.args = append(f.args, args)
return pgconn.CommandTag{}, nil
}

func (f *fakeZendeskLinkExecutor) QueryRow(_ context.Context, sql string, args ...any) pgx.Row {
f.queries = append(f.queries, sql)
f.args = append(f.args, args)
return pgxRowStub{id: "customer-1"}
}

type pgxRowStub struct {
id string
}

func (r pgxRowStub) Scan(dest ...any) error {
if len(dest) > 0 {
if out, ok := dest[0].(*string); ok {
*out = r.id
}
}
return nil
}

func TestZendeskCustomerRequestUpsertUsesTicketMetadata(t *testing.T) {
exec := &fakeZendeskLinkExecutor{}
ticket := zendeskTicketRef{
ID: "123",
URL: "https://acme.zendesk.com/agent/tickets/123",
Subject: "Customer cannot export",
Description: strings.Repeat("a", 2100),
Requester: zendeskRequesterRef{ID: "9", Name: "Ada", Email: "ada@example.com"},
Organization: zendeskOrganizationRef{
ID: "7",
Name: "Acme",
},
}
if err := upsertZendeskCustomerRequest(context.Background(), exec, "workspace-1", "issue-1", ticket); err != nil {
t.Fatal(err)
}
if len(exec.queries) != 2 {
t.Fatalf("queries = %#v", exec.queries)
}
if exec.args[0][1] != "Acme" || exec.args[0][2] != "example.com" || exec.args[0][3] != "7" {
t.Fatalf("customer args = %#v", exec.args[0])
}
if exec.args[1][1] != "customer-1" || exec.args[1][3] != "123" || exec.args[1][5] != "Customer cannot export" {
t.Fatalf("request args = %#v", exec.args[1])
}
if excerpt, ok := exec.args[1][6].(string); !ok || len([]rune(excerpt)) != 2000 {
t.Fatalf("excerpt was not bounded: %#v", exec.args[1][6])
}
}
Loading