Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 25 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Expand Down
131 changes: 131 additions & 0 deletions cmd/root_elevate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}