diff --git a/CHANGELOG.md b/CHANGELOG.md index da5293a..66d9b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- Elevation requests no longer fail with `context deadline exceeded` when the interactive target selector takes longer than 30 seconds + ## [0.5.0] - 2026-02-19 ### Added diff --git a/cmd/root.go b/cmd/root.go index 23b6b2f..5868fed 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,7 @@ import ( ) // apiTimeout is the default timeout for SCA API requests. -const apiTimeout = 30 * time.Second +var apiTimeout = 30 * time.Second // verbose and passedArgValidation are package-level by design: the CLI binary // runs a single command per process, so there is no concurrent access. @@ -415,8 +415,13 @@ func resolveAndElevate( }, } + // Fresh context for elevation — the original ctx may have expired during + // an interactive prompt (the user can take arbitrarily long to select). + elevCtx, elevCancel := context.WithTimeout(context.Background(), apiTimeout) + defer elevCancel() + // Execute elevation - elevateResp, err := elevateService.Elevate(ctx, req) + elevateResp, err := elevateService.Elevate(elevCtx, req) if err != nil { return nil, fmt.Errorf("elevation request failed: %w", err) } @@ -580,7 +585,11 @@ func resolveAndElevateGroupsFilter(ctx context.Context, groupsEligLister groupsE return nil, nil, fmt.Errorf("selection failed: %w", err) } - return elevateGroup(ctx, selected.group, groupsElevator) + // Fresh context for elevation — the original ctx may have expired during + // the interactive prompt. + elevCtx, elevCancel := context.WithTimeout(context.Background(), apiTimeout) + defer elevCancel() + return elevateGroup(elevCtx, selected.group, groupsElevator) } // resolveAndElevateCloudOnly handles the cloud-only path (--provider, direct, or favorite). @@ -610,7 +619,12 @@ func resolveAndElevateCloudOnly(ctx context.Context, rf *resolvedFlags, eligList } resolveTargetCSP(selectedTarget, allTargets, rf.provider) - return elevateCloud(ctx, selectedTarget, elevateService) + + // Fresh context for elevation — the original ctx may have expired during + // the interactive prompt. + elevCtx, elevCancel := context.WithTimeout(context.Background(), apiTimeout) + defer elevCancel() + return elevateCloud(elevCtx, selectedTarget, elevateService) } // resolveAndElevateUnifiedPath handles the unified path (no filter flags) with parallel fetch. @@ -661,12 +675,17 @@ func resolveAndElevateUnifiedPath(ctx context.Context, eligLister eligibilityLis return nil, nil, fmt.Errorf("selection failed: %w", err) } + // Fresh context for elevation — the original ctx may have expired during + // the interactive prompt. + elevCtx, elevCancel := context.WithTimeout(context.Background(), apiTimeout) + defer elevCancel() + switch selected.kind { case selectionCloud: resolveTargetCSP(selected.cloud, cr.targets, "") - return elevateCloud(ctx, selected.cloud, elevateService) + return elevateCloud(elevCtx, selected.cloud, elevateService) case selectionGroup: - return elevateGroup(ctx, selected.group, groupsElevator) + return elevateGroup(elevCtx, selected.group, groupsElevator) default: return nil, nil, errors.New("unexpected selection kind") } diff --git a/cmd/root_elevate_test.go b/cmd/root_elevate_test.go index 8d25ddd..0629651 100644 --- a/cmd/root_elevate_test.go +++ b/cmd/root_elevate_test.go @@ -1593,3 +1593,134 @@ func TestRootElevate_UnifiedInteractiveShowsBoth(t *testing.T) { t.Errorf("output missing expected text, got:\n%s", output) } } + +// TestRootElevate_SlowPromptTimeout verifies that elevation succeeds even when +// the interactive prompt takes longer than apiTimeout. The context for the +// elevation API call must be independent of the prompt duration. +func TestRootElevate_SlowPromptTimeout(t *testing.T) { + origTimeout := apiTimeout + apiTimeout = 50 * time.Millisecond + defer func() { apiTimeout = origTimeout }() + + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + + cloudElig := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "org-1", + WorkspaceID: "sub-1", + WorkspaceName: "Prod-EastUS", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"}, + }, + }, + Total: 1, + }, + } + + groupsElig := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + }, + Total: 1, + }, + } + + // Slow selector simulates a user taking longer than apiTimeout to choose + slowSelector := &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + time.Sleep(100 * time.Millisecond) // 2x apiTimeout + return &items[0], nil + }, + } + + // contextAwareElevator returns context deadline exceeded if ctx is expired, + // matching real HTTP client behavior. + contextAwareGroupsElev := &mockGroupsElevator{ + elevateFunc: func(ctx context.Context, req *models.GroupsElevateRequest) (*models.GroupsElevateResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return &models.GroupsElevateResponse{ + DirectoryID: "dir1", + CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{ + {GroupID: "grp1", SessionID: "sess-grp"}, + }, + }, nil + }, + } + + contextAwareCloudElev := &mockElevateService{ + elevateFunc: func(ctx context.Context, req *models.ElevateRequest) (*models.ElevateResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return &models.ElevateResponse{ + Response: models.ElevateAccessResult{ + CSP: models.CSPAzure, + OrganizationID: "org-1", + Results: []models.ElevateTargetResult{ + {WorkspaceID: "sub-1", RoleID: "role-1", SessionID: "sess-cloud"}, + }, + }, + }, nil + }, + } + + t.Run("unified path - group elevation after slow prompt", func(t *testing.T) { + // Selector returns the group item + sel := &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + time.Sleep(100 * time.Millisecond) + for i := range items { + if items[i].kind == selectionGroup { + return &items[i], nil + } + } + return &items[0], nil + }, + } + + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, nil, sel, groupsElig, contextAwareGroupsElev, config.DefaultConfig()) + output, err := executeCommand(cmd) + + if err != nil { + t.Fatalf("elevation should succeed after slow prompt, got: %v", err) + } + if !strings.Contains(output, "Elevated to group Engineering") { + t.Errorf("unexpected output:\n%s", output) + } + }) + + t.Run("cloud-only path - elevation after slow prompt", func(t *testing.T) { + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, contextAwareCloudElev, slowSelector, groupsElig, nil, config.DefaultConfig()) + output, err := executeCommand(cmd, "--provider", "azure") + + if err != nil { + t.Fatalf("elevation should succeed after slow prompt, got: %v", err) + } + if !strings.Contains(output, "Elevated to Contributor on Prod-EastUS") { + t.Errorf("unexpected output:\n%s", output) + } + }) + + t.Run("groups filter path - elevation after slow prompt", func(t *testing.T) { + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, nil, slowSelector, groupsElig, contextAwareGroupsElev, config.DefaultConfig()) + output, err := executeCommand(cmd, "--groups") + + if err != nil { + t.Fatalf("elevation should succeed after slow prompt, got: %v", err) + } + if !strings.Contains(output, "Elevated to group Engineering") { + t.Errorf("unexpected output:\n%s", output) + } + }) +}