diff --git a/pkg/accessreview/campaign_service.go b/pkg/accessreview/campaign_service.go index c96b65a06..88c6cc88d 100644 --- a/pkg/accessreview/campaign_service.go +++ b/pkg/accessreview/campaign_service.go @@ -65,7 +65,10 @@ func (s *Service) CreateCampaign( } if source.OrganizationID != campaign.OrganizationID { - return fmt.Errorf("cannot create campaign: access source %s does not belong to the same organization", sourceID) + return &CampaignSourceOrganizationMismatchError{ + Operation: "create", + SourceID: sourceID, + } } if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil { @@ -154,7 +157,11 @@ func (s *Service) UpdateCampaign( } if campaign.Status != coredata.AccessReviewCampaignStatusDraft { - return fmt.Errorf("cannot update campaign: status is %s, expected DRAFT", campaign.Status) + return &CampaignInvalidStatusError{ + Operation: "update", + Status: campaign.Status, + Expected: coredata.AccessReviewCampaignStatusDraft, + } } if req.Name != nil && *req.Name != nil { @@ -237,7 +244,11 @@ func (s *Service) AddCampaignSource( } if campaign.Status != coredata.AccessReviewCampaignStatusDraft { - return fmt.Errorf("cannot add scope source: campaign status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft) + return &CampaignInvalidStatusError{ + Operation: "add scope source", + Status: campaign.Status, + Expected: coredata.AccessReviewCampaignStatusDraft, + } } source := &coredata.AccessReviewSource{} @@ -246,7 +257,10 @@ func (s *Service) AddCampaignSource( } if source.OrganizationID != campaign.OrganizationID { - return fmt.Errorf("cannot add scope source: access source %q does not belong to the same organization", req.AccessReviewSourceID) + return &CampaignSourceOrganizationMismatchError{ + Operation: "add scope source", + SourceID: req.AccessReviewSourceID, + } } if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil { @@ -282,7 +296,11 @@ func (s *Service) RemoveCampaignSource( } if campaign.Status != coredata.AccessReviewCampaignStatusDraft { - return fmt.Errorf("cannot remove scope source: campaign status is %s, expected DRAFT", campaign.Status) + return &CampaignInvalidStatusError{ + Operation: "remove scope source", + Status: campaign.Status, + Expected: coredata.AccessReviewCampaignStatusDraft, + } } campaignSource := &coredata.AccessReviewCampaignSource{} @@ -330,10 +348,10 @@ func (s *Service) syncCampaignSources( } if source.OrganizationID != campaign.OrganizationID { - return fmt.Errorf( - "cannot update campaign: access source %s does not belong to the same organization", - sourceID, - ) + return &CampaignSourceOrganizationMismatchError{ + Operation: "update", + SourceID: sourceID, + } } if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil { @@ -390,7 +408,11 @@ func (s *Service) StartCampaign( } if campaign.Status != coredata.AccessReviewCampaignStatusDraft { - return fmt.Errorf("cannot start campaign: status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft) + return &CampaignInvalidStatusError{ + Operation: "start", + Status: campaign.Status, + Expected: coredata.AccessReviewCampaignStatusDraft, + } } var campaignSources coredata.AccessReviewCampaignSources @@ -399,7 +421,7 @@ func (s *Service) StartCampaign( } if len(campaignSources) == 0 { - return fmt.Errorf("cannot start campaign: no scope sources configured") + return ErrCampaignNoScopeSources } now := time.Now() diff --git a/pkg/accessreview/errors.go b/pkg/accessreview/errors.go new file mode 100644 index 000000000..4e559518a --- /dev/null +++ b/pkg/accessreview/errors.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package accessreview + +import ( + "errors" + "fmt" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +var ( + ErrCampaignNoScopeSources = errors.New("cannot start campaign: no scope sources configured") + ErrCampaignInvalidStatus = errors.New("campaign status does not allow this operation") + ErrCampaignSourceOrganizationMismatch = errors.New("access source does not belong to the same organization") +) + +type CampaignInvalidStatusError struct { + Operation string + Status coredata.AccessReviewCampaignStatus + Expected coredata.AccessReviewCampaignStatus +} + +func (e *CampaignInvalidStatusError) Error() string { + switch e.Operation { + case "add scope source", "remove scope source": + return fmt.Sprintf( + "cannot %s: campaign status is %s, expected %s", + e.Operation, + e.Status, + e.Expected, + ) + default: + return fmt.Sprintf( + "cannot %s campaign: status is %s, expected %s", + e.Operation, + e.Status, + e.Expected, + ) + } +} + +func (e *CampaignInvalidStatusError) Is(target error) bool { + return target == ErrCampaignInvalidStatus +} + +type CampaignSourceOrganizationMismatchError struct { + Operation string + SourceID gid.GID +} + +func (e *CampaignSourceOrganizationMismatchError) Error() string { + switch e.Operation { + case "create": + return fmt.Sprintf( + "cannot create campaign: access source %s does not belong to the same organization", + e.SourceID, + ) + case "update": + return fmt.Sprintf( + "cannot update campaign: access source %s does not belong to the same organization", + e.SourceID, + ) + default: + return fmt.Sprintf( + "cannot add scope source: access source %q does not belong to the same organization", + e.SourceID, + ) + } +} + +func (e *CampaignSourceOrganizationMismatchError) Is(target error) bool { + return target == ErrCampaignSourceOrganizationMismatch +} + +func IsCampaignClientError(err error) bool { + return errors.Is(err, ErrCampaignNoScopeSources) || + errors.Is(err, ErrCampaignInvalidStatus) || + errors.Is(err, ErrCampaignSourceOrganizationMismatch) +} diff --git a/pkg/accessreview/errors_test.go b/pkg/accessreview/errors_test.go new file mode 100644 index 000000000..683da4ddc --- /dev/null +++ b/pkg/accessreview/errors_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package accessreview + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +func TestCampaignClientErrors(t *testing.T) { + t.Parallel() + + statusErr := &CampaignInvalidStatusError{ + Operation: "start", + Status: coredata.AccessReviewCampaignStatusInProgress, + Expected: coredata.AccessReviewCampaignStatusDraft, + } + sourceErr := &CampaignSourceOrganizationMismatchError{ + Operation: "update", + SourceID: gid.GID("source-id"), + } + + tests := []struct { + name string + err error + want bool + }{ + { + name: "no scope sources", + err: ErrCampaignNoScopeSources, + want: true, + }, + { + name: "invalid status", + err: statusErr, + want: true, + }, + { + name: "wrapped invalid status", + err: fmt.Errorf("cannot start access review campaign: %w", statusErr), + want: true, + }, + { + name: "source organization mismatch", + err: sourceErr, + want: true, + }, + { + name: "internal error", + err: errors.New("cannot lock campaign: timeout"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, IsCampaignClientError(tt.err)) + }) + } +} diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index e4ebfa8a6..435ffc78a 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -9,7 +9,6 @@ import ( "context" "errors" "fmt" - "strings" "github.com/vikstrous/dataloadgen" "go.gearno.de/kit/log" @@ -725,6 +724,10 @@ func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input }, ) if err != nil { + if accessreview.IsCampaignClientError(err) { + return nil, gqlutils.Invalid(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot create access review campaign", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -757,7 +760,7 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input return nil, gqlutils.NotFound(ctx, err) } - if strings.HasPrefix(err.Error(), "cannot update campaign:") { + if accessreview.IsCampaignClientError(err) { return nil, gqlutils.Invalid(ctx, err) } @@ -802,7 +805,7 @@ func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID) if err != nil { - if strings.HasPrefix(err.Error(), "cannot start campaign:") { + if accessreview.IsCampaignClientError(err) { return nil, gqlutils.Invalid(ctx, err) }