From bd50d8eeaa0437b56a1120a2bbea68f47fa9ce15 Mon Sep 17 00:00:00 2001 From: ryanfkeepers Date: Tue, 23 Jan 2024 16:13:14 -0700 Subject: [PATCH] adds boilerplate cli for chats backup All code is copied and amended from existing cli boilerplate. --- src/cli/backup/backup.go | 1 + src/cli/backup/groups.go | 4 +- src/cli/backup/teamschats.go | 305 +++++++++++++ src/cli/backup/teamschats_e2e_test.go | 631 ++++++++++++++++++++++++++ src/cli/backup/teamschats_test.go | 248 ++++++++++ src/cli/flags/teamschats.go | 13 + src/cli/flags/testdata/flags.go | 1 + src/cli/flags/testdata/teamschats.go | 25 + src/cli/utils/teamschats.go | 101 +++++ 9 files changed, 1327 insertions(+), 2 deletions(-) create mode 100644 src/cli/backup/teamschats.go create mode 100644 src/cli/backup/teamschats_e2e_test.go create mode 100644 src/cli/backup/teamschats_test.go create mode 100644 src/cli/flags/teamschats.go create mode 100644 src/cli/flags/testdata/teamschats.go create mode 100644 src/cli/utils/teamschats.go diff --git a/src/cli/backup/backup.go b/src/cli/backup/backup.go index 9ac27640f..d712020a7 100644 --- a/src/cli/backup/backup.go +++ b/src/cli/backup/backup.go @@ -46,6 +46,7 @@ var serviceCommands = []func(cmd *cobra.Command) *cobra.Command{ addOneDriveCommands, addSharePointCommands, addGroupsCommands, + addTeamsChatsCommands, } // AddCommands attaches all `corso backup * *` commands to the parent. diff --git a/src/cli/backup/groups.go b/src/cli/backup/groups.go index 2a8ab3024..fad35ce5d 100644 --- a/src/cli/backup/groups.go +++ b/src/cli/backup/groups.go @@ -310,7 +310,7 @@ func groupsBackupCreateSelectors( group, cats []string, ) *selectors.GroupsBackup { if filters.PathContains(group).Compare(flags.Wildcard) { - return includeAllGroupWithCategories(ins, cats) + return includeAllGroupsWithCategories(ins, cats) } sel := selectors.NewGroupsBackup(slices.Clone(group)) @@ -318,6 +318,6 @@ func groupsBackupCreateSelectors( return utils.AddGroupsCategories(sel, cats) } -func includeAllGroupWithCategories(ins idname.Cacher, categories []string) *selectors.GroupsBackup { +func includeAllGroupsWithCategories(ins idname.Cacher, categories []string) *selectors.GroupsBackup { return utils.AddGroupsCategories(selectors.NewGroupsBackup(ins.IDs()), categories) } diff --git a/src/cli/backup/teamschats.go b/src/cli/backup/teamschats.go new file mode 100644 index 000000000..d324deead --- /dev/null +++ b/src/cli/backup/teamschats.go @@ -0,0 +1,305 @@ +package backup + +import ( + "context" + "fmt" + + "github.com/alcionai/clues" + "github.com/spf13/cobra" + "golang.org/x/exp/slices" + + "github.com/alcionai/corso/src/cli/flags" + . "github.com/alcionai/corso/src/cli/print" + "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/internal/common/idname" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/filters" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/selectors" + "github.com/alcionai/corso/src/pkg/services/m365" +) + +// ------------------------------------------------------------------------------------------------ +// setup and globals +// ------------------------------------------------------------------------------------------------ + +const ( + teamschatsServiceCommand = "chats" + teamschatsServiceCommandCreateUseSuffix = "--user | '" + flags.Wildcard + "'" + teamschatsServiceCommandDeleteUseSuffix = "--backups " + teamschatsServiceCommandDetailsUseSuffix = "--backup " +) + +const ( + teamschatsServiceCommandCreateExamples = `# Backup all chats with bob@company.hr +corso backup create chats --user bob@company.hr + +# Backup all chats for all users +corso backup create chats --user '*'` + + teamschatsServiceCommandDeleteExamples = `# Delete chats backup with ID 1234abcd-12ab-cd34-56de-1234abcd \ +and 1234abcd-12ab-cd34-56de-1234abce +corso backup delete chats --backups 1234abcd-12ab-cd34-56de-1234abcd,1234abcd-12ab-cd34-56de-1234abce` + + teamschatsServiceCommandDetailsExamples = `# Explore chats in Bob's latest backup (1234abcd...) +corso backup details chats --backup 1234abcd-12ab-cd34-56de-1234abcd` +) + +// called by backup.go to map subcommands to provider-specific handling. +func addTeamsChatsCommands(cmd *cobra.Command) *cobra.Command { + var c *cobra.Command + + switch cmd.Use { + case createCommand: + c, _ = utils.AddCommand(cmd, teamschatsCreateCmd(), utils.MarkPreReleaseCommand()) + + c.Use = c.Use + " " + teamschatsServiceCommandCreateUseSuffix + c.Example = teamschatsServiceCommandCreateExamples + + // Flags addition ordering should follow the order we want them to appear in help and docs: + flags.AddUserFlag(c) + flags.AddDataFlag(c, []string{flags.DataChats}, false) + flags.AddGenericBackupFlags(c) + + case listCommand: + c, _ = utils.AddCommand(cmd, teamschatsListCmd(), utils.MarkPreReleaseCommand()) + + flags.AddBackupIDFlag(c, false) + flags.AddAllBackupListFlags(c) + + case detailsCommand: + c, _ = utils.AddCommand(cmd, teamschatsDetailsCmd(), utils.MarkPreReleaseCommand()) + + c.Use = c.Use + " " + teamschatsServiceCommandDetailsUseSuffix + c.Example = teamschatsServiceCommandDetailsExamples + + flags.AddSkipReduceFlag(c) + + // Flags addition ordering should follow the order we want them to appear in help and docs: + // More generic (ex: --user) and more frequently used flags take precedence. + flags.AddBackupIDFlag(c, true) + flags.AddTeamsChatsDetailsAndRestoreFlags(c) + + case deleteCommand: + c, _ = utils.AddCommand(cmd, teamschatsDeleteCmd(), utils.MarkPreReleaseCommand()) + + c.Use = c.Use + " " + teamschatsServiceCommandDeleteUseSuffix + c.Example = teamschatsServiceCommandDeleteExamples + + flags.AddMultipleBackupIDsFlag(c, false) + flags.AddBackupIDFlag(c, false) + } + + return c +} + +// ------------------------------------------------------------------------------------------------ +// backup create +// ------------------------------------------------------------------------------------------------ + +// `corso backup create chats [...]` +func teamschatsCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: teamschatsServiceCommand, + Aliases: []string{teamsServiceCommand}, + Short: "Backup M365 Chats service data", + RunE: createTeamsChatsCmd, + Args: cobra.NoArgs, + } +} + +// processes a teamschats service backup. +func createTeamsChatsCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + if flags.RunModeFV == flags.RunModeFlagTest { + return nil + } + + if err := validateTeamsChatsBackupCreateFlags(flags.UserFV, flags.CategoryDataFV); err != nil { + return err + } + + r, acct, err := utils.AccountConnectAndWriteRepoConfig( + ctx, + cmd, + path.TeamsChatsService) + if err != nil { + return Only(ctx, err) + } + + defer utils.CloseRepo(ctx, r) + + // TODO: log/print recoverable errors + errs := fault.New(false) + + svcCli, err := m365.NewM365Client(ctx, *acct) + if err != nil { + return Only(ctx, clues.Stack(err)) + } + + ins, err := svcCli.AC.Users().GetAllIDsAndNames(ctx, errs) + if err != nil { + return Only(ctx, clues.Wrap(err, "Failed to retrieve M365 teamschats")) + } + + sel := teamschatsBackupCreateSelectors(ctx, ins, flags.UserFV, flags.CategoryDataFV) + selectorSet := []selectors.Selector{} + + for _, discSel := range sel.SplitByResourceOwner(ins.IDs()) { + selectorSet = append(selectorSet, discSel.Selector) + } + + return genericCreateCommand( + ctx, + r, + "Chats", + selectorSet, + ins) +} + +// ------------------------------------------------------------------------------------------------ +// backup list +// ------------------------------------------------------------------------------------------------ + +// `corso backup list teamschats [...]` +func teamschatsListCmd() *cobra.Command { + return &cobra.Command{ + Use: teamschatsServiceCommand, + Short: "List the history of M365 TeamsChats service backups", + RunE: listTeamsChatsCmd, + Args: cobra.NoArgs, + } +} + +// lists the history of backup operations +func listTeamsChatsCmd(cmd *cobra.Command, args []string) error { + return genericListCommand(cmd, flags.BackupIDFV, path.TeamsChatsService, args) +} + +// ------------------------------------------------------------------------------------------------ +// backup details +// ------------------------------------------------------------------------------------------------ + +// `corso backup details teamschats [...]` +func teamschatsDetailsCmd() *cobra.Command { + return &cobra.Command{ + Use: teamschatsServiceCommand, + Short: "Shows the details of a M365 TeamsChats service backup", + RunE: detailsTeamsChatsCmd, + Args: cobra.NoArgs, + } +} + +// processes a teamschats service backup. +func detailsTeamsChatsCmd(cmd *cobra.Command, args []string) error { + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + if flags.RunModeFV == flags.RunModeFlagTest { + return nil + } + + return runDetailsTeamsChatsCmd(cmd) +} + +func runDetailsTeamsChatsCmd(cmd *cobra.Command) error { + ctx := cmd.Context() + opts := utils.MakeTeamsChatsOpts(cmd) + + sel := utils.IncludeTeamsChatsRestoreDataSelectors(ctx, opts) + sel.Configure(selectors.Config{OnlyMatchItemNames: true}) + utils.FilterTeamsChatsRestoreInfoSelectors(sel, opts) + + ds, err := genericDetailsCommand(cmd, flags.BackupIDFV, sel.Selector) + if err != nil { + return Only(ctx, err) + } + + if len(ds.Entries) > 0 { + ds.PrintEntries(ctx) + } else { + Info(ctx, selectors.ErrorNoMatchingItems) + } + + return nil +} + +// ------------------------------------------------------------------------------------------------ +// backup delete +// ------------------------------------------------------------------------------------------------ + +// `corso backup delete teamschats [...]` +func teamschatsDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: teamschatsServiceCommand, + Short: "Delete backed-up M365 TeamsChats service data", + RunE: deleteTeamsChatsCmd, + Args: cobra.NoArgs, + } +} + +// deletes an teamschats service backup. +func deleteTeamsChatsCmd(cmd *cobra.Command, args []string) error { + backupIDValue := []string{} + + if len(flags.BackupIDsFV) > 0 { + backupIDValue = flags.BackupIDsFV + } else if len(flags.BackupIDFV) > 0 { + backupIDValue = append(backupIDValue, flags.BackupIDFV) + } else { + return clues.New("either --backup or --backups flag is required") + } + + return genericDeleteCommand(cmd, path.TeamsChatsService, "TeamsChats", backupIDValue, args) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func validateTeamsChatsBackupCreateFlags(teamschats, cats []string) error { + if len(teamschats) == 0 { + return clues.New( + "requires one or more --" + + flags.UserFN + " ids, or the wildcard --" + + flags.UserFN + " *") + } + + msg := fmt.Sprintf( + " is an unrecognized data type; only %s is supported", + flags.DataChats) + + allowedCats := utils.TeamsChatsAllowedCategories() + + for _, d := range cats { + if _, ok := allowedCats[d]; !ok { + return clues.New(d + msg) + } + } + + return nil +} + +func teamschatsBackupCreateSelectors( + ctx context.Context, + ins idname.Cacher, + users, cats []string, +) *selectors.TeamsChatsBackup { + if filters.PathContains(users).Compare(flags.Wildcard) { + return includeAllTeamsChatsWithCategories(ins, cats) + } + + sel := selectors.NewTeamsChatsBackup(slices.Clone(users)) + + return utils.AddTeamsChatsCategories(sel, cats) +} + +func includeAllTeamsChatsWithCategories(ins idname.Cacher, categories []string) *selectors.TeamsChatsBackup { + return utils.AddTeamsChatsCategories(selectors.NewTeamsChatsBackup(ins.IDs()), categories) +} diff --git a/src/cli/backup/teamschats_e2e_test.go b/src/cli/backup/teamschats_e2e_test.go new file mode 100644 index 000000000..2e829e628 --- /dev/null +++ b/src/cli/backup/teamschats_e2e_test.go @@ -0,0 +1,631 @@ +package backup_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/alcionai/clues" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/cli" + "github.com/alcionai/corso/src/cli/flags" + "github.com/alcionai/corso/src/cli/print" + cliTD "github.com/alcionai/corso/src/cli/testdata" + "github.com/alcionai/corso/src/internal/common/idname" + "github.com/alcionai/corso/src/internal/operations" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" + "github.com/alcionai/corso/src/pkg/config" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/selectors" + selTD "github.com/alcionai/corso/src/pkg/selectors/testdata" + storeTD "github.com/alcionai/corso/src/pkg/storage/testdata" +) + +// --------------------------------------------------------------------------- +// tests that require no existing backups +// --------------------------------------------------------------------------- + +type NoBackupTeamsChatsE2ESuite struct { + tester.Suite + dpnd dependencies + its intgTesterSetup +} + +func TestNoBackupTeamsChatsE2ESuite(t *testing.T) { + suite.Run(t, &BackupTeamsChatsE2ESuite{Suite: tester.NewE2ESuite( + t, + [][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs})}) +} + +func (suite *NoBackupTeamsChatsE2ESuite) SetupSuite() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + suite.its = newIntegrationTesterSetup(t) + suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService) +} + +func (suite *NoBackupTeamsChatsE2ESuite) TestTeamsChatsBackupListCmd_noBackups() { + t := suite.T() + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + suite.dpnd.recorder.Reset() + + cmd := cliTD.StubRootCmd( + "backup", "list", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath) + cli.BuildCommandTree(cmd) + + cmd.SetErr(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + result := suite.dpnd.recorder.String() + + // as an offhand check: the result should contain the m365 teamschat id + assert.True(t, strings.HasSuffix(result, "No backups available\n")) +} + +// --------------------------------------------------------------------------- +// tests with no prior backup +// --------------------------------------------------------------------------- + +type BackupTeamsChatsE2ESuite struct { + tester.Suite + dpnd dependencies + its intgTesterSetup +} + +func TestBackupTeamsChatsE2ESuite(t *testing.T) { + suite.Run(t, &BackupTeamsChatsE2ESuite{Suite: tester.NewE2ESuite( + t, + [][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs})}) +} + +func (suite *BackupTeamsChatsE2ESuite) SetupSuite() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + suite.its = newIntegrationTesterSetup(t) + suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService) +} + +func (suite *BackupTeamsChatsE2ESuite) TestTeamsChatsBackupCmd_chats() { + runTeamsChatsBackupCategoryTest(suite, flags.DataChats) +} + +func runTeamsChatsBackupCategoryTest(suite *BackupTeamsChatsE2ESuite, category string) { + recorder := strings.Builder{} + recorder.Reset() + + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd, ctx := buildTeamsChatsBackupCmd( + ctx, + suite.dpnd.configFilePath, + suite.its.user.ID, + category, + &recorder) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + result := recorder.String() + t.Log("backup results", result) +} + +func (suite *BackupTeamsChatsE2ESuite) TestTeamsChatsBackupCmd_teamschatNotFound_chats() { + runTeamsChatsBackupTeamsChatNotFoundTest(suite, flags.DataChats) +} + +func runTeamsChatsBackupTeamsChatNotFoundTest(suite *BackupTeamsChatsE2ESuite, category string) { + recorder := strings.Builder{} + recorder.Reset() + + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd, ctx := buildTeamsChatsBackupCmd( + ctx, + suite.dpnd.configFilePath, + "foo@not-there.com", + category, + &recorder) + + // run the command + err := cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) + assert.Contains( + t, + err.Error(), + "not found", + "error missing user not found") + assert.NotContains(t, err.Error(), "runtime error", "panic happened") + + t.Logf("backup error message: %s", err.Error()) + + result := recorder.String() + t.Log("backup results", result) +} + +func (suite *BackupTeamsChatsE2ESuite) TestBackupCreateTeamsChats_badAzureClientIDFlag() { + t := suite.T() + ctx, flush := tester.NewContext(t) + + defer flush() + + suite.dpnd.recorder.Reset() + + cmd := cliTD.StubRootCmd( + "backup", "create", "chats", + "--teamschat", suite.its.user.ID, + "--azure-client-id", "invalid-value") + cli.BuildCommandTree(cmd) + + cmd.SetErr(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) +} + +func (suite *BackupTeamsChatsE2ESuite) TestBackupCreateTeamsChats_fromConfigFile() { + t := suite.T() + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + suite.dpnd.recorder.Reset() + + cmd := cliTD.StubRootCmd( + "backup", "create", "chats", + "--teamschat", suite.its.user.ID, + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath) + cli.BuildCommandTree(cmd) + + cmd.SetOut(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) +} + +// AWS flags +func (suite *BackupTeamsChatsE2ESuite) TestBackupCreateTeamsChats_badAWSFlags() { + t := suite.T() + ctx, flush := tester.NewContext(t) + + defer flush() + + suite.dpnd.recorder.Reset() + + cmd := cliTD.StubRootCmd( + "backup", "create", "chats", + "--teamschat", suite.its.user.ID, + "--aws-access-key", "invalid-value", + "--aws-secret-access-key", "some-invalid-value") + cli.BuildCommandTree(cmd) + + cmd.SetOut(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + // since invalid aws creds are explicitly set, should see a failure + require.Error(t, err, clues.ToCore(err)) +} + +// --------------------------------------------------------------------------- +// tests prepared with a previous backup +// --------------------------------------------------------------------------- + +type PreparedBackupTeamsChatsE2ESuite struct { + tester.Suite + dpnd dependencies + backupOps map[path.CategoryType]string + its intgTesterSetup +} + +func TestPreparedBackupTeamsChatsE2ESuite(t *testing.T) { + suite.Run(t, &PreparedBackupTeamsChatsE2ESuite{ + Suite: tester.NewE2ESuite( + t, + [][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *PreparedBackupTeamsChatsE2ESuite) SetupSuite() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + suite.its = newIntegrationTesterSetup(t) + suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService) + suite.backupOps = make(map[path.CategoryType]string) + + var ( + teamschats = []string{suite.its.user.ID} + ins = idname.NewCache(map[string]string{suite.its.user.ID: suite.its.user.ID}) + cats = []path.CategoryType{ + path.ChatsCategory, + } + ) + + for _, set := range cats { + var ( + sel = selectors.NewTeamsChatsBackup(teamschats) + scopes []selectors.TeamsChatsScope + ) + + switch set { + case path.ChatsCategory: + scopes = selTD.TeamsChatsBackupChatScope(sel) + } + + sel.Include(scopes) + + bop, err := suite.dpnd.repo.NewBackupWithLookup(ctx, sel.Selector, ins) + require.NoError(t, err, clues.ToCore(err)) + + err = bop.Run(ctx) + require.NoError(t, err, clues.ToCore(err)) + + bIDs := string(bop.Results.BackupID) + + // sanity check, ensure we can find the backup and its details immediately + b, err := suite.dpnd.repo.Backup(ctx, string(bop.Results.BackupID)) + require.NoError(t, err, "retrieving recent backup by ID") + require.Equal(t, bIDs, string(b.ID), "repo backup matches results id") + + _, b, errs := suite.dpnd.repo.GetBackupDetails(ctx, bIDs) + require.NoError(t, errs.Failure(), "retrieving recent backup details by ID") + require.Empty(t, errs.Recovered(), "retrieving recent backup details by ID") + require.Equal(t, bIDs, string(b.ID), "repo details matches results id") + + suite.backupOps[set] = string(b.ID) + } +} + +func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsListCmd_chats() { + runTeamsChatsListCmdTest(suite, path.ChatsCategory) +} + +func runTeamsChatsListCmdTest(suite *PreparedBackupTeamsChatsE2ESuite, category path.CategoryType) { + suite.dpnd.recorder.Reset() + + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd := cliTD.StubRootCmd( + "backup", "list", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath) + cli.BuildCommandTree(cmd) + cmd.SetOut(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + // compare the output + result := suite.dpnd.recorder.String() + assert.Contains(t, result, suite.backupOps[category]) +} + +func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsListCmd_singleID_chats() { + runTeamsChatsListSingleCmdTest(suite, path.ChatsCategory) +} + +func runTeamsChatsListSingleCmdTest(suite *PreparedBackupTeamsChatsE2ESuite, category path.CategoryType) { + suite.dpnd.recorder.Reset() + + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + bID := suite.backupOps[category] + + cmd := cliTD.StubRootCmd( + "backup", "list", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--backup", string(bID)) + cli.BuildCommandTree(cmd) + + cmd.SetOut(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + // compare the output + result := suite.dpnd.recorder.String() + assert.Contains(t, result, bID) +} + +func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsListCmd_badID() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd := cliTD.StubRootCmd( + "backup", "list", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--backup", "smarfs") + cli.BuildCommandTree(cmd) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) +} + +func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsDetailsCmd_chats() { + runTeamsChatsDetailsCmdTest(suite, path.ChatsCategory) +} + +func runTeamsChatsDetailsCmdTest(suite *PreparedBackupTeamsChatsE2ESuite, category path.CategoryType) { + suite.dpnd.recorder.Reset() + + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + bID := suite.backupOps[category] + + // fetch the details from the repo first + deets, _, errs := suite.dpnd.repo.GetBackupDetails(ctx, string(bID)) + require.NoError(t, errs.Failure(), clues.ToCore(errs.Failure())) + require.Empty(t, errs.Recovered()) + + cmd := cliTD.StubRootCmd( + "backup", "details", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--"+flags.BackupFN, string(bID)) + cli.BuildCommandTree(cmd) + cmd.SetOut(&suite.dpnd.recorder) + + ctx = print.SetRootCmd(ctx, cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + // compare the output + result := suite.dpnd.recorder.String() + + i := 0 + foundFolders := 0 + + for _, ent := range deets.Entries { + // Skip folders as they don't mean anything to the end teamschat. + if ent.Folder != nil { + foundFolders++ + continue + } + + suite.Run(fmt.Sprintf("detail %d", i), func() { + assert.Contains(suite.T(), result, ent.ShortRef) + }) + + i++ + } + + // We only backup the default folder for each category so there should be at + // least that folder (we don't make details entries for prefix folders). + assert.GreaterOrEqual(t, foundFolders, 1) +} + +// --------------------------------------------------------------------------- +// tests for deleting backups +// --------------------------------------------------------------------------- + +type BackupDeleteTeamsChatsE2ESuite struct { + tester.Suite + dpnd dependencies + backupOps [3]operations.BackupOperation +} + +func TestBackupDeleteTeamsChatsE2ESuite(t *testing.T) { + suite.Run(t, &BackupDeleteTeamsChatsE2ESuite{ + Suite: tester.NewE2ESuite( + t, + [][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *BackupDeleteTeamsChatsE2ESuite) SetupSuite() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService) + + m365TeamsChatID := tconfig.M365TeamID(t) + teamschats := []string{m365TeamsChatID} + + // some tests require an existing backup + sel := selectors.NewTeamsChatsBackup(teamschats) + sel.Include(selTD.TeamsChatsBackupChatScope(sel)) + + for i := 0; i < cap(suite.backupOps); i++ { + backupOp, err := suite.dpnd.repo.NewBackup(ctx, sel.Selector) + require.NoError(t, err, clues.ToCore(err)) + + suite.backupOps[i] = backupOp + + err = suite.backupOps[i].Run(ctx) + require.NoError(t, err, clues.ToCore(err)) + } +} + +func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd := cliTD.StubRootCmd( + "backup", "delete", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--"+flags.BackupIDsFN, + fmt.Sprintf("%s,%s", + string(suite.backupOps[0].Results.BackupID), + string(suite.backupOps[1].Results.BackupID))) + cli.BuildCommandTree(cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + // a follow-up details call should fail, due to the backup ID being deleted + cmd = cliTD.StubRootCmd( + "backup", "details", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--backups", string(suite.backupOps[0].Results.BackupID)) + cli.BuildCommandTree(cmd) + + err = cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) +} + +func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd_SingleID() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd := cliTD.StubRootCmd( + "backup", "delete", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--"+flags.BackupFN, + string(suite.backupOps[2].Results.BackupID)) + cli.BuildCommandTree(cmd) + + // run the command + err := cmd.ExecuteContext(ctx) + require.NoError(t, err, clues.ToCore(err)) + + // a follow-up details call should fail, due to the backup ID being deleted + cmd = cliTD.StubRootCmd( + "backup", "details", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--backup", string(suite.backupOps[2].Results.BackupID)) + cli.BuildCommandTree(cmd) + + err = cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) +} + +func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd_UnknownID() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd := cliTD.StubRootCmd( + "backup", "delete", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath, + "--"+flags.BackupIDsFN, uuid.NewString()) + cli.BuildCommandTree(cmd) + + // unknown backupIDs should error since the modelStore can't find the backup + err := cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) +} + +func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd_NoBackupID() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + ctx = config.SetViper(ctx, suite.dpnd.vpr) + + defer flush() + + cmd := cliTD.StubRootCmd( + "backup", "delete", "chats", + "--"+flags.ConfigFileFN, suite.dpnd.configFilePath) + cli.BuildCommandTree(cmd) + + // empty backupIDs should error since no data provided + err := cmd.ExecuteContext(ctx) + require.Error(t, err, clues.ToCore(err)) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func buildTeamsChatsBackupCmd( + ctx context.Context, + configFile, resource, category string, + recorder *strings.Builder, +) (*cobra.Command, context.Context) { + cmd := cliTD.StubRootCmd( + "backup", "create", "chats", + "--"+flags.ConfigFileFN, configFile, + "--"+flags.UserFN, resource, + "--"+flags.CategoryDataFN, category) + cli.BuildCommandTree(cmd) + cmd.SetOut(recorder) + + return cmd, print.SetRootCmd(ctx, cmd) +} diff --git a/src/cli/backup/teamschats_test.go b/src/cli/backup/teamschats_test.go new file mode 100644 index 000000000..177cc29e7 --- /dev/null +++ b/src/cli/backup/teamschats_test.go @@ -0,0 +1,248 @@ +package backup + +import ( + "testing" + + "github.com/alcionai/clues" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/cli/flags" + flagsTD "github.com/alcionai/corso/src/cli/flags/testdata" + cliTD "github.com/alcionai/corso/src/cli/testdata" + "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/pkg/control" +) + +type TeamsChatsUnitSuite struct { + tester.Suite +} + +func TestTeamsChatsUnitSuite(t *testing.T) { + suite.Run(t, &TeamsChatsUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *TeamsChatsUnitSuite) TestAddTeamsChatsCommands() { + expectUse := teamschatsServiceCommand + + table := []struct { + name string + use string + expectUse string + expectShort string + expectRunE func(*cobra.Command, []string) error + }{ + { + name: "create teamschats", + use: createCommand, + expectUse: expectUse + " " + teamschatsServiceCommandCreateUseSuffix, + expectShort: teamschatsCreateCmd().Short, + expectRunE: createTeamsChatsCmd, + }, + { + name: "list teamschats", + use: listCommand, + expectUse: expectUse, + expectShort: teamschatsListCmd().Short, + expectRunE: listTeamsChatsCmd, + }, + { + name: "details teamschats", + use: detailsCommand, + expectUse: expectUse + " " + teamschatsServiceCommandDetailsUseSuffix, + expectShort: teamschatsDetailsCmd().Short, + expectRunE: detailsTeamsChatsCmd, + }, + { + name: "delete teamschats", + use: deleteCommand, + expectUse: expectUse + " " + teamschatsServiceCommandDeleteUseSuffix, + expectShort: teamschatsDeleteCmd().Short, + expectRunE: deleteTeamsChatsCmd, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + cmd := &cobra.Command{Use: test.use} + + c := addTeamsChatsCommands(cmd) + require.NotNil(t, c) + + cmds := cmd.Commands() + require.Len(t, cmds, 1) + + child := cmds[0] + assert.Equal(t, test.expectUse, child.Use) + assert.Equal(t, test.expectShort, child.Short) + tester.AreSameFunc(t, test.expectRunE, child.RunE) + }) + } +} + +func (suite *TeamsChatsUnitSuite) TestValidateTeamsChatsBackupCreateFlags() { + table := []struct { + name string + cats []string + expect assert.ErrorAssertionFunc + }{ + { + name: "none", + cats: []string{}, + expect: assert.NoError, + }, + { + name: "chats", + cats: []string{flags.DataChats}, + expect: assert.NoError, + }, + { + name: "all allowed", + cats: []string{ + flags.DataChats, + }, + expect: assert.NoError, + }, + { + name: "bad inputs", + cats: []string{"foo"}, + expect: assert.Error, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + err := validateTeamsChatsBackupCreateFlags([]string{"*"}, test.cats) + test.expect(suite.T(), err, clues.ToCore(err)) + }) + } +} + +func (suite *TeamsChatsUnitSuite) TestBackupCreateFlags() { + t := suite.T() + + cmd := cliTD.SetUpCmdHasFlags( + t, + &cobra.Command{Use: createCommand}, + addTeamsChatsCommands, + []cliTD.UseCobraCommandFn{ + flags.AddAllProviderFlags, + flags.AddAllStorageFlags, + }, + flagsTD.WithFlags( + teamschatsServiceCommand, + []string{ + "--" + flags.RunModeFN, flags.RunModeFlagTest, + "--" + flags.UserFN, flagsTD.FlgInputs(flagsTD.UsersInput), + "--" + flags.CategoryDataFN, flagsTD.FlgInputs(flagsTD.TeamsChatsCategoryDataInput), + }, + flagsTD.PreparedGenericBackupFlags(), + flagsTD.PreparedProviderFlags(), + flagsTD.PreparedStorageFlags())) + + opts := utils.MakeTeamsChatsOpts(cmd) + co := utils.Control() + backupOpts := utils.ParseBackupOptions() + + // TODO(ashmrtn): Remove flag checks on control.Options to control.Backup once + // restore flags are switched over too and we no longer parse flags beyond + // connection info into control.Options. + assert.Equal(t, control.FailFast, backupOpts.FailureHandling) + assert.True(t, backupOpts.Incrementals.ForceFullEnumeration) + assert.True(t, backupOpts.Incrementals.ForceItemDataRefresh) + + assert.Equal(t, control.FailFast, co.FailureHandling) + assert.True(t, co.ToggleFeatures.DisableIncrementals) + assert.True(t, co.ToggleFeatures.ForceItemDataDownload) + + assert.ElementsMatch(t, flagsTD.UsersInput, opts.Users) + flagsTD.AssertGenericBackupFlags(t, cmd) + flagsTD.AssertProviderFlags(t, cmd) + flagsTD.AssertStorageFlags(t, cmd) +} + +func (suite *TeamsChatsUnitSuite) TestBackupListFlags() { + t := suite.T() + + cmd := cliTD.SetUpCmdHasFlags( + t, + &cobra.Command{Use: listCommand}, + addTeamsChatsCommands, + []cliTD.UseCobraCommandFn{ + flags.AddAllProviderFlags, + flags.AddAllStorageFlags, + }, + flagsTD.WithFlags( + teamschatsServiceCommand, + []string{ + "--" + flags.RunModeFN, flags.RunModeFlagTest, + "--" + flags.BackupFN, flagsTD.BackupInput, + }, + flagsTD.PreparedBackupListFlags(), + flagsTD.PreparedProviderFlags(), + flagsTD.PreparedStorageFlags())) + + assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV) + flagsTD.AssertBackupListFlags(t, cmd) + flagsTD.AssertProviderFlags(t, cmd) + flagsTD.AssertStorageFlags(t, cmd) +} + +func (suite *TeamsChatsUnitSuite) TestBackupDetailsFlags() { + t := suite.T() + + cmd := cliTD.SetUpCmdHasFlags( + t, + &cobra.Command{Use: detailsCommand}, + addTeamsChatsCommands, + []cliTD.UseCobraCommandFn{ + flags.AddAllProviderFlags, + flags.AddAllStorageFlags, + }, + flagsTD.WithFlags( + teamschatsServiceCommand, + []string{ + "--" + flags.RunModeFN, flags.RunModeFlagTest, + "--" + flags.BackupFN, flagsTD.BackupInput, + "--" + flags.SkipReduceFN, + }, + flagsTD.PreparedTeamsChatsFlags(), + flagsTD.PreparedProviderFlags(), + flagsTD.PreparedStorageFlags())) + + co := utils.Control() + + assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV) + assert.True(t, co.SkipReduce) + flagsTD.AssertProviderFlags(t, cmd) + flagsTD.AssertStorageFlags(t, cmd) + flagsTD.AssertTeamsChatsFlags(t, cmd) +} + +func (suite *TeamsChatsUnitSuite) TestBackupDeleteFlags() { + t := suite.T() + + cmd := cliTD.SetUpCmdHasFlags( + t, + &cobra.Command{Use: deleteCommand}, + addTeamsChatsCommands, + []cliTD.UseCobraCommandFn{ + flags.AddAllProviderFlags, + flags.AddAllStorageFlags, + }, + flagsTD.WithFlags( + teamschatsServiceCommand, + []string{ + "--" + flags.RunModeFN, flags.RunModeFlagTest, + "--" + flags.BackupFN, flagsTD.BackupInput, + }, + flagsTD.PreparedProviderFlags(), + flagsTD.PreparedStorageFlags())) + + assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV) + flagsTD.AssertProviderFlags(t, cmd) + flagsTD.AssertStorageFlags(t, cmd) +} diff --git a/src/cli/flags/teamschats.go b/src/cli/flags/teamschats.go new file mode 100644 index 000000000..39fde2276 --- /dev/null +++ b/src/cli/flags/teamschats.go @@ -0,0 +1,13 @@ +package flags + +import ( + "github.com/spf13/cobra" +) + +const ( + DataChats = "chats" +) + +func AddTeamsChatsDetailsAndRestoreFlags(cmd *cobra.Command) { + // TODO: add details flags +} diff --git a/src/cli/flags/testdata/flags.go b/src/cli/flags/testdata/flags.go index 09a9c12b4..69de53159 100644 --- a/src/cli/flags/testdata/flags.go +++ b/src/cli/flags/testdata/flags.go @@ -21,6 +21,7 @@ var ( ExchangeCategoryDataInput = []string{"email", "events", "contacts"} SharepointCategoryDataInput = []string{"files", "lists", "pages"} GroupsCategoryDataInput = []string{"files", "lists", "pages", "messages"} + TeamsChatsCategoryDataInput = []string{"chats"} ChannelInput = []string{"channel1", "channel2"} MessageInput = []string{"message1", "message2"} diff --git a/src/cli/flags/testdata/teamschats.go b/src/cli/flags/testdata/teamschats.go new file mode 100644 index 000000000..9203ad586 --- /dev/null +++ b/src/cli/flags/testdata/teamschats.go @@ -0,0 +1,25 @@ +package testdata + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func PreparedTeamsChatsFlags() []string { + return []string{ + // FIXME: populate when adding filters + // "--" + flags.ChatCreatedAfterFN, ChatCreatedAfterInput, + // "--" + flags.ChatCreatedBeforeFN, ChatCreatedBeforeInput, + // "--" + flags.ChatLastMessageAfterFN, ChatLastMessageAfterInput, + // "--" + flags.ChatLastMessageBeforeFN, ChatLastMessageBeforeInput, + } +} + +func AssertTeamsChatsFlags(t *testing.T, cmd *cobra.Command) { + // FIXME: populate when adding filters + // assert.Equal(t, ChatCreatedAfterInput, flags.ChatCreatedAfterFV) + // assert.Equal(t, ChatCreatedBeforeInput, flags.ChatCreatedBeforeFV) + // assert.Equal(t, ChatLastMessageAfterInput, flags.ChatLastMessageAfterFV) + // assert.Equal(t, ChatLastMessageBeforeInput, flags.ChatLastMessageBeforeFV) +} diff --git a/src/cli/utils/teamschats.go b/src/cli/utils/teamschats.go new file mode 100644 index 000000000..1467949a3 --- /dev/null +++ b/src/cli/utils/teamschats.go @@ -0,0 +1,101 @@ +package utils + +import ( + "context" + + "github.com/alcionai/clues" + "github.com/spf13/cobra" + + "github.com/alcionai/corso/src/cli/flags" + "github.com/alcionai/corso/src/pkg/selectors" +) + +type TeamsChatsOpts struct { + Users []string + + ExportCfg ExportCfgOpts + + Populated flags.PopulatedFlags +} + +func TeamsChatsAllowedCategories() map[string]struct{} { + return map[string]struct{}{ + flags.DataChats: {}, + } +} + +func AddTeamsChatsCategories(sel *selectors.TeamsChatsBackup, cats []string) *selectors.TeamsChatsBackup { + if len(cats) == 0 { + sel.Include(sel.AllData()) + } + + for _, d := range cats { + switch d { + case flags.DataChats: + sel.Include(sel.Chats(selectors.Any())) + } + } + + return sel +} + +func MakeTeamsChatsOpts(cmd *cobra.Command) TeamsChatsOpts { + return TeamsChatsOpts{ + Users: flags.UserFV, + + ExportCfg: makeExportCfgOpts(cmd), + + // populated contains the list of flags that appear in the + // command, according to pflags. Use this to differentiate + // between an "empty" and a "missing" value. + Populated: flags.GetPopulatedFlags(cmd), + } +} + +// ValidateTeamsChatsRestoreFlags checks common flags for correctness and interdependencies +func ValidateTeamsChatsRestoreFlags(backupID string, opts TeamsChatsOpts, isRestore bool) error { + if len(backupID) == 0 { + return clues.New("a backup ID is required") + } + + // restore isn't currently supported + if isRestore { + return clues.New("restore not supported") + } + + return nil +} + +// AddTeamsChatsFilter adds the scope of the provided values to the selector's +// filter set +func AddTeamsChatsFilter( + sel *selectors.TeamsChatsRestore, + v string, + f func(string) []selectors.TeamsChatsScope, +) { + if len(v) == 0 { + return + } + + sel.Filter(f(v)) +} + +// IncludeTeamsChatsRestoreDataSelectors builds the common data-selector +// inclusions for teamschats commands. +func IncludeTeamsChatsRestoreDataSelectors(ctx context.Context, opts TeamsChatsOpts) *selectors.TeamsChatsRestore { + users := opts.Users + + if len(opts.Users) == 0 { + users = selectors.Any() + } + + return selectors.NewTeamsChatsRestore(users) +} + +// FilterTeamsChatsRestoreInfoSelectors builds the common info-selector filters. +func FilterTeamsChatsRestoreInfoSelectors( + sel *selectors.TeamsChatsRestore, + opts TeamsChatsOpts, +) { + // TODO: populate when adding filters +}