fix(groups): improve cloud identity errors

This commit is contained in:
Peter Steinberger 2026-01-17 18:15:27 +00:00
parent f30aad0e9d
commit 40089bcbc7
6 changed files with 71 additions and 15 deletions

View File

@ -65,7 +65,7 @@ func (c *CalendarTeamCmd) Run(ctx context.Context, flags *RootFlags) error {
// Get group members via Cloud Identity API
cloudSvc, err := newCloudIdentityService(ctx, account)
if err != nil {
return wrapCloudIdentityError(err)
return wrapCloudIdentityError(err, account)
}
memberEmails, err := collectGroupMemberEmails(ctx, cloudSvc, groupEmail)

View File

@ -9,6 +9,7 @@ import (
"google.golang.org/api/cloudidentity/v1"
"github.com/steipete/gogcli/internal/errfmt"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
@ -41,7 +42,7 @@ func (c *GroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
svc, err := newCloudIdentityService(ctx, account)
if err != nil {
return wrapCloudIdentityError(err)
return wrapCloudIdentityError(err, account)
}
// Search for all groups the user belongs to
@ -53,7 +54,7 @@ func (c *GroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
Context(ctx).
Do()
if err != nil {
return wrapCloudIdentityError(err)
return wrapCloudIdentityError(err, account)
}
if outfmt.IsJSON(ctx) {
@ -102,15 +103,18 @@ func (c *GroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
}
// wrapCloudIdentityError provides helpful error messages for common Cloud Identity API issues.
func wrapCloudIdentityError(err error) error {
func wrapCloudIdentityError(err error, account string) error {
errStr := err.Error()
if strings.Contains(errStr, "accessNotConfigured") ||
strings.Contains(errStr, "Cloud Identity API has not been used") {
return fmt.Errorf("cloud Identity API is not enabled; enable it at: https://console.developers.google.com/apis/api/cloudidentity.googleapis.com/overview (%w)", err)
return errfmt.NewUserFacingError("Cloud Identity API is not enabled; enable it at: https://console.developers.google.com/apis/api/cloudidentity.googleapis.com/overview", err)
}
if strings.Contains(errStr, "insufficientPermissions") ||
strings.Contains(errStr, "insufficient authentication scopes") {
return fmt.Errorf("insufficient permissions for Cloud Identity API; re-authenticate with the cloud-identity.groups.readonly scope: gog auth add <account> --services groups\n\nOriginal error: %w", err)
return errfmt.NewUserFacingError("Insufficient permissions for Cloud Identity API; re-authenticate with the cloud-identity.groups.readonly scope: gog auth add <account> --services groups", err)
}
if isConsumerAccount(account) && (strings.Contains(errStr, "invalid argument") || strings.Contains(errStr, "badRequest")) {
return errfmt.NewUserFacingError("Cloud Identity groups require a Google Workspace/Cloud Identity account; consumer accounts (gmail.com/googlemail.com) are not supported.", err)
}
return err
}
@ -147,7 +151,7 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
svc, err := newCloudIdentityService(ctx, account)
if err != nil {
return wrapCloudIdentityError(err)
return wrapCloudIdentityError(err, account)
}
// First, look up the group by email to get its resource name
@ -211,6 +215,11 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}
func isConsumerAccount(account string) bool {
account = strings.ToLower(strings.TrimSpace(account))
return strings.HasSuffix(account, "@gmail.com") || strings.HasSuffix(account, "@googlemail.com")
}
// lookupGroupByEmail finds a group by its email address and returns its resource name.
func lookupGroupByEmail(ctx context.Context, svc *cloudidentity.Service, email string) (string, error) {
resp, err := svc.Groups.Lookup().

View File

@ -9,22 +9,29 @@ import (
)
func TestWrapCloudIdentityError(t *testing.T) {
err := wrapCloudIdentityError(errors.New("accessNotConfigured: boom"))
if !strings.Contains(err.Error(), "cloud Identity API is not enabled") {
err := wrapCloudIdentityError(errors.New("accessNotConfigured: boom"), "user@company.com")
if !strings.Contains(err.Error(), "Cloud Identity API is not enabled") {
t.Fatalf("unexpected error: %v", err)
}
err = wrapCloudIdentityError(errors.New("insufficientPermissions: nope"))
if !strings.Contains(err.Error(), "insufficient permissions") {
err = wrapCloudIdentityError(errors.New("insufficientPermissions: nope"), "user@company.com")
if !strings.Contains(err.Error(), "Insufficient permissions") {
t.Fatalf("unexpected error: %v", err)
}
other := errors.New("other")
if !errors.Is(wrapCloudIdentityError(other), other) {
if !errors.Is(wrapCloudIdentityError(other, "user@company.com"), other) {
t.Fatalf("expected passthrough error")
}
}
func TestWrapCloudIdentityError_ConsumerAccount(t *testing.T) {
err := wrapCloudIdentityError(errors.New("badRequest: Request contains an invalid argument."), "person@gmail.com")
if !strings.Contains(err.Error(), "consumer accounts") {
t.Fatalf("unexpected consumer error: %v", err)
}
}
func TestGetRelationType(t *testing.T) {
if got := getRelationType("DIRECT"); got != "direct" {
t.Fatalf("unexpected relation: %q", got)

View File

@ -74,17 +74,17 @@ func TestGroupsList_NoGroups_Text(t *testing.T) {
func TestWrapCloudIdentityError_Messages(t *testing.T) {
accessErr := errors.New("accessNotConfigured")
if err := wrapCloudIdentityError(accessErr); err == nil || !strings.Contains(strings.ToLower(err.Error()), "cloud identity api is not enabled") {
if err := wrapCloudIdentityError(accessErr, "user@company.com"); err == nil || !strings.Contains(err.Error(), "Cloud Identity API is not enabled") {
t.Fatalf("unexpected error: %v", err)
}
permErr := errors.New("insufficientPermissions")
if err := wrapCloudIdentityError(permErr); err == nil || !strings.Contains(err.Error(), "insufficient permissions") {
if err := wrapCloudIdentityError(permErr, "user@company.com"); err == nil || !strings.Contains(err.Error(), "Insufficient permissions") {
t.Fatalf("unexpected error: %v", err)
}
other := errors.New("other")
if err := wrapCloudIdentityError(other); err == nil || err.Error() != "other" {
if err := wrapCloudIdentityError(other, "user@company.com"); err == nil || err.Error() != "other" {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@ -53,6 +53,11 @@ func Format(err error) string {
return err.Error()
}
var userErr *UserFacingError
if errors.As(err, &userErr) {
return userErr.Message
}
var gerr *ggoogleapi.Error
if errors.As(err, &gerr) {
reason := ""
@ -70,6 +75,32 @@ func Format(err error) string {
return err.Error()
}
// UserFacingError forces a specific message, while preserving the underlying cause.
type UserFacingError struct {
Message string
Cause error
}
func (e *UserFacingError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func (e *UserFacingError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func NewUserFacingError(message string, cause error) error {
return &UserFacingError{Message: message, Cause: cause}
}
// formatParseError enhances Kong parse errors with helpful hints.
func formatParseError(err *kong.ParseError) string {
msg := err.Error()

View File

@ -50,6 +50,15 @@ func TestFormat_KeyNotFound(t *testing.T) {
}
}
func TestFormat_UserFacingError(t *testing.T) {
err := NewUserFacingError("friendly", errNope)
got := Format(err)
if got != "friendly" {
t.Fatalf("unexpected: %q", got)
}
}
func TestFormat_GoogleAPIError(t *testing.T) {
err := &ggoogleapi.Error{
Code: 403,