Add GetUserInfo to services/m365 (#2991)

Adds an interface for GetUserInfo, which gets
corso-specific metadata for a single user.  Also
does some refactoring around discovery for
better interface consistency, both as variables
and as access layers.

---

#### Does this PR need a docs update or release note?

- [x]  No

#### Type of change

- [x] 🌻 Feature

#### Test Plan

- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Keepers 2023-04-06 18:36:50 -06:00 committed by GitHub
parent b953eb1bd5
commit 3cc29649ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 270 additions and 61 deletions

View File

@ -2,6 +2,7 @@ package backup
import (
"context"
"fmt"
"github.com/alcionai/clues"
"github.com/pkg/errors"
@ -169,6 +170,8 @@ func createExchangeCmd(cmd *cobra.Command, args []string) error {
return Only(ctx, clues.Wrap(err, "Failed to retrieve M365 users"))
}
fmt.Printf("\n-----\nINS %+v\n-----\n", ins)
selectorSet := []selectors.Selector{}
for _, discSel := range sel.SplitByResourceOwner(ins.IDs()) {

View File

@ -293,7 +293,7 @@ func (suite *PreparedBackupExchangeE2ESuite) SetupSuite() {
defer flush()
suite.m365UserID = tester.M365UserID(t)
suite.m365UserID = strings.ToLower(tester.M365UserID(t))
// init the repo first
suite.repo, err = repository.Initialize(ctx, suite.acct, suite.st, control.Options{})

View File

@ -207,7 +207,7 @@ func (suite *BackupDeleteOneDriveE2ESuite) SetupSuite() {
require.NoError(t, err, clues.ToCore(err))
var (
m365UserID = tester.M365UserID(t)
m365UserID = strings.ToLower(tester.M365UserID(t))
users = []string{m365UserID}
idToName = map[string]string{m365UserID: m365UserID}
nameToID = map[string]string{m365UserID: m365UserID}

View File

@ -159,7 +159,7 @@ func (suite *BackupDeleteSharePointE2ESuite) SetupSuite() {
require.NoError(t, err, clues.ToCore(err))
var (
m365SiteID = tester.M365SiteID(t)
m365SiteID = strings.ToLower(tester.M365SiteID(t))
sites = []string{m365SiteID}
idToName = map[string]string{m365SiteID: m365SiteID}
nameToID = map[string]string{m365SiteID: m365SiteID}

View File

@ -2,6 +2,7 @@ package restore_test
import (
"context"
"strings"
"testing"
"github.com/alcionai/clues"
@ -73,7 +74,7 @@ func (suite *RestoreExchangeE2ESuite) SetupSuite() {
}
suite.vpr, suite.cfgFP = tester.MakeTempTestConfigClone(t, force)
suite.m365UserID = tester.M365UserID(t)
suite.m365UserID = strings.ToLower(tester.M365UserID(t))
var (
users = []string{suite.m365UserID}

View File

@ -1,6 +1,10 @@
package common
import "golang.org/x/exp/maps"
import (
"strings"
"golang.org/x/exp/maps"
)
type IDNamer interface {
// the canonical id of the thing, generated and usable
@ -26,13 +30,13 @@ type IDsNames struct {
// IDOf returns the id associated with the given name.
func (in IDsNames) IDOf(name string) (string, bool) {
id, ok := in.NameToID[name]
id, ok := in.NameToID[strings.ToLower(name)]
return id, ok
}
// NameOf returns the name associated with the given id.
func (in IDsNames) NameOf(id string) (string, bool) {
name, ok := in.IDToName[id]
name, ok := in.IDToName[strings.ToLower(id)]
return name, ok
}

View File

@ -8,8 +8,8 @@ import (
"github.com/alcionai/corso/src/internal/common"
"github.com/alcionai/corso/src/internal/connector/discovery"
"github.com/alcionai/corso/src/internal/connector/discovery/api"
"github.com/alcionai/corso/src/internal/connector/exchange"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/onedrive"
"github.com/alcionai/corso/src/internal/connector/sharepoint"
"github.com/alcionai/corso/src/internal/connector/support"
@ -19,7 +19,6 @@ import (
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors"
)
@ -171,7 +170,7 @@ func verifyBackupInputs(sels selectors.Selector, siteIDs []string) error {
func checkServiceEnabled(
ctx context.Context,
au api.Users,
gi discovery.GetInfoer,
service path.ServiceType,
resource string,
) (bool, error) {
@ -180,14 +179,13 @@ func checkServiceEnabled(
return true, nil
}
_, info, err := discovery.User(ctx, au, resource)
info, err := gi.GetInfo(ctx, resource)
if err != nil {
return false, err
}
if _, ok := info.DiscoveredServices[service]; !ok {
logger.Ctx(ctx).Error("service not enabled")
return false, nil
if !info.ServiceEnabled(service) {
return false, clues.Wrap(graph.ErrServiceNotEnabled, "checking service access")
}
return true, nil

View File

@ -45,6 +45,18 @@ func newUserInfo() *UserInfo {
}
}
// ServiceEnabled returns true if the UserInfo has an entry for the
// service. If no entry exists, the service is assumed to not be enabled.
func (ui *UserInfo) ServiceEnabled(service path.ServiceType) bool {
if ui == nil || len(ui.DiscoveredServices) == 0 {
return false
}
_, ok := ui.DiscoveredServices[service]
return ok
}
// ---------------------------------------------------------------------------
// methods
// ---------------------------------------------------------------------------
@ -160,7 +172,6 @@ func (c Users) GetInfo(ctx context.Context, userID string) (*UserInfo, error) {
// TODO: OneDrive
_, err = c.stable.Client().UsersById(userID).MailFolders().Get(ctx, nil)
if err != nil {
if !graph.IsErrExchangeMailFolderNotFound(err) {
return nil, graph.Wrap(ctx, err, "getting user's mail folder")

View File

@ -20,13 +20,17 @@ type getter interface {
GetByID(context.Context, string) (models.Userable, error)
}
type getInfoer interface {
type GetInfoer interface {
GetInfo(context.Context, string) (*api.UserInfo, error)
}
type getWithInfoer interface {
getter
getInfoer
GetInfoer
}
type getAller interface {
GetAll(ctx context.Context, errs *fault.Bus) ([]models.Userable, error)
}
// ---------------------------------------------------------------------------
@ -48,25 +52,28 @@ func apiClient(ctx context.Context, acct account.Account) (api.Client, error) {
}
// ---------------------------------------------------------------------------
// api
// users
// ---------------------------------------------------------------------------
// Users fetches all users in the tenant.
func Users(
ctx context.Context,
acct account.Account,
ga getAller,
errs *fault.Bus,
) ([]models.Userable, error) {
client, err := apiClient(ctx, acct)
users, err := ga.GetAll(ctx, errs)
if err != nil {
return nil, err
return nil, clues.Wrap(err, "getting all users")
}
return client.Users().GetAll(ctx, errs)
return users, nil
}
// User fetches a single user's data.
func User(ctx context.Context, gwi getWithInfoer, userID string) (models.Userable, *api.UserInfo, error) {
func User(
ctx context.Context,
gwi getWithInfoer,
userID string,
) (models.Userable, *api.UserInfo, error) {
u, err := gwi.GetByID(ctx, userID)
if err != nil {
if graph.IsErrUserNotFound(err) {
@ -84,6 +91,25 @@ func User(ctx context.Context, gwi getWithInfoer, userID string) (models.Userabl
return u, ui, nil
}
// UserInfo produces extensible user info: metadata that is relevant
// or identified in Corso, but not in m365.
func UserInfo(
ctx context.Context,
gi GetInfoer,
userID string,
) (*api.UserInfo, error) {
ui, err := gi.GetInfo(ctx, userID)
if err != nil {
return nil, clues.Wrap(err, "getting user info")
}
return ui, nil
}
// ---------------------------------------------------------------------------
// sites
// ---------------------------------------------------------------------------
// Sites fetches all sharepoint sites in the tenant
func Sites(
ctx context.Context,

View File

@ -4,15 +4,18 @@ import (
"testing"
"github.com/alcionai/clues"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/connector/discovery"
"github.com/alcionai/corso/src/internal/connector/discovery/api"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/credentials"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
)
type DiscoveryIntegrationSuite struct {
@ -37,7 +40,13 @@ func (suite *DiscoveryIntegrationSuite) TestUsers() {
errs = fault.New(true)
)
users, err := discovery.Users(ctx, acct, errs)
creds, err := acct.M365Config()
require.NoError(t, err)
cli, err := api.NewClient(creds)
require.NoError(t, err)
users, err := discovery.Users(ctx, cli.Users(), errs)
assert.NoError(t, err, clues.ToCore(err))
ferrs := errs.Errors()
@ -47,9 +56,6 @@ func (suite *DiscoveryIntegrationSuite) TestUsers() {
}
func (suite *DiscoveryIntegrationSuite) TestUsers_InvalidCredentials() {
ctx, flush := tester.NewContext()
defer flush()
table := []struct {
name string
acct func(t *testing.T) account.Account
@ -72,25 +78,23 @@ func (suite *DiscoveryIntegrationSuite) TestUsers_InvalidCredentials() {
return a
},
},
{
name: "Empty Credentials",
acct: func(t *testing.T) account.Account {
// intentionally swallowing the error here
a, _ := account.NewAccount(account.ProviderM365)
return a
},
},
}
for _, test := range table {
suite.Run(test.name, func() {
var (
t = suite.T()
a = test.acct(t)
errs = fault.New(true)
)
ctx, flush := tester.NewContext()
defer flush()
users, err := discovery.Users(ctx, a, errs)
t := suite.T()
acct := test.acct(t)
creds, err := acct.M365Config()
require.NoError(t, err)
cli, err := api.NewClient(creds)
require.NoError(t, err)
users, err := discovery.Users(ctx, cli.Users(), fault.New(true))
assert.Empty(t, users, "returned some users")
assert.NotNil(t, err)
})
@ -166,3 +170,55 @@ func (suite *DiscoveryIntegrationSuite) TestSites_InvalidCredentials() {
})
}
}
func (suite *DiscoveryIntegrationSuite) TestUserInfo() {
t := suite.T()
acct := tester.NewM365Account(t)
userID := tester.M365UserID(t)
creds, err := acct.M365Config()
require.NoError(t, err)
cli, err := api.NewClient(creds)
require.NoError(t, err)
uapi := cli.Users()
table := []struct {
name string
user string
expect *api.UserInfo
}{
{
name: "standard test user",
user: userID,
expect: &api.UserInfo{
DiscoveredServices: map[path.ServiceType]struct{}{
path.ExchangeService: {},
path.OneDriveService: {},
},
},
},
{
name: "user does not exist",
user: uuid.NewString(),
expect: &api.UserInfo{
DiscoveredServices: map[path.ServiceType]struct{}{
path.OneDriveService: {}, // currently statically populated
},
},
},
}
for _, test := range table {
suite.Run(test.name, func() {
ctx, flush := tester.NewContext()
defer flush()
t := suite.T()
result, err := discovery.UserInfo(ctx, uapi, test.user)
require.NoError(t, err, clues.ToCore(err))
assert.Equal(t, test.expect, result)
})
}
}

View File

@ -61,6 +61,10 @@ var (
// https://learn.microsoft.com/en-us/graph/errors#code-property
ErrInvalidDelta = clues.New("invalid delta token")
// ErrServiceNotEnabled identifies that a resource owner does not have
// access to a given service.
ErrServiceNotEnabled = clues.New("service is not enabled for that resource owner")
// Timeout errors are identified for tracking the need to retry calls.
// Other delay errors, like throttling, are already handled by the
// graph client's built-in retries.

View File

@ -152,7 +152,7 @@ func getOwnerIDAndNameFrom(
// and populate with maps as a result. Only
// return owner, owner as a very last resort.
return owner, owner, nil
return "", "", clues.New("not found within tenant")
}
// createService constructor for graphService component

View File

@ -56,12 +56,14 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
ins common.IDsNames
expectID string
expectName string
expectErr require.ErrorAssertionFunc
}{
{
name: "nil ins",
owner: ownerID,
expectID: ownerID,
expectName: ownerID,
expectID: "",
expectName: "",
expectErr: require.Error,
},
{
name: "only id map with owner id",
@ -72,6 +74,7 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
},
expectID: ownerID,
expectName: ownerName,
expectErr: require.NoError,
},
{
name: "only name map with owner id",
@ -80,8 +83,9 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
IDToName: nil,
NameToID: nti,
},
expectID: ownerID,
expectName: ownerID,
expectID: "",
expectName: "",
expectErr: require.Error,
},
{
name: "only id map with owner name",
@ -90,8 +94,9 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
IDToName: itn,
NameToID: nil,
},
expectID: ownerName,
expectName: ownerName,
expectID: "",
expectName: "",
expectErr: require.Error,
},
{
name: "only name map with owner name",
@ -102,6 +107,7 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
},
expectID: ownerID,
expectName: ownerName,
expectErr: require.NoError,
},
{
name: "both maps with owner id",
@ -112,6 +118,7 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
},
expectID: ownerID,
expectName: ownerName,
expectErr: require.NoError,
},
{
name: "both maps with owner name",
@ -122,6 +129,7 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
},
expectID: ownerID,
expectName: ownerName,
expectErr: require.NoError,
},
{
name: "non-matching maps with owner id",
@ -130,8 +138,9 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
IDToName: map[string]string{"foo": "bar"},
NameToID: map[string]string{"fnords": "smarf"},
},
expectID: ownerID,
expectName: ownerID,
expectID: "",
expectName: "",
expectErr: require.Error,
},
{
name: "non-matching with owner name",
@ -140,8 +149,9 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
IDToName: map[string]string{"foo": "bar"},
NameToID: map[string]string{"fnords": "smarf"},
},
expectID: ownerName,
expectName: ownerName,
expectID: "",
expectName: "",
expectErr: require.Error,
},
}
for _, test := range table {
@ -152,7 +162,7 @@ func (suite *GraphConnectorUnitSuite) TestPopulateOwnerIDAndNamesFrom() {
)
id, name, err := gc.PopulateOwnerIDAndNamesFrom(test.owner, test.ins)
require.NoError(t, err, clues.ToCore(err))
test.expectErr(t, err, clues.ToCore(err))
assert.Equal(t, test.expectID, id)
assert.Equal(t, test.expectName, name)
})

View File

@ -10,16 +10,34 @@ import (
"github.com/alcionai/corso/src/internal/common"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/connector/discovery"
"github.com/alcionai/corso/src/internal/connector/discovery/api"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
)
// ServiceAccess is true if a resource owner is capable of
// accessing or utilizing the specified service.
type ServiceAccess struct {
Exchange bool
// TODO: onedrive, sharepoint
}
// ---------------------------------------------------------------------------
// Users
// ---------------------------------------------------------------------------
// User is the minimal information required to identify and display a user.
type User struct {
PrincipalName string
ID string
Name string
}
type UserInfo struct {
ServicesEnabled ServiceAccess
}
// UsersCompat returns a list of users in the specified M365 tenant.
// TODO(ashmrtn): Remove when upstream consumers of the SDK support the fault
// package.
@ -35,9 +53,13 @@ func UsersCompat(ctx context.Context, acct account.Account) ([]*User, error) {
}
// Users returns a list of users in the specified M365 tenant
// TODO: Implement paging support
func Users(ctx context.Context, acct account.Account, errs *fault.Bus) ([]*User, error) {
users, err := discovery.Users(ctx, acct, errs)
uapi, err := makeUserAPI(acct)
if err != nil {
return nil, clues.Wrap(err, "getting users").WithClues(ctx)
}
users, err := discovery.Users(ctx, uapi, errs)
if err != nil {
return nil, err
}
@ -47,7 +69,7 @@ func Users(ctx context.Context, acct account.Account, errs *fault.Bus) ([]*User,
for _, u := range users {
pu, err := parseUser(u)
if err != nil {
return nil, clues.Wrap(err, "parsing userable")
return nil, clues.Wrap(err, "formatting user data")
}
ret = append(ret, pu)
@ -103,8 +125,38 @@ func UsersMap(
return ins, nil
}
// UserInfo returns the corso-specific set of user metadata.
func GetUserInfo(
ctx context.Context,
acct account.Account,
userID string,
) (*UserInfo, error) {
uapi, err := makeUserAPI(acct)
if err != nil {
return nil, clues.Wrap(err, "getting user info").WithClues(ctx)
}
ui, err := discovery.UserInfo(ctx, uapi, userID)
if err != nil {
return nil, err
}
info := UserInfo{
ServicesEnabled: ServiceAccess{
Exchange: ui.ServiceEnabled(path.ExchangeService),
},
}
return &info, nil
}
// ---------------------------------------------------------------------------
// Sites
// ---------------------------------------------------------------------------
// Site is the minimal information required to identify and display a SharePoint site.
type Site struct {
// WebURL that displays the item in the browser
// WebURL is the url for the site, works as an alias for the user name.
WebURL string
// ID is of the format: <site collection hostname>.<site collection unique id>.<site unique id>
@ -173,3 +225,21 @@ func SitesMap(
return ins, nil
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func makeUserAPI(acct account.Account) (api.Users, error) {
creds, err := acct.M365Config()
if err != nil {
return api.Users{}, clues.Wrap(err, "getting m365 account creds")
}
cli, err := api.NewClient(creds)
if err != nil {
return api.Users{}, clues.Wrap(err, "constructing api client")
}
return cli.Users(), nil
}

View File

@ -1,14 +1,16 @@
package m365
package m365_test
import (
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365"
)
type M365IntegrationSuite struct {
@ -33,7 +35,7 @@ func (suite *M365IntegrationSuite) TestUsers() {
acct = tester.NewM365Account(suite.T())
)
users, err := Users(ctx, acct, fault.New(true))
users, err := m365.Users(ctx, acct, fault.New(true))
assert.NoError(t, err, clues.ToCore(err))
assert.NotEmpty(t, users)
@ -48,6 +50,30 @@ func (suite *M365IntegrationSuite) TestUsers() {
}
}
func (suite *M365IntegrationSuite) TestGetUserInfo() {
ctx, flush := tester.NewContext()
defer flush()
var (
t = suite.T()
acct = tester.NewM365Account(t)
uid = tester.M365UserID(t)
)
info, err := m365.GetUserInfo(ctx, acct, uid)
require.NoError(t, err, clues.ToCore(err))
require.NotNil(t, info)
require.NotEmpty(t, info)
expect := &m365.UserInfo{
ServicesEnabled: m365.ServiceAccess{
Exchange: true,
},
}
assert.Equal(t, expect, info)
}
func (suite *M365IntegrationSuite) TestSites() {
ctx, flush := tester.NewContext()
defer flush()
@ -57,7 +83,7 @@ func (suite *M365IntegrationSuite) TestSites() {
acct = tester.NewM365Account(suite.T())
)
sites, err := Sites(ctx, acct, fault.New(true))
sites, err := m365.Sites(ctx, acct, fault.New(true))
assert.NoError(t, err, clues.ToCore(err))
assert.NotEmpty(t, sites)