diff --git a/docs/stackit_beta_intake.md b/docs/stackit_beta_intake.md index 9a3672fb8..fa29e493f 100644 --- a/docs/stackit_beta_intake.md +++ b/docs/stackit_beta_intake.md @@ -36,4 +36,5 @@ stackit beta intake [flags] * [stackit beta intake list](./stackit_beta_intake_list.md) - Lists all Intakes * [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners * [stackit beta intake update](./stackit_beta_intake_update.md) - Updates an Intake +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users diff --git a/docs/stackit_beta_intake_user.md b/docs/stackit_beta_intake_user.md new file mode 100644 index 000000000..a09b51fb1 --- /dev/null +++ b/docs/stackit_beta_intake_user.md @@ -0,0 +1,38 @@ +## stackit beta intake user + +Provides functionality for Intake Users + +### Synopsis + +Provides functionality for Intake Users. + +``` +stackit beta intake user [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta intake user create](./stackit_beta_intake_user_create.md) - Creates a new Intake User +* [stackit beta intake user delete](./stackit_beta_intake_user_delete.md) - Deletes an Intake User +* [stackit beta intake user describe](./stackit_beta_intake_user_describe.md) - Shows details of an Intake User +* [stackit beta intake user list](./stackit_beta_intake_user_list.md) - Lists all Intake Users +* [stackit beta intake user update](./stackit_beta_intake_user_update.md) - Updates an Intake User + diff --git a/docs/stackit_beta_intake_user_create.md b/docs/stackit_beta_intake_user_create.md new file mode 100644 index 000000000..84ec49bdf --- /dev/null +++ b/docs/stackit_beta_intake_user_create.md @@ -0,0 +1,49 @@ +## stackit beta intake user create + +Creates a new Intake User + +### Synopsis + +Creates a new Intake User for a specific Intake. + +``` +stackit beta intake user create [flags] +``` + +### Examples + +``` + Create a new Intake User with required parameters + $ stackit beta intake user create --display-name intake-user --intake-id xxx --password "SuperSafepass123\!" + + Create a new Intake User for the dead-letter queue with labels + $ stackit beta intake user create --display-name dlq-user --intake-id xxx --password "SuperSafepass123\!" --type dead-letter --labels "env=prod" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user create" + --intake-id string The UUID of the Intake to associate the user with + --labels stringToString Labels in key=value format, separated by commas (default []) + --password string Password for the user. Must contain lower, upper, number, and special characters (min 12 chars) + --type string Type of user. One of 'intake' (default) or 'dead-letter' (default "intake") +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_delete.md b/docs/stackit_beta_intake_user_delete.md new file mode 100644 index 000000000..cf6fad990 --- /dev/null +++ b/docs/stackit_beta_intake_user_delete.md @@ -0,0 +1,41 @@ +## stackit beta intake user delete + +Deletes an Intake User + +### Synopsis + +Deletes an Intake User. + +``` +stackit beta intake user delete USER_ID [flags] +``` + +### Examples + +``` + Delete an Intake User with ID "xxx" for Intake "yyy" + $ stackit beta intake user delete xxx --intake-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user delete" + --intake-id string Intake ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_describe.md b/docs/stackit_beta_intake_user_describe.md new file mode 100644 index 000000000..18f04e693 --- /dev/null +++ b/docs/stackit_beta_intake_user_describe.md @@ -0,0 +1,44 @@ +## stackit beta intake user describe + +Shows details of an Intake User + +### Synopsis + +Shows details of an Intake User. + +``` +stackit beta intake user describe USER_ID [flags] +``` + +### Examples + +``` + Get details of an Intake User with ID "xxx" for Intake "yyy" + $ stackit beta intake user describe xxx --intake-id yyy + + Get details of an Intake User with ID "xxx" in JSON format + $ stackit beta intake user describe xxx --intake-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user describe" + --intake-id string Intake ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_list.md b/docs/stackit_beta_intake_user_list.md new file mode 100644 index 000000000..b1a25a14a --- /dev/null +++ b/docs/stackit_beta_intake_user_list.md @@ -0,0 +1,48 @@ +## stackit beta intake user list + +Lists all Intake Users + +### Synopsis + +Lists all Intake Users for a specific Intake. + +``` +stackit beta intake user list [flags] +``` + +### Examples + +``` + List all users for an Intake + $ stackit beta intake user list --intake-id xxx + + List all users for an Intake in JSON format + $ stackit beta intake user list --intake-id xxx --output-format json + + List up to 5 users for an Intake + $ stackit beta intake user list --intake-id xxx --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user list" + --intake-id string Intake ID + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_update.md b/docs/stackit_beta_intake_user_update.md new file mode 100644 index 000000000..93c591a15 --- /dev/null +++ b/docs/stackit_beta_intake_user_update.md @@ -0,0 +1,49 @@ +## stackit beta intake user update + +Updates an Intake User + +### Synopsis + +Updates an Intake User. Only the specified fields are updated. + +``` +stackit beta intake user update USER_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake User + $ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name" + + Update the password and description for an Intake User + $ stackit beta intake user update xxx --intake-id yyy --password "NewSecret123\!" --description "Updated description" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user update" + --intake-id string Intake ID + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --password string Password for the user. Must contain lower, upper, number, and special characters (min 12 chars) + --type string Type of user. One of 'intake' or 'dead-letter' +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/internal/cmd/beta/intake/intake.go b/internal/cmd/beta/intake/intake.go index 528a9e392..0a38cb9e1 100644 --- a/internal/cmd/beta/intake/intake.go +++ b/internal/cmd/beta/intake/intake.go @@ -8,6 +8,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -28,6 +29,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(runner.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) // Intake instance subcommands cmd.AddCommand(create.NewCmd(params)) diff --git a/internal/cmd/beta/intake/user/create/create.go b/internal/cmd/beta/intake/user/create/create.go new file mode 100644 index 000000000..78a0e4b72 --- /dev/null +++ b/internal/cmd/beta/intake/user/create/create.go @@ -0,0 +1,173 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +const ( + displayNameFlag = "display-name" + intakeIdFlag = "intake-id" + passwordFlag = "password" + userTypeFlag = "type" + descriptionFlag = "description" + labelsFlag = "labels" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName *string + IntakeId *string + Password *string + UserType *string + Description *string + Labels *map[string]string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake User", + Long: "Creates a new Intake User for a specific Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake User with required parameters`, + `$ stackit beta intake user create --display-name intake-user --intake-id xxx --password "SuperSafepass123\!"`), + examples.NewExample( + `Create a new Intake User for the dead-letter queue with labels`, + `$ stackit beta intake user create --display-name dlq-user --intake-id xxx --password "SuperSafepass123\!" --type dead-letter --labels "env=prod"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create an Intake User for project %q?", projectLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Creating STACKIT Intake User") + _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *model.IntakeId, resp.GetId()).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake User creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "The UUID of the Intake to associate the user with") + cmd.Flags().String(passwordFlag, "", "Password for the user. Must contain lower, upper, number, and special characters (min 12 chars)") + cmd.Flags().String(userTypeFlag, string(intake.USERTYPE_INTAKE), "Type of user. One of 'intake' (default) or 'dead-letter'") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, intakeIdFlag, passwordFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + IntakeId: flags.FlagToStringPointer(p, cmd, intakeIdFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + UserType: flags.FlagToStringPointer(p, cmd, userTypeFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeUserRequest { + req := apiClient.CreateIntakeUser(ctx, model.ProjectId, model.Region, *model.IntakeId) + + var userType *intake.UserType + if model.UserType != nil { + userType = utils.Ptr(intake.UserType(*model.UserType)) + } + + payload := intake.CreateIntakeUserPayload{ + DisplayName: model.DisplayName, + Password: model.Password, + Type: userType, + Description: model.Description, + Labels: model.Labels, + } + + req = req.CreateIntakeUserPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeUserResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered creation of Intake User for project %q, but no user ID was returned.\n", projectLabel) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake User for project %q. User ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/create/create_test.go b/internal/cmd/beta/intake/user/create/create_test.go new file mode 100644 index 000000000..4ddcaaee4 --- /dev/null +++ b/internal/cmd/beta/intake/user/create/create_test.go @@ -0,0 +1,293 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testDisplayName = "testuser" + testPassword = "Secret12345!" + testUserType = "intake" + testDescription = "This is a test user" + testLabelsString = "env=test,team=dev" +) + +var ( + // testCtx dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "team": "dev"} +) + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + intakeIdFlag: testIntakeId, + passwordFlag: testPassword, + userTypeFlag: testUserType, + descriptionFlag: testDescription, + labelsFlag: testLabelsString, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr(testDisplayName), + IntakeId: utils.Ptr(testIntakeId), + Password: utils.Ptr(testPassword), + UserType: utils.Ptr(testUserType), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateIntakeUserPayload for tests +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakeUserPayload)) intake.CreateIntakeUserPayload { + userType := intake.UserType(testUserType) + payload := intake.CreateIntakeUserPayload{ + DisplayName: utils.Ptr(testDisplayName), + Password: utils.Ptr(testPassword), + Type: &userType, + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiCreateIntakeUserRequest)) intake.ApiCreateIntakeUserRequest { + request := testClient.CreateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId) + request = request.CreateIntakeUserPayload(fixtureCreatePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "display name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "password missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, passwordFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + intakeIdFlag: testIntakeId, + passwordFlag: testPassword, + userTypeFlag: testUserType, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + // UserType has a default value in the command definition, so it should still be populated + model.UserType = utils.Ptr(string(intake.USERTYPE_INTAKE)) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiCreateIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + model.UserType = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeUserRequest) { + *request = (*request).CreateIntakeUserPayload(fixtureCreatePayload(func(payload *intake.CreateIntakeUserPayload) { + payload.Description = nil + payload.Labels = nil + payload.Type = nil + })) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/delete/delete.go b/internal/cmd/beta/intake/user/delete/delete.go new file mode 100644 index 000000000..9b695ced3 --- /dev/null +++ b/internal/cmd/beta/intake/user/delete/delete.go @@ -0,0 +1,125 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string +} + +// NewCmd creates a new cobra command for deleting an Intake User +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", userIdArg), + Short: "Deletes an Intake User", + Long: "Deletes an Intake User.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake User with ID "xxx" for Intake "yyy"`, + `$ stackit beta intake user delete xxx --intake-id yyy`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to delete Intake User %q?", model.UserId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Deleting STACKIT Intake User") + _, err = wait.DeleteIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake User deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Outputf("%s STACKIT Intake User %s\n", operationState, model.UserId) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +// parseInput parses the command arguments and flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), + UserId: userId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake User +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeUserRequest { + req := apiClient.DeleteIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + return req +} diff --git a/internal/cmd/beta/intake/user/delete/delete_test.go b/internal/cmd/beta/intake/user/delete/delete_test.go new file mode 100644 index 000000000..714678bb1 --- /dev/null +++ b/internal/cmd/beta/intake/user/delete/delete_test.go @@ -0,0 +1,174 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + // testCtx is a dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient is a mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +// fixtureArgValues generates a slice of arguments for tests +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeUserRequest)) intake.ApiDeleteIntakeUserRequest { + request := testClient.DeleteIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[intakeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiDeleteIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/describe/describe.go b/internal/cmd/beta/intake/user/describe/describe.go new file mode 100644 index 000000000..7a6cae10c --- /dev/null +++ b/internal/cmd/beta/intake/user/describe/describe.go @@ -0,0 +1,134 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", userIdArg), + Short: "Shows details of an Intake User", + Long: "Shows details of an Intake User.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake User with ID "xxx" for Intake "yyy"`, + `$ stackit beta intake user describe xxx --intake-id yyy`), + examples.NewExample( + `Get details of an Intake User with ID "xxx" in JSON format`, + `$ stackit beta intake user describe xxx --intake-id yyy --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake User: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), + UserId: userId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeUserRequest { + req := apiClient.GetIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, user *intake.IntakeUserResponse) error { + if user == nil { + return fmt.Errorf("received nil response, could not display details") + } + + return p.OutputResult(outputFormat, user, func() error { + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", user.GetId()) + table.AddRow("Name", user.GetDisplayName()) + table.AddRow("State", user.GetState()) + + if user.Type != nil { + table.AddRow("Type", *user.Type) + } + + table.AddRow("Username", user.GetUser()) + table.AddRow("Created", user.GetCreateTime()) + table.AddRow("Labels", user.GetLabels()) + + if description := user.GetDescription(); description != "" { + table.AddRow("Description", description) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/describe/describe_test.go b/internal/cmd/beta/intake/user/describe/describe_test.go new file mode 100644 index 000000000..2e2628c2e --- /dev/null +++ b/internal/cmd/beta/intake/user/describe/describe_test.go @@ -0,0 +1,211 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeUserRequest)) intake.ApiGetIntakeUserRequest { + request := testClient.GetIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[intakeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiGetIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + user *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", user: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, user: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, user: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "nil user", + args: args{user: nil}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.user); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/list/list.go b/internal/cmd/beta/intake/user/list/list.go new file mode 100644 index 000000000..8b8d7cfe0 --- /dev/null +++ b/internal/cmd/beta/intake/user/list/list.go @@ -0,0 +1,161 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +const ( + intakeIdFlag = "intake-id" + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId *string + Limit *int64 +} + +// NewCmd creates a new cobra command for listing Intake Users +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intake Users", + Long: "Lists all Intake Users for a specific Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all users for an Intake`, + `$ stackit beta intake user list --intake-id xxx`), + examples.NewExample( + `List all users for an Intake in JSON format`, + `$ stackit beta intake user list --intake-id xxx --output-format json`), + examples.NewExample( + `List up to 5 users for an Intake`, + `$ stackit beta intake user list --intake-id xxx --limit 5`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intake Users: %w", err) + } + users := resp.GetIntakeUsers() + + // Truncate output + if model.Limit != nil && len(users) > int(*model.Limit) { + users = users[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(users) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, *model.IntakeId, users) + }, + } + configureFlags(cmd) + return cmd +} + +// configureFlags adds the flags to the command +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringPointer(p, cmd, intakeIdFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intake Users +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakeUsersRequest { + req := apiClient.ListIntakeUsers(ctx, model.ProjectId, model.Region, *model.IntakeId) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel, intakeId string, users []intake.IntakeUserResponse) error { + return p.OutputResult(outputFormat, users, func() error { + if len(users) == 0 { + p.Outputf("No intake users found for intake %q in project %q\n", intakeId, projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DISPLAY NAME", "TYPE", "STATE") + for i := range users { + user := users[i] + userType := "" + if user.Type != nil { + userType = string(*user.Type) + } + table.AddRow( + user.GetId(), + user.GetDisplayName(), + userType, + user.GetState(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/list/list_test.go b/internal/cmd/beta/intake/user/list/list_test.go new file mode 100644 index 000000000..9d60462c1 --- /dev/null +++ b/internal/cmd/beta/intake/user/list/list_test.go @@ -0,0 +1,225 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testLimit = int64(5) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: utils.Ptr(testIntakeId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiListIntakeUsersRequest)) intake.ApiListIntakeUsersRequest { + request := testClient.ListIntakeUsers(testCtx, testProjectId, testRegion, testIntakeId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[intakeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit is negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiListIntakeUsersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + intakeId string + users []intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakeId: testIntakeId, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, intakeId: testIntakeId, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{intakeId: testIntakeId, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{intakeId: testIntakeId, users: nil}, + wantErr: false, + }, + { + name: "empty user in slice", + args: args{ + intakeId: testIntakeId, + users: []intake.IntakeUserResponse{{}}, + }, + wantErr: false, + }, + { + name: "with project label", + args: args{ + projectLabel: "my-project", + intakeId: testIntakeId, + users: []intake.IntakeUserResponse{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.intakeId, tt.args.users); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/update/update.go b/internal/cmd/beta/intake/user/update/update.go new file mode 100644 index 000000000..424be00c6 --- /dev/null +++ b/internal/cmd/beta/intake/user/update/update.go @@ -0,0 +1,168 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +const ( + userIdArg = "USER_ID" + + intakeIdFlag = "intake-id" + displayNameFlag = "display-name" + descriptionFlag = "description" + passwordFlag = "password" + userTypeFlag = "type" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string + DisplayName *string + Description *string + Password *string + UserType *string + Labels *map[string]string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", userIdArg), + Short: "Updates an Intake User", + Long: "Updates an Intake User. Only the specified fields are updated.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake User`, + `$ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name"`), + examples.NewExample( + `Update the password and description for an Intake User`, + `$ stackit beta intake user update xxx --intake-id yyy --password "NewSecret123\!" --description "Updated description"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Updating STACKIT Intake User") + _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake User update: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().String(passwordFlag, "", "Password for the user. Must contain lower, upper, number, and special characters (min 12 chars)") + cmd.Flags().String(userTypeFlag, "", "Type of user. One of 'intake' or 'dead-letter'") + cmd.Flags().StringToString(labelsFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), + UserId: userId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + UserType: flags.FlagToStringPointer(p, cmd, userTypeFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + } + + if model.DisplayName == nil && model.Description == nil && model.Password == nil && model.UserType == nil && model.Labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeUserRequest { + req := apiClient.UpdateIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + + payload := intake.UpdateIntakeUserPayload{ + DisplayName: model.DisplayName, + Description: model.Description, + Password: model.Password, + Labels: model.Labels, + } + + if model.UserType != nil { + userType := intake.UserType(*model.UserType) + payload.Type = &userType + } + + req = req.UpdateIntakeUserPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, resp *intake.IntakeUserResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered update of Intake User for intake %q, but no user ID was returned.\n", model.IntakeId) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake User for intake %q. User ID: %s\n", operationState, model.IntakeId, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/update/update_test.go b/internal/cmd/beta/intake/user/update/update_test.go new file mode 100644 index 000000000..cd95b5bde --- /dev/null +++ b/internal/cmd/beta/intake/user/update/update_test.go @@ -0,0 +1,259 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{testUserId} + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + displayNameFlag: "new-display-name", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + UserId: testUserId, + DisplayName: utils.Ptr("new-display-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiUpdateIntakeUserRequest)) intake.ApiUpdateIntakeUserRequest { + request := testClient.UpdateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId) + payload := intake.UpdateIntakeUserPayload{ + DisplayName: utils.Ptr("new-display-name"), + } + request = request.UpdateIntakeUserPayload(payload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no optional flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[descriptionFlag] = "new description" + flagValues[labelsFlag] = "env=prod,team=sre" + flagValues[userTypeFlag] = "dead-letter" + flagValues[passwordFlag] = "NewSecret123!" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("new description") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + model.UserType = utils.Ptr("dead-letter") + model.Password = utils.Ptr("NewSecret123!") + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiUpdateIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: fixtureRequest(), + }, + { + description: "update description", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + }), + expectedReq: fixtureRequest(func(request *intake.ApiUpdateIntakeUserRequest) { + payload := intake.UpdateIntakeUserPayload{ + Description: utils.Ptr("new-desc"), + } + *request = (*request).UpdateIntakeUserPayload(payload) + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.Description = utils.Ptr("final-desc") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + model.UserType = utils.Ptr("dead-letter") + model.Password = utils.Ptr("Secret123!") + }), + expectedReq: fixtureRequest(func(request *intake.ApiUpdateIntakeUserRequest) { + userType := intake.UserType("dead-letter") + payload := intake.UpdateIntakeUserPayload{ + DisplayName: utils.Ptr("another-name"), + Description: utils.Ptr("final-desc"), + Labels: utils.Ptr(map[string]string{"a": "b"}), + Type: &userType, + Password: utils.Ptr("Secret123!"), + } + *request = (*request).UpdateIntakeUserPayload(payload) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + intakeId string + resp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", projectLabel: "my-project", intakeId: "intake-id-123", resp: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}}, + wantErr: false, + }, + { + name: "nil response", + args: args{outputFormat: print.JSONOutputFormat, resp: nil}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", resp: nil}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: tt.args.outputFormat}, IntakeId: tt.args.intakeId}, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/user.go b/internal/cmd/beta/intake/user/user.go new file mode 100644 index 000000000..4d62d3d89 --- /dev/null +++ b/internal/cmd/beta/intake/user/user.go @@ -0,0 +1,31 @@ +package user + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Provides functionality for Intake Users", + Long: "Provides functionality for Intake Users.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + // Pass the params down to each action command + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + + return cmd +}