diff --git a/src/internal/operations/opstatus_string.go b/src/internal/operations/opstatus_string.go index 0468287b4..776120fb2 100644 --- a/src/internal/operations/opstatus_string.go +++ b/src/internal/operations/opstatus_string.go @@ -1,4 +1,4 @@ -// Code generated by "stringer -type=opStatus -linecomment"; DO NOT EDIT. +// Code generated by "stringer -type=OpStatus -linecomment"; DO NOT EDIT. package operations @@ -15,13 +15,13 @@ func _() { _ = x[NoData-4] } -const _opStatus_name = "Status UnknownIn ProgressCompletedFailedNo Data" +const _OpStatus_name = "Status UnknownIn ProgressCompletedFailedNo Data" -var _opStatus_index = [...]uint8{0, 14, 25, 34, 40, 47} +var _OpStatus_index = [...]uint8{0, 14, 25, 34, 40, 47} func (i OpStatus) String() string { - if i < 0 || i >= OpStatus(len(_opStatus_index)-1) { - return "opStatus(" + strconv.FormatInt(int64(i), 10) + ")" + if i < 0 || i >= OpStatus(len(_OpStatus_index)-1) { + return "OpStatus(" + strconv.FormatInt(int64(i), 10) + ")" } - return _opStatus_name[_opStatus_index[i]:_opStatus_index[i+1]] + return _OpStatus_name[_OpStatus_index[i]:_OpStatus_index[i+1]] } diff --git a/src/internal/tester/tconfig/config.go b/src/internal/tester/tconfig/config.go index fe8597da9..a900f26f2 100644 --- a/src/internal/tester/tconfig/config.go +++ b/src/internal/tester/tconfig/config.go @@ -26,6 +26,8 @@ const ( TestCfgSecondarySiteID = "secondarym365siteid" TestCfgSiteID = "m365siteid" TestCfgSiteURL = "m365siteurl" + TestCfgTeamID = "m365teamid" + TestCfgGroupID = "m365groupid" TestCfgUserID = "m365userid" TestCfgSecondaryUserID = "secondarym365userid" TestCfgTertiaryUserID = "tertiarym365userid" @@ -41,6 +43,8 @@ const ( EnvCorsoM365LoadTestOrgUsers = "CORSO_M365_LOAD_TEST_ORG_USERS" EnvCorsoM365TestSiteID = "CORSO_M365_TEST_SITE_ID" EnvCorsoM365TestSiteURL = "CORSO_M365_TEST_SITE_URL" + EnvCorsoM365TestTeamID = "CORSO_M365_TEST_TEAM_ID" + EnvCorsoM365TestGroupID = "CORSO_M365_TEST_GROUP_ID" EnvCorsoM365TestUserID = "CORSO_M365_TEST_USER_ID" EnvCorsoSecondaryM365TestSiteID = "CORSO_SECONDARY_M365_TEST_SITE_ID" EnvCorsoSecondaryM365TestUserID = "CORSO_SECONDARY_M365_TEST_USER_ID" @@ -150,6 +154,18 @@ func ReadTestConfig() (map[string]string, error) { os.Getenv(EnvCorsoM365TestSiteID), vpr.GetString(TestCfgSiteID), "4892edf5-2ebf-46be-a6e5-a40b2cbf1c1a,38ab6d06-fc82-4417-af93-22d8733c22be") + fallbackTo( + testEnv, + TestCfgTeamID, + os.Getenv(EnvCorsoM365TestTeamID), + vpr.GetString(TestCfgTeamID), + "6f24b40d-b13d-4752-980f-f5fb9fba7aa0") + fallbackTo( + testEnv, + TestCfgGroupID, + os.Getenv(EnvCorsoM365TestGroupID), + vpr.GetString(TestCfgGroupID), + "6f24b40d-b13d-4752-980f-f5fb9fba7aa0") fallbackTo( testEnv, TestCfgSiteURL, diff --git a/src/internal/tester/tconfig/protected_resources.go b/src/internal/tester/tconfig/protected_resources.go index bd2fded46..caac0c586 100644 --- a/src/internal/tester/tconfig/protected_resources.go +++ b/src/internal/tester/tconfig/protected_resources.go @@ -220,3 +220,29 @@ func UnlicensedM365UserID(t *testing.T) string { return strings.ToLower(cfg[TestCfgSecondaryUserID]) } + +// Teams + +// M365TeamsID returns a teamID string representing the m365TeamsID described +// by either the env var CORSO_M365_TEST_TEAM_ID, the corso_test.toml config +// file or the default value (in that order of priority). The default is a +// last-attempt fallback that will only work on alcion's testing org. +func M365TeamsID(t *testing.T) string { + cfg, err := ReadTestConfig() + require.NoError(t, err, "retrieving m365 team id from test configuration: %+v", clues.ToCore(err)) + + return strings.ToLower(cfg[TestCfgTeamID]) +} + +// Groups + +// M365GroupID returns a groupID string representing the m365GroupID described +// by either the env var CORSO_M365_TEST_GROUP_ID, the corso_test.toml config +// file or the default value (in that order of priority). The default is a +// last-attempt fallback that will only work on alcion's testing org. +func M365GroupID(t *testing.T) string { + cfg, err := ReadTestConfig() + require.NoError(t, err, "retrieving m365 group id from test configuration: %+v", clues.ToCore(err)) + + return strings.ToLower(cfg[TestCfgTeamID]) +} diff --git a/src/pkg/path/service_type.go b/src/pkg/path/service_type.go index 318de2c62..0028bca4b 100644 --- a/src/pkg/path/service_type.go +++ b/src/pkg/path/service_type.go @@ -29,6 +29,8 @@ const ( ExchangeMetadataService // exchangeMetadata OneDriveMetadataService // onedriveMetadata SharePointMetadataService // sharepointMetadata + GroupsService // groups + GroupsMetadataService // groupsMetadata ) func toServiceType(service string) ServiceType { diff --git a/src/pkg/path/servicetype_string.go b/src/pkg/path/servicetype_string.go index 6d6b960d8..6fa499364 100644 --- a/src/pkg/path/servicetype_string.go +++ b/src/pkg/path/servicetype_string.go @@ -15,11 +15,13 @@ func _() { _ = x[ExchangeMetadataService-4] _ = x[OneDriveMetadataService-5] _ = x[SharePointMetadataService-6] + _ = x[GroupsService-7] + _ = x[GroupsMetadataService-8] } -const _ServiceType_name = "UnknownServiceexchangeonedrivesharepointexchangeMetadataonedriveMetadatasharepointMetadata" +const _ServiceType_name = "UnknownServiceexchangeonedrivesharepointexchangeMetadataonedriveMetadatasharepointMetadatagroupsgroupsMetadata" -var _ServiceType_index = [...]uint8{0, 14, 22, 30, 40, 56, 72, 90} +var _ServiceType_index = [...]uint8{0, 14, 22, 30, 40, 56, 72, 90, 96, 110} func (i ServiceType) String() string { if i < 0 || i >= ServiceType(len(_ServiceType_index)-1) { diff --git a/src/pkg/services/m365/api/groups.go b/src/pkg/services/m365/api/groups.go new file mode 100644 index 000000000..c2a27dad3 --- /dev/null +++ b/src/pkg/services/m365/api/groups.go @@ -0,0 +1,205 @@ +package api + +import ( + "context" + + "github.com/alcionai/clues" + msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core" + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/common/str" + "github.com/alcionai/corso/src/internal/common/tform" + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/logger" +) + +const ( + teamsAdditionalDataLabel = "Team" + ResourceProvisioningOptions = "resourceProvisioningOptions" +) + +// --------------------------------------------------------------------------- +// controller +// --------------------------------------------------------------------------- + +func (c Client) Groups() Groups { + return Groups{c} +} + +// On creation of each Teams team a corrsponding group gets created. +// The group acts as the protected resource, and all teams data like events, +// drive and mail messages are owned by that group. + +// Groups is an interface-compliant provider of the client. +type Groups struct { + Client +} + +// GetAllGroups retrieves all groups. +func (c Groups) GetAll( + ctx context.Context, + errs *fault.Bus, +) ([]models.Groupable, error) { + service, err := c.Service() + if err != nil { + return nil, err + } + + return getGroups(ctx, errs, service) +} + +// GetTeams retrieves all Teams. +func (c Groups) GetTeams( + ctx context.Context, + errs *fault.Bus, +) ([]models.Groupable, error) { + service, err := c.Service() + if err != nil { + return nil, err + } + + groups, err := getGroups(ctx, errs, service) + if err != nil { + return nil, err + } + + return OnlyTeams(ctx, groups), nil +} + +// GetAll retrieves all groups. +func getGroups( + ctx context.Context, + errs *fault.Bus, + service graph.Servicer, +) ([]models.Groupable, error) { + resp, err := service.Client().Groups().Get(ctx, nil) + if err != nil { + return nil, graph.Wrap(ctx, err, "getting all groups") + } + + iter, err := msgraphgocore.NewPageIterator[models.Groupable]( + resp, + service.Adapter(), + models.CreateGroupCollectionResponseFromDiscriminatorValue) + if err != nil { + return nil, graph.Wrap(ctx, err, "creating groups iterator") + } + + var ( + groups = make([]models.Groupable, 0) + el = errs.Local() + ) + + iterator := func(item models.Groupable) bool { + if el.Failure() != nil { + return false + } + + err := ValidateGroup(item) + if err != nil { + el.AddRecoverable(ctx, graph.Wrap(ctx, err, "validating groups")) + } else { + groups = append(groups, item) + } + + return true + } + + if err := iter.Iterate(ctx, iterator); err != nil { + return nil, graph.Wrap(ctx, err, "iterating all groups") + } + + return groups, el.Failure() +} + +func OnlyTeams(ctx context.Context, groups []models.Groupable) []models.Groupable { + log := logger.Ctx(ctx) + + var teams []models.Groupable + + for _, g := range groups { + if g.GetAdditionalData()[ResourceProvisioningOptions] != nil { + val, _ := tform.AnyValueToT[[]any](ResourceProvisioningOptions, g.GetAdditionalData()) + for _, v := range val { + s, err := str.AnyToString(v) + if err != nil { + log.Debug("could not be converted to string value: ", ResourceProvisioningOptions) + continue + } + + if s == teamsAdditionalDataLabel { + teams = append(teams, g) + } + } + } + } + + return teams +} + +// GetID retrieves group by groupID. +func (c Groups) GetByID( + ctx context.Context, + identifier string, +) (models.Groupable, error) { + service, err := c.Service() + if err != nil { + return nil, err + } + + resp, err := service.Client().Groups().ByGroupId(identifier).Get(ctx, nil) + if err != nil { + err := graph.Wrap(ctx, err, "getting group by id") + + return nil, err + } + + return resp, graph.Stack(ctx, err).OrNil() +} + +// GetTeamByID retrieves group by groupID. +func (c Groups) GetTeamByID( + ctx context.Context, + identifier string, +) (models.Groupable, error) { + service, err := c.Service() + if err != nil { + return nil, err + } + + resp, err := service.Client().Groups().ByGroupId(identifier).Get(ctx, nil) + if err != nil { + err := graph.Wrap(ctx, err, "getting group by id") + + return nil, err + } + + groups := []models.Groupable{resp} + + if len(OnlyTeams(ctx, groups)) == 0 { + err := clues.New("given teamID is not related to any team") + + return nil, err + } + + return resp, graph.Stack(ctx, err).OrNil() +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// ValidateGroup ensures the item is a Groupable, and contains the necessary +// identifiers that we handle with all groups. +func ValidateGroup(item models.Groupable) error { + if item.GetId() == nil { + return clues.New("missing ID") + } + + if item.GetDisplayName() == nil { + return clues.New("missing display name") + } + + return nil +} diff --git a/src/pkg/services/m365/api/groups_test.go b/src/pkg/services/m365/api/groups_test.go new file mode 100644 index 000000000..8ce0f8f6b --- /dev/null +++ b/src/pkg/services/m365/api/groups_test.go @@ -0,0 +1,250 @@ +package api_test + +import ( + "testing" + + "github.com/alcionai/clues" + "github.com/google/uuid" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/services/m365/api" +) + +type GroupUnitSuite struct { + tester.Suite +} + +func TestGroupsUnitSuite(t *testing.T) { + suite.Run(t, &GroupUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *GroupUnitSuite) TestValidateGroup() { + group := models.NewGroup() + group.SetDisplayName(ptr.To("testgroup")) + group.SetId(ptr.To("testID")) + + tests := []struct { + name string + args models.Groupable + errCheck assert.ErrorAssertionFunc + errIsSkippable bool + }{ + { + name: "Valid group ", + args: func() *models.Group { + s := models.NewGroup() + s.SetId(ptr.To("id")) + s.SetDisplayName(ptr.To("testgroup")) + return s + }(), + errCheck: assert.NoError, + }, + { + name: "No name", + args: func() *models.Group { + s := models.NewGroup() + s.SetId(ptr.To("id")) + return s + }(), + errCheck: assert.Error, + }, + { + name: "No ID", + args: func() *models.Group { + s := models.NewGroup() + s.SetDisplayName(ptr.To("testgroup")) + return s + }(), + errCheck: assert.Error, + }, + } + + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + + err := api.ValidateGroup(test.args) + test.errCheck(t, err, clues.ToCore(err)) + + if test.errIsSkippable { + assert.ErrorIs(t, err, api.ErrKnownSkippableCase) + } + }) + } +} + +type GroupsIntgSuite struct { + tester.Suite + its intgTesterSetup +} + +func TestGroupsIntgSuite(t *testing.T) { + suite.Run(t, &GroupsIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *GroupsIntgSuite) SetupSuite() { + suite.its = newIntegrationTesterSetup(suite.T()) +} + +func (suite *GroupsIntgSuite) TestGetAllGroups() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + groups, err := suite.its.ac. + Groups(). + GetAll(ctx, fault.New(true)) + require.NoError(t, err) + require.NotZero(t, len(groups), "must have at least one group") +} + +func (suite *GroupsIntgSuite) TestGetAllTeams() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + teams, err := suite.its.ac. + Groups(). + GetTeams(ctx, fault.New(true)) + require.NoError(t, err) + require.NotZero(t, len(teams), "must have at least one teams") + + groups, err := suite.its.ac. + Groups(). + GetAll(ctx, fault.New(true)) + require.NoError(t, err) + require.NotZero(t, len(groups), "must have at least one group") + + var isTeam bool + + if len(groups) > len(teams) { + isTeam = true + } + + assert.True(t, isTeam, "must only return teams") +} + +func (suite *GroupsIntgSuite) TestTeams_GetByID() { + var ( + t = suite.T() + teamID = tconfig.M365TeamsID(t) + ) + + teamsAPI := suite.its.ac.Groups() + + table := []struct { + name string + id string + expectErr func(*testing.T, error) + }{ + { + name: "3 part id", + id: teamID, + expectErr: func(t *testing.T, err error) { + assert.NoError(t, err, clues.ToCore(err)) + }, + }, + { + name: "malformed id", + id: uuid.NewString(), + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, + { + name: "random id", + id: uuid.NewString() + "," + uuid.NewString(), + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, + + { + name: "malformed url", + id: "barunihlda", + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + _, err := teamsAPI.GetTeamByID(ctx, test.id) + test.expectErr(t, err) + }) + } +} + +func (suite *GroupsIntgSuite) TestGroups_GetByID() { + var ( + t = suite.T() + groupID = tconfig.M365GroupID(t) + ) + + groupsAPI := suite.its.ac.Groups() + + table := []struct { + name string + id string + expectErr func(*testing.T, error) + }{ + { + name: "3 part id", + id: groupID, + expectErr: func(t *testing.T, err error) { + assert.NoError(t, err, clues.ToCore(err)) + }, + }, + { + name: "malformed id", + id: uuid.NewString(), + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, + { + name: "random id", + id: uuid.NewString() + "," + uuid.NewString(), + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, + + { + name: "malformed url", + id: "barunihlda", + expectErr: func(t *testing.T, err error) { + assert.Error(t, err, clues.ToCore(err)) + }, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + _, err := groupsAPI.GetByID(ctx, test.id) + test.expectErr(t, err) + }) + } +} diff --git a/src/pkg/services/m365/api/helper_test.go b/src/pkg/services/m365/api/helper_test.go index 2f258ae3f..a9c12324f 100644 --- a/src/pkg/services/m365/api/helper_test.go +++ b/src/pkg/services/m365/api/helper_test.go @@ -83,6 +83,7 @@ type intgTesterSetup struct { siteID string siteDriveID string siteDriveRootFolderID string + teamID string } func newIntegrationTesterSetup(t *testing.T) intgTesterSetup { @@ -131,5 +132,13 @@ func newIntegrationTesterSetup(t *testing.T) intgTesterSetup { its.siteDriveRootFolderID = ptr.Val(siteDriveRootFolder.GetId()) + // teams + its.teamID = tconfig.M365TeamsID(t) + + team, err := its.ac.Groups().GetTeamByID(ctx, its.teamID) + require.NoError(t, err, clues.ToCore(err)) + + its.teamID = ptr.Val(team.GetId()) + return its }