diff --git a/internal/cmd/webhook/create.go b/internal/cmd/webhook/create.go index e6fa2c5f..92a39a51 100644 --- a/internal/cmd/webhook/create.go +++ b/internal/cmd/webhook/create.go @@ -11,9 +11,10 @@ import ( func CreateCmd(ch *cmdutil.Helper) *cobra.Command { var flags struct { - url string - events []string - enabled bool + url string + authorizationHeader string + events []string + enabled bool } cmd := &cobra.Command{ @@ -30,10 +31,11 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { } req := &planetscale.CreateWebhookRequest{ - Organization: ch.Config.Organization, - Database: database, - URL: flags.url, - Events: flags.events, + Organization: ch.Config.Organization, + Database: database, + URL: flags.url, + AuthorizationHeader: flags.authorizationHeader, + Events: flags.events, } if cmd.Flags().Changed("enabled") { @@ -61,6 +63,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { } cmd.Flags().StringVar(&flags.url, "url", "", "The URL to send webhook events to (required)") + cmd.Flags().StringVar(&flags.authorizationHeader, "authorization-header", "", "The complete Authorization header value, for example Bearer token") cmd.Flags().StringSliceVar(&flags.events, "events", nil, "Comma-separated list of events to subscribe to") cmd.Flags().BoolVar(&flags.enabled, "enabled", true, "Whether the webhook is enabled") diff --git a/internal/cmd/webhook/create_test.go b/internal/cmd/webhook/create_test.go index 389c903d..79623da2 100644 --- a/internal/cmd/webhook/create_test.go +++ b/internal/cmd/webhook/create_test.go @@ -26,6 +26,7 @@ func TestWebhook_CreateCmd(t *testing.T) { db := "mydb" url := "https://example.com/webhook" events := []string{"branch.created", "branch.deleted"} + authorizationHeader := "Bearer automation-token" createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) webhook := &ps.Webhook{ @@ -42,6 +43,7 @@ func TestWebhook_CreateCmd(t *testing.T) { c.Assert(req.Organization, qt.Equals, org) c.Assert(req.Database, qt.Equals, db) c.Assert(req.URL, qt.Equals, url) + c.Assert(req.AuthorizationHeader, qt.Equals, authorizationHeader) c.Assert(req.Events, qt.DeepEquals, events) return webhook, nil }, @@ -60,7 +62,7 @@ func TestWebhook_CreateCmd(t *testing.T) { } cmd := CreateCmd(ch) - cmd.SetArgs([]string{db, "--url", url, "--events", "branch.created,branch.deleted"}) + cmd.SetArgs([]string{db, "--url", url, "--authorization-header", authorizationHeader, "--events", "branch.created,branch.deleted"}) err := cmd.Execute() c.Assert(err, qt.IsNil) diff --git a/internal/cmd/webhook/update.go b/internal/cmd/webhook/update.go index a1521f60..05c59540 100644 --- a/internal/cmd/webhook/update.go +++ b/internal/cmd/webhook/update.go @@ -11,9 +11,11 @@ import ( func UpdateCmd(ch *cmdutil.Helper) *cobra.Command { var flags struct { - url string - events []string - enabled bool + url string + authorizationHeader string + clearAuthorizationHeader bool + events []string + enabled bool } cmd := &cobra.Command{ @@ -43,6 +45,20 @@ func UpdateCmd(ch *cmdutil.Helper) *cobra.Command { changed = true } + if cmd.Flags().Changed("authorization-header") { + if flags.authorizationHeader == "" { + return fmt.Errorf("--authorization-header cannot be empty; use --clear-authorization-header to remove the configured header") + } + req.AuthorizationHeader = &flags.authorizationHeader + changed = true + } + + if flags.clearAuthorizationHeader { + empty := "" + req.AuthorizationHeader = &empty + changed = true + } + if cmd.Flags().Changed("events") { req.Events = flags.events changed = true @@ -54,7 +70,7 @@ func UpdateCmd(ch *cmdutil.Helper) *cobra.Command { } if !changed { - return fmt.Errorf("at least one of --url, --events, or --enabled must be provided") + return fmt.Errorf("at least one of --url, --authorization-header, --clear-authorization-header, --events, or --enabled must be provided") } end := ch.Printer.PrintProgress(fmt.Sprintf("Updating webhook %s for %s", printer.BoldBlue(webhookID), printer.BoldBlue(database))) @@ -78,8 +94,11 @@ func UpdateCmd(ch *cmdutil.Helper) *cobra.Command { } cmd.Flags().StringVar(&flags.url, "url", "", "The URL to send webhook events to") + cmd.Flags().StringVar(&flags.authorizationHeader, "authorization-header", "", "The complete Authorization header value, for example Bearer token") + cmd.Flags().BoolVar(&flags.clearAuthorizationHeader, "clear-authorization-header", false, "Remove the configured Authorization header") cmd.Flags().StringSliceVar(&flags.events, "events", nil, "Comma-separated list of events to subscribe to") cmd.Flags().BoolVar(&flags.enabled, "enabled", true, "Whether the webhook is enabled") + cmd.MarkFlagsMutuallyExclusive("authorization-header", "clear-authorization-header") return cmd } diff --git a/internal/cmd/webhook/update_test.go b/internal/cmd/webhook/update_test.go index 0293fb46..90a11664 100644 --- a/internal/cmd/webhook/update_test.go +++ b/internal/cmd/webhook/update_test.go @@ -120,6 +120,128 @@ func TestWebhook_UpdateCmd_EnabledFlag(t *testing.T) { c.Assert(svc.UpdateFnInvoked, qt.IsTrue) } +func TestWebhook_UpdateCmd_AuthorizationHeaderFlag(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "mydb" + webhookID := "webhook-123" + authorizationHeader := "Bearer automation-token" + + webhook := &ps.Webhook{ + ID: webhookID, + URL: "https://example.com/webhook", + Enabled: true, + } + + svc := &mock.WebhooksService{ + UpdateFn: func(ctx context.Context, req *ps.UpdateWebhookRequest) (*ps.Webhook, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.ID, qt.Equals, webhookID) + c.Assert(*req.AuthorizationHeader, qt.Equals, authorizationHeader) + return webhook, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Webhooks: svc, + }, nil + }, + } + + cmd := UpdateCmd(ch) + cmd.SetArgs([]string{db, webhookID, "--authorization-header", authorizationHeader}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.UpdateFnInvoked, qt.IsTrue) +} + +func TestWebhook_UpdateCmd_ClearAuthorizationHeaderFlag(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "mydb" + webhookID := "webhook-123" + webhook := &ps.Webhook{ID: webhookID, URL: "https://example.com/webhook", Enabled: true} + + svc := &mock.WebhooksService{ + UpdateFn: func(ctx context.Context, req *ps.UpdateWebhookRequest) (*ps.Webhook, error) { + c.Assert(req.AuthorizationHeader, qt.IsNotNil) + c.Assert(*req.AuthorizationHeader, qt.Equals, "") + return webhook, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{Webhooks: svc}, nil + }, + } + + cmd := UpdateCmd(ch) + cmd.SetArgs([]string{db, webhookID, "--clear-authorization-header"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.UpdateFnInvoked, qt.IsTrue) +} + +func TestWebhook_UpdateCmd_RejectsEmptyAuthorizationHeader(t *testing.T) { + c := qt.New(t) + + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(nil), + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{}, nil + }, + } + + cmd := UpdateCmd(ch) + cmd.SetArgs([]string{"mydb", "webhook-123", "--authorization-header", ""}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, `--authorization-header cannot be empty; use --clear-authorization-header to remove the configured header`) +} + +func TestWebhook_UpdateCmd_RejectsSetAndClearAuthorizationHeader(t *testing.T) { + c := qt.New(t) + + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(nil), + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{}, nil + }, + } + + cmd := UpdateCmd(ch) + cmd.SetArgs([]string{"mydb", "webhook-123", "--authorization-header", "Bearer automation-token", "--clear-authorization-header"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, `if any flags in the group \[authorization-header clear-authorization-header\] are set none of the others can be; \[authorization-header clear-authorization-header\] were all set`) +} + func TestWebhook_UpdateCmd_RequiresAtLeastOneFlag(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/webhook/webhook.go b/internal/cmd/webhook/webhook.go index 8404dd28..d0a1b300 100644 --- a/internal/cmd/webhook/webhook.go +++ b/internal/cmd/webhook/webhook.go @@ -33,12 +33,13 @@ func WebhookCmd(ch *cmdutil.Helper) *cobra.Command { // Webhook returns a table and json serializable webhook for printing. type Webhook struct { - ID string `header:"id" json:"id"` - URL string `header:"url" json:"url"` - Events string `header:"events" json:"events"` - Enabled bool `header:"enabled" json:"enabled"` - CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` - UpdatedAt int64 `header:"updated_at,timestamp(ms|utc|human)" json:"updated_at"` + ID string `header:"id" json:"id"` + URL string `header:"url" json:"url"` + AuthorizationHeaderConfigured bool `header:"authorization header configured" json:"authorization_header_configured"` + Events string `header:"events" json:"events"` + Enabled bool `header:"enabled" json:"enabled"` + CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` + UpdatedAt int64 `header:"updated_at,timestamp(ms|utc|human)" json:"updated_at"` orig *ps.Webhook } @@ -50,13 +51,14 @@ func (w *Webhook) MarshalJSON() ([]byte, error) { // toWebhook returns a struct that prints out the various fields of a webhook model. func toWebhook(webhook *ps.Webhook) *Webhook { return &Webhook{ - ID: webhook.ID, - URL: webhook.URL, - Events: strings.Join(webhook.Events, ", "), - Enabled: webhook.Enabled, - CreatedAt: printer.GetMilliseconds(webhook.CreatedAt), - UpdatedAt: printer.GetMilliseconds(webhook.UpdatedAt), - orig: webhook, + ID: webhook.ID, + URL: webhook.URL, + AuthorizationHeaderConfigured: webhook.AuthorizationHeaderConfigured, + Events: strings.Join(webhook.Events, ", "), + Enabled: webhook.Enabled, + CreatedAt: printer.GetMilliseconds(webhook.CreatedAt), + UpdatedAt: printer.GetMilliseconds(webhook.UpdatedAt), + orig: webhook, } } @@ -70,13 +72,14 @@ func toWebhooks(webhooks []*ps.Webhook) []*Webhook { // WebhookWithSecret includes the webhook secret for display. type WebhookWithSecret struct { - ID string `header:"id" json:"id"` - URL string `header:"url" json:"url"` - Secret string `header:"secret" json:"secret"` - Events string `header:"events" json:"events"` - Enabled bool `header:"enabled" json:"enabled"` - CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` - UpdatedAt int64 `header:"updated_at,timestamp(ms|utc|human)" json:"updated_at"` + ID string `header:"id" json:"id"` + URL string `header:"url" json:"url"` + Secret string `header:"secret" json:"secret"` + AuthorizationHeaderConfigured bool `header:"authorization header configured" json:"authorization_header_configured"` + Events string `header:"events" json:"events"` + Enabled bool `header:"enabled" json:"enabled"` + CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` + UpdatedAt int64 `header:"updated_at,timestamp(ms|utc|human)" json:"updated_at"` orig *ps.Webhook } @@ -88,13 +91,14 @@ func (w *WebhookWithSecret) MarshalJSON() ([]byte, error) { // toWebhookWithSecret returns a struct that includes the webhook secret. func toWebhookWithSecret(webhook *ps.Webhook) *WebhookWithSecret { return &WebhookWithSecret{ - ID: webhook.ID, - URL: webhook.URL, - Secret: webhook.Secret, - Events: strings.Join(webhook.Events, ", "), - Enabled: webhook.Enabled, - CreatedAt: printer.GetMilliseconds(webhook.CreatedAt), - UpdatedAt: printer.GetMilliseconds(webhook.UpdatedAt), - orig: webhook, + ID: webhook.ID, + URL: webhook.URL, + Secret: webhook.Secret, + AuthorizationHeaderConfigured: webhook.AuthorizationHeaderConfigured, + Events: strings.Join(webhook.Events, ", "), + Enabled: webhook.Enabled, + CreatedAt: printer.GetMilliseconds(webhook.CreatedAt), + UpdatedAt: printer.GetMilliseconds(webhook.UpdatedAt), + orig: webhook, } } diff --git a/internal/planetscale/webhooks.go b/internal/planetscale/webhooks.go index 3e253013..af1c7678 100644 --- a/internal/planetscale/webhooks.go +++ b/internal/planetscale/webhooks.go @@ -26,16 +26,17 @@ type webhooksResponse struct { // Webhook represents a PlanetScale webhook. type Webhook struct { - ID string `json:"id"` - URL string `json:"url"` - Secret string `json:"secret"` - Enabled bool `json:"enabled"` - LastSentResult string `json:"last_sent_result"` - LastSentSuccess bool `json:"last_sent_success"` - LastSentAt time.Time `json:"last_sent_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Events []string `json:"events"` + ID string `json:"id"` + URL string `json:"url"` + Secret string `json:"secret"` + AuthorizationHeaderConfigured bool `json:"authorization_header_configured"` + Enabled bool `json:"enabled"` + LastSentResult string `json:"last_sent_result"` + LastSentSuccess bool `json:"last_sent_success"` + LastSentAt time.Time `json:"last_sent_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Events []string `json:"events"` } // ListWebhooksRequest is the request for listing webhooks. @@ -46,11 +47,12 @@ type ListWebhooksRequest struct { // CreateWebhookRequest is the request for creating a webhook. type CreateWebhookRequest struct { - Organization string `json:"-"` - Database string `json:"-"` - URL string `json:"url"` - Enabled *bool `json:"enabled,omitempty"` - Events []string `json:"events,omitempty"` + Organization string `json:"-"` + Database string `json:"-"` + URL string `json:"url"` + AuthorizationHeader string `json:"authorization_header,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Events []string `json:"events,omitempty"` } // GetWebhookRequest is the request for getting a webhook. @@ -62,12 +64,13 @@ type GetWebhookRequest struct { // UpdateWebhookRequest is the request for updating a webhook. type UpdateWebhookRequest struct { - Organization string `json:"-"` - Database string `json:"-"` - ID string `json:"-"` - URL *string `json:"url,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Events []string `json:"events,omitempty"` + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` + URL *string `json:"url,omitempty"` + AuthorizationHeader *string `json:"authorization_header,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Events []string `json:"events,omitempty"` } // DeleteWebhookRequest is the request for deleting a webhook. diff --git a/internal/planetscale/webhooks_test.go b/internal/planetscale/webhooks_test.go index 8b73c5a0..0a208e51 100644 --- a/internal/planetscale/webhooks_test.go +++ b/internal/planetscale/webhooks_test.go @@ -29,6 +29,7 @@ func TestWebhooks_List(t *testing.T) { "id": "webhook-123", "url": "https://example.com/webhook", "secret": "secret-123", + "authorization_header_configured": true, "enabled": true, "last_sent_result": "success", "last_sent_success": true, @@ -56,6 +57,7 @@ func TestWebhooks_List(t *testing.T) { c.Assert(len(webhooks), qt.Equals, 1) c.Assert(webhooks[0].ID, qt.Equals, "webhook-123") c.Assert(webhooks[0].URL, qt.Equals, "https://example.com/webhook") + c.Assert(webhooks[0].AuthorizationHeaderConfigured, qt.IsTrue) c.Assert(webhooks[0].Enabled, qt.IsTrue) c.Assert(webhooks[0].Events, qt.DeepEquals, []string{"branch.ready", "deploy_request.opened"}) } @@ -98,7 +100,7 @@ func TestWebhooks_List_WithPagination(t *testing.T) { func TestWebhooks_Create(t *testing.T) { c := qt.New(t) - wantBody := []byte("{\"url\":\"https://example.com/webhook\",\"enabled\":true,\"events\":[\"branch.ready\"]}\n") + wantBody := []byte("{\"url\":\"https://example.com/webhook\",\"authorization_header\":\"Bearer automation-token\",\"enabled\":true,\"events\":[\"branch.ready\"]}\n") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(201) @@ -132,11 +134,12 @@ func TestWebhooks_Create(t *testing.T) { enabled := true webhook, err := client.Webhooks.Create(ctx, &CreateWebhookRequest{ - Organization: testOrg, - Database: testDatabase, - URL: "https://example.com/webhook", - Enabled: &enabled, - Events: []string{"branch.ready"}, + Organization: testOrg, + Database: testDatabase, + URL: "https://example.com/webhook", + AuthorizationHeader: "Bearer automation-token", + Enabled: &enabled, + Events: []string{"branch.ready"}, }) c.Assert(err, qt.IsNil) @@ -191,7 +194,7 @@ func TestWebhooks_Get(t *testing.T) { func TestWebhooks_Update(t *testing.T) { c := qt.New(t) - wantBody := []byte("{\"url\":\"https://example.com/new-webhook\",\"enabled\":false}\n") + wantBody := []byte("{\"url\":\"https://example.com/new-webhook\",\"authorization_header\":\"Bearer automation-token\",\"enabled\":false}\n") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -224,13 +227,15 @@ func TestWebhooks_Update(t *testing.T) { ctx := context.Background() newURL := "https://example.com/new-webhook" enabled := false + authorizationHeader := "Bearer automation-token" webhook, err := client.Webhooks.Update(ctx, &UpdateWebhookRequest{ - Organization: testOrg, - Database: testDatabase, - ID: "webhook-123", - URL: &newURL, - Enabled: &enabled, + Organization: testOrg, + Database: testDatabase, + ID: "webhook-123", + URL: &newURL, + AuthorizationHeader: &authorizationHeader, + Enabled: &enabled, }) c.Assert(err, qt.IsNil) @@ -240,6 +245,40 @@ func TestWebhooks_Update(t *testing.T) { c.Assert(webhook.UpdatedAt, qt.Equals, time.Date(2021, 1, 15, 10, 19, 23, 0, time.UTC)) } +func TestWebhooks_Update_ClearAuthorizationHeader(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"authorization_header\":\"\"}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks/webhook-123") + + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + + _, err = w.Write([]byte(`{"id":"webhook-123","url":"https://example.com/webhook"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + empty := "" + webhook, err := client.Webhooks.Update(context.Background(), &UpdateWebhookRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "webhook-123", + AuthorizationHeader: &empty, + }) + + c.Assert(err, qt.IsNil) + c.Assert(webhook.ID, qt.Equals, "webhook-123") +} + func TestWebhooks_Delete(t *testing.T) { c := qt.New(t)