Refactor access review campaign source API

Expose campaign sources as first-class nodes, paginate fetch attempts
instead of denormalized status fields, and bind entries to their
campaign snapshot. Update GraphQL, MCP, CLI, console, and e2e coverage
to match.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-15 14:42:58 +02:00
parent 82c9800677
commit eed6bf579d
25 changed files with 1078 additions and 692 deletions

View File

@@ -14,7 +14,6 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
@@ -55,29 +54,21 @@ func (r *accessReviewCampaignResolver) Sources(ctx context.Context, obj *types.A
campaignSources, err := r.accessReview.ListCampaignSources(ctx, scope, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot list campaign sources: %w", err))
}
r.logger.ErrorCtx(ctx, "cannot list campaign sources", log.Error(err))
attempts, err := r.accessReview.ListLatestFetchAttempts(ctx, scope, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot list latest fetch attempts: %w", err))
}
attemptByCampaignSourceID := make(map[gid.GID]*coredata.AccessReviewCampaignSourceFetchAttempt, len(attempts))
for _, attempt := range attempts {
attemptByCampaignSourceID[attempt.AccessReviewCampaignSourceID] = attempt
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.AccessReviewCampaignSource, len(campaignSources))
for i, campaignSource := range campaignSources {
result[i] = types.NewAccessReviewCampaignSource(campaignSource, attemptByCampaignSourceID[campaignSource.ID])
result[i] = types.NewAccessReviewCampaignSource(campaignSource)
}
return result, nil
}
// Entries is the resolver for the entries field.
func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, campaignSourceID *gid.GID, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) {
func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
@@ -97,21 +88,14 @@ func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.A
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var (
p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField]
)
if campaignSourceID != nil {
p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.ID, *campaignSourceID, cursor, filter)
} else {
p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter)
}
p, err := r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter)
if err != nil {
panic(fmt.Errorf("cannot list access entries: %w", err))
r.logger.ErrorCtx(ctx, "cannot list access entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewEntryConnection(p, r, obj.ID, campaignSourceID, filter), nil
return types.NewAccessReviewEntryConnection(p, r, obj.ID, nil, filter), nil
}
// PendingEntryCount is the resolver for the pendingEntryCount field.
@@ -123,7 +107,9 @@ func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, ob
count, err := r.accessReview.CountPendingEntriesForCampaignID(ctx, scope, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot count pending access entries: %w", err))
r.logger.ErrorCtx(ctx, "cannot count pending access entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
@@ -138,7 +124,9 @@ func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *type
stats, err := r.accessReview.CampaignStatistics(ctx, scope, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get campaign statistics: %w", err))
r.logger.ErrorCtx(ctx, "cannot get campaign statistics", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewStatistics(stats), nil
@@ -160,13 +148,38 @@ func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context,
case *organizationResolver:
count, err := r.accessReview.CountCampaignsForOrganizationID(ctx, scope, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count access review campaigns: %w", err))
r.logger.ErrorCtx(ctx, "cannot count access review campaigns", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
r.logger.ErrorCtx(ctx, "unsupported resolver", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
// Campaign is the resolver for the campaign field.
func (r *accessReviewCampaignSourceResolver) Campaign(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewCampaign, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.GetCampaign(ctx, scope, obj.Campaign.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewCampaign(campaign), nil
}
// Source is the resolver for the source field.
@@ -175,7 +188,7 @@ func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *ty
return nil, nil
}
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionCampaignGet)
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
@@ -186,35 +199,48 @@ func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *ty
return nil, nil
}
panic(fmt.Errorf("cannot get access review source: %w", err))
r.logger.ErrorCtx(ctx, "cannot get access review source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewSource(source), nil
}
// FetchAttempts is the resolver for the fetchAttempts field.
func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource) ([]*types.AccessReviewCampaignSourceFetchAttempt, error) {
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionCampaignGet)
func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignSourceFetchAttemptOrder) (*types.AccessReviewCampaignSourceFetchAttemptConnection, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
attempts, err := r.accessReview.ListFetchAttempts(ctx, scope, obj.ID)
pageOrderBy := page.OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]{
Field: coredata.AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.ListFetchAttemptsForCampaignSourceID(ctx, scope, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list fetch attempts: %w", err))
r.logger.ErrorCtx(ctx, "cannot list fetch attempts", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.AccessReviewCampaignSourceFetchAttempt, len(attempts))
for i, attempt := range attempts {
result[i] = types.NewAccessReviewCampaignSourceFetchAttempt(attempt)
}
return result, nil
return types.NewAccessReviewCampaignSourceFetchAttemptConnection(p, r, obj.ID), nil
}
// Entries is the resolver for the entries field.
func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) {
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionEntryList)
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
}
@@ -233,31 +259,52 @@ func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *t
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.CampaignID, obj.ID, cursor, filter)
p, err := r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.Campaign.ID, obj.ID, cursor, filter)
if err != nil {
panic(fmt.Errorf("cannot list access entries: %w", err))
r.logger.ErrorCtx(ctx, "cannot list access entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
sourceID := obj.ID
return types.NewAccessReviewEntryConnection(p, r, obj.CampaignID, &sourceID, filter), nil
return types.NewAccessReviewEntryConnection(p, r, obj.Campaign.ID, &sourceID, filter), nil
}
// Statistics is the resolver for the statistics field.
func (r *accessReviewCampaignSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewStatistics, error) {
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionEntryList)
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
}
stats, err := r.accessReview.CampaignSourceStatistics(ctx, scope, obj.CampaignID, obj.ID)
stats, err := r.accessReview.CampaignSourceStatistics(ctx, scope, obj.Campaign.ID, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get source statistics: %w", err))
r.logger.ErrorCtx(ctx, "cannot get source statistics", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewStatistics(stats), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *accessReviewCampaignSourceFetchAttemptConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewCampaignSourceFetchAttemptConnection) (int, error) {
scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionCampaignGet)
if err != nil {
return 0, err
}
count, err := r.accessReview.CountFetchAttemptsForCampaignSourceID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count fetch attempts", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// Campaign is the resolver for the campaign field.
func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaign, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
@@ -271,51 +318,33 @@ func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.Acc
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot get access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot get access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewCampaign(campaign), nil
}
// AccessReviewSource is the resolver for the accessReviewSource field.
func (r *accessReviewEntryResolver) AccessReviewSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewSource, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryGet)
// CampaignSource is the resolver for the campaignSource field.
func (r *accessReviewEntryResolver) CampaignSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaignSource, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
entry, err := r.accessReview.GetEntry(ctx, scope, obj.ID)
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, obj.CampaignSource.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot get access review entry: %w", err))
r.logger.ErrorCtx(ctx, "cannot get campaign source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, entry.AccessReviewCampaignSourceID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
panic(fmt.Errorf("cannot get campaign source: %w", err))
}
if campaignSource.AccessReviewSourceID == nil {
return nil, nil
}
source, err := r.accessReview.GetSource(ctx, scope, *campaignSource.AccessReviewSourceID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
panic(fmt.Errorf("cannot get access review source: %w", err))
}
return types.NewAccessReviewSource(source), nil
return types.NewAccessReviewCampaignSource(campaignSource), nil
}
// DecisionHistory is the resolver for the decisionHistory field.
@@ -327,7 +356,9 @@ func (r *accessReviewEntryResolver) DecisionHistory(ctx context.Context, obj *ty
histories, err := r.accessReview.EntryDecisionHistory(ctx, scope, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get decision history: %w", err))
r.logger.ErrorCtx(ctx, "cannot get decision history", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.AccessReviewEntryDecisionHistoryEntry, len(histories))
@@ -352,24 +383,28 @@ func (r *accessReviewEntryConnectionResolver) TotalCount(ctx context.Context, ob
switch obj.Resolver.(type) {
case *accessReviewCampaignResolver:
if obj.SourceID != nil {
count, err := r.accessReview.CountEntriesForCampaignIDAndSourceID(ctx, scope, obj.ParentID, *obj.SourceID, obj.Filter)
if err != nil {
panic(fmt.Errorf("cannot count access entries: %w", err))
}
return count, nil
}
count, err := r.accessReview.CountEntriesForCampaignID(ctx, scope, obj.ParentID, obj.Filter)
if err != nil {
panic(fmt.Errorf("cannot count access entries: %w", err))
r.logger.ErrorCtx(ctx, "cannot count access entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *accessReviewCampaignSourceResolver:
count, err := r.accessReview.CountEntriesForCampaignIDAndSourceID(ctx, scope, obj.ParentID, *obj.SourceID, obj.Filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count access entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
r.logger.ErrorCtx(ctx, "unsupported resolver", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
// Organization is the resolver for the organization field.
@@ -411,7 +446,9 @@ func (r *accessReviewSourceResolver) Connector(ctx context.Context, obj *types.A
return nil, nil
}
panic(fmt.Errorf("cannot get connector: %w", err))
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewConnector(connector), nil
@@ -477,7 +514,9 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj
return false, nil
}
panic(fmt.Errorf("cannot get connector: %w", err))
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return false, gqlutils.Internal(ctx)
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
@@ -536,7 +575,9 @@ func (r *accessReviewSourceResolver) SelectedOrganization(ctx context.Context, o
return nil, nil
}
panic(fmt.Errorf("cannot get connector: %w", err))
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
@@ -568,13 +609,17 @@ func (r *accessReviewSourceConnectionResolver) TotalCount(ctx context.Context, o
case *organizationResolver:
count, err := r.accessReview.CountSourcesForOrganizationID(ctx, scope, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count access sources: %w", err))
r.logger.ErrorCtx(ctx, "cannot count access sources", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
r.logger.ErrorCtx(ctx, "unsupported resolver", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
// CreateAccessReviewSource is the resolver for the createAccessReviewSource field.
@@ -584,15 +629,20 @@ func (r *mutationResolver) CreateAccessReviewSource(ctx context.Context, input t
return nil, err
}
source, err := r.accessReview.CreateSource(ctx, scope, accessreview.CreateAccessReviewSourceRequest{
OrganizationID: input.OrganizationID,
ConnectorID: input.ConnectorID,
Name: input.Name,
Category: coredata.AccessReviewSourceCategorySaaS,
CsvData: input.CSVData,
})
source, err := r.accessReview.CreateSource(
ctx,
scope,
accessreview.CreateAccessReviewSourceRequest{
OrganizationID: input.OrganizationID,
ConnectorID: input.ConnectorID,
Name: input.Name,
CsvData: input.CSVData,
},
)
if err != nil {
panic(fmt.Errorf("cannot create access source: %w", err))
r.logger.ErrorCtx(ctx, "cannot create access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateAccessReviewSourcePayload{
@@ -607,29 +657,24 @@ func (r *mutationResolver) UpdateAccessReviewSource(ctx context.Context, input t
return nil, err
}
req := accessreview.UpdateAccessReviewSourceRequest{
AccessReviewSourceID: input.AccessReviewSourceID,
}
if input.Name.IsSet() {
req.Name = input.Name.Value()
}
if input.ConnectorID.IsSet() {
req.ConnectorID = gqlutils.UnwrapOmittable(input.ConnectorID)
}
if input.CSVData.IsSet() {
req.CsvData = gqlutils.UnwrapOmittable(input.CSVData)
}
source, err := r.accessReview.UpdateSource(ctx, scope, req)
source, err := r.accessReview.UpdateSource(
ctx,
scope,
accessreview.UpdateAccessReviewSourceRequest{
AccessReviewSourceID: input.AccessReviewSourceID,
Name: gqlutils.UnwrapOmittable(input.Name),
ConnectorID: gqlutils.UnwrapOmittable(input.ConnectorID),
CsvData: gqlutils.UnwrapOmittable(input.CSVData),
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot update access source: %w", err))
r.logger.ErrorCtx(ctx, "cannot update access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UpdateAccessReviewSourcePayload{
@@ -649,7 +694,9 @@ func (r *mutationResolver) DeleteAccessReviewSource(ctx context.Context, input t
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot delete access source: %w", err))
r.logger.ErrorCtx(ctx, "cannot delete access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteAccessReviewSourcePayload{
@@ -677,7 +724,9 @@ func (r *mutationResolver) ConfigureAccessReviewSource(ctx context.Context, inpu
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot configure access source: %w", err))
r.logger.ErrorCtx(ctx, "cannot configure access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.ConfigureAccessReviewSourcePayload{
@@ -697,15 +746,21 @@ func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input
description = *input.Description
}
campaign, err := r.accessReview.CreateCampaign(ctx, scope, accessreview.CreateAccessReviewCampaignRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: description,
FrameworkControls: input.FrameworkControls,
AccessReviewSourceIDs: input.AccessReviewSourceIds,
})
campaign, err := r.accessReview.CreateCampaign(
ctx,
scope,
accessreview.CreateAccessReviewCampaignRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: description,
FrameworkControls: input.FrameworkControls,
AccessReviewSourceIDs: input.AccessReviewSourceIds,
},
)
if err != nil {
panic(fmt.Errorf("cannot create access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot create access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateAccessReviewCampaignPayload{
@@ -720,30 +775,24 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input
return nil, err
}
req := accessreview.UpdateAccessReviewCampaignRequest{
CampaignID: input.AccessReviewCampaignID,
}
if input.Name.IsSet() {
req.Name = input.Name.Value()
}
if input.Description.IsSet() {
req.Description = input.Description.Value()
}
if input.FrameworkControls.IsSet() {
controls := input.FrameworkControls.Value()
req.FrameworkControls = &controls
}
campaign, err := r.accessReview.UpdateCampaign(ctx, scope, req)
campaign, err := r.accessReview.UpdateCampaign(
ctx,
scope,
accessreview.UpdateAccessReviewCampaignRequest{
CampaignID: input.AccessReviewCampaignID,
Name: gqlutils.UnwrapOmittable(input.Name),
Description: gqlutils.UnwrapOmittable(input.Description),
FrameworkControls: gqlutils.UnwrapOmittable(input.FrameworkControls),
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot update access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot update access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UpdateAccessReviewCampaignPayload{
@@ -763,7 +812,9 @@ func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot delete access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot delete access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteAccessReviewCampaignPayload{
@@ -780,7 +831,9 @@ func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input
campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil {
panic(fmt.Errorf("cannot start access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot start access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.StartAccessReviewCampaignPayload{
@@ -797,7 +850,9 @@ func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input
campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil {
panic(fmt.Errorf("cannot close access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot close access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CloseAccessReviewCampaignPayload{
@@ -814,7 +869,9 @@ func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input
campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil {
panic(fmt.Errorf("cannot cancel access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot cancel access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CancelAccessReviewCampaignPayload{
@@ -829,12 +886,18 @@ func (r *mutationResolver) AddAccessReviewCampaignSource(ctx context.Context, in
return nil, err
}
campaign, err := r.accessReview.AddCampaignSource(ctx, scope, accessreview.AddCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
})
campaign, err := r.accessReview.AddCampaignSource(
ctx,
scope,
accessreview.AddCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
},
)
if err != nil {
panic(fmt.Errorf("cannot add scope source to access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot add scope source to access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AddAccessReviewCampaignSourcePayload{
@@ -849,12 +912,18 @@ func (r *mutationResolver) RemoveAccessReviewCampaignSource(ctx context.Context,
return nil, err
}
campaign, err := r.accessReview.RemoveCampaignSource(ctx, scope, accessreview.RemoveCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
})
campaign, err := r.accessReview.RemoveCampaignSource(
ctx,
scope,
accessreview.RemoveCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
},
)
if err != nil {
panic(fmt.Errorf("cannot remove scope source from access review campaign: %w", err))
r.logger.ErrorCtx(ctx, "cannot remove scope source from access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RemoveAccessReviewCampaignSourcePayload{
@@ -869,35 +938,24 @@ func (r *mutationResolver) RecordAccessReviewEntryDecision(ctx context.Context,
return nil, err
}
// Resolve the profile ID from the session's identity.
// The profile may not exist for every identity, in which
// case decided_by will be left nil.
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, fmt.Errorf("no identity in context")
}
req := accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: input.AccessReviewEntryID,
Decision: input.Decision,
DecisionNote: input.DecisionNote,
}
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessReviewEntryID)
if err == nil {
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
if err == nil {
req.DecidedByID = &profile.ID
}
}
entry, err := r.accessReview.RecordDecision(ctx, scope, req)
entry, err := r.accessReview.RecordDecision(
ctx,
scope,
accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: input.AccessReviewEntryID,
Decision: input.Decision,
DecisionNote: input.DecisionNote,
DecidedByID: authn.IdentityIDFromContext(ctx),
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot record access entry decision: %w", err))
r.logger.ErrorCtx(ctx, "cannot record access entry decision", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RecordAccessReviewEntryDecisionPayload{
@@ -933,29 +991,10 @@ func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context,
tenantID := input.Decisions[0].AccessReviewEntryID.TenantID()
scope := coredata.NewScope(tenantID)
// Cache profile lookups per organization so we resolve the correct
// decidedByID for each entry even when a batch spans multiple orgs.
profileCache := make(map[gid.GID]*gid.GID)
decidedByID := &identity.ID
decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions))
for i, d := range input.Decisions {
var decidedByID *gid.GID
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessReviewEntryID)
if err == nil {
if cached, ok := profileCache[organizationID]; ok {
decidedByID = cached
} else {
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
if err == nil {
decidedByID = &profile.ID
}
profileCache[organizationID] = decidedByID
}
}
decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: d.AccessReviewEntryID,
Decision: d.Decision,
@@ -970,7 +1009,9 @@ func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context,
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot record access entry decisions: %w", err))
r.logger.ErrorCtx(ctx, "cannot record access entry decisions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
accessEntries := make([]*types.AccessReviewEntry, len(entries))
@@ -1000,7 +1041,9 @@ func (r *mutationResolver) FlagAccessReviewEntry(ctx context.Context, input type
return nil, gqlutils.NotFound(ctx, err)
}
panic(fmt.Errorf("cannot flag access entry: %w", err))
r.logger.ErrorCtx(ctx, "cannot flag access entry", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.FlagAccessReviewEntryPayload{
@@ -1023,6 +1066,11 @@ func (r *Resolver) AccessReviewCampaignSource() schema.AccessReviewCampaignSourc
return &accessReviewCampaignSourceResolver{r}
}
// AccessReviewCampaignSourceFetchAttemptConnection returns schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver implementation.
func (r *Resolver) AccessReviewCampaignSourceFetchAttemptConnection() schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver {
return &accessReviewCampaignSourceFetchAttemptConnectionResolver{r}
}
// AccessReviewEntry returns schema.AccessReviewEntryResolver implementation.
func (r *Resolver) AccessReviewEntry() schema.AccessReviewEntryResolver {
return &accessReviewEntryResolver{r}
@@ -1046,6 +1094,7 @@ func (r *Resolver) AccessReviewSourceConnection() schema.AccessReviewSourceConne
type accessReviewCampaignResolver struct{ *Resolver }
type accessReviewCampaignConnectionResolver struct{ *Resolver }
type accessReviewCampaignSourceResolver struct{ *Resolver }
type accessReviewCampaignSourceFetchAttemptConnectionResolver struct{ *Resolver }
type accessReviewEntryResolver struct{ *Resolver }
type accessReviewEntryConnectionResolver struct{ *Resolver }
type accessReviewSourceResolver struct{ *Resolver }

View File

@@ -394,6 +394,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewAccessReviewCampaign(campaign), nil
}
case coredata.AccessReviewCampaignSourceEntityType:
action = accessreview.ActionCampaignGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewAccessReviewCampaignSource(campaignSource), nil
}
case coredata.AccessReviewSourceEntityType:
action = accessreview.ActionSourceGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {

View File

@@ -24,28 +24,6 @@ enum AccessReviewCampaignStatus
)
}
enum AccessReviewSourceCategory
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategory"
) {
SAAS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySaaS"
)
CLOUD_INFRA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryCloudInfra"
)
SOURCE_CODE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySourceCode"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryOther"
)
}
enum AccessReviewCampaignSourceFetchStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatus"
@@ -251,6 +229,21 @@ enum AccessReviewEntryOrderField
CREATED_AT
}
enum AccessReviewCampaignSourceFetchAttemptOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchAttemptOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt"
)
}
input AccessReviewCampaignSourceFetchAttemptOrder {
direction: OrderDirection!
field: AccessReviewCampaignSourceFetchAttemptOrderField!
}
enum AccessReviewSourceConnectionStatus {
CONNECTED
DISCONNECTED
@@ -307,10 +300,7 @@ type AccessReviewSource implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessReviewCampaignSourceFetchAttempt
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttempt"
) {
type AccessReviewCampaignSourceFetchAttempt {
id: ID!
attemptNumber: Int!
status: AccessReviewCampaignSourceFetchStatus!
@@ -322,25 +312,22 @@ type AccessReviewCampaignSourceFetchAttempt
updatedAt: Datetime!
}
type AccessReviewCampaignSource
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSource"
) {
type AccessReviewCampaignSource implements Node {
id: ID!
campaign: AccessReviewCampaign! @goField(forceResolver: true)
sourceId: ID
"The live access source this snapshot was taken from. Null once the source is deleted."
source: AccessReviewSource @goField(forceResolver: true)
name: String!
"Current fetch status, derived from the latest fetch attempt."
fetchStatus: AccessReviewCampaignSourceFetchStatus!
fetchedAccountsCount: Int!
attemptCount: Int!
"Generic, user-facing error from the latest attempt. Raw errors stay in logs."
lastError: String
fetchStartedAt: Datetime
fetchCompletedAt: Datetime
"Full append-only history of fetch attempts, most recent first."
fetchAttempts: [AccessReviewCampaignSourceFetchAttempt!]! @goField(forceResolver: true)
fetchAttempts(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessReviewCampaignSourceFetchAttemptOrder
): AccessReviewCampaignSourceFetchAttemptConnection!
@goField(forceResolver: true)
entries(
first: Int
@@ -374,7 +361,6 @@ type AccessReviewCampaign implements Node {
last: Int
before: CursorKey
orderBy: AccessReviewEntryOrder
campaignSourceId: ID
filter: AccessReviewEntryFilter
): AccessReviewEntryConnection! @goField(forceResolver: true)
@@ -388,8 +374,7 @@ type AccessReviewCampaign implements Node {
type AccessReviewEntry implements Node {
id: ID!
campaign: AccessReviewCampaign! @goField(forceResolver: true)
"The live access source this entry came from. Null once the source is deleted."
accessReviewSource: AccessReviewSource @goField(forceResolver: true)
campaignSource: AccessReviewCampaignSource! @goField(forceResolver: true)
email: String!
fullName: String!
role: String!
@@ -468,6 +453,20 @@ type AccessReviewEntryEdge {
node: AccessReviewEntry!
}
type AccessReviewCampaignSourceFetchAttemptConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttemptConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessReviewCampaignSourceFetchAttemptEdge!]!
pageInfo: PageInfo!
}
type AccessReviewCampaignSourceFetchAttemptEdge {
cursor: CursorKey!
node: AccessReviewCampaignSourceFetchAttempt!
}
type AccessReviewStatistics {
totalCount: Int!
decisionCounts: [AccessReviewEntryDecisionCount!]!

View File

@@ -15,17 +15,16 @@
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField]
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField]
AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField]
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField]
AccessReviewCampaignSourceFetchAttemptOrderBy OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]
AccessReviewSourceConnection struct {
TotalCount int
@@ -55,6 +54,15 @@ type (
SourceID *gid.GID
Filter *coredata.AccessReviewEntryFilter
}
AccessReviewCampaignSourceFetchAttemptConnection struct {
TotalCount int
Edges []*AccessReviewCampaignSourceFetchAttemptEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
// AccessReviewSource helpers
@@ -100,53 +108,37 @@ func NewAccessReviewSource(s *coredata.AccessReviewSource) *AccessReviewSource {
}
}
// NewAccessReviewCampaignSource builds the GraphQL scope source from a
// campaign source snapshot. The current fetch state is derived from the latest
// fetch attempt (nil when the source has never been fetched). The live access
// source is resolved lazily via the source field resolver from SourceID.
// NewAccessReviewCampaignSource builds the GraphQL campaign source from a
// snapshot row. Fetch state is resolved lazily via field resolvers on
// AccessReviewCampaignSource. The live access source is resolved via the
// source field resolver from SourceID.
func NewAccessReviewCampaignSource(
campaignSource *coredata.AccessReviewCampaignSource,
latestAttempt *coredata.AccessReviewCampaignSourceFetchAttempt,
) *AccessReviewCampaignSource {
status := coredata.AccessReviewCampaignSourceFetchStatusQueued
fetchedAccountsCount := 0
attemptCount := 0
var (
lastError *string
fetchStartedAt *time.Time
fetchCompletedAt *time.Time
)
if latestAttempt != nil {
status = latestAttempt.Status
fetchedAccountsCount = latestAttempt.FetchedAccountsCount
attemptCount = latestAttempt.AttemptNumber
lastError = latestAttempt.Error
fetchStartedAt = latestAttempt.StartedAt
fetchCompletedAt = latestAttempt.CompletedAt
}
return &AccessReviewCampaignSource{
ID: campaignSource.ID,
CampaignID: campaignSource.AccessReviewCampaignID,
SourceID: campaignSource.AccessReviewSourceID,
Name: campaignSource.Name,
FetchStatus: status,
FetchedAccountsCount: fetchedAccountsCount,
AttemptCount: attemptCount,
LastError: lastError,
FetchStartedAt: fetchStartedAt,
FetchCompletedAt: fetchCompletedAt,
ID: campaignSource.ID,
Campaign: &AccessReviewCampaign{
ID: campaignSource.AccessReviewCampaignID,
},
SourceID: campaignSource.AccessReviewSourceID,
Name: campaignSource.Name,
}
}
// NewAccessReviewCampaignSourceFetchAttempt builds the GraphQL representation of a
// single append-only fetch attempt.
func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignSourceFetchAttempt) *AccessReviewCampaignSourceFetchAttempt {
// single append-only fetch attempt. attemptNumber is the 1-based position in the
// snapshot's history, counting up from the oldest attempt.
func NewAccessReviewCampaignSourceFetchAttempt(
a *coredata.AccessReviewCampaignSourceFetchAttempt,
attemptNumber int,
) *AccessReviewCampaignSourceFetchAttempt {
if a.AttemptNumber > 0 {
attemptNumber = a.AttemptNumber
}
return &AccessReviewCampaignSourceFetchAttempt{
ID: a.ID,
AttemptNumber: a.AttemptNumber,
AttemptNumber: attemptNumber,
Status: a.Status,
FetchedAccountsCount: a.FetchedAccountsCount,
Error: a.Error,
@@ -157,6 +149,37 @@ func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignS
}
}
func NewAccessReviewCampaignSourceFetchAttemptConnection(
p *page.Page[*coredata.AccessReviewCampaignSourceFetchAttempt, coredata.AccessReviewCampaignSourceFetchAttemptOrderField],
parentType any,
parentID gid.GID,
) *AccessReviewCampaignSourceFetchAttemptConnection {
edges := make([]*AccessReviewCampaignSourceFetchAttemptEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessReviewCampaignSourceFetchAttemptEdge(p.Data[i], p.Cursor.OrderBy.Field, 0)
}
return &AccessReviewCampaignSourceFetchAttemptConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewAccessReviewCampaignSourceFetchAttemptEdge(
a *coredata.AccessReviewCampaignSourceFetchAttempt,
orderBy coredata.AccessReviewCampaignSourceFetchAttemptOrderField,
attemptNumber int,
) *AccessReviewCampaignSourceFetchAttemptEdge {
return &AccessReviewCampaignSourceFetchAttemptEdge{
Cursor: a.CursorKey(orderBy),
Node: NewAccessReviewCampaignSourceFetchAttempt(a, attemptNumber),
}
}
// AccessReviewCampaign helpers
func NewAccessReviewCampaignConnection(
@@ -260,6 +283,9 @@ func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
Campaign: &AccessReviewCampaign{
ID: e.AccessReviewCampaignID,
},
CampaignSource: &AccessReviewCampaignSource{
ID: e.AccessReviewCampaignSourceID,
},
Email: e.Email,
FullName: e.FullName,
Role: e.Role,

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type AccessReviewCampaignSource struct {
ID gid.GID `json:"id"`
CampaignID gid.GID `json:"-"`
// SourceID is the live access source this snapshot points at, or nil once
// that source has been deleted. The source field is resolved lazily from it.
SourceID *gid.GID `json:"-"`
Name string `json:"name"`
FetchStatus coredata.AccessReviewCampaignSourceFetchStatus `json:"fetchStatus"`
FetchedAccountsCount int `json:"fetchedAccountsCount"`
AttemptCount int `json:"attemptCount"`
LastError *string `json:"lastError,omitempty"`
FetchStartedAt *time.Time `json:"fetchStartedAt,omitempty"`
FetchCompletedAt *time.Time `json:"fetchCompletedAt,omitempty"`
}
type AccessReviewCampaignSourceFetchAttempt struct {
ID gid.GID `json:"id"`
AttemptNumber int `json:"attemptNumber"`
Status coredata.AccessReviewCampaignSourceFetchStatus `json:"status"`
FetchedAccountsCount int `json:"fetchedAccountsCount"`
Error *string `json:"error,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -16,7 +16,6 @@ package types
import (
"testing"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
@@ -29,28 +28,23 @@ func newTestCampaignSource(tenantID gid.TenantID, sourceID *gid.GID, name string
AccessReviewCampaignID: gid.New(tenantID, coredata.AccessReviewCampaignEntityType),
AccessReviewSourceID: sourceID,
Name: name,
Category: coredata.AccessReviewSourceCategorySaaS,
}
}
func TestNewAccessReviewCampaignSource_DefaultFetchState(t *testing.T) {
func TestNewAccessReviewCampaignSource(t *testing.T) {
t.Parallel()
tenantID := gid.NewTenantID()
sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType)
campaignSource := newTestCampaignSource(tenantID, &sourceID, "Google Workspace")
got := NewAccessReviewCampaignSource(campaignSource, nil)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued {
t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus)
got := NewAccessReviewCampaignSource(campaignSource)
if got.ID != campaignSource.ID {
t.Fatalf("id = %v, want %v", got.ID, campaignSource.ID)
}
if got.FetchedAccountsCount != 0 {
t.Fatalf("fetched accounts count = %d, want 0", got.FetchedAccountsCount)
}
if got.AttemptCount != 0 {
t.Fatalf("attempt count = %d, want 0", got.AttemptCount)
if got.Campaign == nil || got.Campaign.ID != campaignSource.AccessReviewCampaignID {
t.Fatalf("campaign id = %v, want %v", got.Campaign, campaignSource.AccessReviewCampaignID)
}
if got.SourceID == nil || *got.SourceID != sourceID {
@@ -62,41 +56,6 @@ func TestNewAccessReviewCampaignSource_DefaultFetchState(t *testing.T) {
}
}
func TestNewAccessReviewCampaignSource_UsesLatestAttempt(t *testing.T) {
t.Parallel()
now := time.Now()
errMsg := "We couldn't fetch accounts from this source."
tenantID := gid.NewTenantID()
sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType)
campaignSource := newTestCampaignSource(tenantID, &sourceID, "Linear")
attempt := &coredata.AccessReviewCampaignSourceFetchAttempt{
Status: coredata.AccessReviewCampaignSourceFetchStatusFailed,
FetchedAccountsCount: 42,
AttemptNumber: 3,
Error: &errMsg,
StartedAt: &now,
CompletedAt: &now,
}
got := NewAccessReviewCampaignSource(campaignSource, attempt)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusFailed {
t.Fatalf("fetch status = %q, want FAILED", got.FetchStatus)
}
if got.FetchedAccountsCount != 42 {
t.Fatalf("fetched accounts count = %d, want 42", got.FetchedAccountsCount)
}
if got.AttemptCount != 3 {
t.Fatalf("attempt count = %d, want 3", got.AttemptCount)
}
if got.LastError == nil || *got.LastError != errMsg {
t.Fatalf("last error = %v, want %q", got.LastError, errMsg)
}
}
// TestNewAccessReviewCampaignSource_DeletedSource verifies a snapshot whose
// live source has been deleted still renders (with a nil source link) so the
// historical review data remains visible.
@@ -106,7 +65,7 @@ func TestNewAccessReviewCampaignSource_DeletedSource(t *testing.T) {
tenantID := gid.NewTenantID()
campaignSource := newTestCampaignSource(tenantID, nil, "Deleted Source")
got := NewAccessReviewCampaignSource(campaignSource, nil)
got := NewAccessReviewCampaignSource(campaignSource)
if got.SourceID != nil {
t.Fatalf("source id = %v, want nil for deleted source", got.SourceID)
}