introduce errs/core (#4897)

first step towards having a centralized set of error sentinels that can be passed around corso instead of re-using low level error sentinels like those found in the graph/errors.go file.

This PR works as a standalone, and only handles the lowest hanging fruit. SDK consumers will need to change their error enum references, but all api behavior should remain the same.

---

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

- [x]  No

#### Type of change

- [x] 🤖 Supportability/Tests

#### Issue(s)

* #4685

#### Test Plan

- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Keepers 2024-01-03 11:13:41 -07:00 committed by GitHub
parent be6cb13a7b
commit 971d874462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 301 additions and 137 deletions

View File

@ -20,11 +20,11 @@ import (
"github.com/alcionai/corso/src/pkg/backup" "github.com/alcionai/corso/src/pkg/backup"
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/repository" "github.com/alcionai/corso/src/pkg/repository"
"github.com/alcionai/corso/src/pkg/selectors" "github.com/alcionai/corso/src/pkg/selectors"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
"github.com/alcionai/corso/src/pkg/store" "github.com/alcionai/corso/src/pkg/store"
) )
@ -210,7 +210,7 @@ func genericCreateCommand(
err = bo.Run(ictx) err = bo.Run(ictx)
if err != nil { if err != nil {
if errors.Is(err, graph.ErrServiceNotEnabled) { if errors.Is(err, core.ErrServiceNotEnabled) {
logger.Ctx(ctx).Infow("service not enabled", logger.Ctx(ctx).Infow("service not enabled",
"resource_owner_id", bo.ResourceOwner.ID(), "resource_owner_id", bo.ResourceOwner.ID(),
"service", serviceName) "service", serviceName)

View File

@ -17,6 +17,7 @@ import (
bupMD "github.com/alcionai/corso/src/pkg/backup/metadata" bupMD "github.com/alcionai/corso/src/pkg/backup/metadata"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/filters" "github.com/alcionai/corso/src/pkg/filters"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
@ -170,7 +171,7 @@ func verifyBackupInputs(sels selectors.Selector, cachedIDs []string) error {
} }
if !filters.Contains(ids).Compare(sels.ID()) { if !filters.Contains(ids).Compare(sels.ID()) {
return clues.Stack(graph.ErrResourceOwnerNotFound). return clues.Stack(core.ErrResourceOwnerNotFound).
With("selector_protected_resource", sels.DiscreteOwner) With("selector_protected_resource", sels.DiscreteOwner)
} }

View File

@ -16,6 +16,7 @@ import (
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
@ -255,7 +256,7 @@ func (r resourceGetter) GetResourceIDAndNameFrom(
id, name, err = r.getter.GetIDAndName(ctx, owner, api.CallConfig{}) id, name, err = r.getter.GetIDAndName(ctx, owner, api.CallConfig{})
if err != nil { if err != nil {
if graph.IsErrUserNotFound(err) { if graph.IsErrUserNotFound(err) {
return nil, clues.Stack(graph.ErrResourceOwnerNotFound, err) return nil, clues.Stack(core.ErrResourceOwnerNotFound, err)
} }
if graph.IsErrResourceLocked(err) { if graph.IsErrResourceLocked(err) {
@ -266,7 +267,7 @@ func (r resourceGetter) GetResourceIDAndNameFrom(
} }
if len(id) == 0 || len(name) == 0 { if len(id) == 0 || len(name) == 0 {
return nil, clues.Stack(graph.ErrResourceOwnerNotFound) return nil, clues.Stack(core.ErrResourceOwnerNotFound)
} }
return idname.NewProvider(id, name), nil return idname.NewProvider(id, name), nil

View File

@ -6,6 +6,7 @@ import (
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
@ -27,7 +28,7 @@ func IsServiceEnabled(
} }
if graph.IsErrUserNotFound(err) { if graph.IsErrUserNotFound(err) {
return false, clues.Stack(graph.ErrResourceOwnerNotFound, err) return false, clues.Stack(core.ErrResourceOwnerNotFound, err)
} }
if graph.IsErrResourceLocked(err) { if graph.IsErrResourceLocked(err) {

View File

@ -28,6 +28,7 @@ import (
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/dttm" "github.com/alcionai/corso/src/pkg/dttm"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
@ -231,7 +232,7 @@ func (op *BackupOperation) Run(ctx context.Context) (err error) {
if !enabled { if !enabled {
// Return named error so that we can check for it in caller. // Return named error so that we can check for it in caller.
err = clues.Wrap(graph.ErrServiceNotEnabled, "service not enabled for backup") err = clues.Stack(core.ErrServiceNotEnabled)
op.Errors.Fail(err) op.Errors.Fail(err)
return err return err

View File

@ -27,11 +27,11 @@ import (
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/dttm" "github.com/alcionai/corso/src/pkg/dttm"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors" "github.com/alcionai/corso/src/pkg/selectors"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
"github.com/alcionai/corso/src/pkg/store" "github.com/alcionai/corso/src/pkg/store"
) )
@ -246,7 +246,7 @@ func (op *RestoreOperation) do(
} }
if !enabled { if !enabled {
return nil, clues.WrapWC(ctx, graph.ErrServiceNotEnabled, "service not enabled for restore") return nil, clues.StackWC(ctx, core.ErrServiceNotEnabled)
} }
pcfg := observe.ProgressCfg{ pcfg := observe.ProgressCfg{

106
src/pkg/errs/core/core.go Normal file
View File

@ -0,0 +1,106 @@
package core
import "errors"
// -----------------------------------------------------------------------------------------------
// core.Err sentinels are provided to maintain a reference of commonplace errors throughout Corso.
//
// The general idea is that these errors allow the repo (and consumers of its CLI and SDK apis)
// to communicate clearly about the central identity of an error (ie: its "core"), without leaking
// service-specific details and imports from low-level apis, clients, and other packages.
//
// In order to maintain sanity here, a couple rules should be followed.
//
// 1. Sentinels should have generic messages. No references to downstream concepts.
// Basic cleanliness here. Downstream references contaminate the sentinel purpose.
//
// 2. Maintain coarseness.
// We won't need a core.Err version of every lower-level error. Try, where possible,
// to group concepts into broad categories. Ex: prefer "resource not found" over
// "user not found" or "site not found".
//
// 3. Always Stack/Wrap core.Errs. Only once.
// `return core.ErrFoo` should be avoided. Also, if you're handling a error returned
// by some internal package, do your due diligence and make sure it isn't already
// identified by a core.Err at a lower level.
//
// 4. Stacking/Wrapping is the lowest layer's job.
// We prefer to returning sentinels at lower layers instead of parsing errors at
// higher layers. This ensures higher layers only need to run errors.Is and .As
// checks, without needing take on low-level error details.
//
// 5. Add comments to explain the sentinels.
// Future maintainers may not easily grok the intent behind an existing sentinel.
// Because we want to keep the error messages themselves small and clean, a short
// explanation in the comments, even a basic one, can help a lot.
//
// 6. This package gets more important at higher layers.
// The goal is to make life easier for layers that are the most detached from low-
// level and internal packages. The closer that code gets to those lower layers,
// the less important it is to strictly use this package. But since most errors
// bubble up to the SDK and CLI APIs, it is eventually a critical issue that we
// categorize our errors smartly for those end users.
// -----------------------------------------------------------------------------------------------
type Err struct {
msg string
}
func (e Err) Error() string {
return e.msg
}
var (
// currently we have no internal throttling controls. We only try to match
// external throttling requirements. This sentinel assumes that an external
// server has returned one or more throttling errors which has stopped
// operation progress.
ErrApplicationThrottled = &Err{msg: "application throttled"}
// about what it sounds like: we tried to look for a backup by ID, but the
// storage layer couldn't find anything for that ID.
ErrBackupNotFound = &Err{msg: "backup not found"}
// a catch-all for downstream api auth issues. doesn't matter which api.
ErrInsufficientAuthorization = &Err{msg: "insufficient authorization"}
// specifically for repository creation: if we tried to create a repo and
// it already exists with those credentials, we return this error.
ErrRepoAlreadyExists = &Err{msg: "repository already exists"}
// use this when a resource (user, etc; whatever owner is used to own the
// data in the given backup) is unable to be used for backup or restore.
// some nuance here: this is not the same as a broad-scale auth issue.
// it is also not the same as a "not found" issue. it's specific to
// cases where we can find the resource, and have authorization to access
// it, but are told by the external system that the resource is somehow
// unusable.
ErrResourceNotAccessible = &Err{msg: "resource not accesible"}
// use this when a resource (user, etc; whatever owner is used to own the
// data in the given backup) cannot be found in the system by the ID that
// the end user provided.
ErrResourceOwnerNotFound = &Err{msg: "resource owner not found"}
// a service is the set of application data within a given provider. eg:
// if m365 is the provider, then exchange is a service, so is oneDrive.
// this sentinel is used to indicate that the service in question is not
// accessible to the user. this is not the same as an auth error. more
// often its a license issue. as in: the tenant hasn't purchased the use
// of this service (but may have purchased the use of other services in
// the same provider).
ErrServiceNotEnabled = &Err{msg: "service not enabled"}
)
// As is a quality-of-life wrapper around errors.As, to retrieve the core.Err
// out of any arbitrary error.
func As(err error) (*Err, bool) {
if err == nil {
return nil, false
}
var (
ce *Err
ok = errors.As(err, &ce)
)
if !ok {
return nil, ok
}
return ce, ok
}

View File

@ -0,0 +1,91 @@
package core_test
import (
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/errs/core"
)
type ErrUnitSuite struct {
tester.Suite
}
func TestErrUnitSuite(t *testing.T) {
suite.Run(t, &ErrUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *ErrUnitSuite) TestAs() {
// shorthand reference for ease of reading
cErr := core.ErrApplicationThrottled
adHoc := &core.Err{}
table := []struct {
name string
err error
expectOK assert.BoolAssertionFunc
expectErr func(t *testing.T, ce *core.Err)
}{
{
name: "nil",
err: nil,
expectOK: assert.False,
expectErr: func(t *testing.T, ce *core.Err) {
assert.Nil(t, ce)
},
},
{
name: "non-matching",
err: assert.AnError,
expectOK: assert.False,
expectErr: func(t *testing.T, ce *core.Err) {
assert.Nil(t, ce)
},
},
{
name: "matching",
err: cErr,
expectOK: assert.True,
expectErr: func(t *testing.T, ce *core.Err) {
assert.Equal(t, cErr, ce)
},
},
{
name: "adHoc",
err: adHoc,
expectOK: assert.True,
expectErr: func(t *testing.T, ce *core.Err) {
assert.Equal(t, adHoc, ce)
},
},
{
name: "stacked",
err: clues.Stack(assert.AnError, cErr, assert.AnError),
expectOK: assert.True,
expectErr: func(t *testing.T, ce *core.Err) {
assert.Equal(t, cErr, ce)
},
},
{
name: "wrapped",
err: clues.Wrap(cErr, "wrapper"),
expectOK: assert.True,
expectErr: func(t *testing.T, ce *core.Err) {
assert.Equal(t, cErr, ce)
},
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
err, ok := core.As(test.err)
test.expectOK(t, ok)
test.expectErr(t, err)
})
}
}

View File

@ -3,35 +3,17 @@ package errs
import ( import (
"errors" "errors"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/repository" "github.com/alcionai/corso/src/pkg/repository"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
// expose enums, rather than errors, for Is checks. The enum should
// map to a specific internal error that can be used for the actual
// errors.Is comparison.
type errEnum string
const (
ApplicationThrottled errEnum = "application-throttled"
BackupNotFound errEnum = "backup-not-found"
InsufficientAuthorization errEnum = "insufficient-authorization"
RepoAlreadyExists errEnum = "repository-already-exists"
ResourceNotAccessible errEnum = "resource-not-accesible"
ResourceOwnerNotFound errEnum = "resource-owner-not-found"
ServiceNotEnabled errEnum = "service-not-enabled"
)
// map of enums to errors. We might want to re-use an enum for multiple // map of enums to errors. We might want to re-use an enum for multiple
// internal errors (ex: "ServiceNotEnabled" may exist in both graph and // internal errors.
// non-graph producers). var externalToInternal = map[*core.Err][]error{
var externalToInternal = map[errEnum][]error{ core.ErrBackupNotFound: {repository.ErrorBackupNotFound},
ApplicationThrottled: {graph.ErrApplicationThrottled}, core.ErrRepoAlreadyExists: {repository.ErrorRepoAlreadyExists},
BackupNotFound: {repository.ErrorBackupNotFound}, core.ErrResourceNotAccessible: {graph.ErrResourceLocked},
RepoAlreadyExists: {repository.ErrorRepoAlreadyExists},
ResourceNotAccessible: {graph.ErrResourceLocked},
ResourceOwnerNotFound: {graph.ErrResourceOwnerNotFound},
ServiceNotEnabled: {graph.ErrServiceNotEnabled},
} }
type ErrCheck func(error) bool type ErrCheck func(error) bool
@ -41,23 +23,27 @@ type ErrCheck func(error) bool
// many places of error handling, we primarily rely on error comparison // many places of error handling, we primarily rely on error comparison
// checks. This allows us to apply those comparison checks instead of relying // checks. This allows us to apply those comparison checks instead of relying
// only on sentinels. // only on sentinels.
var externalToInternalCheck = map[errEnum][]ErrCheck{ var externalToInternalCheck = map[*core.Err][]ErrCheck{
ApplicationThrottled: {graph.IsErrApplicationThrottled}, core.ErrApplicationThrottled: {graph.IsErrApplicationThrottled},
ResourceNotAccessible: {graph.IsErrResourceLocked}, core.ErrResourceNotAccessible: {graph.IsErrResourceLocked},
ResourceOwnerNotFound: {graph.IsErrItemNotFound}, core.ErrResourceOwnerNotFound: {graph.IsErrItemNotFound},
InsufficientAuthorization: {graph.IsErrInsufficientAuthorization}, core.ErrInsufficientAuthorization: {graph.IsErrInsufficientAuthorization},
} }
// Internal returns the internal errors and error checking functions which // Internal returns the internal errors and error checking functions which
// match to the public error enum. // match to the public error enum.
func Internal(enum errEnum) ([]error, []ErrCheck) { func Internal(ce *core.Err) ([]error, []ErrCheck) {
return externalToInternal[enum], externalToInternalCheck[enum] return externalToInternal[ce], externalToInternalCheck[ce]
} }
// Is checks if the provided error contains an internal error that matches // Is checks if the provided error contains an internal error that matches
// the public error category. // the public error category.
func Is(err error, enum errEnum) bool { func Is(err error, ce *core.Err) bool {
internalErrs, ok := externalToInternal[enum] if errors.Is(err, ce) {
return true
}
internalErrs, ok := externalToInternal[ce]
if ok { if ok {
for _, target := range internalErrs { for _, target := range internalErrs {
if errors.Is(err, target) { if errors.Is(err, target) {
@ -66,7 +52,7 @@ func Is(err error, enum errEnum) bool {
} }
} }
internalChecks, ok := externalToInternalCheck[enum] internalChecks, ok := externalToInternalCheck[ce]
if ok { if ok {
for _, check := range internalChecks { for _, check := range internalChecks {
if check(err) { if check(err) {

View File

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/repository" "github.com/alcionai/corso/src/pkg/repository"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
graphTD "github.com/alcionai/corso/src/pkg/services/m365/api/graph/testdata" graphTD "github.com/alcionai/corso/src/pkg/services/m365/api/graph/testdata"
@ -23,36 +24,24 @@ func TestErrUnitSuite(t *testing.T) {
func (suite *ErrUnitSuite) TestInternal_errs() { func (suite *ErrUnitSuite) TestInternal_errs() {
table := []struct { table := []struct {
get errEnum get *core.Err
expect []error expect []error
}{ }{
{ {
get: ApplicationThrottled, get: core.ErrRepoAlreadyExists,
expect: []error{graph.ErrApplicationThrottled},
},
{
get: RepoAlreadyExists,
expect: []error{repository.ErrorRepoAlreadyExists}, expect: []error{repository.ErrorRepoAlreadyExists},
}, },
{ {
get: BackupNotFound, get: core.ErrBackupNotFound,
expect: []error{repository.ErrorBackupNotFound}, expect: []error{repository.ErrorBackupNotFound},
}, },
{ {
get: ServiceNotEnabled, get: core.ErrResourceNotAccessible,
expect: []error{graph.ErrServiceNotEnabled},
},
{
get: ResourceOwnerNotFound,
expect: []error{graph.ErrResourceOwnerNotFound},
},
{
get: ResourceNotAccessible,
expect: []error{graph.ErrResourceLocked}, expect: []error{graph.ErrResourceLocked},
}, },
} }
for _, test := range table { for _, test := range table {
suite.Run(string(test.get), func() { suite.Run(test.get.Error(), func() {
// can't compare func signatures // can't compare func signatures
errs, _ := Internal(test.get) errs, _ := Internal(test.get)
assert.ElementsMatch(suite.T(), test.expect, errs) assert.ElementsMatch(suite.T(), test.expect, errs)
@ -62,57 +51,56 @@ func (suite *ErrUnitSuite) TestInternal_errs() {
func (suite *ErrUnitSuite) TestInternal_checks() { func (suite *ErrUnitSuite) TestInternal_checks() {
table := []struct { table := []struct {
get errEnum get *core.Err
err error err error
expectHasChecks assert.ValueAssertionFunc expectHasChecks assert.ValueAssertionFunc
expect assert.BoolAssertionFunc expect assert.BoolAssertionFunc
}{ }{
{ {
get: ApplicationThrottled, get: core.ErrApplicationThrottled,
err: graph.ErrApplicationThrottled, err: graphTD.ODataErr(string(graph.ApplicationThrottled)),
expectHasChecks: assert.NotEmpty, expectHasChecks: assert.NotEmpty,
expect: assert.True, expect: assert.True,
}, },
{ {
get: RepoAlreadyExists, get: core.ErrRepoAlreadyExists,
err: graph.ErrApplicationThrottled, err: graphTD.ODataErr(string(graph.ApplicationThrottled)),
expectHasChecks: assert.Empty, expectHasChecks: assert.Empty,
expect: assert.False, expect: assert.False,
}, },
{ {
get: BackupNotFound, get: core.ErrBackupNotFound,
err: repository.ErrorBackupNotFound, err: repository.ErrorBackupNotFound,
expectHasChecks: assert.Empty, expectHasChecks: assert.Empty,
expect: assert.False, expect: assert.False,
}, },
{ {
get: ServiceNotEnabled, get: core.ErrResourceOwnerNotFound,
err: graph.ErrServiceNotEnabled, err: graphTD.ODataErr(string(graph.ItemNotFound)),
expectHasChecks: assert.Empty,
expect: assert.False,
},
{
get: ResourceOwnerNotFound,
// won't match, checks itemNotFound, which isn't an error enum
err: graph.ErrResourceOwnerNotFound,
expectHasChecks: assert.NotEmpty, expectHasChecks: assert.NotEmpty,
expect: assert.False, expect: assert.True,
}, },
{ {
get: ResourceNotAccessible, get: core.ErrResourceOwnerNotFound,
err: graphTD.ODataErr(string(graph.ErrorItemNotFound)),
expectHasChecks: assert.NotEmpty,
expect: assert.True,
},
{
get: core.ErrResourceNotAccessible,
err: graph.ErrResourceLocked, err: graph.ErrResourceLocked,
expectHasChecks: assert.NotEmpty, expectHasChecks: assert.NotEmpty,
expect: assert.True, expect: assert.True,
}, },
{ {
get: InsufficientAuthorization, get: core.ErrInsufficientAuthorization,
err: graphTD.ODataErr(string(graph.AuthorizationRequestDenied)), err: graphTD.ODataErr(string(graph.AuthorizationRequestDenied)),
expectHasChecks: assert.NotEmpty, expectHasChecks: assert.NotEmpty,
expect: assert.True, expect: assert.True,
}, },
} }
for _, test := range table { for _, test := range table {
suite.Run(string(test.get), func() { suite.Run(test.get.Error(), func() {
t := suite.T() t := suite.T()
_, checks := Internal(test.get) _, checks := Internal(test.get)
@ -135,40 +123,28 @@ func (suite *ErrUnitSuite) TestInternal_checks() {
func (suite *ErrUnitSuite) TestIs() { func (suite *ErrUnitSuite) TestIs() {
table := []struct { table := []struct {
target errEnum target *core.Err
err error err error
}{ }{
{ {
target: ApplicationThrottled, target: core.ErrRepoAlreadyExists,
err: graph.ErrApplicationThrottled,
},
{
target: RepoAlreadyExists,
err: repository.ErrorRepoAlreadyExists, err: repository.ErrorRepoAlreadyExists,
}, },
{ {
target: BackupNotFound, target: core.ErrBackupNotFound,
err: repository.ErrorBackupNotFound, err: repository.ErrorBackupNotFound,
}, },
{ {
target: ServiceNotEnabled, target: core.ErrResourceNotAccessible,
err: graph.ErrServiceNotEnabled,
},
{
target: ResourceOwnerNotFound,
err: graph.ErrResourceOwnerNotFound,
},
{
target: ResourceNotAccessible,
err: graph.ErrResourceLocked, err: graph.ErrResourceLocked,
}, },
{ {
target: InsufficientAuthorization, target: core.ErrInsufficientAuthorization,
err: graphTD.ODataErr(string(graph.AuthorizationRequestDenied)), err: graphTD.ODataErr(string(graph.AuthorizationRequestDenied)),
}, },
} }
for _, test := range table { for _, test := range table {
suite.Run(string(test.target), func() { suite.Run(test.target.Error(), func() {
var ( var (
w = clues.Wrap(test.err, "wrap") w = clues.Wrap(test.err, "wrap")
s = clues.Stack(test.err) s = clues.Stack(test.err)

View File

@ -30,7 +30,7 @@ import (
type errorCode string type errorCode string
const ( const (
applicationThrottled errorCode = "ApplicationThrottled" ApplicationThrottled errorCode = "ApplicationThrottled"
// this authN error is a catch-all used by graph in a variety of cases: // this authN error is a catch-all used by graph in a variety of cases:
// users without licenses, bad jwts, missing account permissions, etc. // users without licenses, bad jwts, missing account permissions, etc.
AuthenticationError errorCode = "AuthenticationError" AuthenticationError errorCode = "AuthenticationError"
@ -43,7 +43,7 @@ const (
cannotOpenFileAttachment errorCode = "ErrorCannotOpenFileAttachment" cannotOpenFileAttachment errorCode = "ErrorCannotOpenFileAttachment"
emailFolderNotFound errorCode = "ErrorSyncFolderNotFound" emailFolderNotFound errorCode = "ErrorSyncFolderNotFound"
ErrorAccessDenied errorCode = "ErrorAccessDenied" ErrorAccessDenied errorCode = "ErrorAccessDenied"
errorItemNotFound errorCode = "ErrorItemNotFound" ErrorItemNotFound errorCode = "ErrorItemNotFound"
// This error occurs when an email is enumerated but retrieving it fails // This error occurs when an email is enumerated but retrieving it fails
// - we believe - due to it pre-dating mailbox creation. Possible explanations // - we believe - due to it pre-dating mailbox creation. Possible explanations
// are mailbox creation racing with email receipt or a similar issue triggered // are mailbox creation racing with email receipt or a similar issue triggered
@ -57,7 +57,7 @@ const (
// that doesn't exist. // that doesn't exist.
invalidUser errorCode = "ErrorInvalidUser" invalidUser errorCode = "ErrorInvalidUser"
invalidAuthenticationToken errorCode = "InvalidAuthenticationToken" invalidAuthenticationToken errorCode = "InvalidAuthenticationToken"
itemNotFound errorCode = "itemNotFound" ItemNotFound errorCode = "itemNotFound"
MailboxNotEnabledForRESTAPI errorCode = "MailboxNotEnabledForRESTAPI" MailboxNotEnabledForRESTAPI errorCode = "MailboxNotEnabledForRESTAPI"
malwareDetected errorCode = "malwareDetected" malwareDetected errorCode = "malwareDetected"
// nameAlreadyExists occurs when a request with // nameAlreadyExists occurs when a request with
@ -104,11 +104,10 @@ const (
LabelsSkippable = "skippable_errors" LabelsSkippable = "skippable_errors"
) )
// These errors are graph specific. That means they don't have a clear parallel in
// pkg/errs/core. If these errors need to trickle outward to non-m365 layers, we
// need to find a sufficiently coarse errs/core sentinel to use as transformation.
var ( var (
// ErrApplicationThrottled occurs if throttling retries are exhausted and completely
// fails out.
ErrApplicationThrottled = clues.New("application throttled")
// The folder or item was deleted between the time we identified // The folder or item was deleted between the time we identified
// it and when we tried to fetch data for it. // it and when we tried to fetch data for it.
ErrDeletedInFlight = clues.New("deleted in flight") ErrDeletedInFlight = clues.New("deleted in flight")
@ -131,18 +130,11 @@ var (
// This makes the resource inaccessible for any Corso operations. // This makes the resource inaccessible for any Corso operations.
ErrResourceLocked = clues.New("resource has been locked and must be unlocked by an administrator") ErrResourceLocked = clues.New("resource has been locked and must be unlocked by an administrator")
// 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")
ErrResourceOwnerNotFound = clues.New("resource owner not found in tenant")
ErrTokenExpired = clues.New("jwt token expired") ErrTokenExpired = clues.New("jwt token expired")
) )
func IsErrApplicationThrottled(err error) bool { func IsErrApplicationThrottled(err error) bool {
return errors.Is(err, ErrApplicationThrottled) || return parseODataErr(err).hasErrorCode(err, ApplicationThrottled)
parseODataErr(err).hasErrorCode(err, applicationThrottled)
} }
func IsErrAuthenticationError(err error) bool { func IsErrAuthenticationError(err error) bool {
@ -160,8 +152,8 @@ func IsErrDeletedInFlight(err error) bool {
if parseODataErr(err).hasErrorCode( if parseODataErr(err).hasErrorCode(
err, err,
errorItemNotFound, ErrorItemNotFound,
itemNotFound, ItemNotFound,
syncFolderNotFound) { syncFolderNotFound) {
return true return true
} }
@ -170,7 +162,7 @@ func IsErrDeletedInFlight(err error) bool {
} }
func IsErrItemNotFound(err error) bool { func IsErrItemNotFound(err error) bool {
return parseODataErr(err).hasErrorCode(err, itemNotFound, errorItemNotFound) return parseODataErr(err).hasErrorCode(err, ItemNotFound, ErrorItemNotFound)
} }
func IsErrInvalidDelta(err error) bool { func IsErrInvalidDelta(err error) bool {
@ -188,7 +180,7 @@ func IsErrQuotaExceeded(err error) bool {
func IsErrExchangeMailFolderNotFound(err error) bool { func IsErrExchangeMailFolderNotFound(err error) bool {
// Not sure if we can actually see a resourceNotFound error here. I've only // Not sure if we can actually see a resourceNotFound error here. I've only
// seen the latter two. // seen the latter two.
return parseODataErr(err).hasErrorCode(err, ResourceNotFound, errorItemNotFound, MailboxNotEnabledForRESTAPI) return parseODataErr(err).hasErrorCode(err, ResourceNotFound, ErrorItemNotFound, MailboxNotEnabledForRESTAPI)
} }
func IsErrUserNotFound(err error) bool { func IsErrUserNotFound(err error) bool {

View File

@ -79,7 +79,7 @@ func (suite *GraphErrorsUnitSuite) TestIsErrApplicationThrottled() {
}, },
{ {
name: "applicationThrottled oDataErr", name: "applicationThrottled oDataErr",
err: graphTD.ODataErr(string(applicationThrottled)), err: graphTD.ODataErr(string(ApplicationThrottled)),
expect: assert.True, expect: assert.True,
}, },
} }
@ -186,7 +186,7 @@ func (suite *GraphErrorsUnitSuite) TestIsErrDeletedInFlight() {
}, },
{ {
name: "not-found oDataErr", name: "not-found oDataErr",
err: graphTD.ODataErr(string(errorItemNotFound)), err: graphTD.ODataErr(string(ErrorItemNotFound)),
expect: assert.True, expect: assert.True,
}, },
{ {
@ -858,12 +858,12 @@ func (suite *GraphErrorsUnitSuite) TestIsErrItemNotFound() {
}, },
{ {
name: "item not found oDataErr", name: "item not found oDataErr",
err: graphTD.ODataErr(string(itemNotFound)), err: graphTD.ODataErr(string(ItemNotFound)),
expect: assert.True, expect: assert.True,
}, },
{ {
name: "error item not found oDataErr", name: "error item not found oDataErr",
err: graphTD.ODataErr(string(errorItemNotFound)), err: graphTD.ODataErr(string(ErrorItemNotFound)),
expect: assert.True, expect: assert.True,
}, },
} }

View File

@ -14,6 +14,7 @@ import (
"github.com/alcionai/corso/src/internal/events" "github.com/alcionai/corso/src/internal/events"
"github.com/alcionai/corso/src/internal/version" "github.com/alcionai/corso/src/internal/version"
"github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
) )
@ -120,7 +121,7 @@ func (hw httpWrapper) Request(
} }
if IsErrApplicationThrottled(err) { if IsErrApplicationThrottled(err) {
return nil, Stack(ctx, clues.Stack(ErrApplicationThrottled, err)) return nil, Stack(ctx, clues.Stack(core.ErrApplicationThrottled, err))
} }
var http2StreamErr http2.StreamError var http2StreamErr http2.StreamError

View File

@ -18,6 +18,7 @@ import (
"github.com/alcionai/corso/src/internal/common/idname" "github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/internal/events" "github.com/alcionai/corso/src/internal/events"
"github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/filters" "github.com/alcionai/corso/src/pkg/filters"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
@ -395,7 +396,7 @@ func (aw *adapterWrap) Send(
// those retries are well handled in middleware already. We want to ensure // those retries are well handled in middleware already. We want to ensure
// that the error gets wrapped with the appropriate sentinel here. // that the error gets wrapped with the appropriate sentinel here.
if IsErrApplicationThrottled(err) { if IsErrApplicationThrottled(err) {
return nil, clues.StackWC(ictx, ErrApplicationThrottled, err).WithTrace(1) return nil, clues.StackWC(ictx, core.ErrApplicationThrottled, err).WithTrace(1)
} }
// exit most errors without retry // exit most errors without retry

View File

@ -15,6 +15,7 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/common/str" "github.com/alcionai/corso/src/internal/common/str"
"github.com/alcionai/corso/src/internal/common/tform" "github.com/alcionai/corso/src/internal/common/tform"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
@ -204,7 +205,7 @@ func getGroupFromResponse(ctx context.Context, resp models.GroupCollectionRespon
vs := resp.GetValue() vs := resp.GetValue()
if len(vs) == 0 { if len(vs) == 0 {
return nil, clues.StackWC(ctx, graph.ErrResourceOwnerNotFound) return nil, clues.StackWC(ctx, core.ErrResourceOwnerNotFound)
} else if len(vs) > 1 { } else if len(vs) > 1 {
return nil, clues.StackWC(ctx, graph.ErrMultipleResultsMatchIdentifier) return nil, clues.StackWC(ctx, graph.ErrMultipleResultsMatchIdentifier)
} }

View File

@ -14,6 +14,7 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/internal/tester/tconfig"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
graphTD "github.com/alcionai/corso/src/pkg/services/m365/api/graph/testdata" graphTD "github.com/alcionai/corso/src/pkg/services/m365/api/graph/testdata"
@ -201,7 +202,7 @@ func (suite *GroupsIntgSuite) TestGroups_GetByID() {
name: "invalid id", name: "invalid id",
id: uuid.NewString(), id: uuid.NewString(),
expectErr: func(t *testing.T, err error) { expectErr: func(t *testing.T, err error) {
assert.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) assert.ErrorIs(t, err, core.ErrResourceOwnerNotFound, clues.ToCore(err))
}, },
}, },
{ {
@ -215,7 +216,7 @@ func (suite *GroupsIntgSuite) TestGroups_GetByID() {
name: "invalid displayName", name: "invalid displayName",
id: "jabberwocky", id: "jabberwocky",
expectErr: func(t *testing.T, err error) { expectErr: func(t *testing.T, err error) {
assert.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) assert.ErrorIs(t, err, core.ErrResourceOwnerNotFound, clues.ToCore(err))
}, },
}, },
} }

View File

@ -13,6 +13,7 @@ import (
"github.com/microsoftgraph/msgraph-sdk-go/sites" "github.com/microsoftgraph/msgraph-sdk-go/sites"
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
@ -153,7 +154,7 @@ func (c Sites) GetByID(
// a 404 when getting sites by ID returns an itemNotFound // a 404 when getting sites by ID returns an itemNotFound
// error code, instead of something more sensible. // error code, instead of something more sensible.
if graph.IsErrItemNotFound(err) { if graph.IsErrItemNotFound(err) {
err = clues.Stack(graph.ErrResourceOwnerNotFound, err) err = clues.Stack(core.ErrResourceOwnerNotFound, err)
} }
if graph.IsErrResourceLocked(err) { if graph.IsErrResourceLocked(err) {
@ -199,7 +200,7 @@ func (c Sites) GetByID(
// a 404 when getting sites by ID returns an itemNotFound // a 404 when getting sites by ID returns an itemNotFound
// error code, instead of something more sensible. // error code, instead of something more sensible.
if graph.IsErrItemNotFound(err) { if graph.IsErrItemNotFound(err) {
err = clues.Stack(graph.ErrResourceOwnerNotFound, err) err = clues.Stack(core.ErrResourceOwnerNotFound, err)
} }
if graph.IsErrResourceLocked(err) { if graph.IsErrResourceLocked(err) {

View File

@ -14,8 +14,8 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/internal/tester/tconfig"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
type SitesUnitSuite struct { type SitesUnitSuite struct {
@ -181,7 +181,7 @@ func (suite *SitesIntgSuite) TestSites_GetByID() {
name: "random id", name: "random id",
id: uuid.NewString() + "," + uuid.NewString(), id: uuid.NewString() + "," + uuid.NewString(),
expectErr: func(t *testing.T, err error) bool { expectErr: func(t *testing.T, err error) bool {
assert.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) assert.ErrorIs(t, err, core.ErrResourceOwnerNotFound, clues.ToCore(err))
return true return true
}, },
}, },
@ -213,7 +213,7 @@ func (suite *SitesIntgSuite) TestSites_GetByID() {
name: "well formed url, no sites match", name: "well formed url, no sites match",
id: modifiedSiteURL, id: modifiedSiteURL,
expectErr: func(t *testing.T, err error) bool { expectErr: func(t *testing.T, err error) bool {
assert.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) assert.ErrorIs(t, err, core.ErrResourceOwnerNotFound, clues.ToCore(err))
return true return true
}, },
}, },

View File

@ -13,6 +13,7 @@ import (
"github.com/alcionai/corso/src/internal/common/idname" "github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
@ -198,7 +199,7 @@ func EvaluateMailboxError(err error) error {
// must occur before MailFolderNotFound, due to overlapping cases. // must occur before MailFolderNotFound, due to overlapping cases.
if graph.IsErrUserNotFound(err) { if graph.IsErrUserNotFound(err) {
return clues.Stack(graph.ErrResourceOwnerNotFound, err) return clues.Stack(core.ErrResourceOwnerNotFound, err)
} }
if graph.IsErrResourceLocked(err) { if graph.IsErrResourceLocked(err) {

View File

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
graphTD "github.com/alcionai/corso/src/pkg/services/m365/api/graph/testdata" graphTD "github.com/alcionai/corso/src/pkg/services/m365/api/graph/testdata"
) )
@ -82,7 +83,7 @@ func (suite *UsersUnitSuite) TestEvaluateMailboxError() {
name: "mail inbox err - user not found", name: "mail inbox err - user not found",
err: graphTD.ODataErr(string(graph.RequestResourceNotFound)), err: graphTD.ODataErr(string(graph.RequestResourceNotFound)),
expect: func(t *testing.T, err error) { expect: func(t *testing.T, err error) {
assert.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) assert.ErrorIs(t, err, core.ErrResourceOwnerNotFound, clues.ToCore(err))
}, },
}, },
{ {

View File

@ -12,8 +12,8 @@ import (
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/internal/tester/tconfig"
"github.com/alcionai/corso/src/pkg/errs" "github.com/alcionai/corso/src/pkg/errs"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
type GroupsIntgSuite struct { type GroupsIntgSuite struct {
@ -108,8 +108,8 @@ func (suite *GroupsIntgSuite) TestGroupByID_notFound() {
group, err := suite.cli.GroupByID(ctx, uuid.NewString()) group, err := suite.cli.GroupByID(ctx, uuid.NewString())
require.Nil(t, group) require.Nil(t, group)
require.ErrorIs(t, err, graph.ErrResourceOwnerNotFound, clues.ToCore(err)) require.ErrorIs(t, err, core.ErrResourceOwnerNotFound, clues.ToCore(err))
require.True(t, errs.Is(err, errs.ResourceOwnerNotFound)) require.True(t, errs.Is(err, core.ErrResourceOwnerNotFound))
} }
func (suite *GroupsIntgSuite) TestGroups() { func (suite *GroupsIntgSuite) TestGroups() {

View File

@ -10,6 +10,7 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/common/str" "github.com/alcionai/corso/src/internal/common/str"
"github.com/alcionai/corso/src/internal/common/tform" "github.com/alcionai/corso/src/internal/common/tform"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
@ -87,7 +88,7 @@ func getAllSites(
sites, err := ga.GetAll(ctx, fault.New(true)) sites, err := ga.GetAll(ctx, fault.New(true))
if err != nil { if err != nil {
if clues.HasLabel(err, graph.LabelsNoSharePointLicense) { if clues.HasLabel(err, graph.LabelsNoSharePointLicense) {
return nil, clues.Stack(graph.ErrServiceNotEnabled, err) return nil, clues.Stack(core.ErrServiceNotEnabled, err)
} }
return nil, clues.Wrap(err, "retrieving sites") return nil, clues.Wrap(err, "retrieving sites")

View File

@ -14,6 +14,7 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/internal/tester/tconfig"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "github.com/alcionai/corso/src/pkg/services/m365/api/graph"
@ -145,7 +146,7 @@ func (suite *siteUnitSuite) TestGetAllSites() {
return mockGASites{nil, graph.Stack(ctx, odErr)} return mockGASites{nil, graph.Stack(ctx, odErr)}
}, },
expectErr: func(t *testing.T, err error) { expectErr: func(t *testing.T, err error) {
assert.ErrorIs(t, err, graph.ErrServiceNotEnabled, clues.ToCore(err)) assert.ErrorIs(t, err, core.ErrServiceNotEnabled, clues.ToCore(err))
}, },
}, },
{ {