add sharepoint details command (#1618)

## Description

Adds the `backup details sharepoint` command
to the CLI.  E2E support is already functional.
Additional filters will be added as needs are
identified.

## Type of change

- [x] 🌻 Feature

## Issue(s)

* #1614 

## Test Plan

- [x]  Unit test
This commit is contained in:
Keepers 2022-11-29 16:37:05 -07:00 committed by GitHub
parent f1ea464ad6
commit 2778cd567e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 549 additions and 65 deletions

View File

@ -453,8 +453,7 @@ func detailsExchangeCmd(cmd *cobra.Command, args []string) error {
return nil
}
// runDetailsExchangeCmd actually performs the lookup in backup details. Assumes
// len(backupID) > 0.
// runDetailsExchangeCmd actually performs the lookup in backup details.
func runDetailsExchangeCmd(
ctx context.Context,
r repository.BackupGetter,

View File

@ -90,7 +90,7 @@ func addOneDriveCommands(parent *cobra.Command) *cobra.Command {
c, fs = utils.AddCommand(parent, oneDriveListCmd())
fs.StringVar(&backupID,
"backup", "",
utils.BackupFN, "",
"ID of the backup to retrieve.")
case detailsCommand:
@ -344,8 +344,7 @@ func detailsOneDriveCmd(cmd *cobra.Command, args []string) error {
return nil
}
// runDetailsOneDriveCmd actually performs the lookup in backup details. Assumes
// len(backupID) > 0.
// runDetailsOneDriveCmd actually performs the lookup in backup details.
func runDetailsOneDriveCmd(
ctx context.Context,
r repository.BackupGetter,

View File

@ -1,6 +1,8 @@
package backup
import (
"context"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -12,6 +14,7 @@ import (
"github.com/alcionai/corso/src/internal/kopia"
"github.com/alcionai/corso/src/internal/model"
"github.com/alcionai/corso/src/pkg/backup"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/repository"
"github.com/alcionai/corso/src/pkg/selectors"
@ -23,7 +26,9 @@ import (
// ------------------------------------------------------------------------------------------------
var (
site []string
site []string
libraryPaths []string
libraryItems []string
sharepointData []string
)
@ -33,10 +38,10 @@ const (
)
const (
sharePointServiceCommand = "sharepoint"
sharePointServiceCommandCreateUseSuffix = "--site <siteId> | '" + utils.Wildcard + "'"
sharePointServiceCommandDeleteUseSuffix = "--backup <backupId>"
// sharePointServiceCommandDetailsUseSuffix = "--backup <backupId>"
sharePointServiceCommand = "sharepoint"
sharePointServiceCommandCreateUseSuffix = "--site <siteId> | '" + utils.Wildcard + "'"
sharePointServiceCommandDeleteUseSuffix = "--backup <backupId>"
sharePointServiceCommandDetailsUseSuffix = "--backup <backupId>"
)
const (
@ -54,9 +59,9 @@ corso backup create sharepoint --site '*'`
sharePointServiceCommandDeleteExamples = `# Delete SharePoint backup with ID 1234abcd-12ab-cd34-56de-1234abcd
corso backup delete sharepoint --backup 1234abcd-12ab-cd34-56de-1234abcd`
// sharePointServiceCommandDetailsExamples = `# Explore <site>'s files from backup 1234abcd-12ab-cd34-56de-1234abcd
//
// corso backup details sharepoint --backup 1234abcd-12ab-cd34-56de-1234abcd --site <site_id>`
sharePointServiceCommandDetailsExamples = `# Explore <site>'s files from backup 1234abcd-12ab-cd34-56de-1234abcd
corso backup details sharepoint --backup 1234abcd-12ab-cd34-56de-1234abcd --site <site_id>`
)
// called by backup.go to map parent subcommands to provider-specific handling.
@ -80,18 +85,45 @@ func addSharePointCommands(parent *cobra.Command) *cobra.Command {
fs.StringSliceVar(
&sharepointData,
utils.DataFN, nil,
"Select one or more types of data to backup: "+dataLibraries)
"Select one or more types of data to backup: "+dataLibraries+".")
options.AddOperationFlags(c)
case listCommand:
c, fs = utils.AddCommand(parent, sharePointListCmd(), utils.HideCommand())
fs.StringVar(&backupID,
"backup", "",
utils.BackupFN, "",
"ID of the backup to retrieve.")
// case detailsCommand:
// c, fs = utils.AddCommand(parent, sharePointDetailsCmd())
case detailsCommand:
c, fs = utils.AddCommand(parent, sharePointDetailsCmd())
c.Use = c.Use + " " + sharePointServiceCommandDetailsUseSuffix
c.Example = sharePointServiceCommandDetailsExamples
fs.StringVar(&backupID,
utils.BackupFN, "",
"ID of the backup to retrieve.")
cobra.CheckErr(c.MarkFlagRequired(utils.BackupFN))
// sharepoint hierarchy flags
fs.StringSliceVar(
&libraryPaths,
utils.LibraryFN, nil,
"Select backup details by Library name.")
fs.StringSliceVar(
&libraryItems,
utils.LibraryItemFN, nil,
"Select backup details by library item name or ID.")
// info flags
// fs.StringVar(
// &fileCreatedAfter,
// utils.FileCreatedAfterFN, "",
// "Select backup details for items created after this datetime.")
case deleteCommand:
c, fs = utils.AddCommand(parent, sharePointDeleteCmd(), utils.HideCommand())
@ -282,3 +314,93 @@ func deleteSharePointCmd(cmd *cobra.Command, args []string) error {
return nil
}
// ------------------------------------------------------------------------------------------------
// backup details
// ------------------------------------------------------------------------------------------------
// `corso backup details onedrive [<flag>...]`
func sharePointDetailsCmd() *cobra.Command {
return &cobra.Command{
Use: sharePointServiceCommand,
Short: "Shows the details of a M365 SharePoint service backup",
RunE: detailsSharePointCmd,
Args: cobra.NoArgs,
Example: sharePointServiceCommandDetailsExamples,
}
}
// lists the history of backup operations
func detailsSharePointCmd(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if utils.HasNoFlagsAndShownHelp(cmd) {
return nil
}
s, acct, err := config.GetStorageAndAccount(ctx, true, nil)
if err != nil {
return Only(ctx, err)
}
r, err := repository.Connect(ctx, acct, s, options.Control())
if err != nil {
return Only(ctx, errors.Wrapf(err, "Failed to connect to the %s repository", s.Provider))
}
defer utils.CloseRepo(ctx, r)
opts := utils.SharePointOpts{
Sites: site,
LibraryPaths: libraryPaths,
LibraryItems: libraryItems,
Populated: utils.GetPopulatedFlags(cmd),
}
ds, err := runDetailsSharePointCmd(ctx, r, backupID, opts)
if err != nil {
return Only(ctx, err)
}
if len(ds.Entries) == 0 {
Info(ctx, selectors.ErrorNoMatchingItems)
return nil
}
ds.PrintEntries(ctx)
return nil
}
// runDetailsSharePointCmd actually performs the lookup in backup details.
func runDetailsSharePointCmd(
ctx context.Context,
r repository.BackupGetter,
backupID string,
opts utils.SharePointOpts,
) (*details.Details, error) {
if err := utils.ValidateSharePointRestoreFlags(backupID, opts); err != nil {
return nil, err
}
d, _, err := r.BackupDetails(ctx, backupID)
if err != nil {
if errors.Is(err, kopia.ErrNotFound) {
return nil, errors.Errorf("no backup exists with the id %s", backupID)
}
return nil, errors.Wrap(err, "Failed to get backup details in the repository")
}
sel := selectors.NewSharePointRestore()
utils.IncludeSharePointRestoreDataSelectors(sel, opts)
utils.FilterSharePointRestoreInfoSelectors(sel, opts)
// if no selector flags were specified, get all data in the service.
if len(sel.Scopes()) == 0 {
sel.Include(sel.Sites(selectors.Any()))
}
return sel.Reduce(ctx, d), nil
}

View File

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/cli/utils/testdata"
"github.com/alcionai/corso/src/internal/tester"
)
@ -37,10 +38,10 @@ func (suite *SharePointSuite) TestAddSharePointCommands() {
"list sharepoint", listCommand, expectUse,
sharePointListCmd().Short, listSharePointCmd,
},
// {
// "details sharepoint", detailsCommand, expectUse + " " + sharePointServiceCommandDetailsUseSuffix,
// sharePointDetailsCmd().Short, detailsSharePointCmd,
// },
{
"details sharepoint", detailsCommand, expectUse + " " + sharePointServiceCommandDetailsUseSuffix,
sharePointDetailsCmd().Short, detailsSharePointCmd,
},
{
"delete sharepoint", deleteCommand, expectUse + " " + sharePointServiceCommandDeleteUseSuffix,
sharePointDeleteCmd().Short, deleteSharePointCmd,
@ -87,40 +88,40 @@ func (suite *SharePointSuite) TestValidateSharePointBackupCreateFlags() {
}
}
// func (suite *SharePointSuite) TestSharePointBackupDetailsSelectors() {
// ctx, flush := tester.NewContext()
// defer flush()
func (suite *SharePointSuite) TestSharePointBackupDetailsSelectors() {
ctx, flush := tester.NewContext()
defer flush()
// for _, test := range testdata.SharePointOptionDetailLookups {
// suite.T().Run(test.Name, func(t *testing.T) {
// output, err := runDetailsSharePointCmd(
// ctx,
// test.BackupGetter,
// "backup-ID",
// test.Opts,
// )
// assert.NoError(t, err)
for _, test := range testdata.SharePointOptionDetailLookups {
suite.T().Run(test.Name, func(t *testing.T) {
output, err := runDetailsSharePointCmd(
ctx,
test.BackupGetter,
"backup-ID",
test.Opts,
)
assert.NoError(t, err)
// assert.ElementsMatch(t, test.Expected, output.Entries)
// })
// }
// }
assert.ElementsMatch(t, test.Expected, output.Entries)
})
}
}
// func (suite *SharePointSuite) TestSharePointBackupDetailsSelectorsBadFormats() {
// ctx, flush := tester.NewContext()
// defer flush()
func (suite *SharePointSuite) TestSharePointBackupDetailsSelectorsBadFormats() {
ctx, flush := tester.NewContext()
defer flush()
// for _, test := range testdata.BadSharePointOptionsFormats {
// suite.T().Run(test.Name, func(t *testing.T) {
// output, err := runDetailsSharePointCmd(
// ctx,
// test.BackupGetter,
// "backup-ID",
// test.Opts,
// )
for _, test := range testdata.BadSharePointOptionsFormats {
suite.T().Run(test.Name, func(t *testing.T) {
output, err := runDetailsSharePointCmd(
ctx,
test.BackupGetter,
"backup-ID",
test.Opts,
)
// assert.Error(t, err)
// assert.Empty(t, output)
// })
// }
// }
assert.Error(t, err)
assert.Empty(t, output)
})
}
}

View File

@ -4,12 +4,21 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/cli/utils"
"github.com/alcionai/corso/src/pkg/selectors"
)
func (suite *ExchangeUtilsSuite) TestIncludeOneDriveRestoreDataSelectors() {
type OneDriveUtilsSuite struct {
suite.Suite
}
func TestOneDriveUtilsSuite(t *testing.T) {
suite.Run(t, new(OneDriveUtilsSuite))
}
func (suite *OneDriveUtilsSuite) TestIncludeOneDriveRestoreDataSelectors() {
var (
empty = []string{}
single = []string{"single"}

View File

@ -0,0 +1,97 @@
package utils
import (
"errors"
"github.com/alcionai/corso/src/pkg/selectors"
)
const (
LibraryItemFN = "library-item"
LibraryFN = "library"
)
type SharePointOpts struct {
Sites []string
LibraryItems []string
LibraryPaths []string
Populated PopulatedFlags
}
// ValidateSharePointRestoreFlags checks common flags for correctness and interdependencies
func ValidateSharePointRestoreFlags(backupID string, opts SharePointOpts) error {
if len(backupID) == 0 {
return errors.New("a backup ID is required")
}
// if _, ok := opts.Populated[FileCreatedAfterFN]; ok && !IsValidTimeFormat(opts.FileCreatedAfter) {
// return errors.New("invalid time format for created-after")
// }
return nil
}
// AddSharePointFilter adds the scope of the provided values to the selector's
// filter set
func AddSharePointFilter(
sel *selectors.SharePointRestore,
v string,
f func(string) []selectors.SharePointScope,
) {
if len(v) == 0 {
return
}
sel.Filter(f(v))
}
// IncludeSharePointRestoreDataSelectors builds the common data-selector
// inclusions for SharePoint commands.
func IncludeSharePointRestoreDataSelectors(
sel *selectors.SharePointRestore,
opts SharePointOpts,
) {
lp, ln := len(opts.LibraryPaths), len(opts.LibraryItems)
// only use the inclusion if either a path or item name
// is specified
if lp+ln == 0 {
return
}
if len(opts.Sites) == 0 {
opts.Sites = selectors.Any()
}
// either scope the request to a set of sites
if lp+ln == 0 {
sel.Include(sel.Sites(opts.Sites))
return
}
opts.LibraryPaths = trimFolderSlash(opts.LibraryPaths)
if ln == 0 {
opts.LibraryItems = selectors.Any()
}
containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.LibraryPaths)
if len(containsFolders) > 0 {
sel.Include(sel.LibraryItems(opts.Sites, containsFolders, opts.LibraryItems))
}
if len(prefixFolders) > 0 {
sel.Include(sel.LibraryItems(opts.Sites, prefixFolders, opts.LibraryItems, selectors.PrefixMatch()))
}
}
// FilterSharePointRestoreInfoSelectors builds the common info-selector filters.
func FilterSharePointRestoreInfoSelectors(
sel *selectors.SharePointRestore,
opts SharePointOpts,
) {
// AddSharePointFilter(sel, opts.FileCreatedAfter, sel.CreatedAfter)
}

View File

@ -0,0 +1,99 @@
package utils_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/cli/utils"
"github.com/alcionai/corso/src/pkg/selectors"
)
type SharePointUtilsSuite struct {
suite.Suite
}
func TestSharePointUtilsSuite(t *testing.T) {
suite.Run(t, new(SharePointUtilsSuite))
}
func (suite *ExchangeUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
var (
empty = []string{}
single = []string{"single"}
multi = []string{"more", "than", "one"}
containsOnly = []string{"contains"}
prefixOnly = []string{"/prefix"}
containsAndPrefix = []string{"contains", "/prefix"}
)
table := []struct {
name string
opts utils.SharePointOpts
expectIncludeLen int
}{
{
name: "no inputs",
opts: utils.SharePointOpts{
Sites: empty,
LibraryPaths: empty,
LibraryItems: empty,
},
expectIncludeLen: 0,
},
{
name: "single inputs",
opts: utils.SharePointOpts{
Sites: single,
LibraryPaths: single,
LibraryItems: single,
},
expectIncludeLen: 1,
},
{
name: "multi inputs",
opts: utils.SharePointOpts{
Sites: multi,
LibraryPaths: multi,
LibraryItems: multi,
},
expectIncludeLen: 1,
},
{
name: "library contains",
opts: utils.SharePointOpts{
Sites: empty,
LibraryPaths: containsOnly,
LibraryItems: empty,
},
expectIncludeLen: 1,
},
{
name: "library prefixes",
opts: utils.SharePointOpts{
Sites: empty,
LibraryPaths: prefixOnly,
LibraryItems: empty,
},
expectIncludeLen: 1,
},
{
name: "library prefixes and contains",
opts: utils.SharePointOpts{
Sites: empty,
LibraryPaths: containsAndPrefix,
LibraryItems: empty,
},
expectIncludeLen: 2,
},
}
for _, test := range table {
suite.T().Run(test.name, func(t *testing.T) {
sel := selectors.NewSharePointRestore()
// no return, mutates sel as a side effect
utils.IncludeSharePointRestoreDataSelectors(sel, test.opts)
assert.Len(t, sel.Includes, test.expectIncludeLen)
})
}
}

View File

@ -395,6 +395,94 @@ var (
}
)
type SharePointOptionsTest struct {
Name string
Opts utils.SharePointOpts
BackupGetter *MockBackupGetter
Expected []details.DetailsEntry
}
var (
// BadSharePointOptionsFormats contains SharePointOpts with flags that should
// cause errors about the format of the input flag. Mocks are configured to
// allow the system to run if it doesn't throw an error on formatting.
BadSharePointOptionsFormats = []SharePointOptionsTest{
// {
// Name: "BadFileCreatedBefore",
// Opts: utils.OneDriveOpts{
// FileCreatedBefore: "foo",
// Populated: utils.PopulatedFlags{
// utils.FileCreatedBeforeFN: struct{}{},
// },
// },
// },
// {
// Name: "EmptyFileCreatedBefore",
// Opts: utils.OneDriveOpts{
// FileCreatedBefore: "",
// Populated: utils.PopulatedFlags{
// utils.FileCreatedBeforeFN: struct{}{},
// },
// },
// },
}
// SharePointOptionDetailLookups contains flag inputs and expected results for
// some choice input patterns. This set is not exhaustive. All inputs and
// outputs are according to the data laid out in selectors/testdata. Mocks are
// configured to return the full dataset listed in selectors/testdata.
SharePointOptionDetailLookups = []SharePointOptionsTest{
{
Name: "AllLibraryItems",
Expected: testdata.SharePointLibraryItems,
Opts: utils.SharePointOpts{
LibraryPaths: selectors.Any(),
},
},
{
Name: "FolderPrefixMatch",
Expected: testdata.SharePointLibraryItems,
Opts: utils.SharePointOpts{
LibraryPaths: []string{testdata.SharePointLibraryFolder},
},
},
{
Name: "FolderPrefixMatchTrailingSlash",
Expected: testdata.SharePointLibraryItems,
Opts: utils.SharePointOpts{
LibraryPaths: []string{testdata.SharePointLibraryFolder + "/"},
},
},
{
Name: "FolderPrefixMatchTrailingSlash",
Expected: testdata.SharePointLibraryItems,
Opts: utils.SharePointOpts{
LibraryPaths: []string{testdata.SharePointLibraryFolder + "/"},
},
},
{
Name: "ShortRef",
Expected: []details.DetailsEntry{
testdata.SharePointLibraryItems[0],
testdata.SharePointLibraryItems[1],
},
Opts: utils.SharePointOpts{
LibraryItems: []string{
testdata.SharePointLibraryItems[0].ShortRef,
testdata.SharePointLibraryItems[1].ShortRef,
},
},
},
// {
// Name: "CreatedBefore",
// Expected: []details.DetailsEntry{testdata.SharePointLibraryItems[1]},
// Opts: utils.SharePointOpts{
// FileCreatedBefore: common.FormatTime(testdata.Time1.Add(time.Second)),
// },
// },
}
)
// MockBackupGetter implements the repo.BackupGetter interface and returns
// (selectors/testdata.GetDetailsSet(), nil, nil) when BackupDetails is called
// on the nil instance. If an instance is given or Backups is called returns an

View File

@ -352,12 +352,14 @@ func (i ExchangeInfo) Values() []string {
// SharePointInfo describes a sharepoint item
type SharePointInfo struct {
ItemType ItemType `json:"itemType,omitempty"`
ItemName string `json:"itemName,omitempty"`
Created time.Time `json:"created,omitempty"`
Modified time.Time `josn:"modified,omitempty"`
WebURL string `json:"webUrl,omitempty"`
Size int64 `json:"size,omitempty"`
Created time.Time `json:"created,omitempty"`
ItemName string `json:"itemName,omitempty"`
ItemType ItemType `json:"itemType,omitempty"`
Modified time.Time `josn:"modified,omitempty"`
Owner string `json:"owner,omitempty"`
ParentPath string `json:"parentPath"`
Size int64 `json:"size,omitempty"`
WebURL string `json:"webUrl,omitempty"`
}
// Headers returns the human-readable names of properties in a SharePointInfo
@ -374,13 +376,13 @@ func (i SharePointInfo) Values() []string {
// OneDriveInfo describes a oneDrive item
type OneDriveInfo struct {
ItemType ItemType `json:"itemType,omitempty"`
ParentPath string `json:"parentPath"`
ItemName string `json:"itemName"`
Size int64 `json:"size,omitempty"`
Owner string `json:"owner,omitempty"`
Created time.Time `json:"created,omitempty"`
ItemName string `json:"itemName"`
ItemType ItemType `json:"itemType,omitempty"`
Modified time.Time `json:"modified,omitempty"`
Owner string `json:"owner,omitempty"`
ParentPath string `json:"parentPath"`
Size int64 `json:"size,omitempty"`
}
// Headers returns the human-readable names of properties in a OneDriveInfo

View File

@ -228,6 +228,70 @@ var (
},
},
}
SharePointRootPath = mustParsePath("tenant-id/sharepoint/site-id/libraries/drives/foo/root:", false)
SharePointLibraryPath = mustAppendPath(SharePointRootPath, "library", false)
SharePointBasePath1 = mustAppendPath(SharePointLibraryPath, "a", false)
SharePointBasePath2 = mustAppendPath(SharePointLibraryPath, "b", false)
SharePointLibraryItemPath1 = mustAppendPath(SharePointLibraryPath, ItemName1, true)
SharePointLibraryItemPath2 = mustAppendPath(SharePointBasePath1, ItemName2, true)
SharePointLibraryItemPath3 = mustAppendPath(SharePointBasePath2, ItemName3, true)
SharePointLibraryFolder = stdpath.Join(SharePointLibraryPath.Folders()[3:]...)
SharePointParentLibrary1 = stdpath.Join(SharePointBasePath1.Folders()[3:]...)
SharePointParentLibrary2 = stdpath.Join(SharePointBasePath2.Folders()[3:]...)
SharePointLibraryItems = []details.DetailsEntry{
{
RepoRef: SharePointLibraryItemPath1.String(),
ShortRef: SharePointLibraryItemPath1.ShortRef(),
ParentRef: SharePointLibraryItemPath1.ToBuilder().Dir().ShortRef(),
ItemInfo: details.ItemInfo{
SharePoint: &details.SharePointInfo{
ItemType: details.SharePointItem,
ParentPath: SharePointLibraryFolder,
ItemName: SharePointLibraryItemPath1.Item() + "name",
Size: int64(23),
Owner: UserEmail1,
Created: Time2,
Modified: Time4,
},
},
},
{
RepoRef: SharePointLibraryItemPath2.String(),
ShortRef: SharePointLibraryItemPath2.ShortRef(),
ParentRef: SharePointLibraryItemPath2.ToBuilder().Dir().ShortRef(),
ItemInfo: details.ItemInfo{
SharePoint: &details.SharePointInfo{
ItemType: details.SharePointItem,
ParentPath: SharePointParentLibrary1,
ItemName: SharePointLibraryItemPath2.Item() + "name",
Size: int64(42),
Owner: UserEmail1,
Created: Time1,
Modified: Time3,
},
},
},
{
RepoRef: SharePointLibraryItemPath3.String(),
ShortRef: SharePointLibraryItemPath3.ShortRef(),
ParentRef: SharePointLibraryItemPath3.ToBuilder().Dir().ShortRef(),
ItemInfo: details.ItemInfo{
SharePoint: &details.SharePointInfo{
ItemType: details.SharePointItem,
ParentPath: SharePointParentLibrary2,
ItemName: SharePointLibraryItemPath3.Item() + "name",
Size: int64(19),
Owner: UserEmail2,
Created: Time2,
Modified: Time4,
},
},
},
}
)
func GetDetailsSet() *details.Details {
@ -249,6 +313,10 @@ func GetDetailsSet() *details.Details {
entries = append(entries, e)
}
for _, e := range SharePointLibraryItems {
entries = append(entries, e)
}
return &details.Details{
DetailsModel: details.DetailsModel{
Entries: entries,