diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index ab2293bc353..a144f4491fd 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -167,63 +167,6 @@ var adminCommands = []*cli.Command{ }, }, }, - { - Name: "user-manager", - Usage: "Runs different helpful user commands", - Subcommands: []*cli.Command{ - // TODO: reset password for user - { - Name: "conflicts", - Usage: "runs a conflict resolution to find users with multiple entries", - CustomHelpTemplate: ` -This command will find users with multiple entries in the database and try to resolve the conflicts. -explanation of each field: - -explanation of each field: -* email - the user’s email -* login - the user’s login/username -* last_seen_at - the user’s last login -* auth_module - if the user was created/signed in using an authentication provider -* conflict_email - a boolean if we consider the email to be a conflict -* conflict_login - a boolean if we consider the login to be a conflict - -# lists all the conflicting users -grafana-cli user-manager conflicts list - -# creates a conflict patch file to edit -grafana-cli user-manager conflicts generate-file - -# reads edited conflict patch file for validation -grafana-cli user-manager conflicts validate-file - -# validates and ingests edited patch file -grafana-cli user-manager conflicts ingest-file -`, - Subcommands: []*cli.Command{ - { - Name: "list", - Usage: "returns a list of users with more than one entry in the database", - Action: runListConflictUsers(), - }, - { - Name: "generate-file", - Usage: "creates a conflict users file. Safe to execute multiple times.", - Action: runGenerateConflictUsersFile(), - }, - { - Name: "validate-file", - Usage: "validates the conflict users file. Safe to execute multiple times.", - Action: runValidateConflictUsersFile(), - }, - { - Name: "ingest-file", - Usage: "ingests the conflict users file. > Note: This is irreversible it will change the state of the database.", - Action: runIngestConflictUsersFile(), - }, - }, - }, - }, - }, } var Commands = []*cli.Command{ diff --git a/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf b/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf deleted file mode 100644 index cc0dc9489c5..00000000000 --- a/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf +++ /dev/null @@ -1,64 +0,0 @@ -terraform { - required_providers { - grafana = { - source = "grafana/grafana" - } - } -} - -// Configure the Grafana Provider -provider "grafana" { - url = "http://localhost:3000/" - auth = "admin:admin" -} - -// login conflict -// Creating the grafana-login -resource "grafana_user" "grafana-login" { - email = "grafana_login@grafana.com" - login = "GRAFANA_LOGIN" - password = "grafana_login@grafana.com" - is_admin = false -} - -// Creating the grafana-login -resource "grafana_user" "grafana-login-2" { - email = "grafana_login_2@grafana.com" - login = "grafana_login" - password = "grafana_login@grafana.com" - is_admin = false -} - -// email conflict -// Creating the grafana-email -resource "grafana_user" "grafana-email" { - email = "grafana_email@grafana.com" - login = "user_login_a" - password = "grafana_email@grafana.com" - is_admin = false -} - -// Creating the grafana-email -resource "grafana_user" "grafana-email-2" { - email = "GRAFANA_EMAIL@grafana.com" - login = "user_login_b" - password = "grafana_email@grafana.com" - is_admin = false -} - -// email and login conflict -// Creating the grafana-user -resource "grafana_user" "grafana-user" { - email = "grafana_user@grafana.com" - login = "grafana_user" - password = "grafana_user@grafana.com" - is_admin = false -} - -// Creating the grafana-user -resource "grafana_user" "grafana-user-2" { - email = "GRAFANA_USER@grafana.com" - login = "GRAFANA_USER" - password = "grafana_user@grafana.com" - is_admin = false -} diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go deleted file mode 100644 index ac5c27aab88..00000000000 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ /dev/null @@ -1,802 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "unicode" - - "github.com/fatih/color" - "github.com/urfave/cli/v2" - - "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" - "github.com/grafana/grafana/pkg/services/authz/zanzana" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/services/sqlstore/migrations" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" -) - -func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, tracing.Tracer, featuremgmt.FeatureToggles, error) { - configOptions := strings.Split(cmd.String("configOverrides"), " ") - configOptions = append(configOptions, cmd.Args().Slice()...) - cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{ - Config: cmd.ConfigFile(), - HomePath: cmd.HomePath(), - Args: append(configOptions, "cfg:log.level=error"), // tailing arguments have precedence over the options string - }) - - if err != nil { - return nil, nil, nil, err - } - - features, err := featuremgmt.ProvideManagerService(cfg) - if err != nil { - return nil, nil, nil, err - } - - tracingCfg, err := tracing.ProvideTracingConfig(cfg) - if err != nil { - return nil, nil, nil, fmt.Errorf("%v: %w", "failed to initialize tracer config", err) - } - - tracer, err := tracing.ProvideService(tracingCfg) - if err != nil { - return nil, nil, nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) - } - - return cfg, tracer, features, err -} - -func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx *cli.Context) (*ConflictResolver, error) { - cfg, tracer, features, err := initConflictCfg(cmd) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to load configuration", err) - } - s, err := getSqlStore(cfg, tracer, features) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to get to sql", err) - } - conflicts, err := GetUsersWithConflictingEmailsOrLogins(ctx, s) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) - } - quotaService := quotaimpl.ProvideService(s, cfg) - userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService()) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to get user service", err) - } - routing := routing.ProvideRegister() - - acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient(), permreg.ProvidePermissionRegistry(), nil) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to get access control", err) - } - resolver := ConflictResolver{Users: conflicts, Store: s, userService: userService, ac: acService} - resolver.BuildConflictBlocks(conflicts, f) - return &resolver, nil -} - -func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, error) { - bus := bus.ProvideBus(tracer) - return sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) -} - -func runListConflictUsers() func(context *cli.Context) error { - return func(context *cli.Context) error { - cmd := &utils.ContextCommandLine{Context: context} - whiteBold := color.New(color.FgWhite).Add(color.Bold) - r, err := initializeConflictResolver(cmd, whiteBold.Sprintf, context) - if err != nil { - return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) - } - if len(r.Users) < 1 { - logger.Info(color.GreenString("No Conflicting users found.\n\n")) - return nil - } - logger.Info("\n\nShowing conflicts\n\n") - logger.Info(r.ToStringPresentation()) - logger.Info("\n") - if len(r.DiscardedBlocks) != 0 { - r.logDiscardedUsers() - } - return nil - } -} - -func runGenerateConflictUsersFile() func(context *cli.Context) error { - return func(context *cli.Context) error { - cmd := &utils.ContextCommandLine{Context: context} - r, err := initializeConflictResolver(cmd, fmt.Sprintf, context) - if err != nil { - return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) - } - if len(r.Users) < 1 { - logger.Info(color.GreenString("No Conflicting users found.\n\n")) - return nil - } - tmpFile, err := generateConflictUsersFile(r) - if err != nil { - return fmt.Errorf("generating file return error: %w", err) - } - logger.Infof("\n\ngenerated file\n") - logger.Infof("%s\n\n", tmpFile.Name()) - logger.Infof("once the file is edited and resolved conflicts, you can either validate or ingest the file\n\n") - if len(r.DiscardedBlocks) != 0 { - r.logDiscardedUsers() - } - return nil - } -} - -func runValidateConflictUsersFile() func(context *cli.Context) error { - return func(context *cli.Context) error { - cmd := &utils.ContextCommandLine{Context: context} - r, err := initializeConflictResolver(cmd, fmt.Sprintf, context) - if err != nil { - return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) - } - - // read in the file to validate - // read in the file to ingest - arg := cmd.Args().First() - if arg == "" { - return fmt.Errorf("please specify a absolute path to file to read from") - } - b, err := os.ReadFile(filepath.Clean(arg)) - if err != nil { - logger.Error(color.RedString("validation failed with an error")) - return fmt.Errorf("could not read file with error %s", err) - } - validErr := getValidConflictUsers(r, b) - if validErr != nil { - logger.Error(color.RedString("validation failed with an error")) - return fmt.Errorf("could not validate file with error:\n%s", validErr) - } - logger.Info(color.GreenString("File validation complete.\n")) - logger.Info("File can be used with the `ingest-file` command.\n\n") - return nil - } -} - -func runIngestConflictUsersFile() func(context *cli.Context) error { - return func(context *cli.Context) error { - cmd := &utils.ContextCommandLine{Context: context} - r, err := initializeConflictResolver(cmd, fmt.Sprintf, context) - if err != nil { - return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) - } - - // read in the file to ingest - arg := cmd.Args().First() - if arg == "" { - return errors.New("please specify a absolute path to file to read from") - } - b, err := os.ReadFile(filepath.Clean(arg)) - if err != nil { - return fmt.Errorf("could not read file with error %e", err) - } - validErr := getValidConflictUsers(r, b) - if validErr != nil { - return fmt.Errorf("could not validate file with error:\n%s", validErr) - } - // should we rebuild blocks here? - // kind of a weird thing maybe? - if len(r.ValidUsers) == 0 { - return fmt.Errorf("no users") - } - r.showChanges() - if !confirm("\n\nWe encourage users to create a db backup before running this command. \n Proceed with operation") { - return fmt.Errorf("user cancelled") - } - err = r.MergeConflictingUsers(context.Context) - if err != nil { - return fmt.Errorf("not able to merge with %e", err) - } - logger.Info("\n\nconflicts resolved.\n") - return nil - } -} - -func getDocumentationForFile() string { - return `# Conflicts File -# This file is generated by the grafana-cli command ` + color.CyanString("grafana-cli admin user-manager conflicts generate-file") + `. -# -# Commands: -# +, keep = keep user -# -, delete = delete user -# -# The fields conflict_email and conflict_login -# indicate that we see a conflict in email and/or login with another user. -# Both these fields can be true. -# -# There needs to be exactly one picked user per conflict block. -# -# The lines can be re-ordered. -# -# If you feel like you want to wait with a specific block, -# delete all lines regarding that conflict block. -# email - the user’s email -# login - the user’s login/username -# last_seen_at - the user’s last login -# auth_module - if the user was created/signed in using an authentication provider -# conflict_email - a boolean if we consider the email to be a conflict -# conflict_login - a boolean if we consider the login to be a conflict -# -` -} - -func generateConflictUsersFile(r *ConflictResolver) (*os.File, error) { - tmpFile, err := os.CreateTemp(os.TempDir(), "conflicting_user_*.diff") - if err != nil { - return nil, err - } - if _, err := tmpFile.WriteString(getDocumentationForFile()); err != nil { - return nil, err - } - if _, err := tmpFile.WriteString(r.ToStringPresentation()); err != nil { - return nil, err - } - return tmpFile, nil -} - -func getValidConflictUsers(r *ConflictResolver, b []byte) error { - newConflicts := make(ConflictingUsers, 0) - // need to verify that id or email exists - previouslySeenIds := map[string]bool{} - previouslySeenEmails := map[string]bool{} - previouslySeenLogins := map[string]bool{} - for _, users := range r.Blocks { - for _, u := range users { - previouslySeenIds[strings.ToLower(u.ID)] = true - previouslySeenEmails[strings.ToLower(u.Email)] = true - previouslySeenLogins[strings.ToLower(u.Login)] = true - } - } - // tested in https://regex101.com/r/una3zC/1 - diffPattern := `^[+-]` - // compiling since in a loop - matchingExpression, err := regexp.Compile(diffPattern) - if err != nil { - return fmt.Errorf("unable to compile regex %s: %w", diffPattern, err) - } - counterKeepUsersForBlock := map[string]int{} - counterDeleteUsersForBlock := map[string]int{} - currentBlock := "" - for rowNumber, row := range strings.Split(string(b), "\n") { - // end of file - if row == "" { - break - } - // if the row starts with a #, it is a comment - if row[0] == '#' { - continue - } - - entryRow := matchingExpression.MatchString(row) - // not an entry row -> is a conflict block row - if !entryRow { - // check for malformed row - // rows should be of the form - // conflict: - // or - // + id: - // - id: - if (row[0] != '-') && (row[0] != '+') && (row[0] != 'c') { - return fmt.Errorf("invalid start character (expected '+,-') found %c for row number %d", row[0], rowNumber+1) - } - - // is a conflict block row - // conflict: hej - currentBlock = row - continue - } - // need to track how many keep users we have for a block - if _, ok := counterKeepUsersForBlock[currentBlock]; !ok { - counterKeepUsersForBlock[currentBlock] = 0 - } - if _, ok := counterDeleteUsersForBlock[currentBlock]; !ok { - counterDeleteUsersForBlock[currentBlock] = 0 - } - if row[0] == '+' { - counterKeepUsersForBlock[currentBlock] += 1 - } - if row[0] == '-' { - counterDeleteUsersForBlock[currentBlock] += 1 - } - newUser := &ConflictingUser{} - err := newUser.Marshal(row) - if err != nil { - return fmt.Errorf("could not parse the content of the file with error %e", err) - } - if newUser.ConflictEmail != "" && !previouslySeenEmails[strings.ToLower(newUser.Email)] { - return fmt.Errorf("not valid email: %s, email not seen in previous conflicts", newUser.Email) - } - if newUser.ConflictLogin != "" && !previouslySeenLogins[strings.ToLower(newUser.Login)] { - return fmt.Errorf("not valid login: %s, login not seen in previous conflicts", newUser.Login) - } - // valid entry - newConflicts = append(newConflicts, *newUser) - } - for block, count := range counterKeepUsersForBlock { - // check if we only have one addition for each block - if count != 1 { - return fmt.Errorf("invalid number of users to keep, expected 1, got %d for block: %s", count, block) - } - } - for block, count := range counterDeleteUsersForBlock { - // check if we have at least one deletion for each block - if count < 1 { - return fmt.Errorf("invalid number of users to delete, should be at least 1, got %d for block %s", count, block) - } - } - r.ValidUsers = newConflicts - r.BuildConflictBlocks(newConflicts, fmt.Sprintf) - return nil -} - -func (r *ConflictResolver) MergeConflictingUsers(ctx context.Context) error { - for block, users := range r.Blocks { - if len(users) < 2 { - return fmt.Errorf("not enough users to perform merge, found %d for id %s, should be at least 2", len(users), block) - } - var intoUser user.User - var intoUserId int64 - var fromUserIds []int64 - - // creating a session for each block of users - // we want to rollback incase something happens during update / delete - if err := r.Store.InTransaction(ctx, func(ctx context.Context) error { - for _, u := range users { - if u.Direction == "+" { - id, err := strconv.ParseInt(u.ID, 10, 64) - if err != nil { - return fmt.Errorf("could not convert id in +") - } - intoUserId = id - } else if u.Direction == "-" { - id, err := strconv.ParseInt(u.ID, 10, 64) - if err != nil { - return fmt.Errorf("could not convert id in -") - } - fromUserIds = append(fromUserIds, id) - } - } - if _, err := r.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: intoUserId}); err != nil { - return fmt.Errorf("could not find intoUser: %w", err) - } - for _, fromUserId := range fromUserIds { - _, err := r.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: fromUserId}) - if err != nil && errors.Is(err, user.ErrUserNotFound) { - fmt.Printf("user with id %d does not exist, skipping\n", fromUserId) - } - if err != nil { - return fmt.Errorf("could not find fromUser: %w", err) - } - // delete the user - delErr := r.userService.Delete(ctx, &user.DeleteUserCommand{UserID: fromUserId}) - if delErr != nil { - return fmt.Errorf("error during deletion of user: %w", delErr) - } - delACErr := r.ac.DeleteUserPermissions(ctx, 0, fromUserId) - if delACErr != nil { - return fmt.Errorf("error during deletion of user access control: %w", delACErr) - } - } - - updateMainCommand := &user.UpdateUserCommand{ - UserID: intoUser.ID, - Login: strings.ToLower(intoUser.Login), - Email: strings.ToLower(intoUser.Email), - } - updateErr := r.userService.Update(ctx, updateMainCommand) - if updateErr != nil { - return fmt.Errorf("could not update user: %w", updateErr) - } - - return nil - }); err != nil { - return err - } - } - return nil -} - -/* -hej@test.com+hej@test.com -all of the permissions, roles and ownership will be transferred to the user. -+ id: 1, email: hej@test.com, login: hej@test.com -these user(s) will be deleted and their permissions transferred. -- id: 2, email: HEJ@TEST.COM, login: HEJ@TEST.COM -- id: 3, email: hej@TEST.com, login: hej@TEST.com -*/ -func (r *ConflictResolver) showChanges() { - if len(r.ValidUsers) == 0 { - fmt.Println("no changes will take place as we have no valid users.") - return - } - - var b strings.Builder - for block, users := range r.Blocks { - if _, ok := r.DiscardedBlocks[block]; ok { - // skip block - continue - } - - // looping as we want to can get these out of order (meaning the + and -) - var mainUser ConflictingUser - for _, u := range users { - if u.Direction == "+" { - mainUser = u - break - } - } - b.WriteString("Keep the following user.\n") - b.WriteString(block) - b.WriteByte('\n') - b.WriteString(color.GreenString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, mainUser.Email, mainUser.Login))) - for _, r := range fmt.Sprintf("%s%s", mainUser.Email, mainUser.Login) { - if unicode.IsUpper(r) { - b.WriteString("Will be change to:\n") - b.WriteString(color.GreenString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, strings.ToLower(mainUser.Email), strings.ToLower(mainUser.Login)))) - break - } - } - b.WriteString("\n\n") - b.WriteString("The following user(s) will be deleted.\n") - for _, user := range users { - if user.ID == mainUser.ID { - continue - } - // mergeable users - b.WriteString(color.RedString(fmt.Sprintf("id: %s, email: %s, login: %s\n", user.ID, user.Email, user.Login))) - } - b.WriteString("\n\n") - } - logger.Info("\n\nChanges that will take place\n\n") - logger.Info(b.String()) -} - -// Formatter make it possible for us to write to terminal and to a file -// with different formats depending on the usecase -type Formatter func(format string, a ...any) string - -func shouldDiscardBlock(seenUsersInBlock map[string]string, block string, user ConflictingUser) bool { - // loop through users to see if we should skip this block - // we have some more tricky scenarios where we have more than two users that can have conflicts with each other - // we have made the approach to discard any users that we have seen - if _, ok := seenUsersInBlock[user.ID]; ok { - // we have seen the user in different block than the current block - if seenUsersInBlock[user.ID] != block { - return true - } - } - seenUsersInBlock[user.ID] = block - return false -} - -// BuildConflictBlocks builds blocks of users where each block is a unique email/login -// NOTE: currently this function assumes that the users are in order of grouping already -func (r *ConflictResolver) BuildConflictBlocks(users ConflictingUsers, f Formatter) { - discardedBlocks := make(map[string]bool) - seenUsersToBlock := make(map[string]string) - blocks := make(map[string]ConflictingUsers) - for _, user := range users { - // conflict blocks is how we identify a conflict in the user base. - var conflictBlock string - // sqlite generates string : ""/true - // postgres generates string : false/true - if user.ConflictEmail == "false" { - user.ConflictEmail = "" - } - if user.ConflictLogin == "false" { - user.ConflictLogin = "" - } - if user.ConflictEmail != "" { - conflictBlock = f("conflict: %s", strings.ToLower(user.Email)) - } else if user.ConflictLogin != "" { - conflictBlock = f("conflict: %s", strings.ToLower(user.Login)) - } else if user.ConflictEmail != "" && user.ConflictLogin != "" { - // both conflicts - // should not be here unless changed in sql - conflictBlock = f("conflict: %s%s", strings.ToLower(user.Email), strings.ToLower(user.Login)) - } - - // discard logic - if shouldDiscardBlock(seenUsersToBlock, conflictBlock, user) { - discardedBlocks[conflictBlock] = true - } - - // adding users to blocks - if _, ok := blocks[conflictBlock]; !ok { - blocks[conflictBlock] = []ConflictingUser{user} - continue - } - // skip user thats already part of the block - // since we get duplicate entries - if contains(blocks[conflictBlock], user) { - continue - } - blocks[conflictBlock] = append(blocks[conflictBlock], user) - } - r.Blocks = blocks - r.DiscardedBlocks = discardedBlocks -} - -func contains(cu ConflictingUsers, target ConflictingUser) bool { - for _, u := range cu { - if u.ID == target.ID { - return true - } - } - return false -} - -func (r *ConflictResolver) logDiscardedUsers() { - keys := make([]string, 0, len(r.DiscardedBlocks)) - for block := range r.DiscardedBlocks { - for _, u := range r.Blocks[block] { - keys = append(keys, u.ID) - } - } - warn := color.YellowString("Note: We discarded some conflicts that have multiple conflicting types involved.") - logger.Infof(` -%s - -users discarded with more than one conflict: -ids: %s - -Solve conflicts and run the command again to see other conflicts. -`, warn, strings.Join(keys, ",")) -} - -// handling tricky cases:: -// if we have seen a user already -// note the conflict of that user -// discard that conflict for next time that the user runs the command - -// only present one conflict per user -// go through each conflict email/login -// if any has ids that have already been seen -// discard that conflict -// make note to the user to run again after fixing these conflicts -func (r *ConflictResolver) ToStringPresentation() string { - /* - hej@test.com+hej@test.com - + id: 1, email: hej@test.com, login: hej@test.com - - id: 2, email: HEJ@TEST.COM, login: HEJ@TEST.COM - - id: 3, email: hej@TEST.com, login: hej@TEST.com - */ - startOfBlock := make(map[string]bool) - var b strings.Builder - for block, users := range r.Blocks { - if _, ok := r.DiscardedBlocks[block]; ok { - // skip block - continue - } - for _, user := range users { - if !startOfBlock[block] { - b.WriteString(fmt.Sprintf("%s\n", block)) - startOfBlock[block] = true - b.WriteString(fmt.Sprintf("+ id: %s, email: %s, login: %s, last_seen_at: %s, auth_module: %s, conflict_email: %s, conflict_login: %s\n", - user.ID, - user.Email, - user.Login, - user.LastSeenAt, - user.AuthModule, - user.ConflictEmail, - user.ConflictLogin, - )) - continue - } - // mergeable users - b.WriteString(fmt.Sprintf("- id: %s, email: %s, login: %s, last_seen_at: %s, auth_module: %s, conflict_email: %s, conflict_login: %s\n", - user.ID, - user.Email, - user.Login, - user.LastSeenAt, - user.AuthModule, - user.ConflictEmail, - user.ConflictLogin, - )) - } - } - return b.String() -} - -type ConflictResolver struct { - Store *sqlstore.SQLStore - userService user.Service - ac accesscontrol.Service - Config *setting.Cfg - Users ConflictingUsers - ValidUsers ConflictingUsers - Blocks map[string]ConflictingUsers - DiscardedBlocks map[string]bool -} - -type ConflictingUser struct { - // direction is the +/- which indicates if we should keep or delete the user - Direction string `xorm:"direction"` - ID string `xorm:"id"` - Email string `xorm:"email"` - Login string `xorm:"login"` - LastSeenAt string `xorm:"last_seen_at"` - AuthModule string `xorm:"auth_module"` - ConflictEmail string `xorm:"conflict_email"` - ConflictLogin string `xorm:"conflict_login"` -} - -type ConflictingUsers []ConflictingUser - -func (c *ConflictingUser) Marshal(filerow string) error { - // example view of the file to ingest - // +/- id: 1, email: hej, auth_module: LDAP - trimmedSpaces := strings.ReplaceAll(filerow, " ", "") - if trimmedSpaces[0] == '+' { - c.Direction = "+" - } else if trimmedSpaces[0] == '-' { - c.Direction = "-" - } else { - return fmt.Errorf("unable to get which operation was chosen") - } - trimmed := strings.TrimLeft(trimmedSpaces, "+-") - values := strings.Split(trimmed, ",") - - if len(values) < 3 { - return fmt.Errorf("expected at least 3 values in entry row") - } - // expected fields - id := strings.Split(values[0], ":") - email := strings.Split(values[1], ":") - login := strings.Split(values[2], ":") - c.ID = id[1] - c.Email = email[1] - c.Login = login[1] - - // why trim values, 2022-08-20:19:17:12 - lastSeenAt := strings.TrimPrefix(values[3], "last_seen_at:") - authModule := strings.Split(values[4], ":") - if len(authModule) < 2 { - c.AuthModule = "" - } else { - c.AuthModule = authModule[1] - } - c.LastSeenAt = lastSeenAt - - // which conflict - conflictEmail := strings.Split(values[5], ":") - conflictLogin := strings.Split(values[6], ":") - if len(conflictEmail) < 2 { - c.ConflictEmail = "" - } else { - c.ConflictEmail = conflictEmail[1] - } - if len(conflictLogin) < 2 { - c.ConflictLogin = "" - } else { - c.ConflictLogin = conflictLogin[1] - } - return nil -} - -func GetUsersWithConflictingEmailsOrLogins(ctx *cli.Context, s *sqlstore.SQLStore) (ConflictingUsers, error) { - queryUsers := make([]ConflictingUser, 0) - outerErr := s.WithDbSession(ctx.Context, func(dbSession *db.Session) error { - var rawSQL string - if s.GetDialect().DriverName() == migrator.Postgres { - rawSQL = conflictUserEntriesSQLPostgres() - } else if s.GetDialect().DriverName() == migrator.SQLite { - rawSQL = conflictingUserEntriesSQL(s) - } - err := dbSession.SQL(rawSQL).Find(&queryUsers) - return err - }) - if outerErr != nil { - return queryUsers, outerErr - } - return queryUsers, nil -} - -// conflictingUserEntriesSQL orders conflicting users by their user_identification -// sorts the users by their useridentification and ids -func conflictingUserEntriesSQL(s *sqlstore.SQLStore) string { - userDialect := db.DB.GetDialect(s).Quote("user") - - sqlQuery := ` - SELECT DISTINCT - u1.id, - u1.email, - u1.login, - u1.last_seen_at, - user_auth.auth_module, - ( SELECT - 'true' - FROM - ` + userDialect + ` - WHERE (LOWER(u1.email) = LOWER(u2.email)) AND(u1.email != u2.email)) AS conflict_email, - ( SELECT - 'true' - FROM - ` + userDialect + ` - WHERE (LOWER(u1.login) = LOWER(u2.login) AND(u1.login != u2.login))) AS conflict_login - FROM - ` + userDialect + ` AS u1, ` + userDialect + ` AS u2 - LEFT JOIN user_auth on user_auth.user_id = u1.id - WHERE (conflict_email IS NOT NULL - OR conflict_login IS NOT NULL) - AND (u1.` + notServiceAccount(s) + `) - ORDER BY conflict_email, conflict_login, u1.id` - return sqlQuery -} - -func conflictUserEntriesSQLPostgres() string { - sqlQuery := ` -SELECT DISTINCT - u1.id, - u1.email, - u1.login, - u1.last_seen_at, - ua.auth_module, - ((LOWER(u1.email) = LOWER(u2.email)) - AND(u1.email != u2.email)) AS conflict_email, - ((LOWER(u1.login) = LOWER(u2.login)) - AND(u1.login != u2.login)) AS conflict_login -FROM - "user" AS u1, - "user" AS u2 - LEFT JOIN user_auth AS ua ON ua.user_id = u2.id -WHERE ((LOWER(u1.email) = LOWER(u2.email)) - AND(u1.email != u2.email)) IS TRUE - OR((LOWER(u1.login) = LOWER(u2.login)) - AND(u1.login != u2.login)) IS TRUE - AND(u1.is_service_account = FALSE) -ORDER BY - conflict_email, - conflict_login, - u1.id; -; - ` - return sqlQuery -} - -func notServiceAccount(ss *sqlstore.SQLStore) string { - return fmt.Sprintf("is_service_account = %s", - ss.GetDialect().BooleanStr(false)) -} - -// confirm function asks for user input -// returns bool -func confirm(confirmPrompt string) bool { - var input string - logger.Infof("%s? [y|n]: ", confirmPrompt) - - _, err := fmt.Scanln(&input) - if err != nil { - logger.Infof("could not parse input from user for confirmation") - return false - } - input = strings.ToLower(input) - if input == "y" || input == "yes" { - return true - } - return false -} diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go deleted file mode 100644 index c0aafacd3c0..00000000000 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ /dev/null @@ -1,964 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "os" - "sort" - "testing" - "time" - - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/services/team/teamimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/services/user/usertest" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tests/testsuite" -) - -// "Skipping conflicting users test for mysql as it does make unique constraint case insensitive by default -const ignoredDatabase = migrator.MySQL - -func TestMain(m *testing.M) { - testsuite.Run(m) -} - -func TestBuildConflictBlock(t *testing.T) { - type testBuildConflictBlock struct { - desc string - users []user.User - expectedBlock string - wantDiscardedBlock string - wantConflictUser *ConflictingUser - wantedNumberOfUsers int - } - testOrgID := 1 - testCases := []testBuildConflictBlock{ - { - desc: "should get one block with only 3 users", - users: []user.User{ - { - Email: "ldap-editor", - Login: "ldap-editor", - OrgID: int64(testOrgID), - }, - { - Email: "LDAP-EDITOR", - Login: "LDAP-EDITOR", - OrgID: int64(testOrgID), - }, - { - Email: "overlapping conflict", - Login: "LDAP-editor", - OrgID: int64(testOrgID), - }, - { - Email: "OVERLAPPING conflict", - Login: "no conflict", - OrgID: int64(testOrgID), - }, - }, - expectedBlock: "conflict: ldap-editor", - wantDiscardedBlock: "conflict: overlapping conflict", - wantedNumberOfUsers: 3, - }, - { - desc: "should get conflict_email true and conflict_login empty string", - users: []user.User{ - { - Email: "conflict@email", - Login: "login", - OrgID: int64(testOrgID), - }, - { - Email: "conflict@EMAIL", - Login: "plainlogin", - OrgID: int64(testOrgID), - }, - }, - expectedBlock: "conflict: conflict@email", - wantedNumberOfUsers: 2, - wantConflictUser: &ConflictingUser{ConflictEmail: "true", ConflictLogin: ""}, - }, - { - desc: "should get conflict_email empty string and conflict_login true", - users: []user.User{ - { - Email: "regular@email", - Login: "CONFLICTLOGIN", - OrgID: int64(testOrgID), - }, - { - Email: "regular-no-conflict@email", - Login: "conflictlogin", - OrgID: int64(testOrgID), - }, - }, - expectedBlock: "conflict: conflictlogin", - wantedNumberOfUsers: 2, - wantConflictUser: &ConflictingUser{ConflictEmail: "", ConflictLogin: "true"}, - }, - } - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - // Restore after destructive operation - sqlStore, cfg := db.InitTestDBWithCfg(t) - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - userStore := userimpl.ProvideStore(sqlStore, cfg) - for _, u := range tc.users { - u := user.User{ - Email: u.Email, - Name: u.Name, - Login: u.Login, - OrgID: int64(testOrgID), - Created: time.Now(), - Updated: time.Now(), - } - // call user store instead of user service so as not to prevent conflicting users - _, err := userStore.Insert(context.Background(), &u) - require.NoError(t, err, u) - } - m, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - require.NoError(t, err) - r := ConflictResolver{Store: sqlStore} - r.BuildConflictBlocks(m, fmt.Sprintf) - require.Equal(t, tc.wantedNumberOfUsers, len(r.Blocks[tc.expectedBlock])) - if tc.wantDiscardedBlock != "" { - require.Equal(t, true, r.DiscardedBlocks[tc.wantDiscardedBlock]) - } - if tc.wantConflictUser != nil { - for _, u := range m { - require.Equal(t, tc.wantConflictUser.ConflictEmail, u.ConflictEmail) - require.Equal(t, tc.wantConflictUser.ConflictLogin, u.ConflictLogin) - } - } - } - }) - } -} - -func TestBuildConflictBlockFromFileRepresentation(t *testing.T) { - type testBuildConflictBlock struct { - desc string - users []user.User - fileString string - expectedBlocks []string - expectedIdsInBlocks map[string][]string - } - testOrgID := 1 - testCases := []testBuildConflictBlock{ - { - desc: "should be able to parse the fileString containing the conflicts", - users: []user.User{ - { - Email: "test", - Login: "test", - OrgID: int64(testOrgID), - }, - { - Email: "TEST", - Login: "TEST", - OrgID: int64(testOrgID), - }, - { - Email: "test2", - Login: "test2", - OrgID: int64(testOrgID), - }, - { - Email: "TEST2", - Login: "TEST2", - OrgID: int64(testOrgID), - }, - { - Email: "Test2", - Login: "Test2", - OrgID: int64(testOrgID), - }, - }, - fileString: `conflict: test -- id: 2, email: test, login: test, last_seen_at: 2012-09-19T08:31:20Z, auth_module: , conflict_email: true, conflict_login: true -+ id: 3, email: TEST, login: TEST, last_seen_at: 2012-09-19T08:31:29Z, auth_module: , conflict_email: true, conflict_login: true -conflict: test2 -- id: 4, email: test2, login: test2, last_seen_at: 2012-09-19T08:31:41Z, auth_module: , conflict_email: true, conflict_login: true -+ id: 5, email: TEST2, login: TEST2, last_seen_at: 2012-09-19T08:31:51Z, auth_module: , conflict_email: true, conflict_login: true -- id: 6, email: Test2, login: Test2, last_seen_at: 2012-09-19T08:32:03Z, auth_module: , conflict_email: true, conflict_login: true`, - expectedBlocks: []string{"conflict: test", "conflict: test2"}, - expectedIdsInBlocks: map[string][]string{"conflict: test": {"2", "3"}, "conflict: test2": {"4", "5", "6"}}, - }, - { - desc: "should be able to parse the fileString containing the conflicts 123", - users: []user.User{ - { - Email: "saml-misi@example.org", - Login: "saml-misi", - OrgID: int64(testOrgID), - }, - { - Email: "saml-misi@example", - Login: "saml-Misi", - OrgID: int64(testOrgID), - }, - }, - fileString: `conflict: saml-misi -+ id: 5, email: saml-misi@example.org, login: saml-misi, last_seen_at: 2022-09-22T12:00:49Z, auth_module: auth.saml, conflict_email: , conflict_login: true -- id: 15, email: saml-misi@example, login: saml-Misi, last_seen_at: 2012-09-26T11:31:32Z, auth_module: , conflict_email: , conflict_login: true`, - expectedBlocks: []string{"conflict: saml-misi"}, - expectedIdsInBlocks: map[string][]string{"conflict: saml-misi": {"5", "15"}}, - }, - } - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - // Restore after destructive operation - sqlStore, cfg := db.InitTestDBWithCfg(t) - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - userStore := userimpl.ProvideStore(sqlStore, cfg) - for _, u := range tc.users { - u := user.User{ - Email: u.Email, - Name: u.Name, - Login: u.Login, - OrgID: int64(testOrgID), - Created: time.Now(), - Updated: time.Now(), - } - // call user store instead of user service so as not to prevent conflicting users - _, err := userStore.Insert(context.Background(), &u) - require.NoError(t, err) - } - - conflicts, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - r := ConflictResolver{Users: conflicts, Store: sqlStore} - r.BuildConflictBlocks(conflicts, fmt.Sprintf) - require.NoError(t, err) - validErr := getValidConflictUsers(&r, []byte(tc.fileString)) - require.NoError(t, validErr) - - // test starts here - keys := make([]string, 0) - for k := range r.Blocks { - keys = append(keys, k) - } - sort.Strings(keys) - require.Equal(t, tc.expectedBlocks, keys) - - // we want to validate the ids in the blocks - for _, block := range tc.expectedBlocks { - // checking for parsing of ids - conflictIds := []string{} - for _, u := range r.Blocks[block] { - conflictIds = append(conflictIds, u.ID) - } - require.Equal(t, tc.expectedIdsInBlocks[block], conflictIds) - } - } - }) - } -} -func TestGetConflictingUsers(t *testing.T) { - type testListConflictingUsers struct { - desc string - users []user.User - want int - wantErr error - } - testOrgID := 1 - testCases := []testListConflictingUsers{ - { - desc: "should get login conflicting users", - users: []user.User{ - { - Email: "xo", - Login: "ldap-admin", - OrgID: int64(testOrgID), - }, - { - Email: "ldap-admin", - Login: "LDAP-ADMIN", - OrgID: int64(testOrgID), - }, - }, - want: 2, - }, - { - desc: "should get email conflicting users", - users: []user.User{ - { - Email: "oauth-admin@example.org", - Login: "No confli", - OrgID: int64(testOrgID), - }, - { - Email: "oauth-admin@EXAMPLE.ORG", - Login: "oauth-admin", - OrgID: int64(testOrgID), - }, - }, - want: 2, - }, - { - desc: "should be 5 conflicting users, each conflict gets 2 users", - users: []user.User{ - { - Email: "user1", - Login: "USER_DUPLICATE_TEST_LOGIN", - OrgID: int64(testOrgID), - }, - { - Email: "user2", - Login: "user_duplicate_test_login", - OrgID: int64(testOrgID), - }, - { - Email: "USER2", - Login: "no-conflict-login", - OrgID: int64(testOrgID), - }, - { - Email: "no-conflict", - Login: "user_DUPLICATE_test_login", - OrgID: int64(testOrgID), - }, - }, - want: 5, - }, - { - desc: "should be 8 conflicting users, each conflict gets 2 users", - users: []user.User{ - { - Email: "user1", - Login: "USER_DUPLICATE_TEST_LOGIN", - OrgID: int64(testOrgID), - }, - { - Email: "user2", - Login: "user_duplicate_test_login", - OrgID: int64(testOrgID), - }, - { - Email: "USER2", - Login: "no-conflict-login", - OrgID: int64(testOrgID), - }, - { - Email: "xo", - Login: "ldap-admin", - OrgID: int64(testOrgID), - }, - { - Email: "ldap-admin", - Login: "LDAP-ADMIN", - OrgID: int64(testOrgID), - }, - { - Email: "oauth-admin@example.org", - Login: "No confli", - OrgID: int64(testOrgID), - }, - { - Email: "oauth-admin@EXAMPLE.ORG", - Login: "oauth-admin", - OrgID: int64(testOrgID), - }, - }, - want: 8, - }, - { - desc: "should not get service accounts", - users: []user.User{ - { - Email: "sa-x", - Login: "sa-x", - OrgID: int64(testOrgID), - IsServiceAccount: true, - }, - { - Email: "sa-X", - Login: "sa-X", - OrgID: int64(testOrgID), - IsServiceAccount: true, - }, - }, - want: 0, - }, - { - desc: "should get nil when no users in database", - users: []user.User{}, - want: 0, - wantErr: nil, - }, - } - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - // Restore after destructive operation - sqlStore, cfg := db.InitTestDBWithCfg(t) - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - userStore := userimpl.ProvideStore(sqlStore, cfg) - for _, u := range tc.users { - u := user.User{ - Email: u.Email, - Name: u.Name, - Login: u.Login, - OrgID: int64(testOrgID), - IsServiceAccount: u.IsServiceAccount, - Created: time.Now(), - Updated: time.Now(), - } - // call user store instead of user service so as not to prevent conflicting users - _, err := userStore.Insert(context.Background(), &u) - require.NoError(t, err) - } - m, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - require.NoError(t, err) - require.Equal(t, tc.want, len(m)) - if tc.wantErr != nil { - require.EqualError(t, err, tc.wantErr.Error()) - } - } - }) - } -} - -func TestGenerateConflictingUsersFile(t *testing.T) { - type testGenerateConflictUsers struct { - desc string - users []user.User - expectedDiscardedBlock string - expectedBlocks []string - expectedEmailInBlocks map[string][]string - } - testOrgID := 1 - testCases := []testGenerateConflictUsers{ - { - desc: "should get conflicting users", - users: []user.User{ - { - Email: "user1", - Login: "USER_DUPLICATE_TEST_LOGIN", - OrgID: int64(testOrgID), - }, - { - Email: "user2", - Login: "user_duplicate_test_login", - OrgID: int64(testOrgID), - }, - { - Email: "USER2", - Login: "no-conflict-login", - OrgID: int64(testOrgID), - }, - { - Email: "xo", - Login: "ldap-admin", - OrgID: int64(testOrgID), - }, - { - Email: "ldap-admin", - Login: "LDAP-ADMIN", - OrgID: int64(testOrgID), - }, - { - Email: "oauth-admin@example.org", - Login: "No conflict", - OrgID: int64(testOrgID), - }, - { - Email: "oauth-admin@EXAMPLE.ORG", - Login: "oauth-admin", - OrgID: int64(testOrgID), - }, - }, - expectedBlocks: []string{"conflict: ldap-admin", "conflict: user_duplicate_test_login", "conflict: oauth-admin@example.org", "conflict: user2"}, - expectedEmailInBlocks: map[string][]string{ - "conflict: ldap-admin": {"ldap-admin", "xo"}, - "conflict: user_duplicate_test_login": {"user1", "user2"}, - "conflict: oauth-admin@example.org": {"oauth-admin@EXAMPLE.ORG", "oauth-admin@example.org"}, - "conflict: user2": {"USER2", "user2"}, - }, - expectedDiscardedBlock: "conflict: user2", - }, - { - desc: "should get only one block with 3 users", - users: []user.User{ - { - Email: "ldap-editor", - Login: "ldap-editor", - OrgID: int64(testOrgID), - }, - { - Email: "LDAP-EDITOR", - Login: "LDAP-EDITOR", - OrgID: int64(testOrgID), - }, - { - Email: "No confli", - Login: "LDAP-editor", - OrgID: int64(testOrgID), - }, - }, - expectedBlocks: []string{"conflict: ldap-editor"}, - expectedEmailInBlocks: map[string][]string{"conflict: ldap-editor": {"ldap-editor", "LDAP-EDITOR", "No confli"}}, - }, - } - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - // Restore after destructive operation - sqlStore, cfg := db.InitTestDBWithCfg(t) - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - userStore := userimpl.ProvideStore(sqlStore, cfg) - for _, u := range tc.users { - cmd := user.User{ - Email: u.Email, - Name: u.Name, - Login: u.Login, - OrgID: int64(testOrgID), - Created: time.Now(), - Updated: time.Now(), - } - // call user store instead of user service so as not to prevent conflicting users - _, err := userStore.Insert(context.Background(), &cmd) - require.NoError(t, err) - } - m, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - require.NoError(t, err) - r := ConflictResolver{Store: sqlStore} - r.BuildConflictBlocks(m, fmt.Sprintf) - if tc.expectedDiscardedBlock != "" { - require.Equal(t, true, r.DiscardedBlocks[tc.expectedDiscardedBlock]) - } - - // test starts here - keys := make([]string, 0) - for k := range r.Blocks { - keys = append(keys, k) - } - expectedBlocks := tc.expectedBlocks - sort.Strings(keys) - sort.Strings(expectedBlocks) - require.Equal(t, expectedBlocks, keys) - - // we want to validate the ids in the blocks - for _, block := range tc.expectedBlocks { - // checking for parsing of ids - conflictEmails := []string{} - for _, u := range r.Blocks[block] { - conflictEmails = append(conflictEmails, u.Email) - } - expectedEmailsInBlock := tc.expectedEmailInBlocks[block] - sort.Strings(conflictEmails) - sort.Strings(expectedEmailsInBlock) - require.Equal(t, expectedEmailsInBlock, conflictEmails) - } - } - }) - } -} - -func TestRunValidateConflictUserFile(t *testing.T) { - t.Run("should validate file thats gets created", func(t *testing.T) { - // Restore after destructive operation - sqlStore := db.InitTestDB(t) - - const testOrgID int64 = 1 - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - // add additional user with conflicting login where DOMAIN is upper case - err := sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - // create a user - // add additional user with conflicting login where DOMAIN is upper case - dupUserLogincmd := user.CreateUserCommand{ - Email: "userduplicatetest1@test.com", - Login: "user_duplicate_test_1_login", - OrgID: testOrgID, - } - rawSQL := fmt.Sprintf( - "INSERT INTO %s (email, login, org_id, version, is_admin, created, updated) VALUES (?,?,?,0,%s,\"2024-03-18T15:25:32\",\"2024-03-18T15:25:32\")", - sqlStore.Quote("user"), - sqlStore.GetDialect().BooleanStr(false), - ) - result, err := sess.Exec(rawSQL, dupUserLogincmd.Email, dupUserLogincmd.Login, dupUserLogincmd.OrgID) - if err != nil { - return err - } - n, err := result.RowsAffected() - if err != nil { - return err - } else if n == 0 { - return user.ErrUserNotFound - } - dupUserEmailcmd := user.CreateUserCommand{ - Email: "USERDUPLICATETEST1@TEST.COM", - Login: "USER_DUPLICATE_TEST_1_LOGIN", - OrgID: testOrgID, - } - result, err = sess.Exec(rawSQL, dupUserEmailcmd.Email, dupUserEmailcmd.Login, dupUserEmailcmd.OrgID) - if err != nil { - return err - } - n, err = result.RowsAffected() - if err != nil { - return err - } else if n == 0 { - return user.ErrUserNotFound - } - return nil - }) - require.NoError(t, err) - - // get users - conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - require.NoError(t, err) - r := ConflictResolver{Store: sqlStore} - r.BuildConflictBlocks(conflictUsers, fmt.Sprintf) - tmpFile, err := generateConflictUsersFile(&r) - require.NoError(t, err) - - b, err := os.ReadFile(tmpFile.Name()) - require.NoError(t, err) - - validErr := getValidConflictUsers(&r, b) - require.NoError(t, validErr) - require.Equal(t, 2, len(r.ValidUsers)) - } - }) -} - -func TestIntegrationMergeUser(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - t.Run("should be able to merge user", func(t *testing.T) { - // Restore after destructive operation - sqlStore := db.InitTestDB(t) - teamSvc, err := teamimpl.ProvideService(sqlStore, setting.NewCfg(), tracing.InitializeTracerForTest()) - require.NoError(t, err) - team1, err := teamSvc.CreateTeam(context.Background(), "team1 name", "", 1) - require.NoError(t, err) - const testOrgID int64 = 1 - - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - // add additional user with conflicting login where DOMAIN is upper case - - err = sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - // the order of adding the conflict matters - cmd := user.User{ - Email: "userduplicatetest1@test.com", - Name: "user name 1", - Login: "user_duplicate_test_1_login", - OrgID: testOrgID, - Created: time.Now(), - Updated: time.Now(), - } - - // call user store instead of user service so as not to prevent conflicting users - rawSQL := fmt.Sprintf( - "INSERT INTO %s (email, login, org_id, version, is_admin, created, updated) VALUES (?,?,?,0,%s,?,?)", - sqlStore.Quote("user"), - sqlStore.GetDialect().BooleanStr(false), - ) - result, err := sess.Exec(rawSQL, cmd.Email, cmd.Login, cmd.OrgID, cmd.Created, cmd.Updated) - if err != nil { - return err - } - n, err := result.RowsAffected() - if err != nil { - return err - } else if n == 0 { - return user.ErrUserNotFound - } - require.NoError(t, err) - return nil - }) - if err != nil { - t.Error(err) - } - - err = sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - cmd := user.User{ - Email: "USERDUPLICATETEST1@TEST.COM", - Name: "user name 1", - Login: "USER_DUPLICATE_TEST_1_LOGIN", - OrgID: testOrgID, - Created: time.Now(), - Updated: time.Now(), - } - // call user store instead of user service so as not to prevent conflicting users - rawSQL := fmt.Sprintf( - "INSERT INTO %s (email, login, org_id, version, is_admin, created, updated) VALUES (?,?,?,0,%s,?,?)", - sqlStore.Quote("user"), - sqlStore.GetDialect().BooleanStr(false), - ) - result, err := sess.Exec(rawSQL, cmd.Email, cmd.Login, cmd.OrgID, cmd.Created, cmd.Updated) - if err != nil { - return err - } - n, err := result.RowsAffected() - if err != nil { - return err - } else if n == 0 { - return user.ErrUserNotFound - } - require.NoError(t, err) - // this is the user we want to update to another team - return teamimpl.AddOrUpdateTeamMemberHook(sess, 1, testOrgID, team1.ID, false, 0) - }) - if err != nil { - t.Error(err) - } - - // get users - conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - require.NoError(t, err) - r := ConflictResolver{ - Store: sqlStore, - userService: usertest.NewUserServiceFake(), - ac: actest.FakeService{}, - } - r.BuildConflictBlocks(conflictUsers, fmt.Sprintf) - tmpFile, err := generateConflictUsersFile(&r) - require.NoError(t, err) - // validation to get newConflicts - // edited file - b, err := os.ReadFile(tmpFile.Name()) - require.NoError(t, err) - validErr := getValidConflictUsers(&r, b) - require.NoError(t, validErr) - require.Equal(t, 2, len(r.ValidUsers)) - - // test starts here - err = r.MergeConflictingUsers(context.Background()) - require.NoError(t, err) - } - }) -} - -func TestIntegrationMergeUserFromNewFileInput(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - t.Run("should be able to merge users after choosing a different user to keep", func(t *testing.T) { - type testBuildConflictBlock struct { - desc string - users []user.User - fileString string - expectedValidationErr error - expectedBlocks []string - expectedIdsInBlocks map[string][]string - } - testOrgID := 1 - m := make(map[string][]string) - conflict1 := "conflict: test" - conflict2 := "conflict: test2" - m[conflict1] = []string{"2", "3"} - m[conflict2] = []string{"4", "5", "6"} - testCases := []testBuildConflictBlock{ - { - desc: "should be able to parse the fileString containing the conflicts", - users: []user.User{ - { - Email: "TEST", - Login: "TEST", - OrgID: int64(testOrgID), - }, - { - Email: "test", - Login: "test", - OrgID: int64(testOrgID), - }, - { - Email: "test2", - Login: "test2", - OrgID: int64(testOrgID), - }, - { - Email: "TEST2", - Login: "TEST2", - OrgID: int64(testOrgID), - }, - { - Email: "Test2", - Login: "Test2", - OrgID: int64(testOrgID), - }, - }, - fileString: `conflict: test -- id: 1, email: test, login: test, last_seen_at: 2012-09-19T08:31:20Z, auth_module:, conflict_email: true, conflict_login: true -+ id: 2, email: TEST, login: TEST, last_seen_at: 2012-09-19T08:31:29Z, auth_module:, conflict_email: true, conflict_login: true -conflict: test2 -- id: 3, email: test2, login: test2, last_seen_at: 2012-09-19T08:31:41Z, auth_module: , conflict_email: true, conflict_login: true -+ id: 4, email: TEST2, login: TEST2, last_seen_at: 2012-09-19T08:31:51Z, auth_module: , conflict_email: true, conflict_login: true -- id: 5, email: Test2, login: Test2, last_seen_at: 2012-09-19T08:32:03Z, auth_module: , conflict_email: true, conflict_login: true`, - expectedBlocks: []string{"conflict: test", "conflict: test2"}, - expectedIdsInBlocks: m, - }, - { - desc: "should give error for having wrong number of users to keep", - users: []user.User{ - { - Email: "TEST", - Login: "TEST", - OrgID: int64(testOrgID), - }, - { - Email: "test", - Login: "test", - OrgID: int64(testOrgID), - }, - }, - fileString: `conflict: test -+ id: 1, email: test, login: test, last_seen_at: 2012-09-19T08:31:20Z, auth_module:, conflict_email: true, conflict_login: true -+ id: 2, email: TEST, login: TEST, last_seen_at: 2012-09-19T08:31:29Z, auth_module:, conflict_email: true, conflict_login: true -`, - expectedValidationErr: fmt.Errorf("invalid number of users to keep, expected 1, got 2 for block: conflict: test"), - expectedBlocks: []string{"conflict: test"}, - }, - { - desc: "should give error for having wrong character for user", - users: []user.User{ - { - Email: "TEST", - Login: "TEST", - OrgID: int64(testOrgID), - }, - { - Email: "test", - Login: "test", - OrgID: int64(testOrgID), - }, - }, - fileString: `conflict: test -+ id: 1, email: test, login: test, last_seen_at: 2012-09-19T08:31:20Z, auth_module:, conflict_email: true, conflict_login: true -% id: 2, email: TEST, login: TEST, last_seen_at: 2012-09-19T08:31:29Z, auth_module:, conflict_email: true, conflict_login: true -`, - expectedValidationErr: fmt.Errorf("invalid start character (expected '+,-') found %% for row number 3"), - expectedBlocks: []string{"conflict: test"}, - }, - } - for _, tc := range testCases { - // Restore after destructive operation - sqlStore := db.InitTestDB(t) - if sqlStore.GetDialect().DriverName() != ignoredDatabase { - err := sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - for _, u := range tc.users { - cmd := user.User{ - Email: u.Email, - Name: u.Name, - Login: u.Login, - OrgID: int64(testOrgID), - Created: time.Now(), - Updated: time.Now(), - } - // call user store instead of user service so as not to prevent conflicting users - rawSQL := fmt.Sprintf( - "INSERT INTO %s (email, login, org_id, version, is_admin, created, updated) VALUES (?,?,?,0,%s,?,?)", - sqlStore.Quote("user"), - sqlStore.GetDialect().BooleanStr(false), - ) - result, err := sess.Exec(rawSQL, cmd.Email, cmd.Login, cmd.OrgID, cmd.Created, cmd.Updated) - if err != nil { - return err - } - n, err := result.RowsAffected() - if err != nil { - return err - } else if n == 0 { - return user.ErrUserNotFound - } - require.NoError(t, err) - } - return nil - }) - if err != nil { - t.Fatal(err) - } - - // add additional user with conflicting login where DOMAIN is upper case - conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) - require.NoError(t, err) - userFake := usertest.NewUserServiceFake() - userFake.ExpectedUser = &user.User{Email: "test", Login: "test", OrgID: int64(testOrgID)} - r := ConflictResolver{ - Store: sqlStore, - userService: userFake, - ac: actest.FakeService{}, - } - r.BuildConflictBlocks(conflictUsers, fmt.Sprintf) - require.NoError(t, err) - // validation to get newConflicts - // edited file - // b, err := os.ReadFile(tmpFile.Name()) - // mocked file input - b := tc.fileString - require.NoError(t, err) - validErr := getValidConflictUsers(&r, []byte(b)) - if tc.expectedValidationErr != nil { - require.Equal(t, tc.expectedValidationErr, validErr) - } else { - require.NoError(t, validErr) - } - - // test starts here - err = r.MergeConflictingUsers(context.Background()) - require.NoError(t, err) - } - } - }) -} - -func TestMarshalConflictUser(t *testing.T) { - testCases := []struct { - name string - inputRow string - expectedUser ConflictingUser - }{ - { - name: "should be able to marshal expected input row", - inputRow: "+ id: 4, email: userduplicatetest1@test.com, login: userduplicatetest1, last_seen_at: 2012-07-26T16:08:11Z, auth_module: auth.saml, conflict_email: true, conflict_login: ", - expectedUser: ConflictingUser{ - Direction: "+", - ID: "4", - Email: "userduplicatetest1@test.com", - Login: "userduplicatetest1", - LastSeenAt: "2012-07-26T16:08:11Z", - AuthModule: "auth.saml", - ConflictEmail: "true", - ConflictLogin: "", - }, - }, - { - name: "should be able to marshal expected input row", - inputRow: "+ id: 1, email: userduplicatetest1@test.com, login: user_duplicate_test_1_login, last_seen_at: 2012-07-26T16:08:11Z, auth_module: , conflict_email: , conflict_login: true", - expectedUser: ConflictingUser{ - Direction: "+", - ID: "1", - Email: "userduplicatetest1@test.com", - Login: "user_duplicate_test_1_login", - LastSeenAt: "2012-07-26T16:08:11Z", - AuthModule: "", - ConflictEmail: "", - ConflictLogin: "true", - }, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - user := ConflictingUser{} - err := user.Marshal(tc.inputRow) - require.NoError(t, err) - require.Equal(t, tc.expectedUser.Direction, user.Direction) - require.Equal(t, tc.expectedUser.ID, user.ID) - require.Equal(t, tc.expectedUser.Email, user.Email) - require.Equal(t, tc.expectedUser.Login, user.Login) - require.Equal(t, tc.expectedUser.LastSeenAt, user.LastSeenAt) - require.Equal(t, tc.expectedUser.ConflictEmail, user.ConflictEmail) - require.Equal(t, tc.expectedUser.ConflictLogin, user.ConflictLogin) - }) - } -}