From 2d80c8327d2be031cf40f3c015fcbbf39e573cd1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <33255434+caiopizzol@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:07:24 -0300 Subject: [PATCH] feat: add provider app creation command --- cmd/create_provider_app.go | 170 ++++++++++++++++++++++++++++ cmd/create_provider_app_test.go | 101 +++++++++++++++++ request/api.go | 18 +++ request/create_provider_app_test.go | 91 +++++++++++++++ request/types.go | 7 ++ 5 files changed, 387 insertions(+) create mode 100644 cmd/create_provider_app.go create mode 100644 cmd/create_provider_app_test.go create mode 100644 request/create_provider_app_test.go diff --git a/cmd/create_provider_app.go b/cmd/create_provider_app.go new file mode 100644 index 0000000..29407c4 --- /dev/null +++ b/cmd/create_provider_app.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +const maxClientSecretBytes = 16 * 1024 + +var ( + errClientSecretEmpty = errors.New("client secret cannot be empty") + errClientSecretMultiline = errors.New("client secret must contain exactly one line") + errClientSecretTooLong = errors.New("client secret is too long") +) + +type providerAppClient interface { + CreateProviderApp(ctx context.Context, params *request.CreateProviderAppParams) (*request.ProviderApp, error) +} + +type providerAppClientFactory func(projectId string, apiKey string) providerAppClient + +type projectIdResolver func() string + +func readClientSecret(reader io.Reader) (string, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxClientSecretBytes+1)) + if err != nil { + return "", fmt.Errorf("read client secret: %w", err) + } + + if len(data) > maxClientSecretBytes { + return "", errClientSecretTooLong + } + + secret := strings.TrimSuffix(string(data), "\n") + secret = strings.TrimSuffix(secret, "\r") + + if strings.TrimSpace(secret) == "" { + return "", errClientSecretEmpty + } + + if strings.ContainsAny(secret, "\r\n") { + return "", errClientSecretMultiline + } + + return secret, nil +} + +func promptClientSecret() (string, error) { + prompt := promptui.Prompt{ + Label: "Client secret", + Mask: '*', + Stdin: os.Stdin, + Stdout: os.Stdout, + } + + secret, err := prompt.Run() + if err != nil { + return "", fmt.Errorf("read client secret: %w", err) + } + + if strings.TrimSpace(secret) == "" { + return "", errClientSecretEmpty + } + + return secret, nil +} + +func providerAppCreatedMessage(providerApp *request.ProviderApp) string { + scopes := "none" + if len(providerApp.Scopes) > 0 { + scopes = strings.Join(providerApp.Scopes, ", ") + } + + return fmt.Sprintf("Provider App ID: %s, Provider: %s, Scopes: %s", + providerApp.Id, providerApp.Provider, scopes) +} + +func newCreateProviderAppCmd( + newClient providerAppClientFactory, + getProjectId projectIdResolver, +) *cobra.Command { + var ( + clientId string + scopes []string + clientSecretStdin bool + ) + + cmd := &cobra.Command{ + Use: "create:provider-app ", + Short: "Create a provider app", + Long: "Create an OAuth provider app in an Ampersand project. " + + "The client secret is requested in a masked prompt unless --client-secret-stdin is set.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + projectId := getProjectId() + apiKey := flags.GetAPIKey() + + if flags.GetDebugMode() { + logger.Fatal("Debug logging is not available when submitting a client secret") + } + + var ( + clientSecret string + err error + ) + + if clientSecretStdin { + clientSecret, err = readClientSecret(cmd.InOrStdin()) + } else { + clientSecret, err = promptClientSecret() + } + + if err != nil { + logger.FatalErr("Unable to read client secret", err) + } + + client := newClient(projectId, apiKey) + + providerApp, err := client.CreateProviderApp(cmd.Context(), &request.CreateProviderAppParams{ + Provider: args[0], + ClientId: clientId, + ClientSecret: clientSecret, + Scopes: scopes, + }) + if err != nil { + if errors.Is(err, clerk.ErrNoSessions) { + logger.Fatal("Authenticated session has expired, please log in using amp login") + } else { + logger.Fatal("Unable to create provider app. The API rejected the request") + } + } + + logger.Info(providerAppCreatedMessage(providerApp)) + }, + } + + cmd.Flags().StringVar(&clientId, "client-id", "", "OAuth client ID") + cmd.Flags().StringArrayVar(&scopes, "scope", nil, "OAuth scope to register (repeat for multiple scopes)") + cmd.Flags().BoolVar(&clientSecretStdin, "client-secret-stdin", false, + "Read the OAuth client secret from stdin instead of prompting") + + err := cmd.MarkFlagRequired("client-id") + if err != nil { + logger.FatalErr("unable to require client-id flag", err) + } + + return cmd +} + +var createProviderAppCmd = newCreateProviderAppCmd( //nolint:gochecknoglobals + func(projectId string, apiKey string) providerAppClient { + return request.NewAPIClient(projectId, &apiKey) + }, + flags.GetProjectOrFail, +) + +func init() { + rootCmd.AddCommand(createProviderAppCmd) +} diff --git a/cmd/create_provider_app_test.go b/cmd/create_provider_app_test.go new file mode 100644 index 0000000..908c91e --- /dev/null +++ b/cmd/create_provider_app_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/amp-labs/cli/request" +) + +const testClientSecret = "test-client-secret" + +type fakeProviderAppClient struct { + createdWith *request.CreateProviderAppParams +} + +func (f *fakeProviderAppClient) CreateProviderApp( + _ context.Context, + params *request.CreateProviderAppParams, +) (*request.ProviderApp, error) { + f.createdWith = params + + return &request.ProviderApp{ + Id: "provider-app-id", + Provider: params.Provider, + ClientId: params.ClientId, + ClientSecret: params.ClientSecret, + Scopes: params.Scopes, + }, nil +} + +func TestCreateProviderAppCommandReadsClientSecretFromStdin(t *testing.T) { + t.Parallel() + + client := &fakeProviderAppClient{} + cmd := newCreateProviderAppCmd(func(projectId string, _ string) providerAppClient { + if projectId != "test-project" { + t.Fatalf("project ID = %q, want test-project", projectId) + } + + return client + }, func() string { + return "test-project" + }) + cmd.SetIn(strings.NewReader(testClientSecret + "\n")) + cmd.SetArgs([]string{ + "asana", + "--client-id", "test-client-id", + "--scope", "projects:read", + "--scope", "projects:write", + "--client-secret-stdin", + }) + + err := cmd.Execute() + if err != nil { + t.Fatalf("execute create provider app command: %v", err) + } + + want := request.CreateProviderAppParams{ + Provider: "asana", + ClientId: "test-client-id", + ClientSecret: testClientSecret, + Scopes: []string{"projects:read", "projects:write"}, + } + if client.createdWith == nil || !providerAppParamsEqual(client.createdWith, &want) { + t.Fatalf("create provider app params = %#v, want %#v", client.createdWith, want) + } +} + +func TestReadClientSecretRejectsMultipleLines(t *testing.T) { + t.Parallel() + + _, err := readClientSecret(strings.NewReader("first\nsecond\n")) + if err == nil { + t.Fatal("read client secret succeeded, want error") + } +} + +func TestProviderAppCreatedMessageOmitsCredentials(t *testing.T) { + t.Parallel() + + message := providerAppCreatedMessage(&request.ProviderApp{ + Id: "provider-app-id", + Provider: "asana", + ClientId: "test-client-id", + ClientSecret: testClientSecret, + Scopes: []string{"projects:read"}, + }) + + if strings.Contains(message, "test-client-id") || strings.Contains(message, testClientSecret) { + t.Fatalf("message contains credentials: %q", message) + } +} + +func providerAppParamsEqual(left *request.CreateProviderAppParams, right *request.CreateProviderAppParams) bool { + return left.Provider == right.Provider && + left.ClientId == right.ClientId && + left.ClientSecret == right.ClientSecret && + slices.Equal(left.Scopes, right.Scopes) +} diff --git a/request/api.go b/request/api.go index e7a9ecd..b0f2f13 100644 --- a/request/api.go +++ b/request/api.go @@ -237,6 +237,24 @@ func (c *APIClient) ListProviderApps(ctx context.Context) ([]*ProviderApp, error return providerApps, nil } +func (c *APIClient) CreateProviderApp(ctx context.Context, params *CreateProviderAppParams) (*ProviderApp, error) { + createURL := fmt.Sprintf("%s/projects/%s/provider-apps", c.Root, c.ProjectId) + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + var providerApp ProviderApp + + _, err = c.Client.Post(ctx, createURL, params, &providerApp, auth) //nolint:bodyclose + if err != nil { + return nil, err + } + + return &providerApp, nil +} + func (c *APIClient) ListProjects(ctx context.Context) ([]*Project, error) { listURL := c.Root + "/projects" diff --git a/request/create_provider_app_test.go b/request/create_provider_app_test.go new file mode 100644 index 0000000..0201ffc --- /dev/null +++ b/request/create_provider_app_test.go @@ -0,0 +1,91 @@ +package request + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" +) + +const providerAppTestAPIKey = "test-api-key" + +func newProviderAppTestAPIClient(server *httptest.Server) *APIClient { + apiKey := providerAppTestAPIKey + + return &APIClient{ + Root: server.URL + "/v1", + ProjectId: "skills-onboarding-cli-dev", + APIKey: &apiKey, + Client: &Client{Client: server.Client()}, + } +} + +func TestCreateProviderApp(t *testing.T) { + t.Parallel() + + wantParams := CreateProviderAppParams{ + Provider: "asana", + ClientId: "test-client-id", + ClientSecret: "test-client-secret", + Scopes: []string{"projects:read", "projects:write"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost || + req.URL.Path != "/v1/projects/skills-onboarding-cli-dev/provider-apps" { + t.Errorf("request = %s %s, want POST provider-apps path", req.Method, req.URL.Path) + } + + if req.Header.Get("X-Api-Key") != providerAppTestAPIKey { + t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key")) + } + + var params CreateProviderAppParams + + err := json.NewDecoder(req.Body).Decode(¶ms) + if err != nil { + t.Errorf("decode request: %v", err) + } + + if !createProviderAppParamsEqual(params, wantParams) { + t.Errorf("request body = %#v, want %#v", params, wantParams) + } + + writer.Header().Set("Content-Type", "application/json") + + err = json.NewEncoder(writer).Encode(ProviderApp{ + Id: "provider-app-id", + Provider: params.Provider, + ClientId: params.ClientId, + Scopes: params.Scopes, + ProjectId: "project-id", + CreateTime: time.Date(2026, time.August, 18, 12, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer server.Close() + + providerApp, err := newProviderAppTestAPIClient(server).CreateProviderApp(t.Context(), &wantParams) + if err != nil { + t.Fatalf("create provider app: %v", err) + } + + if providerApp.Id != "provider-app-id" || providerApp.Provider != "asana" { + t.Fatalf("provider app = %#v", providerApp) + } + + if providerApp.ClientSecret != "" { + t.Fatalf("provider app response contains client secret") + } +} + +func createProviderAppParamsEqual(left CreateProviderAppParams, right CreateProviderAppParams) bool { + return left.Provider == right.Provider && + left.ClientId == right.ClientId && + left.ClientSecret == right.ClientSecret && + slices.Equal(left.Scopes, right.Scopes) +} diff --git a/request/types.go b/request/types.go index 540388c..2ad5c45 100644 --- a/request/types.go +++ b/request/types.go @@ -55,6 +55,13 @@ type ProviderApp struct { ProjectId string `json:"projectId"` } +type CreateProviderAppParams struct { + Provider string `json:"provider"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes,omitempty"` +} + type Config struct { Id string `json:"id"` RevisionId string `json:"revisionId"`