diff --git a/src/pkg/selectors/exchange.go b/src/pkg/selectors/exchange.go index ad4a26a44..8b4770a93 100644 --- a/src/pkg/selectors/exchange.go +++ b/src/pkg/selectors/exchange.go @@ -168,7 +168,10 @@ func makeExchangeScope(granularity string, cat exchangeCategory, vs []string) Ex } func makeExchangeUserScope(user, granularity string, cat exchangeCategory, vs []string) ExchangeScope { - return makeExchangeScope(granularity, cat, vs).set(ExchangeUser, user) + es := makeExchangeScope(granularity, cat, vs).set(ExchangeUser, user) + es[scopeKeyResource] = user + es[scopeKeyDataType] = cat.dataType() + return es } // Produces one or more exchange contact scopes. @@ -288,6 +291,8 @@ func makeExchangeFilterScope(cat, filterCat exchangeCategory, vs []string) Excha scopeKeyGranularity: Filter, scopeKeyCategory: cat.String(), scopeKeyInfoFilter: filterCat.String(), + scopeKeyResource: Filter, + scopeKeyDataType: cat.dataType(), filterCat.String(): join(vs...), } } @@ -436,6 +441,19 @@ func exchangeCatAtoI(s string) exchangeCategory { } } +// exchangeDataType returns the human-readable name of the core data type. +// Ex: ExchangeContactFolder.dataType() => ExchangeContact.String() +// Ex: ExchangeEvent.dataType() => ExchangeEvent.String(). +func (ec exchangeCategory) dataType() string { + switch ec { + case ExchangeContact, ExchangeContactFolder: + return ExchangeContact.String() + case ExchangeMail, ExchangeMailFolder: + return ExchangeMail.String() + } + return ec.String() +} + // Granularity describes the granularity (directory || item) // of the data in scope func (s ExchangeScope) Granularity() string { diff --git a/src/pkg/selectors/selectors.go b/src/pkg/selectors/selectors.go index aab4c2ec4..eed621963 100644 --- a/src/pkg/selectors/selectors.go +++ b/src/pkg/selectors/selectors.go @@ -2,6 +2,7 @@ package selectors import ( "encoding/json" + "fmt" "strings" "github.com/pkg/errors" @@ -21,6 +22,8 @@ const ( scopeKeyCategory = "category" scopeKeyGranularity = "granularity" scopeKeyInfoFilter = "info_filter" + scopeKeyResource = "resource" + scopeKeyDataType = "type" ) // The granularity exprerssed by the scope. Groups imply non-item granularity, @@ -60,7 +63,7 @@ type Selector struct { Service service `json:"service,omitempty"` // The service scope of the data. Exchange, Teams, Sharepoint, etc. Excludes []map[string]string `json:"exclusions,omitempty"` // A slice of exclusion scopes. Exclusions apply globally to all inclusions/filters, with any-match behavior. Filters []map[string]string `json:"filters,omitempty"` // A slice of filter scopes. All inclusions must also match ALL filters. - Includes []map[string]string `json:"scopes,omitempty"` // A slice of inclusion scopes. Comparators must match either one of these, or all filters, to be included. + Includes []map[string]string `json:"includes,omitempty"` // A slice of inclusion scopes. Comparators must match either one of these, or all filters, to be included. } // helper for specific selector instance constructors. @@ -148,6 +151,94 @@ func appendIncludes[T baseScope]( } } +// --------------------------------------------------------------------------- +// Printing Selectors for Human Reading +// --------------------------------------------------------------------------- + +type Printable struct { + Service string `json:"service"` + Excludes map[string][]string `json:"excludes,omitempty"` + Filters map[string][]string `json:"filters,omitempty"` + Includes map[string][]string `json:"includes,omitempty"` +} + +// Printable is the minimized display of a selector, formatted for human readability. +// This transformer assumes that the scopeKeyResource and scopeKeyDataType have been +// added to all scopes as they were created. It is unable to infer resource or data +// type values from existing scope values. +func (s Selector) Printable() Printable { + return Printable{ + Service: s.Service.String(), + Excludes: toResourceTypeMap(s.Excludes), + Filters: toResourceTypeMap(s.Filters), + Includes: toResourceTypeMap(s.Includes), + } +} + +// Resources generates a tabular-readable output of the resources in Printable. +// Only the first (arbitrarily picked) resource is displayed. All others are +// simply counted. If no inclusions exist, uses Filters. If no filters exist, +// defaults to "All". +// Resource refers to the top-level entity in the service. User for Exchange, +// Site for sharepoint, etc. +func (p Printable) Resources() string { + s := resourcesShortFormat(p.Includes) + if len(s) == 0 { + s = resourcesShortFormat(p.Filters) + } + if len(s) == 0 { + s = "All" + } + return s +} + +// returns a string with the resources in the map. Shortened to the first resource key, +// plus, if more exist, " (len-1 more)" +func resourcesShortFormat(m map[string][]string) string { + var s string + for k := range m { + s = k + break + } + if len(s) > 0 && len(m) > 1 { + s = fmt.Sprintf("%s (%d more)", s, len(m)-1) + } + return s +} + +// Transforms the slice to a single map. +// Keys are each map's scopeKeyResource value. +// Values are the set of all scopeKeyDataTypes for a given resource. +func toResourceTypeMap(ms []map[string]string) map[string][]string { + if len(ms) == 0 { + return nil + } + r := make(map[string][]string) + for _, m := range ms { + res := m[scopeKeyResource] + if res == AnyTgt { + res = "All" + } + r[res] = addToSet(r[res], m[scopeKeyDataType]) + } + return r +} + +// returns [v] if set is empty, +// returns self if set contains v, +// appends v to self, otherwise. +func addToSet(set []string, v string) []string { + if len(set) == 0 { + return []string{v} + } + for _, s := range set { + if s == v { + return set + } + } + return append(set, v) +} + // --------------------------------------------------------------------------- // Destination // --------------------------------------------------------------------------- diff --git a/src/pkg/selectors/selectors_test.go b/src/pkg/selectors/selectors_test.go index c31154bdc..284c9782b 100644 --- a/src/pkg/selectors/selectors_test.go +++ b/src/pkg/selectors/selectors_test.go @@ -1,12 +1,41 @@ package selectors import ( + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) +const ( + user = "me@my.onmicrosoft.com" +) + +var ( + dataType = ExchangeEvent.String() +) + +func stubScope() map[string]string { + return map[string]string{ + ExchangeEvent.String(): AnyTgt, + ExchangeUser.String(): user, + scopeKeyCategory: dataType, + scopeKeyGranularity: Group, + scopeKeyResource: user, + scopeKeyDataType: dataType, + } +} + +func stubSelector() Selector { + return Selector{ + Service: ServiceExchange, + Excludes: []map[string]string{stubScope()}, + Filters: []map[string]string{stubScope()}, + Includes: []map[string]string{stubScope()}, + } +} + type SelectorSuite struct { suite.Suite } @@ -32,3 +61,93 @@ func (suite *SelectorSuite) TestExistingDestinationErr() { err := existingDestinationErr("foo", "bar") assert.Error(suite.T(), err) } + +func (suite *SelectorSuite) TestPrintable() { + t := suite.T() + + sel := stubSelector() + p := sel.Printable() + + assert.Equal(t, sel.Service.String(), p.Service) + assert.Equal(t, 1, len(p.Excludes)) + assert.Equal(t, 1, len(p.Filters)) + assert.Equal(t, 1, len(p.Includes)) +} + +func (suite *SelectorSuite) TestPrintable_IncludedResources() { + t := suite.T() + + sel := stubSelector() + p := sel.Printable() + res := p.Resources() + + assert.Equal(t, user, res, "resource should state only the user") + + sel.Includes = []map[string]string{ + stubScope(), + {scopeKeyResource: "smarf", scopeKeyDataType: dataType}, + {scopeKeyResource: "smurf", scopeKeyDataType: dataType}} + p = sel.Printable() + res = p.Resources() + + assert.True(t, strings.HasSuffix(res, "(2 more)"), "resource '"+res+"' should have (2 more) suffix") + + p.Includes = nil + res = p.Resources() + + assert.Equal(t, user, res, "resource on filters should state only the user") + + p.Filters = nil + res = p.Resources() + + assert.Equal(t, "All", res, "resource with no Includes or Filters should state All") +} + +func (suite *SelectorSuite) TestToResourceTypeMap() { + table := []struct { + name string + input []map[string]string + expect map[string][]string + }{ + { + name: "single scope", + input: []map[string]string{stubScope()}, + expect: map[string][]string{ + user: {dataType}, + }, + }, + { + name: "disjoint resources", + input: []map[string]string{ + stubScope(), + { + scopeKeyResource: "smarf", + scopeKeyDataType: dataType, + }, + }, + expect: map[string][]string{ + user: {dataType}, + "smarf": {dataType}, + }, + }, + { + name: "disjoint types", + input: []map[string]string{ + stubScope(), + { + scopeKeyResource: user, + scopeKeyDataType: "other", + }, + }, + expect: map[string][]string{ + user: {dataType, "other"}, + }, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + rtm := toResourceTypeMap(test.input) + assert.Equal(t, test.expect, rtm) + }) + } +}