diff --git a/README.md b/README.md index 1d1856f0..a5342fe1 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,9 @@ gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt gog gmail send --to a@b.com --subject "Hi" --body-file - # Read body from stdin gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --body-html "

Hello

" +gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --signature +gog gmail send --to a@b.com --subject "Hi" --body-html "

Hello

" --signature-name alias@example.com +gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --signature-file ./signature.txt gog gmail drafts list gog gmail drafts create --subject "Draft" --body "Body" gog gmail drafts create --to a@b.com --subject "Draft" --body "Body" diff --git a/docs/spec.md b/docs/spec.md index 589bfe03..dcc1aea5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -260,7 +260,7 @@ Flag aliases: - `gog gmail labels get ` - `gog gmail labels create ` - `gog gmail labels modify [--add ...] [--remove ...]` -- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` +- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...] [--signature|--signature-name EMAIL|--signature-file PATH]` - `gog gmail drafts list [--max N] [--page TOKEN]` - `gog gmail drafts get [--download]` - `gog gmail drafts create --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` diff --git a/docs/watch.md b/docs/watch.md index bb373836..feadc002 100644 --- a/docs/watch.md +++ b/docs/watch.md @@ -48,7 +48,8 @@ gog gmail watch serve \ [--verify-oidc] [--oidc-email ] [--oidc-audience ] \ [--token ] \ [--hook-url ] [--hook-token ] \ - [--include-body] [--max-bytes ] [--save-hook] + [--include-body] [--max-bytes ] \ + [--history-types ...] [--save-hook] gog gmail history --since [--max ] [--page ] ``` @@ -58,6 +59,7 @@ Notes: - `watch renew` reuses stored topic/labels. - `watch stop` calls Gmail stop + clears state. - `watch serve` uses stored hook if `--hook-url` not provided. +- `watch serve --history-types` accepts `messageAdded`, `messageDeleted`, `labelAdded`, `labelRemoved` (repeatable or comma-separated). Default: `messageAdded` (for backward compatibility). ## State diff --git a/internal/cmd/admin_directory_helpers.go b/internal/cmd/admin_directory_helpers.go new file mode 100644 index 00000000..360c498d --- /dev/null +++ b/internal/cmd/admin_directory_helpers.go @@ -0,0 +1,14 @@ +package cmd + +import "os" + +// adminCustomerID returns the customer ID for Admin SDK calls. +// Defaults to "my_customer" (the authenticated admin's domain) but +// can be overridden via the GOG_CUSTOMER_ID environment variable +// for multi-tenant administration. +func adminCustomerID() string { + if id := os.Getenv("GOG_CUSTOMER_ID"); id != "" { + return id + } + return "my_customer" +} diff --git a/internal/cmd/admingroups_test.go b/internal/cmd/admingroups_test.go new file mode 100644 index 00000000..0670c054 --- /dev/null +++ b/internal/cmd/admingroups_test.go @@ -0,0 +1,377 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/groupssettings/v1" + "google.golang.org/api/option" +) + +func TestGroupsCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/groups") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "engineering@example.com", + "name": "Engineering", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &GroupsCreateCmd{Email: "engineering@example.com", Name: "Engineering"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestGroupsSettingsCmd_Get(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/groups/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "engineering@example.com", + "whoCanJoin": "INVITED_CAN_JOIN", + "whoCanPostMessage": "ALL_IN_DOMAIN_CAN_POST", + "whoCanViewGroup": "ALL_IN_DOMAIN_CAN_VIEW", + "whoCanViewMembership": "ALL_IN_DOMAIN_CAN_VIEW", + }) + }) + stubGroupsSettings(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &GroupsSettingsCmd{Group: "engineering@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "WhoCanJoin") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubGroupsSettings(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newGroupsSettings + svc, err := groupssettings.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/groups/v1/groups/"), + ) + if err != nil { + t.Fatalf("new groupssettings service: %v", err) + } + newGroupsSettings = func(context.Context, string) (*groupssettings.Service, error) { return svc, nil } + t.Cleanup(func() { + newGroupsSettings = orig + srv.Close() + }) +} + +func TestReadCSVEmails(t *testing.T) { + content := "email\nalpha@example.com\nALPHA@example.com\nbeta@example.com\n" + path := writeTempFile(t, content) + got, err := readCSVEmails(path) + if err != nil { + t.Fatalf("readCSVEmails: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 emails, got %d", len(got)) + } +} + +func writeTempFile(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "csv-*.csv") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + if _, err := io.WriteString(f, content); err != nil { + t.Fatalf("write: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close: %v", err) + } + return f.Name() +} + +func TestGroupsUpdateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/groups/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "engineering@example.com", + "name": "Engineering Team", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Engineering Team" + cmd := &GroupsUpdateCmd{Group: "engineering@example.com", Name: &newName} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestGroupsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/groups/") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &GroupsDeleteCmd{Group: "old-group@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestGroupsSettingsCmd_Update(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/groups/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "engineering@example.com", + "whoCanJoin": "CAN_REQUEST_TO_JOIN", + }) + }) + stubGroupsSettings(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + whoCanJoin := "CAN_REQUEST_TO_JOIN" + cmd := &GroupsSettingsCmd{Group: "engineering@example.com", WhoCanJoin: &whoCanJoin} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated settings") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestGroupsMembersAddCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/members") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "newmember@example.com", + "role": "MEMBER", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &GroupsMembersAddCmd{Group: "team@example.com", Email: "newmember@example.com", Role: "MEMBER"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Added") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestGroupsMembersRemoveCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/members/") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &GroupsMembersRemoveCmd{Group: "team@example.com", Email: "oldmember@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Removed") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestGroupsMembersSyncCmd_AlreadyInSync(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/members") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "members": []map[string]any{ + {"email": "alpha@example.com", "role": "MEMBER"}, + {"email": "beta@example.com", "role": "MEMBER"}, + }, + }) + }) + stubAdminDirectory(t, h) + + csvContent := "email\nalpha@example.com\nbeta@example.com\n" + csvPath := writeTempFile(t, csvContent) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &GroupsMembersSyncCmd{Group: "team@example.com", File: csvPath} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "already in sync") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestNormalizeGroupRole(t *testing.T) { + tests := []struct { + input string + expected string + wantErr bool + }{ + {"MEMBER", "MEMBER", false}, + {"member", "MEMBER", false}, + {"Manager", "MANAGER", false}, + {"owner", "OWNER", false}, + {"invalid", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := normalizeGroupRole(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q", tt.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.expected { + t.Errorf("normalizeGroupRole(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestFindEmailColumn(t *testing.T) { + tests := []struct { + header []string + expected int + }{ + {[]string{"email", "name"}, 0}, + {[]string{"name", "EmailAddress"}, 1}, + {[]string{"member", "role"}, 0}, + {[]string{"MEMBER_EMAIL", "status"}, 0}, + {[]string{"name", "role"}, -1}, + } + + for _, tt := range tests { + got := findEmailColumn(tt.header) + if got != tt.expected { + t.Errorf("findEmailColumn(%v) = %d, want %d", tt.header, got, tt.expected) + } + } +} + +func TestListGroupMembers(t *testing.T) { + calls := 0 + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/members") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + calls++ + if calls == 1 { + _ = json.NewEncoder(w).Encode(map[string]any{ + "members": []map[string]any{ + {"email": "a@example.com"}, + {"email": "b@example.com"}, + }, + "nextPageToken": "page2", + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "members": []map[string]any{ + {"email": "c@example.com"}, + }, + }) + }) + stubAdminDirectory(t, h) + + svc, _ := newAdminDirectoryForServer(httptest.NewServer(h)) + members, err := listGroupMembers(context.Background(), svc, "team@example.com") + if err != nil { + t.Fatalf("listGroupMembers: %v", err) + } + if len(members) != 3 { + t.Errorf("expected 3 members, got %d", len(members)) + } +} diff --git a/internal/cmd/admins.go b/internal/cmd/admins.go new file mode 100644 index 00000000..13cd9ff2 --- /dev/null +++ b/internal/cmd/admins.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type AdminsCmd struct { + List AdminsListCmd `cmd:"" name:"list" aliases:"ls" help:"List admin role assignments"` + Create AdminsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Assign admin role"` + Delete AdminsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete admin assignment"` +} + +type AdminsListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *AdminsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + call := svc.RoleAssignments.List(adminCustomerID()).MaxResults(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list admin assignments: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Items) == 0 { + u.Err().Println("No admin assignments found") + return nil + } + + roleNames, _ := roleIDNameMap(ctx, svc) + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ASSIGNMENT ID\tROLE\tASSIGNED TO\tSCOPE\tORG UNIT") + for _, assignment := range resp.Items { + if assignment == nil { + continue + } + roleID := strconv.FormatInt(assignment.RoleId, 10) + roleName := roleNames[roleID] + if roleName == "" { + roleName = roleID + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(strconv.FormatInt(assignment.RoleAssignmentId, 10)), + sanitizeTab(roleName), + sanitizeTab(assignment.AssignedTo), + sanitizeTab(assignment.ScopeType), + sanitizeTab(assignment.OrgUnitId), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type AdminsCreateCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + Role string `name:"role" required:"" help:"Role ID or name"` + OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path (scope)"` +} + +func (c *AdminsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + roleID, _, err := resolveRole(ctx, svc, c.Role) + if err != nil { + return err + } + roleIDNum, err := strconv.ParseInt(roleID, 10, 64) + if err != nil { + return fmt.Errorf("invalid role id %q: %w", roleID, err) + } + + assignedTo, err := resolveUserID(ctx, svc, c.User) + if err != nil { + return err + } + + assignment := &admin.RoleAssignment{ + RoleId: roleIDNum, + AssignedTo: assignedTo, + ScopeType: "CUSTOMER", + } + if strings.TrimSpace(c.OrgUnit) != "" { + var orgID string + orgID, err = resolveOrgUnitID(ctx, svc, c.OrgUnit) + if err != nil { + return err + } + assignment.ScopeType = "ORG_UNIT" + assignment.OrgUnitId = orgID + } + + created, err := svc.RoleAssignments.Insert(adminCustomerID(), assignment).Context(ctx).Do() + if err != nil { + return fmt.Errorf("assign role %s to %s: %w", c.Role, c.User, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Assigned role %s to %s (assignment %d)\n", c.Role, c.User, created.RoleAssignmentId) + return nil +} + +type AdminsDeleteCmd struct { + AssignmentID string `arg:"" name:"assignment-id" help:"Role assignment ID"` +} + +func (c *AdminsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete admin assignment %s", c.AssignmentID)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.RoleAssignments.Delete(adminCustomerID(), c.AssignmentID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete admin assignment %s: %w", c.AssignmentID, err) + } + + u.Out().Printf("Deleted admin assignment: %s\n", c.AssignmentID) + return nil +} + +func roleIDNameMap(ctx context.Context, svc *admin.Service) (map[string]string, error) { + roles, err := listAllRoles(ctx, svc) + if err != nil { + return nil, err + } + out := make(map[string]string, len(roles)) + for _, role := range roles { + if role == nil { + continue + } + out[strconv.FormatInt(role.RoleId, 10)] = role.RoleName + } + return out, nil +} + +func resolveUserID(ctx context.Context, svc *admin.Service, user string) (string, error) { + user = strings.TrimSpace(user) + if user == "" { + return "", usage("user required") + } + resp, err := svc.Users.Get(user).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("resolve user %s: %w", user, err) + } + if resp.Id == "" { + return "", fmt.Errorf("user %s has no ID", user) + } + return resp.Id, nil +} + +func resolveOrgUnitID(ctx context.Context, svc *admin.Service, path string) (string, error) { + ou, err := svc.Orgunits.Get(adminCustomerID(), path).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("resolve org unit %s: %w", path, err) + } + if ou.OrgUnitId == "" { + return "", fmt.Errorf("org unit %s has no ID", path) + } + return ou.OrgUnitId, nil +} diff --git a/internal/cmd/admins_test.go b/internal/cmd/admins_test.go new file mode 100644 index 00000000..79c7f422 --- /dev/null +++ b/internal/cmd/admins_test.go @@ -0,0 +1,589 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// ----------------------------------------------------------------------------- +// resolveOrgUnitID tests +// ----------------------------------------------------------------------------- + +func TestResolveOrgUnitID_Success(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitPath": "/Sales", + "orgUnitId": "ou-123456", + "name": "Sales", + }) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + id, err := resolveOrgUnitID(context.Background(), svc, "/Sales") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != "ou-123456" { + t.Fatalf("expected ou-123456, got %q", id) + } +} + +func TestResolveOrgUnitID_NotFound(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error": {"message": "not found"}}`, http.StatusNotFound) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + _, err = resolveOrgUnitID(context.Background(), svc, "/NonExistent") + if err == nil { + t.Fatalf("expected error for not found org unit") + } + if !strings.Contains(err.Error(), "resolve org unit") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveOrgUnitID_EmptyID(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitPath": "/BadOU", + "orgUnitId": "", + "name": "BadOU", + }) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + _, err = resolveOrgUnitID(context.Background(), svc, "/BadOU") + if err == nil { + t.Fatalf("expected error for empty org unit ID") + } + if !strings.Contains(err.Error(), "has no ID") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// resolveUserID tests +// ----------------------------------------------------------------------------- + +func TestResolveUserID_Success(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "user-abc123", + "primaryEmail": "user@example.com", + }) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + id, err := resolveUserID(context.Background(), svc, "user@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != "user-abc123" { + t.Fatalf("expected user-abc123, got %q", id) + } +} + +func TestResolveUserID_EmptyUser(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + _, err = resolveUserID(context.Background(), svc, " ") + if err == nil { + t.Fatalf("expected error for empty user") + } + if !strings.Contains(err.Error(), "user required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveUserID_EmptyID(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "", + "primaryEmail": "nouser@example.com", + }) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + _, err = resolveUserID(context.Background(), svc, "nouser@example.com") + if err == nil { + t.Fatalf("expected error for empty user ID") + } + if !strings.Contains(err.Error(), "has no ID") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// AdminsListCmd tests +// ----------------------------------------------------------------------------- + +func TestAdminsListCmd_NoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &AdminsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for missing account") + } +} + +func TestAdminsListCmd_EmptyResults(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsListCmd{} + + // Empty results should not error, just print message to stderr + err := cmd.Run(testContext(t), flags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAdminsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"roleId": "100", "roleName": "Admin"}, + }, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "roleAssignmentId": "1", + "roleId": "100", + "assignedTo": "user-1", + "scopeType": "CUSTOMER", + }, + }, + }) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "items") { + t.Fatalf("expected JSON items output: %s", out) + } +} + +func TestAdminsListCmd_WithPagination(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"roleId": "100", "roleName": "Admin"}}, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"roleAssignmentId": "1", "roleId": "100", "assignedTo": "user-1", "scopeType": "CUSTOMER"}, + }, + "nextPageToken": "next-page", + }) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsListCmd{Max: 1} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Admin") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAdminsListCmd_NilAssignment(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + // Simulate response with null items (testing nil check in loop) + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + nil, + {"roleAssignmentId": "2", "roleId": "100", "assignedTo": "user-2", "scopeType": "CUSTOMER"}, + }, + }) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "user-2") { + t.Fatalf("unexpected output: %s", out) + } +} + +// ----------------------------------------------------------------------------- +// AdminsCreateCmd tests +// ----------------------------------------------------------------------------- + +func TestAdminsCreateCmd_NoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &AdminsCreateCmd{User: "sam@example.com", Role: "Admin"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for missing account") + } +} + +func TestAdminsCreateCmd_WithOrgUnit(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"roleId": "123", "roleName": "Helpdesk"}}, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/users/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "user-1"}) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitPath": "/Sales", + "orgUnitId": "ou-sales-123", + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + // Verify request body contains ORG_UNIT scope + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload["scopeType"] != "ORG_UNIT" { + t.Errorf("expected ORG_UNIT scope, got %v", payload["scopeType"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{"roleAssignmentId": "99"}) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsCreateCmd{User: "sam@example.com", Role: "Helpdesk", OrgUnit: "/Sales"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Assigned role") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAdminsCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"roleId": "123", "roleName": "Helpdesk"}}, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/users/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "user-1"}) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleAssignmentId": "99", + "roleId": "123", + "assignedTo": "user-1", + }) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsCreateCmd{User: "sam@example.com", Role: "Helpdesk"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "roleAssignmentId") { + t.Fatalf("expected JSON roleAssignmentId: %s", out) + } +} + +func TestAdminsCreateCmd_RoleNotFound(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + // Return roles that don't match the requested one + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"roleId": 123, "roleName": "DifferentRole"}}, + }) + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsCreateCmd{User: "sam@example.com", Role: "NonExistentRole"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for role not found") + } + if !strings.Contains(err.Error(), "role") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// AdminsDeleteCmd tests +// ----------------------------------------------------------------------------- + +func TestAdminsDeleteCmd_NoAccount(t *testing.T) { + flags := &RootFlags{Force: true} + cmd := &AdminsDeleteCmd{AssignmentID: "99"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for missing account") + } +} + +func TestAdminsDeleteCmd_RequiresConfirmation(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &AdminsDeleteCmd{AssignmentID: "99"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error without --force in non-interactive mode") + } + if !strings.Contains(err.Error(), "refusing to delete") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAdminsDeleteCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error": {"message": "forbidden"}}`, http.StatusForbidden) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AdminsDeleteCmd{AssignmentID: "99"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "delete admin assignment") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// roleIDNameMap tests +// ----------------------------------------------------------------------------- + +func TestRoleIDNameMap_Success(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/roles") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"roleId": "100", "roleName": "Super Admin"}, + {"roleId": "200", "roleName": "Helpdesk"}, + nil, // Test nil handling + }, + }) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + m, err := roleIDNameMap(context.Background(), svc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m["100"] != "Super Admin" { + t.Fatalf("expected Super Admin, got %q", m["100"]) + } + if m["200"] != "Helpdesk" { + t.Fatalf("expected Helpdesk, got %q", m["200"]) + } +} + +func TestRoleIDNameMap_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error": {"message": "forbidden"}}`, http.StatusForbidden) + }) + srv := httptest.NewServer(h) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + + _, err = roleIDNameMap(context.Background(), svc) + if err == nil { + t.Fatalf("expected error from API") + } +} diff --git a/internal/cmd/alerts.go b/internal/cmd/alerts.go new file mode 100644 index 00000000..a8194d9f --- /dev/null +++ b/internal/cmd/alerts.go @@ -0,0 +1,361 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + alertcenter "google.golang.org/api/alertcenter/v1beta1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newAlertCenterService = googleapi.NewAlertCenter + +type AlertsCmd struct { + List AlertsListCmd `cmd:"" name:"list" aliases:"ls" help:"List alerts"` + Get AlertsGetCmd `cmd:"" name:"get" help:"Get alert"` + Delete AlertsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete alert"` + Undelete AlertsUndeleteCmd `cmd:"" name:"undelete" help:"Undelete alert"` + Feedback AlertsFeedbackCmd `cmd:"" name:"feedback" help:"Manage alert feedback"` + Settings AlertsSettingsCmd `cmd:"" name:"settings" help:"Alert settings"` +} + +type AlertsListCmd struct { + Filter string `name:"filter" help:"Filter alerts"` + OrderBy string `name:"order-by" help:"Order by (e.g. create_time desc)"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *AlertsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + call := svc.Alerts.List() + if c.Filter != "" { + call = call.Filter(c.Filter) + } + if c.OrderBy != "" { + call = call.OrderBy(c.OrderBy) + } + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list alerts: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Alerts) == 0 { + u.Err().Println("No alerts found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ALERT ID\tTYPE\tSOURCE\tCREATED\tUPDATED\tDELETED") + for _, alert := range resp.Alerts { + if alert == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\n", + sanitizeTab(alert.AlertId), + sanitizeTab(alert.Type), + sanitizeTab(alert.Source), + sanitizeTab(alert.CreateTime), + sanitizeTab(alert.UpdateTime), + alert.Deleted, + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type AlertsGetCmd struct { + AlertID string `arg:"" name:"alert-id" help:"Alert ID"` +} + +func (c *AlertsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + alert, err := svc.Alerts.Get(c.AlertID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get alert %s: %w", c.AlertID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, alert) + } + + u.Out().Printf("Alert ID: %s\n", alert.AlertId) + u.Out().Printf("Type: %s\n", alert.Type) + u.Out().Printf("Source: %s\n", alert.Source) + u.Out().Printf("Created: %s\n", alert.CreateTime) + u.Out().Printf("Updated: %s\n", alert.UpdateTime) + u.Out().Printf("Deleted: %t\n", alert.Deleted) + if alert.StartTime != "" { + u.Out().Printf("Start Time: %s\n", alert.StartTime) + } + if alert.EndTime != "" { + u.Out().Printf("End Time: %s\n", alert.EndTime) + } + return nil +} + +type AlertsDeleteCmd struct { + AlertID string `arg:"" name:"alert-id" help:"Alert ID"` +} + +func (c *AlertsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete alert %s", c.AlertID)); err != nil { + return err + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Alerts.Delete(c.AlertID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete alert %s: %w", c.AlertID, err) + } + + u.Out().Printf("Deleted alert: %s\n", c.AlertID) + return nil +} + +type AlertsUndeleteCmd struct { + AlertID string `arg:"" name:"alert-id" help:"Alert ID"` +} + +func (c *AlertsUndeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Alerts.Undelete(c.AlertID, &alertcenter.UndeleteAlertRequest{}).Context(ctx).Do(); err != nil { + return fmt.Errorf("undelete alert %s: %w", c.AlertID, err) + } + + u.Out().Printf("Undeleted alert: %s\n", c.AlertID) + return nil +} + +type AlertsFeedbackCmd struct { + List AlertsFeedbackListCmd `cmd:"" name:"list" help:"List feedback for alert"` + Create AlertsFeedbackCreateCmd `cmd:"" name:"create" help:"Create feedback for alert"` +} + +type AlertsFeedbackListCmd struct { + AlertID string `name:"alert" help:"Alert ID"` +} + +func (c *AlertsFeedbackListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + alertID := strings.TrimSpace(c.AlertID) + if alertID == "" { + return usage("--alert is required") + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Alerts.Feedback.List(alertID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list feedback for %s: %w", alertID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Feedback) == 0 { + u.Err().Println("No feedback found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "FEEDBACK ID\tTYPE\tEMAIL\tCREATED") + for _, fb := range resp.Feedback { + if fb == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(fb.FeedbackId), + sanitizeTab(fb.Type), + sanitizeTab(fb.Email), + sanitizeTab(fb.CreateTime), + ) + } + return nil +} + +type AlertsFeedbackCreateCmd struct { + AlertID string `arg:"" name:"alert-id" help:"Alert ID"` + Type string `name:"type" required:"" enum:"NOT_USEFUL,SOMEWHAT_USEFUL,VERY_USEFUL" help:"Feedback type"` +} + +func (c *AlertsFeedbackCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + feedback := &alertcenter.AlertFeedback{Type: c.Type} + created, err := svc.Alerts.Feedback.Create(c.AlertID, feedback).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create feedback for %s: %w", c.AlertID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created feedback for alert: %s\n", c.AlertID) + return nil +} + +type AlertsSettingsCmd struct { + Get AlertsSettingsGetCmd `cmd:"" name:"get" help:"Get alert settings"` + Update AlertsSettingsUpdateCmd `cmd:"" name:"update" help:"Update alert settings"` +} + +type AlertsSettingsGetCmd struct{} + +func (c *AlertsSettingsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + settings, err := svc.V1beta1.GetSettings().Context(ctx).Do() + if err != nil { + return fmt.Errorf("get alert settings: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, settings) + } + + u.Out().Printf("Notifications: %d\n", len(settings.Notifications)) + for _, n := range settings.Notifications { + if n == nil || n.CloudPubsubTopic == nil { + continue + } + u.Out().Printf("- %s\n", n.CloudPubsubTopic.TopicName) + } + return nil +} + +type AlertsSettingsUpdateCmd struct { + Notifications string `name:"notifications" help:"Comma-separated Pub/Sub topic names"` +} + +func (c *AlertsSettingsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if strings.TrimSpace(c.Notifications) == "" { + return usage("--notifications is required") + } + + svc, err := newAlertCenterService(ctx, account) + if err != nil { + return err + } + + topics := splitCSV(c.Notifications) + notifications := make([]*alertcenter.Notification, 0, len(topics)) + for _, topic := range topics { + topic = strings.TrimSpace(topic) + if topic == "" { + continue + } + notifications = append(notifications, &alertcenter.Notification{ + CloudPubsubTopic: &alertcenter.CloudPubsubTopic{TopicName: topic}, + }) + } + if len(notifications) == 0 { + return usage("no notifications specified") + } + + settings := &alertcenter.Settings{Notifications: notifications} + settings.ForceSendFields = append(settings.ForceSendFields, "Notifications") + + updated, err := svc.V1beta1.UpdateSettings(settings).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update alert settings: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated alert settings (notifications: %d)\n", len(updated.Notifications)) + return nil +} diff --git a/internal/cmd/alerts_test.go b/internal/cmd/alerts_test.go new file mode 100644 index 00000000..7e39392d --- /dev/null +++ b/internal/cmd/alerts_test.go @@ -0,0 +1,637 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + alertcenter "google.golang.org/api/alertcenter/v1beta1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestAlertsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta1/alerts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alerts": []map[string]any{ + { + "alertId": "alert-1", + "type": "BAD_LOGIN", + "source": "GMAIL", + "createTime": "2026-01-01T00:00:00Z", + "updateTime": "2026-01-01T00:00:00Z", + "deleted": false, + }, + }, + }) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "alert-1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta1/alerts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alerts": []map[string]any{ + { + "alertId": "alert-json-1", + "type": "SUSPICIOUS_LOGIN", + "source": "DRIVE", + "createTime": "2026-01-02T00:00:00Z", + "updateTime": "2026-01-02T00:00:00Z", + "deleted": false, + }, + }, + }) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "alert-json-1") || !strings.Contains(out, "SUSPICIOUS_LOGIN") { + t.Fatalf("expected JSON output with alert data, got: %s", out) + } +} + +func TestAlertsListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta1/alerts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alerts": []map[string]any{}, + }) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsListCmd{} + + // No error expected, just "no alerts" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestAlertsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-get-1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alertId": "alert-get-1", + "type": "PHISHING", + "source": "GMAIL", + "createTime": "2026-01-03T00:00:00Z", + "updateTime": "2026-01-03T01:00:00Z", + "deleted": false, + "startTime": "2026-01-03T00:00:00Z", + "endTime": "2026-01-03T02:00:00Z", + }) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsGetCmd{AlertID: "alert-get-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "alert-get-1") || !strings.Contains(out, "PHISHING") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Start Time:") || !strings.Contains(out, "End Time:") { + t.Fatalf("expected start/end times in output: %s", out) + } +} + +func TestAlertsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-get-json") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alertId": "alert-get-json", + "type": "MALWARE", + "source": "DRIVE", + "createTime": "2026-01-04T00:00:00Z", + "updateTime": "2026-01-04T01:00:00Z", + "deleted": false, + }) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsGetCmd{AlertID: "alert-get-json"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "alert-get-json") || !strings.Contains(out, "MALWARE") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAlertsDeleteCmd(t *testing.T) { + deleted := false + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-delete-1") { + deleted = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AlertsDeleteCmd{AlertID: "alert-delete-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatal("expected delete API call") + } + if !strings.Contains(out, "Deleted alert") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsDeleteCmd_RequiresConfirmation(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &AlertsDeleteCmd{AlertID: "alert-delete-1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when NoInput is set without Force") + } +} + +func TestAlertsUndeleteCmd(t *testing.T) { + undeleted := false + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-undelete-1:undelete") { + undeleted = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alertId": "alert-undelete-1", + "deleted": false, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsUndeleteCmd{AlertID: "alert-undelete-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !undeleted { + t.Fatal("expected undelete API call") + } + if !strings.Contains(out, "Undeleted alert") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsFeedbackListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-fb-list/feedback") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "feedback": []map[string]any{ + { + "feedbackId": "fb-1", + "type": "VERY_USEFUL", + "email": "user@example.com", + "createTime": "2026-01-05T00:00:00Z", + }, + { + "feedbackId": "fb-2", + "type": "NOT_USEFUL", + "email": "user2@example.com", + "createTime": "2026-01-05T01:00:00Z", + }, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsFeedbackListCmd{AlertID: "alert-fb-list"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "fb-1") || !strings.Contains(out, "VERY_USEFUL") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsFeedbackListCmd_RequiresAlertID(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsFeedbackListCmd{AlertID: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when AlertID is empty") + } +} + +func TestAlertsFeedbackListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-fb-json/feedback") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "feedback": []map[string]any{ + { + "feedbackId": "fb-json-1", + "type": "SOMEWHAT_USEFUL", + "email": "json@example.com", + "createTime": "2026-01-06T00:00:00Z", + }, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsFeedbackListCmd{AlertID: "alert-fb-json"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "fb-json-1") || !strings.Contains(out, "SOMEWHAT_USEFUL") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAlertsFeedbackListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-fb-empty/feedback") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "feedback": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsFeedbackListCmd{AlertID: "alert-fb-empty"} + + // No error expected, just "no feedback" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestAlertsFeedbackCreateCmd(t *testing.T) { + var gotType string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-1/feedback"): + var payload struct { + Type string `json:"type"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotType = payload.Type + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "feedbackId": "fb-1", + "type": payload.Type, + }) + return + default: + http.NotFound(w, r) + } + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsFeedbackCreateCmd{AlertID: "alert-1", Type: "VERY_USEFUL"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotType != "VERY_USEFUL" { + t.Fatalf("expected feedback type VERY_USEFUL, got %q", gotType) + } + if !strings.Contains(out, "Created feedback") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsFeedbackCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta1/alerts/alert-create-json/feedback") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "feedbackId": "fb-created-json", + "type": "NOT_USEFUL", + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsFeedbackCreateCmd{AlertID: "alert-create-json", Type: "NOT_USEFUL"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "fb-created-json") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAlertsSettingsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta1/settings") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "notifications": []map[string]any{ + { + "cloudPubsubTopic": map[string]any{ + "topicName": "projects/my-project/topics/alerts", + }, + }, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsSettingsGetCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Notifications:") || !strings.Contains(out, "projects/my-project/topics/alerts") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsSettingsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta1/settings") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "notifications": []map[string]any{ + { + "cloudPubsubTopic": map[string]any{ + "topicName": "projects/test/topics/test-alerts", + }, + }, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsSettingsGetCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "projects/test/topics/test-alerts") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAlertsSettingsUpdateCmd(t *testing.T) { + var gotTopics []string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/v1beta1/settings") { + var payload struct { + Notifications []struct { + CloudPubsubTopic struct { + TopicName string `json:"topicName"` + } `json:"cloudPubsubTopic"` + } `json:"notifications"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + for _, n := range payload.Notifications { + gotTopics = append(gotTopics, n.CloudPubsubTopic.TopicName) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "notifications": payload.Notifications, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsSettingsUpdateCmd{Notifications: "projects/p1/topics/t1,projects/p2/topics/t2"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if len(gotTopics) != 2 { + t.Fatalf("expected 2 topics, got %d", len(gotTopics)) + } + if gotTopics[0] != "projects/p1/topics/t1" || gotTopics[1] != "projects/p2/topics/t2" { + t.Fatalf("unexpected topics: %v", gotTopics) + } + if !strings.Contains(out, "Updated alert settings") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAlertsSettingsUpdateCmd_RequiresNotifications(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsSettingsUpdateCmd{Notifications: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when Notifications is empty") + } +} + +func TestAlertsSettingsUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/v1beta1/settings") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "notifications": []map[string]any{ + { + "cloudPubsubTopic": map[string]any{ + "topicName": "projects/json/topics/alerts", + }, + }, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAlertCenter(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AlertsSettingsUpdateCmd{Notifications: "projects/json/topics/alerts"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "projects/json/topics/alerts") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func stubAlertCenter(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newAlertCenterService + svc, err := alertcenter.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new alertcenter service: %v", err) + } + newAlertCenterService = func(context.Context, string) (*alertcenter.Service, error) { return svc, nil } + t.Cleanup(func() { + newAlertCenterService = orig + srv.Close() + }) +} diff --git a/internal/cmd/aliases.go b/internal/cmd/aliases.go new file mode 100644 index 00000000..4c65a07e --- /dev/null +++ b/internal/cmd/aliases.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type AliasesCmd struct { + List AliasesListCmd `cmd:"" name:"list" aliases:"ls" help:"List aliases"` + Create AliasesCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create alias"` + Delete AliasesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete alias"` +} + +type AliasesListCmd struct { + User string `name:"user" help:"List aliases for a user"` + Group string `name:"group" help:"List aliases for a group"` +} + +func (c *AliasesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + user, group, err := resolveAliasTarget(c.User, c.Group) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + var resp *admin.Aliases + if user != "" { + resp, err = svc.Users.Aliases.List(user).Context(ctx).Do() + } else { + resp, err = svc.Groups.Aliases.List(group).Context(ctx).Do() + } + if err != nil { + return fmt.Errorf("list aliases: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Aliases) == 0 { + u.Err().Println("No aliases found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ALIAS\tTYPE\tTARGET") + for _, raw := range resp.Aliases { + alias := fmt.Sprintf("%v", raw) + if user != "" { + fmt.Fprintf(w, "%s\tuser\t%s\n", sanitizeTab(alias), sanitizeTab(user)) + } else { + fmt.Fprintf(w, "%s\tgroup\t%s\n", sanitizeTab(alias), sanitizeTab(group)) + } + } + + return nil +} + +type AliasesCreateCmd struct { + Alias string `arg:"" name:"alias" help:"Alias to create"` + User string `name:"user" help:"Create alias for a user"` + Group string `name:"group" help:"Create alias for a group"` +} + +func (c *AliasesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + user, group, err := resolveAliasTarget(c.User, c.Group) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + req := &admin.Alias{Alias: c.Alias} + if user != "" { + _, err = svc.Users.Aliases.Insert(user, req).Context(ctx).Do() + } else { + _, err = svc.Groups.Aliases.Insert(group, req).Context(ctx).Do() + } + if err != nil { + return fmt.Errorf("create alias %s: %w", c.Alias, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"alias": c.Alias}) + } + + if user != "" { + u.Out().Printf("Created alias %s for user %s\n", c.Alias, user) + } else { + u.Out().Printf("Created alias %s for group %s\n", c.Alias, group) + } + return nil +} + +type AliasesDeleteCmd struct { + Alias string `arg:"" name:"alias" help:"Alias to delete"` + User string `name:"user" help:"Delete alias for a user"` + Group string `name:"group" help:"Delete alias for a group"` +} + +func (c *AliasesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + user, group, err := resolveAliasTarget(c.User, c.Group) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete alias %s", c.Alias)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if user != "" { + err = svc.Users.Aliases.Delete(user, c.Alias).Context(ctx).Do() + } else { + err = svc.Groups.Aliases.Delete(group, c.Alias).Context(ctx).Do() + } + if err != nil { + return fmt.Errorf("delete alias %s: %w", c.Alias, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"alias": c.Alias}) + } + + if user != "" { + u.Out().Printf("Deleted alias %s for user %s\n", c.Alias, user) + } else { + u.Out().Printf("Deleted alias %s for group %s\n", c.Alias, group) + } + return nil +} + +func resolveAliasTarget(user, group string) (string, string, error) { + user = strings.TrimSpace(user) + group = strings.TrimSpace(group) + if user == "" && group == "" { + return "", "", usage("provide --user or --group") + } + if user != "" && group != "" { + return "", "", usage("provide only one of --user or --group") + } + return user, group, nil +} diff --git a/internal/cmd/aliases_test.go b/internal/cmd/aliases_test.go new file mode 100644 index 00000000..6aeb1f47 --- /dev/null +++ b/internal/cmd/aliases_test.go @@ -0,0 +1,460 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestAliasesListCmd_User(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/aliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "aliases": []string{"alias@example.com"}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesListCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "alias@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAliasesListCmd_Group(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/groups/") || !strings.Contains(r.URL.Path, "/aliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "aliases": []string{"group-alias@example.com", "another-alias@example.com"}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesListCmd{Group: "mygroup@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "group-alias@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAliasesListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/aliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "aliases": []string{"json-alias@example.com"}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesListCmd{User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "json-alias@example.com") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAliasesListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/aliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "aliases": []string{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesListCmd{User: "user@example.com"} + + // No error expected, just "no aliases" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestAliasesListCmd_RequiresUserOrGroup(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when neither User nor Group is provided") + } +} + +func TestAliasesListCmd_CannotProvideBoth(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesListCmd{User: "user@example.com", Group: "group@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when both User and Group are provided") + } +} + +func TestAliasesCreateCmd_User(t *testing.T) { + var gotAlias string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/users/") && strings.Contains(r.URL.Path, "/aliases") { + var payload struct { + Alias string `json:"alias"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotAlias = payload.Alias + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alias": payload.Alias, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesCreateCmd{Alias: "newalias@example.com", User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotAlias != "newalias@example.com" { + t.Fatalf("expected alias newalias@example.com, got %q", gotAlias) + } + if !strings.Contains(out, "Created alias") || !strings.Contains(out, "user") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAliasesCreateCmd_Group(t *testing.T) { + var gotAlias string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/groups/") && strings.Contains(r.URL.Path, "/aliases") { + var payload struct { + Alias string `json:"alias"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotAlias = payload.Alias + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alias": payload.Alias, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesCreateCmd{Alias: "groupalias@example.com", Group: "mygroup@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotAlias != "groupalias@example.com" { + t.Fatalf("expected alias groupalias@example.com, got %q", gotAlias) + } + if !strings.Contains(out, "Created alias") || !strings.Contains(out, "group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAliasesCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/users/") && strings.Contains(r.URL.Path, "/aliases") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "alias": "json-alias@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesCreateCmd{Alias: "json-alias@example.com", User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "json-alias@example.com") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAliasesCreateCmd_RequiresUserOrGroup(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AliasesCreateCmd{Alias: "alias@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when neither User nor Group is provided") + } +} + +func TestAliasesDeleteCmd_User(t *testing.T) { + deleted := false + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/users/") && strings.Contains(r.URL.Path, "/aliases/") { + deleted = true + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AliasesDeleteCmd{Alias: "deleteme@example.com", User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatal("expected delete API call") + } + if !strings.Contains(out, "Deleted alias") || !strings.Contains(out, "user") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAliasesDeleteCmd_Group(t *testing.T) { + deleted := false + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/groups/") && strings.Contains(r.URL.Path, "/aliases/") { + deleted = true + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AliasesDeleteCmd{Alias: "deleteme@example.com", Group: "mygroup@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatal("expected delete API call") + } + if !strings.Contains(out, "Deleted alias") || !strings.Contains(out, "group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAliasesDeleteCmd_RequiresConfirmation(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &AliasesDeleteCmd{Alias: "deleteme@example.com", User: "user@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when NoInput is set without Force") + } +} + +func TestAliasesDeleteCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/users/") && strings.Contains(r.URL.Path, "/aliases/") { + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AliasesDeleteCmd{Alias: "json-delete@example.com", User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "json-delete@example.com") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestAliasesDeleteCmd_RequiresUserOrGroup(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AliasesDeleteCmd{Alias: "alias@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when neither User nor Group is provided") + } +} + +func TestResolveAliasTarget(t *testing.T) { + tests := []struct { + name string + user string + group string + wantUser string + wantGroup string + wantErr bool + }{ + { + name: "user only", + user: "user@example.com", + group: "", + wantUser: "user@example.com", + wantGroup: "", + wantErr: false, + }, + { + name: "group only", + user: "", + group: "group@example.com", + wantUser: "", + wantGroup: "group@example.com", + wantErr: false, + }, + { + name: "neither provided", + user: "", + group: "", + wantUser: "", + wantGroup: "", + wantErr: true, + }, + { + name: "both provided", + user: "user@example.com", + group: "group@example.com", + wantUser: "", + wantGroup: "", + wantErr: true, + }, + { + name: "whitespace user", + user: " user@example.com ", + group: "", + wantUser: "user@example.com", + wantGroup: "", + wantErr: false, + }, + { + name: "whitespace only user", + user: " ", + group: "", + wantUser: "", + wantGroup: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUser, gotGroup, err := resolveAliasTarget(tt.user, tt.group) + if (err != nil) != tt.wantErr { + t.Errorf("resolveAliasTarget() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotUser != tt.wantUser { + t.Errorf("resolveAliasTarget() gotUser = %v, want %v", gotUser, tt.wantUser) + } + if gotGroup != tt.wantGroup { + t.Errorf("resolveAliasTarget() gotGroup = %v, want %v", gotGroup, tt.wantGroup) + } + }) + } +} diff --git a/internal/cmd/analytics.go b/internal/cmd/analytics.go new file mode 100644 index 00000000..03a0da1e --- /dev/null +++ b/internal/cmd/analytics.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newAnalyticsAdminService = googleapi.NewAnalyticsAdmin + +type AnalyticsCmd struct { + Accounts AnalyticsAccountsCmd `cmd:"" name:"accounts" help:"List Analytics accounts"` + Properties AnalyticsPropertiesCmd `cmd:"" name:"properties" help:"List Analytics properties for an account"` + DataStreams AnalyticsDataStreamsCmd `cmd:"" name:"datastreams" help:"List Analytics data streams for a property"` +} + +type AnalyticsAccountsCmd struct { + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *AnalyticsAccountsCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAnalyticsAdminService(ctx, account) + if err != nil { + return err + } + + call := svc.Accounts.List() + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list analytics accounts: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Accounts) == 0 { + u.Err().Println("No analytics accounts found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tDISPLAY NAME") + for _, acc := range resp.Accounts { + if acc == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\n", sanitizeTab(acc.Name), sanitizeTab(acc.DisplayName)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type AnalyticsPropertiesCmd struct { + AccountID string `name:"account-id" help:"Account ID" required:""` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *AnalyticsPropertiesCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + accountID := strings.TrimSpace(c.AccountID) + if accountID == "" { + return usage("--account-id is required") + } + if !strings.HasPrefix(accountID, "accounts/") { + accountID = "accounts/" + accountID + } + + svc, err := newAnalyticsAdminService(ctx, account) + if err != nil { + return err + } + + call := svc.Properties.List().Filter(fmt.Sprintf("parent:%s", accountID)) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list analytics properties: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Properties) == 0 { + u.Err().Println("No properties found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tDISPLAY NAME\tTIME ZONE") + for _, prop := range resp.Properties { + if prop == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(prop.Name), + sanitizeTab(prop.DisplayName), + sanitizeTab(prop.TimeZone), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type AnalyticsDataStreamsCmd struct { + Property string `name:"property" help:"Property ID" required:""` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *AnalyticsDataStreamsCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + propertyID := strings.TrimSpace(c.Property) + if propertyID == "" { + return usage("--property is required") + } + if !strings.HasPrefix(propertyID, "properties/") { + propertyID = "properties/" + propertyID + } + + svc, err := newAnalyticsAdminService(ctx, account) + if err != nil { + return err + } + + call := svc.Properties.DataStreams.List(propertyID) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list analytics datastreams: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.DataStreams) == 0 { + u.Err().Println("No data streams found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tDISPLAY NAME\tTYPE") + for _, ds := range resp.DataStreams { + if ds == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(ds.Name), + sanitizeTab(ds.DisplayName), + sanitizeTab(ds.Type), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} diff --git a/internal/cmd/analytics_test.go b/internal/cmd/analytics_test.go new file mode 100644 index 00000000..255dd449 --- /dev/null +++ b/internal/cmd/analytics_test.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + analyticsadmin "google.golang.org/api/analyticsadmin/v1beta" + "google.golang.org/api/option" +) + +func TestAnalyticsAccountsCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta/accounts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": []map[string]any{{"name": "accounts/123", "displayName": "Acme"}}, + }) + }) + stubAnalytics(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &AnalyticsAccountsCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Acme") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAnalyticsPropertiesCmd(t *testing.T) { + var gotFilter string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta/properties") { + http.NotFound(w, r) + return + } + gotFilter = r.URL.Query().Get("filter") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "properties": []map[string]any{{"name": "properties/123", "displayName": "Web", "timeZone": "UTC"}}, + }) + }) + stubAnalytics(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &AnalyticsPropertiesCmd{AccountID: "123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotFilter != "parent:accounts/123" { + t.Fatalf("unexpected filter: %q", gotFilter) + } + if !strings.Contains(out, "properties/123") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAnalyticsDataStreamsCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1beta/properties/123/dataStreams") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dataStreams": []map[string]any{{"name": "properties/123/dataStreams/1", "displayName": "Web", "type": "WEB_DATA_STREAM"}}, + }) + }) + stubAnalytics(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &AnalyticsDataStreamsCmd{Property: "123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "dataStreams/1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubAnalytics(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newAnalyticsAdminService + svc, err := analyticsadmin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new analytics service: %v", err) + } + newAnalyticsAdminService = func(context.Context, string) (*analyticsadmin.Service, error) { return svc, nil } + t.Cleanup(func() { + newAnalyticsAdminService = orig + srv.Close() + }) +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index a615c208..b70be799 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -1049,7 +1049,7 @@ func (c *AuthKeepCmd) Run(ctx context.Context) error { func parseAuthServices(servicesCSV string) ([]googleauth.Service, error) { trimmed := strings.ToLower(strings.TrimSpace(servicesCSV)) - if trimmed == "" || trimmed == "user" || trimmed == "all" { + if trimmed == "" || trimmed == "user" || trimmed == scopeAll { return googleauth.UserServices(), nil } diff --git a/internal/cmd/auth_comprehensive_test.go b/internal/cmd/auth_comprehensive_test.go new file mode 100644 index 00000000..ad7ab2b4 --- /dev/null +++ b/internal/cmd/auth_comprehensive_test.go @@ -0,0 +1,962 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/secrets" + "github.com/steipete/gogcli/internal/ui" +) + +// ============================================================================ +// AuthAliasListCmd Tests - auth_alias.go line 23 +// ============================================================================ + +func TestAuthAliasListCmd_TextOutput_NoAliases(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + var stdout, stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &AuthAliasListCmd{}, []string{}, ctx, &RootFlags{}); err != nil { + t.Fatalf("list: %v", err) + } + + if !strings.Contains(stderr.String(), "No account aliases") { + t.Fatalf("expected 'No account aliases' in stderr, got: %q", stderr.String()) + } +} + +func TestAuthAliasListCmd_TextOutput_WithAliases(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + // First set an alias + if err := config.SetAccountAlias("work", "work@example.com"); err != nil { + t.Fatalf("SetAccountAlias: %v", err) + } + if err := config.SetAccountAlias("personal", "personal@example.com"); err != nil { + t.Fatalf("SetAccountAlias: %v", err) + } + + // Use captureStdout since tableWriter writes to os.Stdout + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"auth", "alias", "list"}); err != nil { + t.Fatalf("list: %v", err) + } + }) + }) + + if !strings.Contains(out, "ALIAS") || !strings.Contains(out, "EMAIL") { + t.Fatalf("expected table headers, got: %q", out) + } + if !strings.Contains(out, "work") || !strings.Contains(out, "work@example.com") { + t.Fatalf("expected work alias in output, got: %q", out) + } + if !strings.Contains(out, "personal") || !strings.Contains(out, "personal@example.com") { + t.Fatalf("expected personal alias in output, got: %q", out) + } +} + +func TestAuthAliasListCmd_JSONOutput_Empty(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &AuthAliasListCmd{}, []string{}, ctx, &RootFlags{}); err != nil { + t.Fatalf("list: %v", err) + } + }) + + var resp struct { + Aliases map[string]string `json:"aliases"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if len(resp.Aliases) != 0 { + t.Fatalf("expected empty aliases, got: %#v", resp.Aliases) + } +} + +// ============================================================================ +// AuthAliasSetCmd Tests - auth_alias.go line 55 +// ============================================================================ + +func TestAuthAliasSetCmd_TextOutput(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + var stdout bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &AuthAliasSetCmd{}, []string{"myalias", "test@example.com"}, ctx, &RootFlags{}); err != nil { + t.Fatalf("set: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "alias\tmyalias") { + t.Fatalf("expected 'alias\\tmyalias' in output, got: %q", out) + } + if !strings.Contains(out, "email\ttest@example.com") { + t.Fatalf("expected 'email\\ttest@example.com' in output, got: %q", out) + } +} + +func TestAuthAliasSetCmd_EmptyAlias(t *testing.T) { + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &AuthAliasSetCmd{Alias: " ", Email: "test@example.com"} + err = cmd.Run(ctx) + if err == nil { + t.Fatalf("expected error for empty alias") + } + + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 2 { + t.Fatalf("expected usage error (exit code 2), got: %v", err) + } +} + +func TestAuthAliasSetCmd_AliasWithAtSign(t *testing.T) { + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &AuthAliasSetCmd{Alias: "bad@alias", Email: "test@example.com"} + err = cmd.Run(ctx) + if err == nil { + t.Fatalf("expected error for alias with '@'") + } + if !strings.Contains(err.Error(), "must not contain '@'") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestAuthAliasSetCmd_ReservedAlias(t *testing.T) { + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + for _, reserved := range []string{"auto", "AUTO", "default", "DEFAULT"} { + cmd := &AuthAliasSetCmd{Alias: reserved, Email: "test@example.com"} + err = cmd.Run(ctx) + if err == nil { + t.Fatalf("expected error for reserved alias %q", reserved) + } + if !strings.Contains(err.Error(), "reserved") { + t.Fatalf("unexpected error for %q: %v", reserved, err) + } + } +} + +func TestAuthAliasSetCmd_EmptyEmail(t *testing.T) { + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &AuthAliasSetCmd{Alias: "myalias", Email: " "} + err = cmd.Run(ctx) + if err == nil { + t.Fatalf("expected error for empty email") + } + if !strings.Contains(err.Error(), "empty email") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ============================================================================ +// AuthAliasUnsetCmd Tests - auth_alias.go line 89 +// ============================================================================ + +func TestAuthAliasUnsetCmd_TextOutput(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + // First set an alias + if err := config.SetAccountAlias("todelete", "delete@example.com"); err != nil { + t.Fatalf("SetAccountAlias: %v", err) + } + + var stdout bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &AuthAliasUnsetCmd{}, []string{"todelete"}, ctx, &RootFlags{}); err != nil { + t.Fatalf("unset: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "deleted\ttrue") { + t.Fatalf("expected 'deleted\\ttrue' in output, got: %q", out) + } + if !strings.Contains(out, "alias\ttodelete") { + t.Fatalf("expected 'alias\\ttodelete' in output, got: %q", out) + } +} + +func TestAuthAliasUnsetCmd_EmptyAlias(t *testing.T) { + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &AuthAliasUnsetCmd{Alias: " "} + err = cmd.Run(ctx) + if err == nil { + t.Fatalf("expected error for empty alias") + } + + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 2 { + t.Fatalf("expected usage error (exit code 2), got: %v", err) + } +} + +func TestAuthAliasUnsetCmd_NotFound(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &AuthAliasUnsetCmd{Alias: "nonexistent"} + err = cmd.Run(ctx) + if err == nil { + t.Fatalf("expected error for nonexistent alias") + } + if !strings.Contains(err.Error(), "alias not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAuthAliasUnsetCmd_JSONOutput(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + // First set an alias + if err := config.SetAccountAlias("tounset", "unset@example.com"); err != nil { + t.Fatalf("SetAccountAlias: %v", err) + } + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &AuthAliasUnsetCmd{}, []string{"tounset"}, ctx, &RootFlags{}); err != nil { + t.Fatalf("unset: %v", err) + } + }) + + var resp struct { + Deleted bool `json:"deleted"` + Alias string `json:"alias"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if !resp.Deleted || resp.Alias != "tounset" { + t.Fatalf("unexpected response: %#v", resp) + } +} + +// ============================================================================ +// AuthKeyringCmd Tests - auth_keyring.go line 21 +// ============================================================================ + +func TestAuthKeyringCmd_ShowCurrentConfig_NoArgs(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "") + + var stdout, stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &AuthKeyringCmd{}, []string{}, ctx, nil); err != nil { + t.Fatalf("run: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "path\t") { + t.Fatalf("expected 'path' in output, got: %q", out) + } + if !strings.Contains(out, "keyring_backend\t") { + t.Fatalf("expected 'keyring_backend' in output, got: %q", out) + } + if !strings.Contains(out, "source\t") { + t.Fatalf("expected 'source' in output, got: %q", out) + } + if !strings.Contains(stderr.String(), "Hint:") { + t.Fatalf("expected hint in stderr, got: %q", stderr.String()) + } +} + +func TestAuthKeyringCmd_ShowCurrentConfig_JSON(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "file") + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &AuthKeyringCmd{}, []string{}, ctx, nil); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var resp struct { + KeyringBackend string `json:"keyring_backend"` + Source string `json:"source"` + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if resp.KeyringBackend != "file" { + t.Fatalf("expected backend 'file', got: %q", resp.KeyringBackend) + } + if resp.Source != "env" { + t.Fatalf("expected source 'env', got: %q", resp.Source) + } +} + +func TestAuthKeyringCmd_TooManyArgs(t *testing.T) { + var stdout, stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + err = runKong(t, &AuthKeyringCmd{}, []string{"auto", "extra"}, ctx, nil) + if err == nil { + t.Fatalf("expected error for too many args") + } + if !strings.Contains(err.Error(), "too many args") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAuthKeyringCmd_DefaultConvertsToAuto(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "") + + var stdout, stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err = runKong(t, &AuthKeyringCmd{}, []string{"default"}, ctx, nil); err != nil { + t.Fatalf("run: %v", err) + } + + cfg, err := config.ReadConfig() + if err != nil { + t.Fatalf("read config: %v", err) + } + if cfg.KeyringBackend != "auto" { + t.Fatalf("expected 'auto', got: %q", cfg.KeyringBackend) + } +} + +func TestAuthKeyringCmd_JSONOutput_AfterSet(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "") + t.Setenv("GOG_KEYRING_PASSWORD", "") + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &AuthKeyringCmd{}, []string{"keychain"}, ctx, nil); err != nil { + t.Fatalf("run: %v", err) + } + }) + + var resp struct { + Written bool `json:"written"` + Path string `json:"path"` + KeyringBackend string `json:"keyring_backend"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if !resp.Written { + t.Fatalf("expected written=true") + } + if resp.KeyringBackend != "keychain" { + t.Fatalf("expected backend 'keychain', got: %q", resp.KeyringBackend) + } +} + +func TestAuthKeyringCmd_EnvVarOverridesConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "file") + t.Setenv("GOG_KEYRING_PASSWORD", "") + + var stdout, stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &AuthKeyringCmd{}, []string{"keychain"}, ctx, nil); err != nil { + t.Fatalf("run: %v", err) + } + + // Should warn about env var override + if !strings.Contains(stderr.String(), "GOG_KEYRING_BACKEND=file overrides config") { + t.Fatalf("expected env override warning in stderr, got: %q", stderr.String()) + } +} + +func TestAuthKeyringCmd_NilUI(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "") + + // Test with nil UI context (u == nil) + ctx := context.Background() + + // No args - should not panic + if err := (&AuthKeyringCmd{}).Run(ctx); err != nil { + t.Fatalf("run: %v", err) + } + + // With backend arg - should not panic + if err := (&AuthKeyringCmd{Backend: "auto"}).Run(ctx); err != nil { + t.Fatalf("run with backend: %v", err) + } +} + +// ============================================================================ +// bestServiceAccountPathAndMtime Tests - auth.go line 876 +// ============================================================================ + +func TestBestServiceAccountPathAndMtime_ServiceAccountPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + email := "test@example.com" + + // Get the expected service account path + saPath, err := config.ServiceAccountPath(email) + if err != nil { + t.Fatalf("ServiceAccountPath: %v", err) + } + + // Create the config directory + if err = os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Write a service account file + if err = os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + // Get the file's mtime for comparison + stat, err := os.Stat(saPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + expectedMtime := stat.ModTime() + + // Now test the function + path, mtime, ok := bestServiceAccountPathAndMtime(email) + if !ok { + t.Fatalf("expected to find service account") + } + if path != saPath { + t.Fatalf("expected path %q, got %q", saPath, path) + } + if !mtime.Equal(expectedMtime) { + t.Fatalf("expected mtime %v, got %v", expectedMtime, mtime) + } +} + +func TestBestServiceAccountPathAndMtime_KeepServiceAccountPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + email := "keep@example.com" + + // Get the expected keep service account path + keepPath, err := config.KeepServiceAccountPath(email) + if err != nil { + t.Fatalf("KeepServiceAccountPath: %v", err) + } + + // Create the config directory + if err := os.MkdirAll(filepath.Dir(keepPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Write a keep service account file + if err := os.WriteFile(keepPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + // Test the function - should find the keep path (ServiceAccountPath is checked first, then KeepServiceAccountPath) + path, mtime, ok := bestServiceAccountPathAndMtime(email) + if !ok { + t.Fatalf("expected to find service account") + } + if path != keepPath { + t.Fatalf("expected path %q, got %q", keepPath, path) + } + if mtime.IsZero() { + t.Fatalf("expected non-zero mtime") + } +} + +func TestBestServiceAccountPathAndMtime_KeepLegacyPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + email := "legacy@example.com" + + // Get the expected legacy keep service account path + legacyPath, err := config.KeepServiceAccountLegacyPath(email) + if err != nil { + t.Fatalf("KeepServiceAccountLegacyPath: %v", err) + } + + // Create the config directory + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Write a legacy keep service account file + if err := os.WriteFile(legacyPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + // Test the function - should find the legacy path after checking the other paths + path, mtime, ok := bestServiceAccountPathAndMtime(email) + if !ok { + t.Fatalf("expected to find service account") + } + if path != legacyPath { + t.Fatalf("expected path %q, got %q", legacyPath, path) + } + if mtime.IsZero() { + t.Fatalf("expected non-zero mtime") + } +} + +func TestBestServiceAccountPathAndMtime_NotFound(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + email := "notfound@example.com" + + path, mtime, ok := bestServiceAccountPathAndMtime(email) + if ok { + t.Fatalf("expected not to find service account, got path=%q mtime=%v", path, mtime) + } + if path != "" { + t.Fatalf("expected empty path, got %q", path) + } + if !mtime.IsZero() { + t.Fatalf("expected zero mtime, got %v", mtime) + } +} + +func TestBestServiceAccountPathAndMtime_PriorityOrder(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + email := "priority@example.com" + + // Get all paths + saPath, _ := config.ServiceAccountPath(email) + keepPath, _ := config.KeepServiceAccountPath(email) + legacyPath, _ := config.KeepServiceAccountLegacyPath(email) + + // Create the config directory + if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Write all three files + for _, p := range []string{saPath, keepPath, legacyPath} { + if err := os.WriteFile(p, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write %s: %v", p, err) + } + } + + // Test that ServiceAccountPath takes priority + path, _, ok := bestServiceAccountPathAndMtime(email) + if !ok { + t.Fatalf("expected to find service account") + } + if path != saPath { + t.Fatalf("expected ServiceAccountPath %q to take priority, got %q", saPath, path) + } + + // Remove ServiceAccountPath and check KeepServiceAccountPath takes priority + if err := os.Remove(saPath); err != nil { + t.Fatalf("remove: %v", err) + } + + path, _, ok = bestServiceAccountPathAndMtime(email) + if !ok { + t.Fatalf("expected to find service account") + } + if path != keepPath { + t.Fatalf("expected KeepServiceAccountPath %q to take priority, got %q", keepPath, path) + } + + // Remove KeepServiceAccountPath and check legacy path is found + if err := os.Remove(keepPath); err != nil { + t.Fatalf("remove: %v", err) + } + + path, _, ok = bestServiceAccountPathAndMtime(email) + if !ok { + t.Fatalf("expected to find service account") + } + if path != legacyPath { + t.Fatalf("expected legacy path %q, got %q", legacyPath, path) + } +} + +// ============================================================================ +// AuthStatus with Service Account Tests +// ============================================================================ + +func TestAuthStatus_WithServiceAccount(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + t.Setenv("GOG_KEYRING_BACKEND", "file") + + origOpen := openSecretsStore + t.Cleanup(func() { openSecretsStore = origOpen }) + + email := "sa@example.com" + store := newMemSecretsStore() + _ = store.SetToken(config.DefaultClientName, email, secrets.Token{RefreshToken: "rt", Email: email}) + openSecretsStore = func() (secrets.Store, error) { return store, nil } + + // Create service account file + saPath, _ := config.ServiceAccountPath(email) + if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := (&AuthStatusCmd{}).Run(ctx, &RootFlags{Account: email}); err != nil { + t.Fatalf("status: %v", err) + } + }) + + var resp struct { + Account struct { + Email string `json:"email"` + AuthPreferred string `json:"auth_preferred"` + ServiceAccountConfigured bool `json:"service_account_configured"` + ServiceAccountPath string `json:"service_account_path"` + } `json:"account"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if !resp.Account.ServiceAccountConfigured { + t.Fatalf("expected service_account_configured=true") + } + if resp.Account.AuthPreferred != "service_account" { + t.Fatalf("expected auth_preferred='service_account', got %q", resp.Account.AuthPreferred) + } + if resp.Account.ServiceAccountPath != saPath { + t.Fatalf("expected service_account_path=%q, got %q", saPath, resp.Account.ServiceAccountPath) + } +} + +// ============================================================================ +// AuthList with Service Account Tests +// ============================================================================ + +func TestAuthList_WithServiceAccountOnly(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + origOpen := openSecretsStore + t.Cleanup(func() { openSecretsStore = origOpen }) + + store := newMemSecretsStore() + openSecretsStore = func() (secrets.Store, error) { return store, nil } + + email := "saonly@example.com" + + // Create service account file + saPath, _ := config.ServiceAccountPath(email) + if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := (&AuthListCmd{}).Run(ctx); err != nil { + t.Fatalf("list: %v", err) + } + }) + + var resp struct { + Accounts []struct { + Email string `json:"email"` + Auth string `json:"auth"` + Services []string `json:"services"` + CreatedAt string `json:"created_at"` + } `json:"accounts"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if len(resp.Accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(resp.Accounts)) + } + if resp.Accounts[0].Email != email { + t.Fatalf("expected email %q, got %q", email, resp.Accounts[0].Email) + } + if resp.Accounts[0].Auth != "service_account" { + t.Fatalf("expected auth='service_account', got %q", resp.Accounts[0].Auth) + } + if resp.Accounts[0].CreatedAt == "" { + t.Fatalf("expected created_at to be set from file mtime") + } +} + +func TestAuthList_WithOAuthAndServiceAccount(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + origOpen := openSecretsStore + t.Cleanup(func() { openSecretsStore = origOpen }) + + email := "both@example.com" + store := newMemSecretsStore() + _ = store.SetToken(config.DefaultClientName, email, secrets.Token{ + RefreshToken: "rt", + Email: email, + Services: []string{"gmail"}, + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }) + openSecretsStore = func() (secrets.Store, error) { return store, nil } + + // Create service account file + saPath, _ := config.ServiceAccountPath(email) + if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := (&AuthListCmd{}).Run(ctx); err != nil { + t.Fatalf("list: %v", err) + } + }) + + var resp struct { + Accounts []struct { + Email string `json:"email"` + Auth string `json:"auth"` + } `json:"accounts"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if len(resp.Accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(resp.Accounts)) + } + if resp.Accounts[0].Auth != "oauth+service_account" { + t.Fatalf("expected auth='oauth+service_account', got %q", resp.Accounts[0].Auth) + } +} + +func TestAuthList_ServiceAccountCheck_Text(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + origOpen := openSecretsStore + origCheck := checkRefreshToken + t.Cleanup(func() { + openSecretsStore = origOpen + checkRefreshToken = origCheck + }) + + store := newMemSecretsStore() + openSecretsStore = func() (secrets.Store, error) { return store, nil } + checkRefreshToken = func(context.Context, string, string, []string, time.Duration) error { + return nil + } + + email := "sacheck@example.com" + + // Create service account file + saPath, _ := config.ServiceAccountPath(email) + if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"auth", "list", "--check"}); err != nil { + t.Fatalf("list --check: %v", err) + } + }) + }) + + // Service account should show as valid with note that it's not checked + if !strings.Contains(out, email) { + t.Fatalf("expected email in output: %q", out) + } + if !strings.Contains(out, "\ttrue\t") { + t.Fatalf("expected true in output: %q", out) + } + if !strings.Contains(out, "service account (not checked)") { + t.Fatalf("expected 'service account (not checked)' in output: %q", out) + } +} + +func TestAuthList_Text_WithServiceAccount(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + origOpen := openSecretsStore + t.Cleanup(func() { openSecretsStore = origOpen }) + + store := newMemSecretsStore() + openSecretsStore = func() (secrets.Store, error) { return store, nil } + + email := "satext@example.com" + + // Create service account file + saPath, _ := config.ServiceAccountPath(email) + if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"auth", "list"}); err != nil { + t.Fatalf("list: %v", err) + } + }) + }) + + // Should show service account auth type + if !strings.Contains(out, email) { + t.Fatalf("expected email in output: %q", out) + } + if !strings.Contains(out, "service_account") || !strings.Contains(out, "service-account") { + t.Fatalf("expected service_account or service-account in output: %q", out) + } +} diff --git a/internal/cmd/auth_keyring.go b/internal/cmd/auth_keyring.go index 4db69a3b..7f41c783 100644 --- a/internal/cmd/auth_keyring.go +++ b/internal/cmd/auth_keyring.go @@ -63,7 +63,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error { } if backend == "default" { - backend = "auto" + backend = colorAuto } allowed := map[string]struct{}{ diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go new file mode 100644 index 00000000..b0da32ca --- /dev/null +++ b/internal/cmd/batch.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/steipete/gogcli/internal/ui" +) + +type BatchCmd struct { + File string `arg:"" name:"file" help:"Batch file (one command per line, lines starting with # are comments)"` + Parallel int `name:"parallel" help:"Number of commands to run in parallel" default:"1"` + DryRun bool `name:"dry-run" help:"Preview commands without executing"` +} + +func (c *BatchCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if strings.TrimSpace(c.File) == "" { + return usage("file is required") + } + + lines, err := readBatchLines(c.File) + if err != nil { + return err + } + if len(lines) == 0 { + return fmt.Errorf("no commands found") + } + + // Handle dry-run mode + if c.DryRun { + for _, task := range lines { + if u != nil { + u.Err().Printf("[dry-run] line %d: %s\n", task.Line, strings.Join(task.Args, " ")) + } + } + if u != nil { + u.Err().Printf("Batch preview: total=%d (no commands executed)\n", len(lines)) + } + return nil + } + + parallel := c.Parallel + if parallel < 1 { + parallel = 1 + } + + tasks := make(chan batchTask) + results := make(chan error, len(lines)) + var wg sync.WaitGroup + + worker := func() { + defer wg.Done() + for task := range tasks { + if err := executeSubcommand(ctx, flags, task.Args); err != nil { + results <- fmt.Errorf("line %d: %w", task.Line, err) + continue + } + results <- nil + } + } + + for i := 0; i < parallel; i++ { + wg.Add(1) + go worker() + } + + for _, task := range lines { + tasks <- task + } + close(tasks) + go func() { wg.Wait(); close(results) }() + + failed := 0 + for err := range results { + if err != nil { + failed++ + if u != nil { + u.Err().Error(err.Error()) + } + } + } + + if u != nil { + u.Err().Printf("Batch complete: total=%d failed=%d\n", len(lines), failed) + } + if failed > 0 { + return fmt.Errorf("%d commands failed", failed) + } + return nil +} + +type batchTask struct { + Line int + Args []string +} + +func readBatchLines(path string) ([]batchTask, error) { + var scanner *bufio.Scanner + if strings.TrimSpace(path) == "-" { + scanner = bufio.NewScanner(os.Stdin) + } else { + f, err := os.Open(path) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return nil, fmt.Errorf("open batch file: %w", err) + } + defer f.Close() + scanner = bufio.NewScanner(f) + } + + lines := []batchTask{} + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + args, err := splitCommandLine(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + if len(args) == 0 { + continue + } + lines = append(lines, batchTask{Line: lineNo, Args: args}) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read batch file: %w", err) + } + + return lines, nil +} diff --git a/internal/cmd/caa.go b/internal/cmd/caa.go new file mode 100644 index 00000000..25e528f0 --- /dev/null +++ b/internal/cmd/caa.go @@ -0,0 +1,424 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "google.golang.org/api/accesscontextmanager/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newAccessContextManagerService = googleapi.NewAccessContextManager + +type CAACmd struct { + Levels CAALevelsCmd `cmd:"" name:"levels" help:"Manage access levels"` +} + +type CAALevelsCmd struct { + List CAALevelsListCmd `cmd:"" name:"list" help:"List access levels"` + Get CAALevelsGetCmd `cmd:"" name:"get" help:"Get access level"` + Create CAALevelsCreateCmd `cmd:"" name:"create" help:"Create access level"` + Update CAALevelsUpdateCmd `cmd:"" name:"update" help:"Update access level"` + Delete CAALevelsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete access level"` +} + +type CAALevelsListCmd struct { + Policy string `name:"policy" help:"Access policy ID or resource name"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *CAALevelsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + policy := strings.TrimSpace(c.Policy) + if policy == "" { + return usage("--policy is required") + } + + svc, err := newAccessContextManagerService(ctx, account) + if err != nil { + return err + } + + parent := normalizeAccessPolicy(policy) + call := svc.AccessPolicies.AccessLevels.List(parent) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list access levels: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.AccessLevels) == 0 { + u.Err().Println("No access levels found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tTITLE\tTYPE\tDESCRIPTION") + for _, level := range resp.AccessLevels { + if level == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(level.Name), + sanitizeTab(level.Title), + sanitizeTab(accessLevelType(level)), + sanitizeTab(level.Description), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type CAALevelsGetCmd struct { + Name string `arg:"" name:"name" help:"Access level name"` + Policy string `name:"policy" help:"Access policy ID or resource name"` +} + +func (c *CAALevelsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("access level name is required") + } + + svc, err := newAccessContextManagerService(ctx, account) + if err != nil { + return err + } + + fullName, err := normalizeAccessLevelName(c.Policy, name) + if err != nil { + return err + } + + level, err := svc.AccessPolicies.AccessLevels.Get(fullName).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get access level %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, level) + } + + u.Out().Printf("Name: %s\n", level.Name) + if level.Title != "" { + u.Out().Printf("Title: %s\n", level.Title) + } + if level.Description != "" { + u.Out().Printf("Description: %s\n", level.Description) + } + u.Out().Printf("Type: %s\n", accessLevelType(level)) + if level.Custom != nil && level.Custom.Expr != nil && level.Custom.Expr.Expression != "" { + u.Out().Printf("Expression: %s\n", level.Custom.Expr.Expression) + } + if level.Basic != nil { + u.Out().Printf("Conditions: %d\n", len(level.Basic.Conditions)) + } + return nil +} + +type CAALevelsCreateCmd struct { + Name string `arg:"" name:"name" help:"Access level name"` + Description string `name:"description" help:"Access level description"` + Policy string `name:"policy" help:"Access policy ID or resource name"` + Basic bool `name:"basic" help:"Create a basic access level"` + Custom bool `name:"custom" help:"Create a custom access level"` + Conditions []string `name:"condition" help:"Condition JSON (repeatable)"` + Expr string `name:"expr" help:"CEL expression for custom access levels"` +} + +func (c *CAALevelsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("access level name is required") + } + policy := strings.TrimSpace(c.Policy) + if policy == "" { + return usage("--policy is required") + } + if c.Basic == c.Custom { + return usage("exactly one of --basic or --custom is required") + } + + svc, err := newAccessContextManagerService(ctx, account) + if err != nil { + return err + } + + fullName, err := normalizeAccessLevelName(policy, name) + if err != nil { + return err + } + title := accessLevelTitle(fullName) + level := &accesscontextmanager.AccessLevel{ + Name: fullName, + Title: title, + Description: c.Description, + } + + if c.Basic { + var conditions []*accesscontextmanager.Condition + conditions, err = parseCAAConditions(c.Conditions) + if err != nil { + return err + } + if len(conditions) == 0 { + return usage("--condition is required for basic access levels") + } + level.Basic = &accesscontextmanager.BasicLevel{Conditions: conditions} + } + + if c.Custom { + expr := strings.TrimSpace(c.Expr) + if expr == "" { + return usage("--expr is required for custom access levels") + } + level.Custom = &accesscontextmanager.CustomLevel{Expr: &accesscontextmanager.Expr{Expression: expr}} + } + + op, err := svc.AccessPolicies.AccessLevels.Create(normalizeAccessPolicy(policy), level).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create access level: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Created access level: %s\n", fullName) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CAALevelsUpdateCmd struct { + Name string `arg:"" name:"name" help:"Access level name"` + Description *string `name:"description" help:"Access level description"` + Policy string `name:"policy" help:"Access policy ID or resource name"` + Conditions []string `name:"condition" help:"Condition JSON (repeatable)"` + Expr string `name:"expr" help:"CEL expression for custom access levels"` +} + +func (c *CAALevelsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("access level name is required") + } + + svc, err := newAccessContextManagerService(ctx, account) + if err != nil { + return err + } + + fullName, err := normalizeAccessLevelName(c.Policy, name) + if err != nil { + return err + } + + updateMask := make([]string, 0, 3) + patch := &accesscontextmanager.AccessLevel{} + + if c.Description != nil { + patch.Description = strings.TrimSpace(*c.Description) + updateMask = append(updateMask, "description") + } + + expr := strings.TrimSpace(c.Expr) + if expr != "" && len(c.Conditions) > 0 { + return usage("cannot combine --expr with --condition") + } + + if len(c.Conditions) > 0 { + var conditions []*accesscontextmanager.Condition + conditions, err = parseCAAConditions(c.Conditions) + if err != nil { + return err + } + if len(conditions) == 0 { + return usage("no conditions specified") + } + patch.Basic = &accesscontextmanager.BasicLevel{Conditions: conditions} + updateMask = append(updateMask, "basic") + } + + if expr != "" { + patch.Custom = &accesscontextmanager.CustomLevel{Expr: &accesscontextmanager.Expr{Expression: expr}} + updateMask = append(updateMask, "custom") + } + + if len(updateMask) == 0 { + return usage("no updates specified") + } + + op, err := svc.AccessPolicies.AccessLevels.Patch(fullName, patch). + UpdateMask(strings.Join(updateMask, ",")). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("update access level %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Updated access level: %s\n", fullName) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CAALevelsDeleteCmd struct { + Name string `arg:"" name:"name" help:"Access level name"` + Policy string `name:"policy" help:"Access policy ID or resource name"` +} + +func (c *CAALevelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("access level name is required") + } + + fullName, err := normalizeAccessLevelName(c.Policy, name) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete access level %s", fullName)); err != nil { + return err + } + + svc, err := newAccessContextManagerService(ctx, account) + if err != nil { + return err + } + + op, err := svc.AccessPolicies.AccessLevels.Delete(fullName).Context(ctx).Do() + if err != nil { + return fmt.Errorf("delete access level %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Deleted access level: %s\n", fullName) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +func normalizeAccessPolicy(policy string) string { + policy = strings.TrimSpace(policy) + if policy == "" { + return "" + } + if strings.HasPrefix(policy, "accessPolicies/") { + return policy + } + return "accessPolicies/" + policy +} + +func normalizeAccessLevelName(policy, name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", usage("access level name is required") + } + if strings.HasPrefix(name, "accessPolicies/") { + return name, nil + } + policy = strings.TrimSpace(policy) + if policy == "" { + return "", usage("--policy is required when using short names") + } + return fmt.Sprintf("%s/accessLevels/%s", normalizeAccessPolicy(policy), name), nil +} + +func accessLevelType(level *accesscontextmanager.AccessLevel) string { + if level == nil { + return "" + } + if level.Basic != nil { + return "basic" + } + if level.Custom != nil { + return "custom" + } + return trackingUnknown +} + +func accessLevelTitle(name string) string { + if idx := strings.LastIndex(name, "/"); idx >= 0 && idx < len(name)-1 { + return name[idx+1:] + } + return name +} + +func parseCAAConditions(inputs []string) ([]*accesscontextmanager.Condition, error) { + conditions := make([]*accesscontextmanager.Condition, 0, len(inputs)) + for _, raw := range inputs { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + payload, err := readValueOrFile(trimmed) + if err != nil { + return nil, err + } + var cond accesscontextmanager.Condition + if err := json.Unmarshal([]byte(payload), &cond); err != nil { + return nil, fmt.Errorf("parse condition: %w", err) + } + conditions = append(conditions, &cond) + } + return conditions, nil +} diff --git a/internal/cmd/caa_test.go b/internal/cmd/caa_test.go new file mode 100644 index 00000000..4d966579 --- /dev/null +++ b/internal/cmd/caa_test.go @@ -0,0 +1,426 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/accesscontextmanager/v1" + "google.golang.org/api/option" +) + +func TestCAALevelsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accessLevels": []map[string]any{ + { + "name": "accessPolicies/123/accessLevels/level1", + "title": "level1", + "description": "Corp", + "basic": map[string]any{ + "conditions": []map[string]any{{"ipSubnetworks": []string{"10.0.0.0/24"}}}, + }, + }, + }, + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CAALevelsListCmd{Policy: "123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "level1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCAALevelsCreateCmd(t *testing.T) { + var gotName string + var gotSubnet string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels") { + http.NotFound(w, r) + return + } + var payload struct { + Name string `json:"name"` + Basic struct { + Conditions []struct { + IpSubnetworks []string `json:"ipSubnetworks"` + } `json:"conditions"` + } `json:"basic"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotName = payload.Name + if len(payload.Basic.Conditions) > 0 && len(payload.Basic.Conditions[0].IpSubnetworks) > 0 { + gotSubnet = payload.Basic.Conditions[0].IpSubnetworks[0] + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-1", + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CAALevelsCreateCmd{ + Name: "level1", + Policy: "123", + Basic: true, + Conditions: []string{"{\"ipSubnetworks\":[\"10.0.0.0/24\"]}"}, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotName != "accessPolicies/123/accessLevels/level1" { + t.Fatalf("unexpected name: %q", gotName) + } + if gotSubnet != "10.0.0.0/24" { + t.Fatalf("unexpected subnet: %q", gotSubnet) + } + if !strings.Contains(out, "Created access level") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCAALevelsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels/mylevel") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "accessPolicies/123/accessLevels/mylevel", + "title": "mylevel", + "description": "Test level", + "basic": map[string]any{ + "conditions": []map[string]any{{"ipSubnetworks": []string{"10.0.0.0/24"}}}, + }, + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CAALevelsGetCmd{Name: "mylevel", Policy: "123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Name:") || !strings.Contains(out, "mylevel") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Type:") || !strings.Contains(out, "basic") { + t.Fatalf("expected basic type in output: %s", out) + } +} + +func TestCAALevelsGetCmd_FullName(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/accessPolicies/456/accessLevels/fulllevel") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "accessPolicies/456/accessLevels/fulllevel", + "title": "fulllevel", + "description": "Full name test", + "custom": map[string]any{ + "expr": map[string]any{"expression": "device.os_type == \"DESKTOP_MAC\""}, + }, + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CAALevelsGetCmd{Name: "accessPolicies/456/accessLevels/fulllevel"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "fulllevel") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "custom") { + t.Fatalf("expected custom type in output: %s", out) + } +} + +func TestCAALevelsUpdateCmd(t *testing.T) { + var gotUpdateMask string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels/mylevel") { + http.NotFound(w, r) + return + } + gotUpdateMask = r.URL.Query().Get("updateMask") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-2", + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + desc := "Updated description" + cmd := &CAALevelsUpdateCmd{ + Name: "mylevel", + Policy: "123", + Description: &desc, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(gotUpdateMask, "description") { + t.Fatalf("unexpected updateMask: %q", gotUpdateMask) + } + if !strings.Contains(out, "Updated access level") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCAALevelsUpdateCmd_WithConditions(t *testing.T) { + var gotBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels/mylevel") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-3", + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CAALevelsUpdateCmd{ + Name: "mylevel", + Policy: "123", + Conditions: []string{"{\"ipSubnetworks\":[\"192.168.0.0/16\"]}"}, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotBody["basic"] == nil { + t.Fatalf("expected basic conditions in body: %v", gotBody) + } + if !strings.Contains(out, "Updated access level") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCAALevelsDeleteCmd(t *testing.T) { + var deletedName string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels/mylevel") { + http.NotFound(w, r) + return + } + deletedName = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-delete", + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &CAALevelsDeleteCmd{Name: "mylevel", Policy: "123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(deletedName, "mylevel") { + t.Fatalf("expected mylevel in deleted path: %s", deletedName) + } + if !strings.Contains(out, "Deleted access level") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCAALevelsCreateCmd_Custom(t *testing.T) { + var gotBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/accessPolicies/123/accessLevels") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-custom", + }) + }) + stubAccessContextManager(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CAALevelsCreateCmd{ + Name: "customlevel", + Policy: "123", + Custom: true, + Expr: "device.os_type == \"DESKTOP_MAC\"", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotBody["custom"] == nil { + t.Fatalf("expected custom in body: %v", gotBody) + } + if !strings.Contains(out, "Created access level") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestNormalizeAccessPolicy(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"123", "accessPolicies/123"}, + {"accessPolicies/123", "accessPolicies/123"}, + {" 456 ", "accessPolicies/456"}, + {"", ""}, + } + for _, tc := range tests { + got := normalizeAccessPolicy(tc.input) + if got != tc.want { + t.Errorf("normalizeAccessPolicy(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestNormalizeAccessLevelName(t *testing.T) { + tests := []struct { + policy string + name string + want string + wantErr bool + }{ + {"123", "level1", "accessPolicies/123/accessLevels/level1", false}, + {"accessPolicies/123", "level1", "accessPolicies/123/accessLevels/level1", false}, + {"", "accessPolicies/123/accessLevels/level1", "accessPolicies/123/accessLevels/level1", false}, + {"", "level1", "", true}, + {"123", "", "", true}, + } + for _, tc := range tests { + got, err := normalizeAccessLevelName(tc.policy, tc.name) + if tc.wantErr { + if err == nil { + t.Errorf("normalizeAccessLevelName(%q, %q) expected error", tc.policy, tc.name) + } + continue + } + if err != nil { + t.Errorf("normalizeAccessLevelName(%q, %q) error: %v", tc.policy, tc.name, err) + continue + } + if got != tc.want { + t.Errorf("normalizeAccessLevelName(%q, %q) = %q, want %q", tc.policy, tc.name, got, tc.want) + } + } +} + +func TestAccessLevelType(t *testing.T) { + tests := []struct { + name string + level *accesscontextmanager.AccessLevel + want string + }{ + {"nil", nil, ""}, + {"basic", &accesscontextmanager.AccessLevel{Basic: &accesscontextmanager.BasicLevel{}}, "basic"}, + {"custom", &accesscontextmanager.AccessLevel{Custom: &accesscontextmanager.CustomLevel{}}, "custom"}, + {"unknown", &accesscontextmanager.AccessLevel{}, "unknown"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := accessLevelType(tc.level) + if got != tc.want { + t.Errorf("accessLevelType() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestAccessLevelTitle(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"accessPolicies/123/accessLevels/mylevel", "mylevel"}, + {"mylevel", "mylevel"}, + {"", ""}, + // When name ends with /, function returns full name (no empty suffix) + {"accessPolicies/123/accessLevels/", "accessPolicies/123/accessLevels/"}, + } + for _, tc := range tests { + got := accessLevelTitle(tc.name) + if got != tc.want { + t.Errorf("accessLevelTitle(%q) = %q, want %q", tc.name, got, tc.want) + } + } +} + +func stubAccessContextManager(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newAccessContextManagerService + svc, err := accesscontextmanager.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new access context manager service: %v", err) + } + newAccessContextManagerService = func(context.Context, string) (*accesscontextmanager.Service, error) { return svc, nil } + t.Cleanup(func() { + newAccessContextManagerService = orig + srv.Close() + }) +} diff --git a/internal/cmd/calendar_build.go b/internal/cmd/calendar_build.go index eb353748..63e0fc8a 100644 --- a/internal/cmd/calendar_build.go +++ b/internal/cmd/calendar_build.go @@ -10,7 +10,11 @@ import ( "google.golang.org/api/calendar/v3" ) -const tzUTC = "UTC" +const ( + tzUTC = "UTC" + methodEmail = "email" + methodPopup = "popup" +) func buildEventDateTime(value string, allDay bool) *calendar.EventDateTime { value = strings.TrimSpace(value) @@ -139,7 +143,7 @@ func parseReminder(s string) (string, int64, error) { } method := strings.TrimSpace(strings.ToLower(parts[0])) - if method != "email" && method != "popup" { + if method != methodEmail && method != methodPopup { return "", 0, fmt.Errorf("invalid reminder method: %q (expected 'email' or 'popup')", method) } diff --git a/internal/cmd/calendar_create_update_test.go b/internal/cmd/calendar_create_update_test.go index 0439f230..760a357e 100644 --- a/internal/cmd/calendar_create_update_test.go +++ b/internal/cmd/calendar_create_update_test.go @@ -290,7 +290,7 @@ func TestCalendarCreateCmd_EventTypeFocusTimeDefaults(t *testing.T) { if gotEvent.FocusTimeProperties == nil { t.Fatalf("expected focus time properties") } - if gotEvent.FocusTimeProperties.AutoDeclineMode != "declineAllConflictingInvitations" { + if gotEvent.FocusTimeProperties.AutoDeclineMode != declineAllConflicting { t.Fatalf("unexpected autoDeclineMode: %q", gotEvent.FocusTimeProperties.AutoDeclineMode) } if gotEvent.FocusTimeProperties.ChatStatus != defaultFocusChatStatus { @@ -357,7 +357,7 @@ func TestCalendarCreateCmd_EventTypeWorkingLocation(t *testing.T) { if gotEvent.End == nil || gotEvent.End.Date != "2025-01-02" { t.Fatalf("unexpected end date: %#v", gotEvent.End) } - if gotEvent.WorkingLocationProperties == nil || gotEvent.WorkingLocationProperties.Type != "officeLocation" { + if gotEvent.WorkingLocationProperties == nil || gotEvent.WorkingLocationProperties.Type != locTypeOffice { t.Fatalf("unexpected working location props: %#v", gotEvent.WorkingLocationProperties) } } @@ -415,7 +415,7 @@ func TestCalendarUpdateCmd_EventTypeOOO(t *testing.T) { if gotEvent.OutOfOfficeProperties == nil { t.Fatalf("expected out-of-office properties") } - if gotEvent.OutOfOfficeProperties.AutoDeclineMode != "declineAllConflictingInvitations" { + if gotEvent.OutOfOfficeProperties.AutoDeclineMode != declineAllConflicting { t.Fatalf("unexpected autoDeclineMode: %q", gotEvent.OutOfOfficeProperties.AutoDeclineMode) } if gotEvent.OutOfOfficeProperties.DeclineMessage != defaultOOODeclineMsg { diff --git a/internal/cmd/calendar_edit_test.go b/internal/cmd/calendar_edit_test.go index ce005320..8abee413 100644 --- a/internal/cmd/calendar_edit_test.go +++ b/internal/cmd/calendar_edit_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/alecthomas/kong" + "google.golang.org/api/calendar/v3" "github.com/steipete/gogcli/internal/googleauth" ) @@ -77,3 +78,461 @@ func TestCalendarUpdatePatchClearsReminders(t *testing.T) { t.Fatalf("expected Reminders in ForceSendFields") } } + +func TestApplyCreateEventType_Default(t *testing.T) { + cmd := &CalendarCreateCmd{} + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeDefault) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.EventType != eventTypeDefault { + t.Fatalf("expected event type %q, got %q", eventTypeDefault, event.EventType) + } +} + +func TestApplyCreateEventType_FocusTime(t *testing.T) { + cmd := &CalendarCreateCmd{ + FocusAutoDecline: "all", + FocusDeclineMessage: "I'm busy", + FocusChatStatus: "doNotDisturb", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeFocusTime) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.EventType != eventTypeFocusTime { + t.Fatalf("expected event type %q, got %q", eventTypeFocusTime, event.EventType) + } + if event.FocusTimeProperties == nil { + t.Fatal("expected FocusTimeProperties to be set") + } + if event.FocusTimeProperties.AutoDeclineMode != declineAllConflicting { + t.Fatalf("unexpected AutoDeclineMode: %q", event.FocusTimeProperties.AutoDeclineMode) + } + if event.FocusTimeProperties.DeclineMessage != "I'm busy" { + t.Fatalf("unexpected DeclineMessage: %q", event.FocusTimeProperties.DeclineMessage) + } + if event.FocusTimeProperties.ChatStatus != "doNotDisturb" { + t.Fatalf("unexpected ChatStatus: %q", event.FocusTimeProperties.ChatStatus) + } +} + +func TestApplyCreateEventType_FocusTimeDefaults(t *testing.T) { + cmd := &CalendarCreateCmd{} + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeFocusTime) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.FocusTimeProperties == nil { + t.Fatal("expected FocusTimeProperties to be set") + } + // Default auto decline mode should be "all" -> declineAllConflicting + if event.FocusTimeProperties.AutoDeclineMode != declineAllConflicting { + t.Fatalf("unexpected default AutoDeclineMode: %q", event.FocusTimeProperties.AutoDeclineMode) + } + // Default chat status should be "doNotDisturb" + if event.FocusTimeProperties.ChatStatus != defaultFocusChatStatus { + t.Fatalf("unexpected default ChatStatus: %q", event.FocusTimeProperties.ChatStatus) + } +} + +func TestApplyCreateEventType_OutOfOffice(t *testing.T) { + cmd := &CalendarCreateCmd{ + OOOAutoDecline: "new", + OOODeclineMessage: "On vacation", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeOutOfOffice) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.EventType != eventTypeOutOfOffice { + t.Fatalf("expected event type %q, got %q", eventTypeOutOfOffice, event.EventType) + } + if event.OutOfOfficeProperties == nil { + t.Fatal("expected OutOfOfficeProperties to be set") + } + if event.OutOfOfficeProperties.AutoDeclineMode != "declineOnlyNewConflictingInvitations" { + t.Fatalf("unexpected AutoDeclineMode: %q", event.OutOfOfficeProperties.AutoDeclineMode) + } + if event.OutOfOfficeProperties.DeclineMessage != "On vacation" { + t.Fatalf("unexpected DeclineMessage: %q", event.OutOfOfficeProperties.DeclineMessage) + } +} + +func TestApplyCreateEventType_OutOfOfficeDefaults(t *testing.T) { + cmd := &CalendarCreateCmd{} + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeOutOfOffice) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.OutOfOfficeProperties == nil { + t.Fatal("expected OutOfOfficeProperties to be set") + } + if event.OutOfOfficeProperties.DeclineMessage != defaultOOODeclineMsg { + t.Fatalf("unexpected default DeclineMessage: %q", event.OutOfOfficeProperties.DeclineMessage) + } +} + +func TestApplyCreateEventType_WorkingLocation_Home(t *testing.T) { + cmd := &CalendarCreateCmd{ + WorkingLocationType: "home", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeWorkingLocation) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.EventType != eventTypeWorkingLocation { + t.Fatalf("expected event type %q, got %q", eventTypeWorkingLocation, event.EventType) + } + if event.WorkingLocationProperties == nil { + t.Fatal("expected WorkingLocationProperties to be set") + } + if event.WorkingLocationProperties.Type != "homeOffice" { + t.Fatalf("unexpected working location type: %q", event.WorkingLocationProperties.Type) + } +} + +func TestApplyCreateEventType_WorkingLocation_Office(t *testing.T) { + cmd := &CalendarCreateCmd{ + WorkingLocationType: "office", + WorkingOfficeLabel: "HQ", + WorkingBuildingId: "building-123", + WorkingFloorId: "floor-2", + WorkingDeskId: "desk-42", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeWorkingLocation) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.WorkingLocationProperties == nil { + t.Fatal("expected WorkingLocationProperties to be set") + } + if event.WorkingLocationProperties.Type != locTypeOffice { + t.Fatalf("unexpected working location type: %q", event.WorkingLocationProperties.Type) + } + office := event.WorkingLocationProperties.OfficeLocation + if office == nil { + t.Fatal("expected OfficeLocation to be set") + } + if office.Label != "HQ" { + t.Fatalf("unexpected office label: %q", office.Label) + } + if office.BuildingId != "building-123" { + t.Fatalf("unexpected building id: %q", office.BuildingId) + } + if office.FloorId != "floor-2" { + t.Fatalf("unexpected floor id: %q", office.FloorId) + } + if office.DeskId != "desk-42" { + t.Fatalf("unexpected desk id: %q", office.DeskId) + } +} + +func TestApplyCreateEventType_WorkingLocation_Custom(t *testing.T) { + cmd := &CalendarCreateCmd{ + WorkingLocationType: "custom", + WorkingCustomLabel: "Coffee Shop", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeWorkingLocation) + if err != nil { + t.Fatalf("applyCreateEventType: %v", err) + } + if event.WorkingLocationProperties == nil { + t.Fatal("expected WorkingLocationProperties to be set") + } + if event.WorkingLocationProperties.Type != "customLocation" { + t.Fatalf("unexpected working location type: %q", event.WorkingLocationProperties.Type) + } + if event.WorkingLocationProperties.CustomLocation == nil { + t.Fatal("expected CustomLocation to be set") + } + if event.WorkingLocationProperties.CustomLocation.Label != "Coffee Shop" { + t.Fatalf("unexpected custom label: %q", event.WorkingLocationProperties.CustomLocation.Label) + } +} + +func TestApplyCreateEventType_WorkingLocation_MissingType(t *testing.T) { + cmd := &CalendarCreateCmd{} + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeWorkingLocation) + if err == nil { + t.Fatal("expected error for missing working location type") + } +} + +func TestApplyCreateEventType_InvalidAutoDeclineMode(t *testing.T) { + cmd := &CalendarCreateCmd{ + FocusAutoDecline: "invalid", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeFocusTime) + if err == nil { + t.Fatal("expected error for invalid auto decline mode") + } +} + +func TestApplyCreateEventType_InvalidChatStatus(t *testing.T) { + cmd := &CalendarCreateCmd{ + FocusChatStatus: "invalid", + } + event := &calendar.Event{} + + err := cmd.applyCreateEventType(event, eventTypeFocusTime) + if err == nil { + t.Fatal("expected error for invalid chat status") + } +} + +func TestResolveCreateAllDay(t *testing.T) { + tests := []struct { + name string + from string + to string + allDay bool + eventType string + want bool + wantErr bool + }{ + { + name: "default event, allDay=false", + from: "2025-01-01T10:00:00Z", + to: "2025-01-01T11:00:00Z", + allDay: false, + eventType: eventTypeDefault, + want: false, + wantErr: false, + }, + { + name: "default event, allDay=true", + from: "2025-01-01", + to: "2025-01-02", + allDay: true, + eventType: eventTypeDefault, + want: true, + wantErr: false, + }, + { + name: "working location with date-only", + from: "2025-01-01", + to: "2025-01-02", + allDay: false, + eventType: eventTypeWorkingLocation, + want: true, + wantErr: false, + }, + { + name: "working location with datetime (error)", + from: "2025-01-01T10:00:00Z", + to: "2025-01-02T10:00:00Z", + allDay: false, + eventType: eventTypeWorkingLocation, + want: false, + wantErr: true, + }, + { + name: "focus time, allDay=false", + from: "2025-01-01T10:00:00Z", + to: "2025-01-01T11:00:00Z", + allDay: false, + eventType: eventTypeFocusTime, + want: false, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveCreateAllDay(tc.from, tc.to, tc.allDay, tc.eventType) + if (err != nil) != tc.wantErr { + t.Fatalf("resolveCreateAllDay() error = %v, wantErr %v", err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("resolveCreateAllDay() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestApplyEventTypeTransparencyDefault(t *testing.T) { + tests := []struct { + name string + transparency string + eventType string + want string + }{ + { + name: "focus time with no transparency", + transparency: "", + eventType: eventTypeFocusTime, + want: transparencyOpaque, + }, + { + name: "out of office with no transparency", + transparency: "", + eventType: eventTypeOutOfOffice, + want: transparencyOpaque, + }, + { + name: "focus time with transparency set", + transparency: "transparent", + eventType: eventTypeFocusTime, + want: "transparent", + }, + { + name: "default event type", + transparency: "", + eventType: eventTypeDefault, + want: "", + }, + { + name: "working location", + transparency: "", + eventType: eventTypeWorkingLocation, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := applyEventTypeTransparencyDefault(tc.transparency, tc.eventType) + if got != tc.want { + t.Fatalf("applyEventTypeTransparencyDefault() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCalendarCreateCmd_ResolveCreateEventType(t *testing.T) { + tests := []struct { + name string + cmd CalendarCreateCmd + want string + wantErr bool + }{ + { + name: "explicit focus-time", + cmd: CalendarCreateCmd{EventType: "focus-time"}, + want: eventTypeFocusTime, + }, + { + name: "explicit out-of-office", + cmd: CalendarCreateCmd{EventType: "ooo"}, + want: eventTypeOutOfOffice, + }, + { + name: "inferred from focus flags", + cmd: CalendarCreateCmd{FocusAutoDecline: "all"}, + want: eventTypeFocusTime, + }, + { + name: "inferred from ooo flags", + cmd: CalendarCreateCmd{OOODeclineMessage: "Gone"}, + want: eventTypeOutOfOffice, + }, + { + name: "inferred from working location flags", + cmd: CalendarCreateCmd{WorkingLocationType: "home"}, + want: eventTypeWorkingLocation, + }, + { + name: "mixed flags error", + cmd: CalendarCreateCmd{FocusAutoDecline: "all", OOODeclineMessage: "Gone"}, + wantErr: true, + }, + { + name: "focus-time with ooo flags error", + cmd: CalendarCreateCmd{EventType: "focus-time", OOODeclineMessage: "Gone"}, + wantErr: true, + }, + { + name: "no event type flags", + cmd: CalendarCreateCmd{}, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.cmd.resolveCreateEventType() + if (err != nil) != tc.wantErr { + t.Fatalf("resolveCreateEventType() error = %v, wantErr %v", err, tc.wantErr) + } + if !tc.wantErr && got != tc.want { + t.Fatalf("resolveCreateEventType() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCalendarCreateCmd_DefaultSummaryForEventType(t *testing.T) { + tests := []struct { + name string + cmd CalendarCreateCmd + eventType string + want string + }{ + { + name: "focus time", + cmd: CalendarCreateCmd{}, + eventType: eventTypeFocusTime, + want: defaultFocusSummary, + }, + { + name: "out of office", + cmd: CalendarCreateCmd{}, + eventType: eventTypeOutOfOffice, + want: defaultOOOSummary, + }, + { + name: "working location home", + cmd: CalendarCreateCmd{WorkingLocationType: "home"}, + eventType: eventTypeWorkingLocation, + want: "Working from home", + }, + { + name: "working location office", + cmd: CalendarCreateCmd{WorkingLocationType: "office", WorkingOfficeLabel: "HQ"}, + eventType: eventTypeWorkingLocation, + want: "Working from HQ", + }, + { + name: "working location custom", + cmd: CalendarCreateCmd{WorkingLocationType: "custom", WorkingCustomLabel: "Cafe"}, + eventType: eventTypeWorkingLocation, + want: "Working from Cafe", + }, + { + name: "default event type", + cmd: CalendarCreateCmd{}, + eventType: eventTypeDefault, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.cmd.defaultSummaryForEventType(tc.eventType) + if got != tc.want { + t.Fatalf("defaultSummaryForEventType() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/cmd/calendar_event_days_test.go b/internal/cmd/calendar_event_days_test.go index 310ab9b1..a9a3ae99 100644 --- a/internal/cmd/calendar_event_days_test.go +++ b/internal/cmd/calendar_event_days_test.go @@ -2,6 +2,7 @@ package cmd import ( "testing" + "time" "google.golang.org/api/calendar/v3" ) @@ -43,3 +44,600 @@ func TestWrapEventsWithDays(t *testing.T) { t.Fatalf("unexpected start local: %q", wrapped[0].StartLocal) } } + +// Tests for parseEventTime function +func TestParseEventTime_RFC3339(t *testing.T) { + tests := []struct { + name string + value string + tz string + wantOk bool + wantYear int + wantDay time.Weekday + }{ + { + name: "RFC3339 UTC", + value: "2025-01-01T10:00:00Z", + tz: "", + wantOk: true, + wantYear: 2025, + wantDay: time.Wednesday, + }, + { + name: "RFC3339 with offset", + value: "2025-01-01T10:00:00-05:00", + tz: "", + wantOk: true, + wantYear: 2025, + wantDay: time.Wednesday, + }, + { + name: "RFC3339Nano", + value: "2025-01-01T10:00:00.123456789Z", + tz: "", + wantOk: true, + wantYear: 2025, + wantDay: time.Wednesday, + }, + { + name: "RFC3339 with timezone override", + value: "2025-01-01T10:00:00Z", + tz: "America/New_York", + wantOk: true, + wantYear: 2025, + wantDay: time.Wednesday, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := parseEventTime(tc.value, tc.tz) + if ok != tc.wantOk { + t.Fatalf("parseEventTime(%q, %q) ok = %v, want %v", tc.value, tc.tz, ok, tc.wantOk) + } + if !ok { + return + } + if got.Year() != tc.wantYear { + t.Fatalf("parseEventTime(%q, %q).Year() = %d, want %d", tc.value, tc.tz, got.Year(), tc.wantYear) + } + if got.Weekday() != tc.wantDay { + t.Fatalf("parseEventTime(%q, %q).Weekday() = %v, want %v", tc.value, tc.tz, got.Weekday(), tc.wantDay) + } + }) + } +} + +func TestParseEventTime_LocalFormat(t *testing.T) { + // Test the "2006-01-02T15:04:05" format (without timezone suffix) + tests := []struct { + name string + value string + tz string + wantOk bool + wantYear int + }{ + { + name: "local format with valid timezone", + value: "2025-06-15T14:30:00", + tz: "America/New_York", + wantOk: true, + wantYear: 2025, + }, + { + name: "local format with UTC timezone", + value: "2025-06-15T14:30:00", + tz: "UTC", + wantOk: true, + wantYear: 2025, + }, + { + name: "local format without timezone fails", + value: "2025-06-15T14:30:00", + tz: "", + wantOk: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := parseEventTime(tc.value, tc.tz) + if ok != tc.wantOk { + t.Fatalf("parseEventTime(%q, %q) ok = %v, want %v", tc.value, tc.tz, ok, tc.wantOk) + } + if ok && got.Year() != tc.wantYear { + t.Fatalf("parseEventTime(%q, %q).Year() = %d, want %d", tc.value, tc.tz, got.Year(), tc.wantYear) + } + }) + } +} + +func TestParseEventTime_Empty(t *testing.T) { + tests := []struct { + name string + value string + tz string + }{ + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + {"empty with timezone", "", "America/New_York"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, ok := parseEventTime(tc.value, tc.tz) + if ok { + t.Fatalf("parseEventTime(%q, %q) should return false for empty value", tc.value, tc.tz) + } + }) + } +} + +func TestParseEventTime_Invalid(t *testing.T) { + tests := []struct { + name string + value string + tz string + }{ + {"invalid format", "not-a-date", ""}, + {"invalid RFC3339", "2025-01-01T10:00:00", ""}, // missing timezone designator + {"partial date", "2025-01-01", ""}, + {"garbage", "xyz123", "America/New_York"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, ok := parseEventTime(tc.value, tc.tz) + if ok { + t.Fatalf("parseEventTime(%q, %q) should return false for invalid value", tc.value, tc.tz) + } + }) + } +} + +// Tests for parseEventDate function +func TestParseEventDate_Valid(t *testing.T) { + tests := []struct { + name string + value string + tz string + wantOk bool + wantYear int + wantDay time.Weekday + }{ + { + name: "date without timezone", + value: "2025-01-02", + tz: "", + wantOk: true, + wantYear: 2025, + wantDay: time.Thursday, + }, + { + name: "date with UTC timezone", + value: "2025-01-02", + tz: "UTC", + wantOk: true, + wantYear: 2025, + wantDay: time.Thursday, + }, + { + name: "date with America/New_York timezone", + value: "2025-01-02", + tz: "America/New_York", + wantOk: true, + wantYear: 2025, + wantDay: time.Thursday, + }, + { + name: "date with Asia/Tokyo timezone", + value: "2025-07-15", + tz: "Asia/Tokyo", + wantOk: true, + wantYear: 2025, + wantDay: time.Tuesday, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := parseEventDate(tc.value, tc.tz) + if ok != tc.wantOk { + t.Fatalf("parseEventDate(%q, %q) ok = %v, want %v", tc.value, tc.tz, ok, tc.wantOk) + } + if !ok { + return + } + if got.Year() != tc.wantYear { + t.Fatalf("parseEventDate(%q, %q).Year() = %d, want %d", tc.value, tc.tz, got.Year(), tc.wantYear) + } + if got.Weekday() != tc.wantDay { + t.Fatalf("parseEventDate(%q, %q).Weekday() = %v, want %v", tc.value, tc.tz, got.Weekday(), tc.wantDay) + } + }) + } +} + +func TestParseEventDate_Empty(t *testing.T) { + tests := []struct { + name string + value string + tz string + }{ + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + {"empty with timezone", "", "America/New_York"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, ok := parseEventDate(tc.value, tc.tz) + if ok { + t.Fatalf("parseEventDate(%q, %q) should return false for empty value", tc.value, tc.tz) + } + }) + } +} + +func TestParseEventDate_Invalid(t *testing.T) { + tests := []struct { + name string + value string + tz string + }{ + {"datetime format", "2025-01-01T10:00:00Z", ""}, + {"invalid format", "01-02-2025", ""}, + {"garbage", "not-a-date", ""}, + {"partial", "2025-01", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, ok := parseEventDate(tc.value, tc.tz) + if ok { + t.Fatalf("parseEventDate(%q, %q) should return false for invalid value", tc.value, tc.tz) + } + }) + } +} + +// Tests for loadEventLocation function +func TestLoadEventLocation(t *testing.T) { + tests := []struct { + name string + tz string + wantOk bool + }{ + {"valid UTC", "UTC", true}, + {"valid America/New_York", "America/New_York", true}, + {"valid Europe/London", "Europe/London", true}, + {"valid Asia/Tokyo", "Asia/Tokyo", true}, + {"empty string", "", false}, + {"whitespace only", " ", false}, + {"invalid timezone", "Invalid/Timezone", false}, + {"garbage", "not-a-timezone", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + loc, ok := loadEventLocation(tc.tz) + if ok != tc.wantOk { + t.Fatalf("loadEventLocation(%q) ok = %v, want %v", tc.tz, ok, tc.wantOk) + } + if ok && loc == nil { + t.Fatalf("loadEventLocation(%q) returned nil location with ok=true", tc.tz) + } + }) + } +} + +// Tests for dayOfWeekFromEventDateTime function +func TestDayOfWeekFromEventDateTime(t *testing.T) { + tests := []struct { + name string + dt *calendar.EventDateTime + want string + }{ + { + name: "nil input", + dt: nil, + want: "", + }, + { + name: "datetime only", + dt: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"}, + want: "Wednesday", + }, + { + name: "date only", + dt: &calendar.EventDateTime{Date: "2025-01-02"}, + want: "Thursday", + }, + { + name: "datetime with timezone", + dt: &calendar.EventDateTime{DateTime: "2025-01-03T10:00:00Z", TimeZone: "America/New_York"}, + want: "Friday", + }, + { + name: "date with timezone", + dt: &calendar.EventDateTime{Date: "2025-01-04", TimeZone: "UTC"}, + want: "Saturday", + }, + { + name: "both datetime and date (datetime takes precedence)", + dt: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z", Date: "2025-01-02"}, + want: "Wednesday", // DateTime should take precedence + }, + { + name: "empty datetime and date", + dt: &calendar.EventDateTime{}, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := dayOfWeekFromEventDateTime(tc.dt) + if got != tc.want { + t.Fatalf("dayOfWeekFromEventDateTime(%v) = %q, want %q", tc.dt, got, tc.want) + } + }) + } +} + +// Tests for eventDaysOfWeek function +func TestEventDaysOfWeek_NilEvent(t *testing.T) { + start, end := eventDaysOfWeek(nil) + if start != "" || end != "" { + t.Fatalf("eventDaysOfWeek(nil) = (%q, %q), want (\"\", \"\")", start, end) + } +} + +func TestEventDaysOfWeek_NilStartEnd(t *testing.T) { + ev := &calendar.Event{} + start, end := eventDaysOfWeek(ev) + if start != "" || end != "" { + t.Fatalf("eventDaysOfWeek with nil start/end = (%q, %q), want (\"\", \"\")", start, end) + } +} + +func TestEventDaysOfWeek_MixedDateTimeAndDate(t *testing.T) { + ev := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2025-01-06T10:00:00Z"}, + End: &calendar.EventDateTime{Date: "2025-01-07"}, + } + start, end := eventDaysOfWeek(ev) + if start != "Monday" { + t.Fatalf("expected start Monday, got %q", start) + } + if end != "Tuesday" { + t.Fatalf("expected end Tuesday, got %q", end) + } +} + +// Tests for wrapEventsWithDays function +func TestWrapEventsWithDays_Empty(t *testing.T) { + wrapped := wrapEventsWithDays([]*calendar.Event{}) + if len(wrapped) != 0 { + t.Fatalf("expected empty slice, got %d items", len(wrapped)) + } +} + +func TestWrapEventsWithDays_Nil(t *testing.T) { + wrapped := wrapEventsWithDays(nil) + if len(wrapped) != 0 { + t.Fatalf("expected empty slice for nil input, got %d items", len(wrapped)) + } +} + +func TestWrapEventsWithDays_Multiple(t *testing.T) { + events := []*calendar.Event{ + {Start: &calendar.EventDateTime{Date: "2025-01-06"}, End: &calendar.EventDateTime{Date: "2025-01-06"}}, // Monday + {Start: &calendar.EventDateTime{Date: "2025-01-07"}, End: &calendar.EventDateTime{Date: "2025-01-07"}}, // Tuesday + {Start: &calendar.EventDateTime{DateTime: "2025-01-08T10:00:00Z"}, End: &calendar.EventDateTime{DateTime: "2025-01-08T11:00:00Z"}}, // Wednesday + } + wrapped := wrapEventsWithDays(events) + if len(wrapped) != 3 { + t.Fatalf("expected 3 wrapped events, got %d", len(wrapped)) + } + expectedDays := []string{"Monday", "Tuesday", "Wednesday"} + for i, w := range wrapped { + if w.StartDayOfWeek != expectedDays[i] { + t.Fatalf("event %d: expected StartDayOfWeek %q, got %q", i, expectedDays[i], w.StartDayOfWeek) + } + } +} + +// Tests for wrapEventWithDaysWithTimezone function +func TestWrapEventWithDaysWithTimezone_NilEvent(t *testing.T) { + wrapped := wrapEventWithDaysWithTimezone(nil, "", nil) + if wrapped != nil { + t.Fatal("expected nil for nil event input") + } +} + +func TestWrapEventWithDaysWithTimezone_WithCalendarTimezone(t *testing.T) { + ev := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z"}, + } + wrapped := wrapEventWithDaysWithTimezone(ev, "America/New_York", nil) + if wrapped == nil { + t.Fatal("expected non-nil wrapped event") + } + if wrapped.Timezone != "America/New_York" { + t.Fatalf("expected timezone America/New_York, got %q", wrapped.Timezone) + } + if wrapped.StartDayOfWeek != "Wednesday" { + t.Fatalf("expected Wednesday, got %q", wrapped.StartDayOfWeek) + } +} + +func TestWrapEventWithDaysWithTimezone_WithLocation(t *testing.T) { + ev := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z"}, + } + loc, _ := time.LoadLocation("Europe/London") + wrapped := wrapEventWithDaysWithTimezone(ev, "Europe/London", loc) + if wrapped == nil { + t.Fatal("expected non-nil wrapped event") + } + if wrapped.Timezone != "Europe/London" { + t.Fatalf("expected timezone Europe/London, got %q", wrapped.Timezone) + } +} + +func TestWrapEventWithDaysWithTimezone_EventTimezoneOverride(t *testing.T) { + ev := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z", TimeZone: "Asia/Tokyo"}, + End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z", TimeZone: "Asia/Tokyo"}, + } + wrapped := wrapEventWithDaysWithTimezone(ev, "America/New_York", nil) + if wrapped == nil { + t.Fatal("expected non-nil wrapped event") + } + // Calendar timezone should be used, but EventTimezone should capture the event's timezone + if wrapped.Timezone != "America/New_York" { + t.Fatalf("expected calendar timezone America/New_York, got %q", wrapped.Timezone) + } + if wrapped.EventTimezone != "Asia/Tokyo" { + t.Fatalf("expected event timezone Asia/Tokyo, got %q", wrapped.EventTimezone) + } +} + +func TestWrapEventWithDaysWithTimezone_FallbackToEventTimezone(t *testing.T) { + ev := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z", TimeZone: "Europe/Paris"}, + End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z", TimeZone: "Europe/Paris"}, + } + // No calendar timezone provided - should fallback to event timezone + wrapped := wrapEventWithDaysWithTimezone(ev, "", nil) + if wrapped == nil { + t.Fatal("expected non-nil wrapped event") + } + if wrapped.Timezone != "Europe/Paris" { + t.Fatalf("expected fallback to event timezone Europe/Paris, got %q", wrapped.Timezone) + } + // EventTimezone should be empty when it matches the resolved timezone + if wrapped.EventTimezone != "" { + t.Fatalf("expected empty EventTimezone when it matches Timezone, got %q", wrapped.EventTimezone) + } +} + +// Tests for resolveEventTimezone function +func TestResolveEventTimezone(t *testing.T) { + tests := []struct { + name string + event *calendar.Event + calendarTimezone string + loc *time.Location + wantTimezone string + wantLocNil bool + }{ + { + name: "calendar timezone with nil loc", + event: &calendar.Event{}, + calendarTimezone: "America/New_York", + loc: nil, + wantTimezone: "America/New_York", + wantLocNil: false, + }, + { + name: "fallback to event timezone", + event: &calendar.Event{Start: &calendar.EventDateTime{TimeZone: "Europe/London"}}, + calendarTimezone: "", + loc: nil, + wantTimezone: "Europe/London", + wantLocNil: false, + }, + { + name: "invalid calendar timezone fallback to event", + event: &calendar.Event{Start: &calendar.EventDateTime{TimeZone: "Asia/Tokyo"}}, + calendarTimezone: "Invalid/Timezone", + loc: nil, + wantTimezone: "Asia/Tokyo", + wantLocNil: false, + }, + { + name: "both invalid timezones", + event: &calendar.Event{Start: &calendar.EventDateTime{TimeZone: "Invalid/EventTz"}}, + calendarTimezone: "Invalid/CalendarTz", + loc: nil, + wantTimezone: "", + wantLocNil: true, + }, + { + name: "empty everything", + event: &calendar.Event{}, + calendarTimezone: "", + loc: nil, + wantTimezone: "", + wantLocNil: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotTz, gotLoc := resolveEventTimezone(tc.event, tc.calendarTimezone, tc.loc) + if gotTz != tc.wantTimezone { + t.Fatalf("resolveEventTimezone() timezone = %q, want %q", gotTz, tc.wantTimezone) + } + if (gotLoc == nil) != tc.wantLocNil { + t.Fatalf("resolveEventTimezone() loc nil = %v, want %v", gotLoc == nil, tc.wantLocNil) + } + }) + } +} + +func TestResolveEventTimezone_PreserveProvidedLoc(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + event := &calendar.Event{} + + // When loc is provided, it should be preserved + gotTz, gotLoc := resolveEventTimezone(event, "America/New_York", loc) + if gotTz != "America/New_York" { + t.Fatalf("expected timezone America/New_York, got %q", gotTz) + } + if gotLoc != loc { + t.Fatal("expected provided location to be preserved") + } +} + +func TestWrapEventWithDaysWithTimezone_StartLocalEndLocal(t *testing.T) { + // Test that StartLocal and EndLocal are correctly populated + ev := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"}, + End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z"}, + } + wrapped := wrapEventWithDaysWithTimezone(ev, "UTC", nil) + if wrapped == nil { + t.Fatal("expected non-nil wrapped event") + } + // StartLocal should be formatted in the calendar timezone + if wrapped.StartLocal == "" { + t.Fatal("expected StartLocal to be populated") + } + if wrapped.EndLocal == "" { + t.Fatal("expected EndLocal to be populated") + } +} + +func TestWrapEventWithDaysWithTimezone_DateOnlyEvent(t *testing.T) { + // Test that date-only events have correct local times + ev := &calendar.Event{ + Start: &calendar.EventDateTime{Date: "2025-01-02"}, + End: &calendar.EventDateTime{Date: "2025-01-03"}, + } + wrapped := wrapEventWithDaysWithTimezone(ev, "UTC", nil) + if wrapped == nil { + t.Fatal("expected non-nil wrapped event") + } + // For date-only events, StartLocal should be the date string + if wrapped.StartLocal != "2025-01-02" { + t.Fatalf("expected StartLocal '2025-01-02', got %q", wrapped.StartLocal) + } + if wrapped.EndLocal != "2025-01-03" { + t.Fatalf("expected EndLocal '2025-01-03', got %q", wrapped.EndLocal) + } +} diff --git a/internal/cmd/calendar_focus_time.go b/internal/cmd/calendar_focus_time.go index 24a107ea..88f65df3 100644 --- a/internal/cmd/calendar_focus_time.go +++ b/internal/cmd/calendar_focus_time.go @@ -12,6 +12,8 @@ import ( "github.com/steipete/gogcli/internal/ui" ) +const declineAllConflicting = "declineAllConflictingInvitations" + type CalendarFocusTimeCmd struct { CalendarID string `arg:"" name:"calendarId" help:"Calendar ID (default: primary)" default:"primary"` Summary string `name:"summary" help:"Focus time title" default:"Focus Time"` @@ -77,8 +79,8 @@ func validateAutoDeclineMode(s string) (string, error) { switch s { case "", "none": return "declineNone", nil - case "all": - return "declineAllConflictingInvitations", nil + case scopeAll: + return declineAllConflicting, nil case "new": return "declineOnlyNewConflictingInvitations", nil default: diff --git a/internal/cmd/calendar_focus_time_test.go b/internal/cmd/calendar_focus_time_test.go index ca901a93..92a24a42 100644 --- a/internal/cmd/calendar_focus_time_test.go +++ b/internal/cmd/calendar_focus_time_test.go @@ -9,7 +9,7 @@ func TestValidateAutoDeclineMode(t *testing.T) { wantErr bool }{ {"none", "declineNone", false}, - {"all", "declineAllConflictingInvitations", false}, + {"all", declineAllConflicting, false}, {"new", "declineOnlyNewConflictingInvitations", false}, {"", "declineNone", false}, {"invalid", "", true}, diff --git a/internal/cmd/calendar_print_test.go b/internal/cmd/calendar_print_test.go index 11609df8..bc779d34 100644 --- a/internal/cmd/calendar_print_test.go +++ b/internal/cmd/calendar_print_test.go @@ -87,7 +87,7 @@ func TestPrintCalendarEvent_AllFields(t *testing.T) { DeclineMessage: "OOO", }, WorkingLocationProperties: &calendar.EventWorkingLocationProperties{ - Type: "officeLocation", + Type: locTypeOffice, }, Source: &calendar.EventSource{ Url: "https://source.example.com", diff --git a/internal/cmd/calendar_working_location.go b/internal/cmd/calendar_working_location.go index 2f295c3d..17156fcb 100644 --- a/internal/cmd/calendar_working_location.go +++ b/internal/cmd/calendar_working_location.go @@ -12,6 +12,8 @@ import ( "github.com/steipete/gogcli/internal/ui" ) +const locTypeOffice = "officeLocation" + type CalendarWorkingLocationCmd struct { CalendarID string `arg:"" name:"calendarId" help:"Calendar ID (default: primary)" default:"primary"` From string `name:"from" required:"" help:"Start date (YYYY-MM-DD)"` @@ -101,7 +103,7 @@ func buildWorkingLocationProperties(input workingLocationInput) (*calendar.Event props.Type = "homeOffice" props.HomeOffice = map[string]any{} case "office": - props.Type = "officeLocation" + props.Type = locTypeOffice props.OfficeLocation = &calendar.EventWorkingLocationPropertiesOfficeLocation{ Label: strings.TrimSpace(input.OfficeLabel), BuildingId: strings.TrimSpace(input.BuildingId), diff --git a/internal/cmd/calendar_working_location_test.go b/internal/cmd/calendar_working_location_test.go index 38836793..d464e4b5 100644 --- a/internal/cmd/calendar_working_location_test.go +++ b/internal/cmd/calendar_working_location_test.go @@ -129,7 +129,7 @@ func TestCalendarWorkingLocation_RunJSON(t *testing.T) { t.Fatalf("unexpected summary: %q", gotEvent.Summary) } props := gotEvent.WorkingLocationProperties - if props == nil || props.Type != "officeLocation" || props.OfficeLocation == nil { + if props == nil || props.Type != locTypeOffice || props.OfficeLocation == nil { t.Fatalf("unexpected working location props: %#v", props) } if props.OfficeLocation.Label != "HQ" || props.OfficeLocation.BuildingId != "b1" || props.OfficeLocation.FloorId != "f1" || props.OfficeLocation.DeskId != "d1" { diff --git a/internal/cmd/channel.go b/internal/cmd/channel.go new file mode 100644 index 00000000..f91572ed --- /dev/null +++ b/internal/cmd/channel.go @@ -0,0 +1,247 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newCloudChannelService = googleapi.NewCloudChannel + +type ChannelCmd struct { + Customers ChannelCustomersCmd `cmd:"" name:"customers" help:"Cloud Channel customers"` + Offers ChannelOffersCmd `cmd:"" name:"offers" help:"Cloud Channel offers"` + Entitlements ChannelEntitlementsCmd `cmd:"" name:"entitlements" help:"Cloud Channel entitlements"` +} + +type ChannelCustomersCmd struct { + List ChannelCustomersListCmd `cmd:"" name:"list" aliases:"ls" help:"List Cloud Channel customers"` +} + +type ChannelCustomersListCmd struct { + ChannelAccount string `name:"channel-account" help:"Channel account ID or resource (accounts/...)" required:""` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ChannelCustomersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + channelAccount := normalizeChannelAccount(c.ChannelAccount) + if channelAccount == "" { + return usage("--channel-account is required") + } + + svc, err := newCloudChannelService(ctx, account) + if err != nil { + return err + } + + call := svc.Accounts.Customers.List(channelAccount).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list channel customers: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Customers) == 0 { + u.Err().Println("No customers found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "RESOURCE\tDOMAIN\tCLOUD IDENTITY") + for _, cust := range resp.Customers { + if cust == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(cust.Name), + sanitizeTab(cust.Domain), + sanitizeTab(cust.CloudIdentityId), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ChannelOffersCmd struct { + List ChannelOffersListCmd `cmd:"" name:"list" aliases:"ls" help:"List Cloud Channel offers"` +} + +type ChannelOffersListCmd struct { + ChannelAccount string `name:"channel-account" help:"Channel account ID or resource (accounts/...)" required:""` + Filter string `name:"filter" help:"Filter expression"` + Language string `name:"language" help:"Language code (default en-US)"` + Future bool `name:"future" help:"Show future offers"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ChannelOffersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + channelAccount := normalizeChannelAccount(c.ChannelAccount) + if channelAccount == "" { + return usage("--channel-account is required") + } + + svc, err := newCloudChannelService(ctx, account) + if err != nil { + return err + } + + call := svc.Accounts.Offers.List(channelAccount).PageSize(c.Max) + if strings.TrimSpace(c.Filter) != "" { + call = call.Filter(strings.TrimSpace(c.Filter)) + } + if strings.TrimSpace(c.Language) != "" { + call = call.LanguageCode(strings.TrimSpace(c.Language)) + } + if c.Future { + call = call.ShowFutureOffers(true) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list offers: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Offers) == 0 { + u.Err().Println("No offers found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "OFFER\tSKU\tPRODUCT") + for _, offer := range resp.Offers { + if offer == nil { + continue + } + sku := "" + product := "" + if offer.Sku != nil { + sku = offer.Sku.Name + if offer.Sku.Product != nil { + product = offer.Sku.Product.Name + } + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(offer.Name), + sanitizeTab(sku), + sanitizeTab(product), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ChannelEntitlementsCmd struct { + List ChannelEntitlementsListCmd `cmd:"" name:"list" aliases:"ls" help:"List Cloud Channel entitlements"` +} + +type ChannelEntitlementsListCmd struct { + ChannelAccount string `name:"channel-account" help:"Channel account ID or resource (accounts/...)" required:""` + Customer string `name:"customer" help:"Customer ID or resource" required:""` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ChannelEntitlementsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + channelAccount := normalizeChannelAccount(c.ChannelAccount) + if channelAccount == "" { + return usage("--channel-account is required") + } + customer := strings.TrimSpace(c.Customer) + if customer == "" { + return usage("--customer is required") + } + + parent := customer + if !strings.HasPrefix(customer, "accounts/") { + parent = fmt.Sprintf("%s/customers/%s", channelAccount, customer) + } + + svc, err := newCloudChannelService(ctx, account) + if err != nil { + return err + } + + call := svc.Accounts.Customers.Entitlements.List(parent).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list entitlements: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Entitlements) == 0 { + u.Err().Println("No entitlements found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ENTITLEMENT\tOFFER\tSTATE") + for _, ent := range resp.Entitlements { + if ent == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(ent.Name), + sanitizeTab(ent.Offer), + sanitizeTab(ent.ProvisioningState), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +func normalizeChannelAccount(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "accounts/") { + return trimmed + } + return "accounts/" + trimmed +} diff --git a/internal/cmd/channel_test.go b/internal/cmd/channel_test.go new file mode 100644 index 00000000..a543e75b --- /dev/null +++ b/internal/cmd/channel_test.go @@ -0,0 +1,698 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/cloudchannel/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func newCloudChannelServiceStub(t *testing.T, handler http.HandlerFunc) (*cloudchannel.Service, func()) { + t.Helper() + + srv := httptest.NewServer(handler) + svc, err := cloudchannel.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + srv.Close() + t.Fatalf("NewService: %v", err) + } + return svc, srv.Close +} + +func stubCloudChannelService(t *testing.T, svc *cloudchannel.Service) { + t.Helper() + orig := newCloudChannelService + t.Cleanup(func() { newCloudChannelService = orig }) + newCloudChannelService = func(context.Context, string) (*cloudchannel.Service, error) { return svc, nil } +} + +// ChannelCustomersListCmd tests + +func TestChannelCustomersListCmd(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customers": []map[string]any{{ + "name": "accounts/acc/customers/cust1", + "domain": "example.com", + "cloudIdentityId": "CID", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelCustomersListCmd{ChannelAccount: "acc"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestChannelCustomersListCmd_EmptyResults(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customers": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelCustomersListCmd{ChannelAccount: "acc"} + + // Empty results should not error + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestChannelCustomersListCmd_MissingChannelAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelCustomersListCmd{ChannelAccount: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing channel account") + } + if !strings.Contains(err.Error(), "--channel-account is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChannelCustomersListCmd_WithAccountsPrefix(t *testing.T) { + var gotPath string + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customers": []map[string]any{{ + "name": "accounts/acc/customers/cust1", + "domain": "example.com", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelCustomersListCmd{ChannelAccount: "accounts/acc"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should not double the prefix + if strings.Contains(gotPath, "accounts/accounts/") { + t.Fatalf("unexpected double prefix in path: %q", gotPath) + } +} + +func TestChannelCustomersListCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customers": []map[string]any{{ + "name": "accounts/acc/customers/cust1", + "domain": "example.com", + "cloudIdentityId": "CID123", + }}, + "nextPageToken": "token123", + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelCustomersListCmd{ChannelAccount: "acc"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["nextPageToken"] != "token123" { + t.Fatalf("unexpected nextPageToken: %v", parsed["nextPageToken"]) + } +} + +func TestChannelCustomersListCmd_WithPaging(t *testing.T) { + var gotPageSize, gotPageToken string + + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers") { + http.NotFound(w, r) + return + } + gotPageSize = r.URL.Query().Get("pageSize") + gotPageToken = r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customers": []map[string]any{{ + "name": "accounts/acc/customers/cust1", + "domain": "example.com", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelCustomersListCmd{ChannelAccount: "acc", Max: 25, Page: "mytoken"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageSize != "25" { + t.Fatalf("unexpected pageSize: %q", gotPageSize) + } + if gotPageToken != "mytoken" { + t.Fatalf("unexpected pageToken: %q", gotPageToken) + } +} + +// ChannelOffersListCmd tests + +func TestChannelOffersListCmd(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/offers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "offers": []map[string]any{{ + "name": "accounts/acc/offers/offer1", + "sku": map[string]any{ + "name": "sku1", + "product": map[string]any{ + "name": "product1", + }, + }, + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: "acc"} + + ctx := testContextJSON(t) + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed struct { + Offers []struct { + Name string `json:"name"` + Sku struct { + Name string `json:"name"` + Product struct { + Name string `json:"name"` + } `json:"product"` + } `json:"sku"` + } `json:"offers"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Offers) != 1 { + t.Fatalf("expected 1 offer, got %d", len(parsed.Offers)) + } + if parsed.Offers[0].Name != "accounts/acc/offers/offer1" { + t.Fatalf("unexpected offer name: %s", parsed.Offers[0].Name) + } + if parsed.Offers[0].Sku.Name != "sku1" { + t.Fatalf("unexpected sku name: %s", parsed.Offers[0].Sku.Name) + } + if parsed.Offers[0].Sku.Product.Name != "product1" { + t.Fatalf("unexpected product name: %s", parsed.Offers[0].Sku.Product.Name) + } +} + +func TestChannelOffersListCmd_EmptyResults(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/offers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "offers": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: "acc"} + + // Empty results should not error + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestChannelOffersListCmd_MissingChannelAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing channel account") + } + if !strings.Contains(err.Error(), "--channel-account is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChannelOffersListCmd_WithFilter(t *testing.T) { + var gotFilter string + + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/offers") { + http.NotFound(w, r) + return + } + gotFilter = r.URL.Query().Get("filter") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "offers": []map[string]any{{ + "name": "accounts/acc/offers/offer1", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: "acc", Filter: "sku.product.name = 'test'"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotFilter != "sku.product.name = 'test'" { + t.Fatalf("unexpected filter: %q", gotFilter) + } +} + +func TestChannelOffersListCmd_WithLanguage(t *testing.T) { + var gotLanguage string + + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/offers") { + http.NotFound(w, r) + return + } + gotLanguage = r.URL.Query().Get("languageCode") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "offers": []map[string]any{{ + "name": "accounts/acc/offers/offer1", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: "acc", Language: "de-DE"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotLanguage != "de-DE" { + t.Fatalf("unexpected language: %q", gotLanguage) + } +} + +func TestChannelOffersListCmd_WithFutureOffers(t *testing.T) { + var gotFuture string + + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/offers") { + http.NotFound(w, r) + return + } + gotFuture = r.URL.Query().Get("showFutureOffers") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "offers": []map[string]any{{ + "name": "accounts/acc/offers/offer1", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: "acc", Future: true} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotFuture != "true" { + t.Fatalf("unexpected showFutureOffers: %q", gotFuture) + } +} + +func TestChannelOffersListCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/offers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "offers": []map[string]any{{ + "name": "accounts/acc/offers/offer1", + "sku": map[string]any{ + "name": "sku1", + }, + }}, + "nextPageToken": "token456", + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelOffersListCmd{ChannelAccount: "acc"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["nextPageToken"] != "token456" { + t.Fatalf("unexpected nextPageToken: %v", parsed["nextPageToken"]) + } +} + +// ChannelEntitlementsListCmd tests + +func TestChannelEntitlementsListCmd(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers/cust1/entitlements") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entitlements": []map[string]any{{ + "name": "accounts/acc/customers/cust1/entitlements/e1", + "offer": "offers/o1", + "provisioningState": "ACTIVE", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: "cust1"} + + ctx := testContextJSON(t) + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed struct { + Entitlements []struct { + Name string `json:"name"` + Offer string `json:"offer"` + ProvisioningState string `json:"provisioningState"` + } `json:"entitlements"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Entitlements) != 1 { + t.Fatalf("expected 1 entitlement, got %d", len(parsed.Entitlements)) + } + if parsed.Entitlements[0].Name != "accounts/acc/customers/cust1/entitlements/e1" { + t.Fatalf("unexpected entitlement name: %s", parsed.Entitlements[0].Name) + } + if parsed.Entitlements[0].ProvisioningState != "ACTIVE" { + t.Fatalf("unexpected provisioning state: %s", parsed.Entitlements[0].ProvisioningState) + } +} + +func TestChannelEntitlementsListCmd_EmptyResults(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers/cust1/entitlements") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entitlements": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: "cust1"} + + // Empty results should not error + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestChannelEntitlementsListCmd_MissingChannelAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "", Customer: "cust1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing channel account") + } + if !strings.Contains(err.Error(), "--channel-account is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChannelEntitlementsListCmd_MissingCustomer(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing customer") + } + if !strings.Contains(err.Error(), "--customer is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChannelEntitlementsListCmd_WithFullCustomerPath(t *testing.T) { + var gotPath string + + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/entitlements") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entitlements": []map[string]any{{ + "name": "accounts/acc/customers/cust1/entitlements/e1", + "offer": "offers/o1", + "provisioningState": "ACTIVE", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: "accounts/acc/customers/cust1"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // When customer already has full path, it should be used directly + if !strings.Contains(gotPath, "accounts/acc/customers/cust1/entitlements") { + t.Fatalf("unexpected path: %q", gotPath) + } +} + +func TestChannelEntitlementsListCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers/cust1/entitlements") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entitlements": []map[string]any{{ + "name": "accounts/acc/customers/cust1/entitlements/e1", + "offer": "offers/o1", + "provisioningState": "ACTIVE", + }}, + "nextPageToken": "token789", + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: "cust1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["nextPageToken"] != "token789" { + t.Fatalf("unexpected nextPageToken: %v", parsed["nextPageToken"]) + } +} + +func TestChannelEntitlementsListCmd_WithPaging(t *testing.T) { + var gotPageSize, gotPageToken string + + svc, closeSrv := newCloudChannelServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/accounts/acc/customers/cust1/entitlements") { + http.NotFound(w, r) + return + } + gotPageSize = r.URL.Query().Get("pageSize") + gotPageToken = r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entitlements": []map[string]any{{ + "name": "accounts/acc/customers/cust1/entitlements/e1", + "offer": "offers/o1", + "provisioningState": "ACTIVE", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudChannelService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: "cust1", Max: 50, Page: "pagetoken"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageSize != "50" { + t.Fatalf("unexpected pageSize: %q", gotPageSize) + } + if gotPageToken != "pagetoken" { + t.Fatalf("unexpected pageToken: %q", gotPageToken) + } +} + +// normalizeChannelAccount tests + +func TestNormalizeChannelAccount(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {" ", ""}, + {"acc123", "accounts/acc123"}, + {"accounts/acc123", "accounts/acc123"}, + {" acc123 ", "accounts/acc123"}, + {" accounts/acc123 ", "accounts/acc123"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeChannelAccount(tt.input) + if got != tt.want { + t.Fatalf("normalizeChannelAccount(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/cmd/chat_test.go b/internal/cmd/chat_test.go new file mode 100644 index 00000000..41b3601f --- /dev/null +++ b/internal/cmd/chat_test.go @@ -0,0 +1,1206 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "google.golang.org/api/chat/v1" + "google.golang.org/api/option" +) + +// Tests for chat_helpers.go + +func TestNormalizeSpace(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "whitespace only", + input: " ", + wantErr: true, + }, + { + name: "already normalized", + input: "spaces/abc123", + want: "spaces/abc123", + }, + { + name: "without prefix", + input: "abc123", + want: "spaces/abc123", + }, + { + name: "with leading whitespace", + input: " spaces/abc123", + want: "spaces/abc123", + }, + { + name: "with trailing whitespace", + input: "spaces/abc123 ", + want: "spaces/abc123", + }, + { + name: "id only with whitespace", + input: " abc123 ", + want: "spaces/abc123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeSpace(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("normalizeSpace(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("normalizeSpace(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSpaceID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"spaces/abc123", "abc123"}, + {"abc123", "abc123"}, + {"spaces/", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := spaceID(tt.input); got != tt.want { + t.Errorf("spaceID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeUser(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {" ", ""}, + {"users/user@example.com", "users/user@example.com"}, + {"user@example.com", "users/user@example.com"}, + {" user@example.com ", "users/user@example.com"}, + {" users/user@example.com ", "users/user@example.com"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := normalizeUser(tt.input); got != tt.want { + t.Errorf("normalizeUser(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeThread(t *testing.T) { + tests := []struct { + name string + space string + thread string + want string + wantErr bool + }{ + { + name: "empty thread", + space: "spaces/abc", + thread: "", + wantErr: true, + }, + { + name: "whitespace only thread", + space: "spaces/abc", + thread: " ", + wantErr: true, + }, + { + name: "full thread resource", + space: "spaces/abc", + thread: "spaces/abc/threads/t1", + want: "spaces/abc/threads/t1", + }, + { + name: "invalid full resource missing threads", + space: "spaces/abc", + thread: "spaces/abc/messages/m1", + wantErr: true, + }, + { + name: "thread id only", + space: "spaces/abc", + thread: "t1", + want: "spaces/abc/threads/t1", + }, + { + name: "thread with threads/ prefix", + space: "spaces/abc", + thread: "threads/t1", + want: "spaces/abc/threads/t1", + }, + { + name: "invalid thread id with slash", + space: "spaces/abc", + thread: "t1/extra", + wantErr: true, + }, + { + name: "space without prefix", + space: "abc", + thread: "t1", + want: "spaces/abc/threads/t1", + }, + { + name: "empty space", + space: "", + thread: "t1", + wantErr: true, + }, + { + name: "thread id with whitespace", + space: "spaces/abc", + thread: " t1 ", + want: "spaces/abc/threads/t1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeThread(tt.space, tt.thread) + if (err != nil) != tt.wantErr { + t.Errorf("normalizeThread(%q, %q) error = %v, wantErr %v", tt.space, tt.thread, err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("normalizeThread(%q, %q) = %q, want %q", tt.space, tt.thread, got, tt.want) + } + }) + } +} + +func TestRequireWorkspaceAccount(t *testing.T) { + tests := []struct { + account string + wantErr bool + }{ + {"user@gmail.com", true}, + {"user@googlemail.com", true}, + {"user@company.com", false}, + {"user@workspace.org", false}, + {"admin@example.co", false}, + } + + for _, tt := range tests { + t.Run(tt.account, func(t *testing.T) { + err := requireWorkspaceAccount(tt.account) + if (err != nil) != tt.wantErr { + t.Errorf("requireWorkspaceAccount(%q) error = %v, wantErr %v", tt.account, err, tt.wantErr) + } + }) + } +} + +func TestParseCommaArgs(t *testing.T) { + tests := []struct { + name string + values []string + want []string + }{ + { + name: "empty", + values: nil, + want: []string{}, + }, + { + name: "single value", + values: []string{"a@b.com"}, + want: []string{"a@b.com"}, + }, + { + name: "multiple separate values", + values: []string{"a@b.com", "c@d.com"}, + want: []string{"a@b.com", "c@d.com"}, + }, + { + name: "comma separated", + values: []string{"a@b.com,c@d.com"}, + want: []string{"a@b.com", "c@d.com"}, + }, + { + name: "mixed", + values: []string{"a@b.com,c@d.com", "e@f.com"}, + want: []string{"a@b.com", "c@d.com", "e@f.com"}, + }, + { + name: "with whitespace", + values: []string{" a@b.com , c@d.com "}, + want: []string{"a@b.com", "c@d.com"}, + }, + { + name: "empty entries filtered", + values: []string{"a@b.com,,c@d.com", ""}, + want: []string{"a@b.com", "c@d.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCommaArgs(tt.values) + if len(got) != len(tt.want) { + t.Errorf("parseCommaArgs(%v) = %v, want %v", tt.values, got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseCommaArgs(%v)[%d] = %q, want %q", tt.values, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestChatSpaceType(t *testing.T) { + tests := []struct { + name string + space *chat.Space + want string + }{ + { + name: "nil space", + space: nil, + want: "", + }, + { + name: "spaceType set", + space: &chat.Space{SpaceType: "SPACE"}, + want: "SPACE", + }, + { + name: "type set (legacy)", + space: &chat.Space{Type: "DIRECT_MESSAGE"}, + want: "DIRECT_MESSAGE", + }, + { + name: "spaceType preferred over type", + space: &chat.Space{SpaceType: "SPACE", Type: "DM"}, + want: "SPACE", + }, + { + name: "empty", + space: &chat.Space{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chatSpaceType(tt.space); got != tt.want { + t.Errorf("chatSpaceType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestChatMessageSender(t *testing.T) { + tests := []struct { + name string + msg *chat.Message + want string + }{ + { + name: "nil message", + msg: nil, + want: "", + }, + { + name: "nil sender", + msg: &chat.Message{}, + want: "", + }, + { + name: "display name set", + msg: &chat.Message{Sender: &chat.User{DisplayName: "Ada"}}, + want: "Ada", + }, + { + name: "name set", + msg: &chat.Message{Sender: &chat.User{Name: "users/abc"}}, + want: "users/abc", + }, + { + name: "display name preferred", + msg: &chat.Message{Sender: &chat.User{DisplayName: "Ada", Name: "users/abc"}}, + want: "Ada", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chatMessageSender(tt.msg); got != tt.want { + t.Errorf("chatMessageSender() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestChatMessageText(t *testing.T) { + tests := []struct { + name string + msg *chat.Message + want string + }{ + { + name: "nil message", + msg: nil, + want: "", + }, + { + name: "text set", + msg: &chat.Message{Text: "hello"}, + want: "hello", + }, + { + name: "argument text set", + msg: &chat.Message{ArgumentText: "arg text"}, + want: "arg text", + }, + { + name: "text preferred", + msg: &chat.Message{Text: "hello", ArgumentText: "arg text"}, + want: "hello", + }, + { + name: "empty message", + msg: &chat.Message{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chatMessageText(tt.msg); got != tt.want { + t.Errorf("chatMessageText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestChatMessageThread(t *testing.T) { + tests := []struct { + name string + msg *chat.Message + want string + }{ + { + name: "nil message", + msg: nil, + want: "", + }, + { + name: "nil thread", + msg: &chat.Message{}, + want: "", + }, + { + name: "thread set", + msg: &chat.Message{Thread: &chat.Thread{Name: "spaces/abc/threads/t1"}}, + want: "spaces/abc/threads/t1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chatMessageThread(tt.msg); got != tt.want { + t.Errorf("chatMessageThread() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSanitizeChatText(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"hello", "hello"}, + {"hello\tworld", "hello world"}, + {"hello\nworld", "hello world"}, + {"hello\rworld", "hello world"}, + {"hello\t\n\rworld", "hello world"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := sanitizeChatText(tt.input); got != tt.want { + t.Errorf("sanitizeChatText(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// Tests for chat_dm.go + +func TestChatDMSend_MissingEmail(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "dm", "send", "", "--text", "hi"}) + if err == nil { + t.Fatal("expected error for missing email") + } + if !strings.Contains(err.Error(), "required: email") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatDMSend_MissingText(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "dm", "send", "user@example.com"}) + if err == nil { + t.Fatal("expected error for missing text") + } + if !strings.Contains(err.Error(), "required: --text") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatDMSend_ConsumerBlocked(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + newChatService = func(context.Context, string) (*chat.Service, error) { + t.Fatal("unexpected chat service call") + return nil, errUnexpectedChatServiceCall + } + + err := Execute([]string{"--account", "user@gmail.com", "chat", "dm", "send", "other@example.com", "--text", "hi"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Workspace") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatDMSend_InvalidThread(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/dm1", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + err = Execute([]string{"--account", "a@company.com", "chat", "dm", "send", "user@example.com", "--text", "hi", "--thread", "invalid/path/format"}) + if err == nil { + t.Fatal("expected error for invalid thread") + } + if !strings.Contains(err.Error(), "invalid thread") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatDMSend_WithThread_Text(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + var gotThread string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/dm1", + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces/dm1/messages"): + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if thread, ok := body["thread"].(map[string]any); ok { + gotThread, _ = thread["name"].(string) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/dm1/messages/m1", + "thread": map[string]any{"name": "spaces/dm1/threads/t1"}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "dm", "send", "user@example.com", "--text", "hi", "--thread", "t1"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if gotThread != "spaces/dm1/threads/t1" { + t.Fatalf("unexpected thread: %q", gotThread) + } + if !strings.Contains(out, "thread") && !strings.Contains(out, "spaces/dm1/threads/t1") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestChatDMSpace_MissingEmail(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "dm", "space", ""}) + if err == nil { + t.Fatal("expected error for missing email") + } + if !strings.Contains(err.Error(), "required: email") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatDMSpace_ConsumerBlocked(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + newChatService = func(context.Context, string) (*chat.Service, error) { + t.Fatal("unexpected chat service call") + return nil, errUnexpectedChatServiceCall + } + + err := Execute([]string{"--account", "user@gmail.com", "chat", "dm", "space", "other@example.com"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Workspace") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatDMSpace_Text(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/dm1", + "displayName": "DM with User", + "spaceType": "DIRECT_MESSAGE", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "dm", "space", "user@example.com"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "spaces/dm1") { + t.Fatalf("unexpected out=%q", out) + } + if !strings.Contains(out, "DM with User") { + t.Fatalf("unexpected out=%q", out) + } +} + +// Tests for chat_spaces.go + +func TestChatSpacesList_EmptyResults(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spaces": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + errOut := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "list"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(errOut, "No spaces") { + t.Fatalf("unexpected stderr=%q", errOut) + } +} + +func TestChatSpacesList_JSON(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spaces": []map[string]any{ + {"name": "spaces/aaa", "displayName": "Engineering", "spaceType": "SPACE", "spaceThreadingState": "THREADED_MESSAGES"}, + }, + "nextPageToken": "npt", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@company.com", "chat", "spaces", "list"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Spaces []struct { + Resource string `json:"resource"` + Name string `json:"name"` + SpaceType string `json:"type"` + Threading string `json:"threading"` + } `json:"spaces"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Spaces) != 1 { + t.Fatalf("unexpected spaces count: %d", len(parsed.Spaces)) + } + if parsed.Spaces[0].Resource != "spaces/aaa" { + t.Fatalf("unexpected resource: %q", parsed.Spaces[0].Resource) + } + if parsed.Spaces[0].Threading != "THREADED_MESSAGES" { + t.Fatalf("unexpected threading: %q", parsed.Spaces[0].Threading) + } + if parsed.NextPageToken != "npt" { + t.Fatalf("unexpected nextPageToken: %q", parsed.NextPageToken) + } +} + +func TestChatSpacesFind_MissingDisplayName(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "find", ""}) + if err == nil { + t.Fatal("expected error for missing displayName") + } + if !strings.Contains(err.Error(), "required: displayName") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatSpacesFind_NoResults_Text(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spaces": []map[string]any{ + {"name": "spaces/aaa", "displayName": "Engineering", "spaceType": "SPACE"}, + }, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + errOut := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "find", "NonExistent"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(errOut, "No results") { + t.Fatalf("unexpected stderr=%q", errOut) + } +} + +func TestChatSpacesFind_CaseInsensitive(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spaces": []map[string]any{ + {"name": "spaces/aaa", "displayName": "Engineering", "spaceType": "SPACE"}, + }, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "find", "ENGINEERING"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "spaces/aaa") { + t.Fatalf("unexpected out=%q (should find case-insensitive match)", out) + } +} + +func TestChatSpacesCreate_MissingDisplayName(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "create", ""}) + if err == nil { + t.Fatal("expected error for missing displayName") + } + if !strings.Contains(err.Error(), "required: displayName") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatSpacesCreate_ConsumerBlocked(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + newChatService = func(context.Context, string) (*chat.Service, error) { + t.Fatal("unexpected chat service call") + return nil, errUnexpectedChatServiceCall + } + + err := Execute([]string{"--account", "user@gmail.com", "chat", "spaces", "create", "MySpace"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Workspace") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatSpacesCreate_Text(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/new", + "displayName": "Engineering", + "spaceType": "SPACE", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "create", "Engineering"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "spaces/new") { + t.Fatalf("unexpected out=%q", out) + } + if !strings.Contains(out, "Engineering") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestChatSpacesCreate_WithCommaSeparatedMembers(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + var mu sync.Mutex + var gotMembers int + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup") { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + memberships, _ := body["memberships"].([]any) + mu.Lock() + gotMembers = len(memberships) + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/new", + "displayName": "Team", + "spaceType": "SPACE", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + _ = captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "spaces", "create", "Team", "--member", "a@company.com,b@company.com"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + mu.Lock() + defer mu.Unlock() + if gotMembers != 2 { + t.Fatalf("unexpected members count: %d, expected 2", gotMembers) + } +} + +// Tests for chat_messages.go + +func TestChatMessagesListEmptyResults(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/messages") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "messages": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + errOut := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "messages", "list", "spaces/aaa"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(errOut, "No messages") { + t.Fatalf("unexpected stderr=%q", errOut) + } +} + +func TestChatMessagesSend_MissingSpace(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "messages", "send", "", "--text", "hi"}) + if err == nil { + t.Fatal("expected error for missing space") + } + if !strings.Contains(err.Error(), "required: space") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatMessagesSend_MissingText(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "messages", "send", "spaces/aaa"}) + if err == nil { + t.Fatal("expected error for missing text") + } + if !strings.Contains(err.Error(), "required: --text") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChatMessagesSend_Text(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + var gotText string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/messages") { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotText, _ = body["text"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/aaa/messages/m1", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "messages", "send", "spaces/aaa", "--text", "hello world"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if gotText != "hello world" { + t.Fatalf("unexpected text: %q", gotText) + } + if !strings.Contains(out, "spaces/aaa/messages/m1") { + t.Fatalf("unexpected out=%q", out) + } +} + +// Tests for chat_threads.go + +func TestChatThreadsList_EmptyResults(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/messages") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "messages": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + errOut := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := Execute([]string{"--account", "a@company.com", "chat", "threads", "list", "spaces/aaa"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(errOut, "No threads") { + t.Fatalf("unexpected stderr=%q", errOut) + } +} + +func TestChatThreadsList_JSON(t *testing.T) { + origNew := newChatService + t.Cleanup(func() { newChatService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/messages") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "messages": []map[string]any{ + {"name": "spaces/aaa/messages/m1", "thread": map[string]any{"name": "spaces/aaa/threads/t1"}, "text": "hello", "createTime": "2025-01-01T00:00:00Z", "sender": map[string]any{"displayName": "Ada"}}, + {"name": "spaces/aaa/messages/m2", "thread": map[string]any{"name": "spaces/aaa/threads/t2"}, "text": "world", "createTime": "2025-01-02T00:00:00Z"}, + }, + "nextPageToken": "npt", + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := chat.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@company.com", "chat", "threads", "list", "spaces/aaa"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Threads []struct { + Thread string `json:"thread"` + Message string `json:"message"` + Sender string `json:"sender"` + Text string `json:"text"` + CreateTime string `json:"createTime"` + } `json:"threads"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Threads) != 2 { + t.Fatalf("unexpected threads count: %d", len(parsed.Threads)) + } + if parsed.Threads[0].Thread != "spaces/aaa/threads/t1" { + t.Fatalf("unexpected thread: %q", parsed.Threads[0].Thread) + } + if parsed.Threads[0].Sender != "Ada" { + t.Fatalf("unexpected sender: %q", parsed.Threads[0].Sender) + } + if parsed.NextPageToken != "npt" { + t.Fatalf("unexpected nextPageToken: %q", parsed.NextPageToken) + } +} + +func TestChatThreadsList_MissingSpace(t *testing.T) { + err := Execute([]string{"--account", "a@company.com", "chat", "threads", "list", ""}) + if err == nil { + t.Fatal("expected error for missing space") + } + if !strings.Contains(err.Error(), "required: space") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cmd/classroom_test.go b/internal/cmd/classroom_test.go new file mode 100644 index 00000000..10dd5929 --- /dev/null +++ b/internal/cmd/classroom_test.go @@ -0,0 +1,1519 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/classroom/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/ui" +) + +// testCtxWithCurrentStdout creates a context with a UI that uses the current os.Stdout. +// This must be called inside captureStdout to capture output that goes through u.Out().Printf. +func testCtxWithCurrentStdout(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +// stubClassroomService creates a test server and returns a classroom service that uses it. +// The handler is expected to handle all classroom API routes. +func stubClassroomService(t *testing.T, handler http.Handler) (*classroom.Service, func()) { + t.Helper() + srv := httptest.NewServer(handler) + svc, err := classroom.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + srv.Close() + t.Fatalf("NewService: %v", err) + } + return svc, srv.Close +} + +// classroomTestHandler returns a comprehensive mock handler for classroom API endpoints. +func classroomTestHandler(t *testing.T) http.Handler { + t.Helper() + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON := func(data any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(data) + } + + path := r.URL.Path + switch { + // Guardian invitations + case strings.Contains(path, "/userProfiles/") && strings.Contains(path, "/guardianInvitations"): + switch { + case r.Method == http.MethodGet && strings.Contains(path, "/guardianInvitations/"): + writeJSON(map[string]any{ + "invitationId": "gi1", + "studentId": "s1", + "invitedEmailAddress": "guardian@example.com", + "state": "PENDING", + "creationTime": "2024-01-01T00:00:00Z", + }) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "guardianInvitations": []map[string]any{{ + "invitationId": "gi1", + "invitedEmailAddress": "guardian@example.com", + "state": "PENDING", + "creationTime": "2024-01-01T00:00:00Z", + }}, + }) + case r.Method == http.MethodPost: + writeJSON(map[string]any{ + "invitationId": "gi2", + "studentId": "s1", + "invitedEmailAddress": "new@example.com", + "state": "PENDING", + }) + default: + http.NotFound(w, r) + } + // Guardians + case strings.Contains(path, "/userProfiles/") && strings.Contains(path, "/guardians"): + switch { + case r.Method == http.MethodGet && strings.Contains(path, "/guardians/"): + writeJSON(map[string]any{ + "guardianId": "g1", + "studentId": "s1", + "guardianProfile": map[string]any{ + "emailAddress": "guardian@example.com", + "name": map[string]any{"fullName": "Guardian One"}, + }, + }) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "guardians": []map[string]any{{ + "guardianId": "g1", + "studentId": "s1", + "guardianProfile": map[string]any{ + "emailAddress": "guardian@example.com", + "name": map[string]any{"fullName": "Guardian One"}, + }, + }}, + }) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + // User profile + case strings.Contains(path, "/userProfiles/") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "id": "u1", + "emailAddress": "me@example.com", + "name": map[string]any{"fullName": "User One"}, + "verifiedTeacher": true, + }) + // Invitations + case strings.Contains(path, "/invitations"): + switch { + case strings.Contains(path, ":accept") && r.Method == http.MethodPost: + writeJSON(map[string]any{"accepted": true}) + case strings.Contains(path, "/invitations/") && r.Method == http.MethodGet: + writeJSON(map[string]any{"id": "i1", "courseId": "c1", "userId": "u1", "role": "STUDENT"}) + case strings.Contains(path, "/invitations/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "invitations": []map[string]any{{"id": "i1", "courseId": "c1", "userId": "u1", "role": "STUDENT"}}, + }) + case r.Method == http.MethodPost: + writeJSON(map[string]any{"id": "i2", "courseId": "c1", "userId": "u2", "role": "TEACHER"}) + default: + http.NotFound(w, r) + } + // Course work materials + case strings.Contains(path, "/courseWorkMaterials"): + switch { + case strings.Contains(path, "/courseWorkMaterials/") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "id": "m1", + "title": "Material 1", + "description": "Material description", + "state": "PUBLISHED", + "topicId": "t1", + "updateTime": "2024-01-01T00:00:00Z", + }) + case strings.Contains(path, "/courseWorkMaterials/") && r.Method == http.MethodPatch: + writeJSON(map[string]any{"id": "m1", "title": "Updated Material", "state": "PUBLISHED"}) + case strings.Contains(path, "/courseWorkMaterials/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case strings.Contains(path, "/courseWorkMaterials") && r.Method == http.MethodPost: + writeJSON(map[string]any{"id": "m3", "title": "New Material", "state": "DRAFT"}) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "courseWorkMaterial": []map[string]any{{ + "id": "m1", + "title": "Material 1", + "state": "PUBLISHED", + "topicId": "t1", + "updateTime": "2024-01-01T00:00:00Z", + }}, + }) + default: + http.NotFound(w, r) + } + // Coursework + case strings.Contains(path, "/courseWork"): + switch { + case strings.Contains(path, ":modifyAssignees") && r.Method == http.MethodPost: + writeJSON(map[string]any{"id": "cw1", "assigneeMode": "ALL_STUDENTS"}) + case strings.Contains(path, "/courseWork/") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "id": "cw1", + "title": "Assignment 1", + "description": "Do this work", + "state": "PUBLISHED", + "workType": "ASSIGNMENT", + "topicId": "t1", + "maxPoints": 100, + "alternateLink": "https://classroom.google.com/cw1", + "dueDate": map[string]int64{"year": 2024, "month": 12, "day": 25}, + "dueTime": map[string]int64{"hours": 23, "minutes": 59}, + "scheduledTime": "2024-12-01T00:00:00Z", + }) + case strings.Contains(path, "/courseWork/") && r.Method == http.MethodPatch: + writeJSON(map[string]any{"id": "cw1", "title": "Updated Work", "state": "PUBLISHED"}) + case strings.Contains(path, "/courseWork/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case strings.Contains(path, "/courseWork") && r.Method == http.MethodPost: + writeJSON(map[string]any{"id": "cw3", "title": "New Work", "state": "DRAFT"}) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "courseWork": []map[string]any{{ + "id": "cw1", + "title": "Assignment 1", + "state": "PUBLISHED", + "workType": "ASSIGNMENT", + "topicId": "t1", + }}, + }) + default: + http.NotFound(w, r) + } + // Announcements + case strings.Contains(path, "/announcements"): + switch { + case strings.Contains(path, ":modifyAssignees") && r.Method == http.MethodPost: + writeJSON(map[string]any{"id": "a1", "assigneeMode": "INDIVIDUAL_STUDENTS"}) + case strings.Contains(path, "/announcements/") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "id": "a1", + "text": "Hello class!", + "state": "PUBLISHED", + "scheduledTime": "2024-01-01T00:00:00Z", + "alternateLink": "https://classroom.google.com/a1", + }) + case strings.Contains(path, "/announcements/") && r.Method == http.MethodPatch: + writeJSON(map[string]any{"id": "a1", "state": "PUBLISHED"}) + case strings.Contains(path, "/announcements/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case strings.Contains(path, "/announcements") && r.Method == http.MethodPost: + writeJSON(map[string]any{"id": "a3", "state": "DRAFT"}) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "announcements": []map[string]any{{ + "id": "a1", + "text": "Hello class!", + "state": "PUBLISHED", + "updateTime": "2024-01-01T00:00:00Z", + }}, + }) + default: + http.NotFound(w, r) + } + // Topics + case strings.Contains(path, "/topics"): + switch { + case strings.Contains(path, "/topics/") && r.Method == http.MethodGet: + writeJSON(map[string]any{"topicId": "t1", "name": "Topic 1", "courseId": "c1"}) + case strings.Contains(path, "/topics/") && r.Method == http.MethodPatch: + writeJSON(map[string]any{"topicId": "t1", "name": "Updated Topic"}) + case strings.Contains(path, "/topics/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case strings.Contains(path, "/topics") && r.Method == http.MethodPost: + writeJSON(map[string]any{"topicId": "t3", "name": "New Topic"}) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "topic": []map[string]any{{"topicId": "t1", "name": "Topic 1"}}, + }) + default: + http.NotFound(w, r) + } + // Students + case strings.Contains(path, "/students"): + switch { + case strings.Contains(path, "/students/") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "userId": "s1", + "profile": map[string]any{ + "emailAddress": "student@example.com", + "name": map[string]any{"fullName": "Student One"}, + }, + "studentWorkFolder": map[string]any{"id": "folder1"}, + }) + case strings.Contains(path, "/students/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case strings.Contains(path, "/students") && r.Method == http.MethodPost: + writeJSON(map[string]any{ + "userId": "s1", + "profile": map[string]any{ + "emailAddress": "student@example.com", + "name": map[string]any{"fullName": "Student One"}, + }, + }) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "students": []map[string]any{{ + "userId": "s1", + "profile": map[string]any{ + "emailAddress": "student@example.com", + "name": map[string]any{"fullName": "Student One"}, + }, + }}, + }) + default: + http.NotFound(w, r) + } + // Teachers + case strings.Contains(path, "/teachers"): + switch { + case strings.Contains(path, "/teachers/") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "userId": "t1", + "profile": map[string]any{ + "emailAddress": "teacher@example.com", + "name": map[string]any{"fullName": "Teacher One"}, + }, + }) + case strings.Contains(path, "/teachers/") && r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case strings.Contains(path, "/teachers") && r.Method == http.MethodPost: + writeJSON(map[string]any{ + "userId": "t1", + "profile": map[string]any{ + "emailAddress": "teacher@example.com", + "name": map[string]any{"fullName": "Teacher One"}, + }, + }) + case r.Method == http.MethodGet: + writeJSON(map[string]any{ + "teachers": []map[string]any{{ + "userId": "t1", + "profile": map[string]any{ + "emailAddress": "teacher@example.com", + "name": map[string]any{"fullName": "Teacher One"}, + }, + }}, + }) + default: + http.NotFound(w, r) + } + // Courses list + case strings.HasSuffix(path, "/courses") && r.Method == http.MethodGet: + writeJSON(map[string]any{ + "courses": []map[string]any{{ + "id": "c1", + "name": "Biology 101", + "section": "Section A", + "courseState": "ACTIVE", + "ownerId": "me", + }}, + }) + // Courses create + case strings.HasSuffix(path, "/courses") && r.Method == http.MethodPost: + writeJSON(map[string]any{ + "id": "c2", + "name": "New Course", + "courseState": "ACTIVE", + "ownerId": "me", + "enrollmentCode": "abc123", + }) + // Course operations + case strings.Contains(path, "/courses/"): + switch r.Method { + case http.MethodGet: + writeJSON(map[string]any{ + "id": "c1", + "name": "Biology 101", + "section": "Section A", + "descriptionHeading": "Welcome", + "description": "Biology course", + "room": "Room 101", + "courseState": "ACTIVE", + "ownerId": "me", + "enrollmentCode": "abc123", + "alternateLink": "https://classroom.google.com/c/c1", + }) + case http.MethodPatch: + writeJSON(map[string]any{"id": "c1", "name": "Updated Course", "courseState": "ARCHIVED"}) + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + default: + http.NotFound(w, r) + } + }) +} + +// Tests for ClassroomCoursesListCmd +func TestClassroomCoursesListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesListCmd{} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Biology 101") { + t.Errorf("expected course name in output, got %q", out) + } + if !strings.Contains(out, "ACTIVE") { + t.Errorf("expected course state in output, got %q", out) + } +} + +func TestClassroomCoursesListCmd_Run_EmptyList(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"courses": []any{}}) + }) + svc, cleanup := stubClassroomService(t, handler) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesListCmd{} + flags := &RootFlags{Account: "a@b.com"} + + // Empty list should not error - just verify no crash + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +// Tests for ClassroomCoursesGetCmd +func TestClassroomCoursesGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesGetCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"c1", "Biology 101", "Section A", "Room 101"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +func TestClassroomCoursesGetCmd_Run_EmptyCourseID(t *testing.T) { + cmd := &ClassroomCoursesGetCmd{CourseID: ""} + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty courseId") + } + if !strings.Contains(err.Error(), "empty courseId") { + t.Errorf("expected 'empty courseId' error, got %v", err) + } +} + +// Tests for ClassroomCoursesCreateCmd +func TestClassroomCoursesCreateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesCreateCmd{ + Name: "New Course", + OwnerID: "me", + State: "active", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "c2") { + t.Errorf("expected course id in output, got %q", out) + } +} + +func TestClassroomCoursesCreateCmd_Run_EmptyName(t *testing.T) { + cmd := &ClassroomCoursesCreateCmd{Name: "", OwnerID: "me"} + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name") + } +} + +// Tests for ClassroomCoursesUpdateCmd +func TestClassroomCoursesUpdateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesUpdateCmd{ + CourseID: "c1", + Name: "Updated Biology", + State: "archived", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "c1") { + t.Errorf("expected course id in output, got %q", out) + } +} + +func TestClassroomCoursesUpdateCmd_Run_NoUpdates(t *testing.T) { + cmd := &ClassroomCoursesUpdateCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates") + } + if !strings.Contains(err.Error(), "no updates") { + t.Errorf("expected 'no updates' error, got %v", err) + } +} + +// Tests for ClassroomCoursesJoinCmd +func TestClassroomCoursesJoinCmd_Run_Student(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesJoinCmd{ + CourseID: "c1", + Role: "student", + UserID: "me", + EnrollmentCode: "abc", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "s1") { + t.Errorf("expected user id in output, got %q", out) + } +} + +func TestClassroomCoursesJoinCmd_Run_Teacher(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesJoinCmd{ + CourseID: "c1", + Role: "teacher", + UserID: "me", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "t1") { + t.Errorf("expected user id in output, got %q", out) + } +} + +func TestClassroomCoursesJoinCmd_Run_InvalidRole(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesJoinCmd{ + CourseID: "c1", + Role: "invalid", + UserID: "me", + } + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for invalid role") + } + if !strings.Contains(err.Error(), "invalid role") { + t.Errorf("expected 'invalid role' error, got %v", err) + } +} + +// Tests for ClassroomCoursesLeaveCmd +func TestClassroomCoursesLeaveCmd_Run_Student(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesLeaveCmd{ + CourseID: "c1", + Role: "student", + UserID: "s1", + } + flags := &RootFlags{Account: "a@b.com", Force: true} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "removed") && !strings.Contains(out, "true") { + t.Errorf("expected removal confirmation, got %q", out) + } +} + +// Tests for ClassroomCoursesURLCmd +func TestClassroomCoursesURLCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCoursesURLCmd{CourseIDs: []string{"c1"}} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "classroom.google.com") { + t.Errorf("expected classroom URL in output, got %q", out) + } +} + +func TestClassroomCoursesURLCmd_Run_Empty(t *testing.T) { + cmd := &ClassroomCoursesURLCmd{CourseIDs: []string{}} + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing courseId") + } +} + +// Tests for ClassroomCourseworkListCmd +func TestClassroomCourseworkListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCourseworkListCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Assignment 1") { + t.Errorf("expected coursework title in output, got %q", out) + } +} + +// Tests for ClassroomCourseworkGetCmd +func TestClassroomCourseworkGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCourseworkGetCmd{CourseID: "c1", CourseworkID: "cw1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"cw1", "Assignment 1", "ASSIGNMENT", "100"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +func TestClassroomCourseworkGetCmd_Run_Validation(t *testing.T) { + tests := []struct { + name string + courseID string + workID string + wantErr string + }{ + {"empty courseId", "", "cw1", "empty courseId"}, + {"empty courseworkId", "c1", "", "empty courseworkId"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &ClassroomCourseworkGetCmd{CourseID: tt.courseID, CourseworkID: tt.workID} + err := cmd.Run(testContext(t), &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +// Tests for ClassroomCourseworkCreateCmd +func TestClassroomCourseworkCreateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCourseworkCreateCmd{ + CourseID: "c1", + Title: "New Assignment", + Description: "Do your homework", + WorkType: "assignment", + MaxPoints: 100, + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "cw3") { + t.Errorf("expected coursework id in output, got %q", out) + } +} + +func TestClassroomCourseworkCreateCmd_Run_DueTimeWithoutDate(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomCourseworkCreateCmd{ + CourseID: "c1", + Title: "Assignment", + DueTime: "14:00", + } + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for due time without date") + } + if !strings.Contains(err.Error(), "due time requires") { + t.Errorf("expected 'due time requires' error, got %v", err) + } +} + +// Tests for ClassroomCourseworkAssigneesCmd +func TestClassroomCourseworkAssigneesCmd_Run_NoChanges(t *testing.T) { + cmd := &ClassroomCourseworkAssigneesCmd{ + CourseID: "c1", + CourseworkID: "cw1", + } + flags := &RootFlags{Account: "a@b.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no assignee changes") + } +} + +// Tests for ClassroomGuardiansListCmd +func TestClassroomGuardiansListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomGuardiansListCmd{StudentID: "s1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "guardian@example.com") { + t.Errorf("expected guardian email in output, got %q", out) + } +} + +func TestClassroomGuardiansListCmd_Run_EmptyStudentID(t *testing.T) { + cmd := &ClassroomGuardiansListCmd{StudentID: ""} + err := cmd.Run(testContext(t), &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error for empty studentId") + } +} + +// Tests for ClassroomGuardiansGetCmd +func TestClassroomGuardiansGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomGuardiansGetCmd{StudentID: "s1", GuardianID: "g1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"g1", "s1", "guardian@example.com"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +// Tests for ClassroomGuardiansDeleteCmd +func TestClassroomGuardiansDeleteCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomGuardiansDeleteCmd{StudentID: "s1", GuardianID: "g1"} + flags := &RootFlags{Account: "a@b.com", Force: true} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") { + t.Errorf("expected 'deleted' in output, got %q", out) + } +} + +// Tests for ClassroomGuardianInvitesListCmd +func TestClassroomGuardianInvitesListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomGuardianInvitesListCmd{StudentID: "s1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "guardian@example.com") { + t.Errorf("expected guardian email in output, got %q", out) + } +} + +// Tests for ClassroomGuardianInvitesGetCmd +func TestClassroomGuardianInvitesGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomGuardianInvitesGetCmd{StudentID: "s1", InvitationID: "gi1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"gi1", "guardian@example.com", "PENDING"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +// Tests for ClassroomGuardianInvitesCreateCmd +func TestClassroomGuardianInvitesCreateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomGuardianInvitesCreateCmd{StudentID: "s1", Email: "new@example.com"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "gi2") { + t.Errorf("expected invitation id in output, got %q", out) + } +} + +// Tests for ClassroomInvitationsListCmd +func TestClassroomInvitationsListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomInvitationsListCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "STUDENT") { + t.Errorf("expected role in output, got %q", out) + } +} + +// Tests for ClassroomInvitationsGetCmd +func TestClassroomInvitationsGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomInvitationsGetCmd{InvitationID: "i1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"i1", "c1", "u1", "STUDENT"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +// Tests for ClassroomInvitationsCreateCmd +func TestClassroomInvitationsCreateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomInvitationsCreateCmd{ + CourseID: "c1", + UserID: "u2", + Role: "teacher", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "i2") { + t.Errorf("expected invitation id in output, got %q", out) + } +} + +func TestClassroomInvitationsCreateCmd_Run_Validation(t *testing.T) { + tests := []struct { + name string + courseID string + userID string + role string + wantErr string + }{ + {"empty courseId", "", "u1", "teacher", "empty courseId"}, + {"empty userId", "c1", "", "teacher", "empty userId"}, + {"empty role", "c1", "u1", "", "empty role"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &ClassroomInvitationsCreateCmd{CourseID: tt.courseID, UserID: tt.userID, Role: tt.role} + err := cmd.Run(testContext(t), &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +// Tests for ClassroomInvitationsAcceptCmd +func TestClassroomInvitationsAcceptCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomInvitationsAcceptCmd{InvitationID: "i1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "accepted") { + t.Errorf("expected 'accepted' in output, got %q", out) + } +} + +// Tests for ClassroomInvitationsDeleteCmd +func TestClassroomInvitationsDeleteCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomInvitationsDeleteCmd{InvitationID: "i1"} + flags := &RootFlags{Account: "a@b.com", Force: true} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") { + t.Errorf("expected 'deleted' in output, got %q", out) + } +} + +// Tests for ClassroomAnnouncementsListCmd +func TestClassroomAnnouncementsListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomAnnouncementsListCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Hello class!") { + t.Errorf("expected announcement text in output, got %q", out) + } +} + +// Tests for ClassroomAnnouncementsGetCmd +func TestClassroomAnnouncementsGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomAnnouncementsGetCmd{CourseID: "c1", AnnouncementID: "a1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"a1", "Hello class!", "PUBLISHED"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +// Tests for ClassroomAnnouncementsCreateCmd +func TestClassroomAnnouncementsCreateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomAnnouncementsCreateCmd{ + CourseID: "c1", + Text: "New announcement", + State: "draft", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "a3") { + t.Errorf("expected announcement id in output, got %q", out) + } +} + +// Tests for ClassroomAnnouncementsDeleteCmd +func TestClassroomAnnouncementsDeleteCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomAnnouncementsDeleteCmd{CourseID: "c1", AnnouncementID: "a1"} + flags := &RootFlags{Account: "a@b.com", Force: true} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") { + t.Errorf("expected 'deleted' in output, got %q", out) + } +} + +// Tests for ClassroomMaterialsListCmd +func TestClassroomMaterialsListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomMaterialsListCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Material 1") { + t.Errorf("expected material title in output, got %q", out) + } +} + +// Tests for ClassroomMaterialsGetCmd +func TestClassroomMaterialsGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomMaterialsGetCmd{CourseID: "c1", MaterialID: "m1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"m1", "Material 1", "PUBLISHED"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +// Tests for ClassroomMaterialsCreateCmd +func TestClassroomMaterialsCreateCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomMaterialsCreateCmd{ + CourseID: "c1", + Title: "New Material", + State: "draft", + } + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "m3") { + t.Errorf("expected material id in output, got %q", out) + } +} + +// Tests for ClassroomMaterialsUpdateCmd +func TestClassroomMaterialsUpdateCmd_Run_NoUpdates(t *testing.T) { + cmd := &ClassroomMaterialsUpdateCmd{CourseID: "c1", MaterialID: "m1"} + err := cmd.Run(testContext(t), &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error for no updates") + } +} + +// Tests for ClassroomMaterialsDeleteCmd +func TestClassroomMaterialsDeleteCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomMaterialsDeleteCmd{CourseID: "c1", MaterialID: "m1"} + flags := &RootFlags{Account: "a@b.com", Force: true} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") { + t.Errorf("expected 'deleted' in output, got %q", out) + } +} + +// Tests for ClassroomRosterCmd +func TestClassroomRosterCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomRosterCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "teacher") || !strings.Contains(out, "student") { + t.Errorf("expected roles in output, got %q", out) + } +} + +func TestClassroomRosterCmd_Run_StudentsOnly(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomRosterCmd{CourseID: "c1", Students: true} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if strings.Contains(out, "Teacher One") { + t.Errorf("expected no teachers in output, got %q", out) + } +} + +// Tests for ClassroomStudentsListCmd +func TestClassroomStudentsListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomStudentsListCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Student One") { + t.Errorf("expected student name in output, got %q", out) + } +} + +// Tests for ClassroomStudentsGetCmd +func TestClassroomStudentsGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomStudentsGetCmd{CourseID: "c1", UserID: "s1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"s1", "student@example.com", "folder1"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +// Tests for ClassroomTeachersListCmd +func TestClassroomTeachersListCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomTeachersListCmd{CourseID: "c1"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Teacher One") { + t.Errorf("expected teacher name in output, got %q", out) + } +} + +// Tests for ClassroomProfileGetCmd +func TestClassroomProfileGetCmd_Run_Text(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomProfileGetCmd{UserID: "me"} + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"u1", "me@example.com", "User One", "true"} { + if !strings.Contains(out, expected) { + t.Errorf("expected %q in output, got %q", expected, out) + } + } +} + +func TestClassroomProfileGetCmd_Run_DefaultUser(t *testing.T) { + origNew := newClassroomService + t.Cleanup(func() { newClassroomService = origNew }) + + svc, cleanup := stubClassroomService(t, classroomTestHandler(t)) + defer cleanup() + newClassroomService = func(context.Context, string) (*classroom.Service, error) { return svc, nil } + + cmd := &ClassroomProfileGetCmd{} // empty UserID should default to "me" + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testCtxWithCurrentStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "me@example.com") { + t.Errorf("expected profile output, got %q", out) + } +} + +// Test helper function coverage +func TestTruncateClassroomText(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + {"empty string", "", 10, ""}, + {"short string", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncated", "hello world", 5, "hello..."}, + {"zero max", "hello", 0, "hello"}, + {"negative max", "hello", -1, "hello"}, + {"whitespace trimmed", " hello ", 10, "hello"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateClassroomText(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncateClassroomText(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +// Test error cases for missing required account +func TestClassroomCoursesListCmd_Run_MissingAccount(t *testing.T) { + cmd := &ClassroomCoursesListCmd{} + err := cmd.Run(testContext(t), &RootFlags{}) + if err == nil { + t.Fatal("expected error for missing account") + } +} diff --git a/internal/cmd/cloudidentity.go b/internal/cmd/cloudidentity.go new file mode 100644 index 00000000..97d93529 --- /dev/null +++ b/internal/cmd/cloudidentity.go @@ -0,0 +1,609 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "google.golang.org/api/cloudidentity/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newCloudIdentityAdminService = googleapi.NewCloudIdentity + +func cloudIdentityParent() string { + if id := os.Getenv("GOG_CUSTOMER_ID"); id != "" { + return "customers/" + id + } + return "customers/my_customer" +} + +type CloudIdentityCmd struct { + Groups CloudIdentityGroupsCmd `cmd:"" name:"groups" help:"Cloud Identity groups"` + Members CloudIdentityMembersCmd `cmd:"" name:"members" help:"Cloud Identity group members"` + Policies CloudIdentityPoliciesCmd `cmd:"" name:"policies" help:"Cloud Identity policies"` +} + +type CloudIdentityGroupsCmd struct { + List CloudIdentityGroupsListCmd `cmd:"" name:"list" aliases:"ls" help:"List Cloud Identity groups"` + Get CloudIdentityGroupsGetCmd `cmd:"" name:"get" help:"Get a Cloud Identity group"` + Create CloudIdentityGroupsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create a Cloud Identity group"` + Update CloudIdentityGroupsUpdateCmd `cmd:"" name:"update" help:"Update a Cloud Identity group"` + Delete CloudIdentityGroupsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a Cloud Identity group"` +} + +type CloudIdentityGroupsListCmd struct { + Parent string `name:"parent" help:"Customer parent (default: customers/my_customer, override with GOG_CUSTOMER_ID env var)"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *CloudIdentityGroupsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + parent := strings.TrimSpace(c.Parent) + if parent == "" { + parent = cloudIdentityParent() + } + + call := svc.Groups.List().Parent(parent).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list groups: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Groups) == 0 { + u.Err().Println("No groups found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tNAME\tRESOURCE") + for _, group := range resp.Groups { + if group == nil { + continue + } + email := "" + if group.GroupKey != nil { + email = group.GroupKey.Id + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(email), + sanitizeTab(group.DisplayName), + sanitizeTab(group.Name), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type CloudIdentityGroupsGetCmd struct { + Group string `arg:"" name:"group" help:"Group email or resource name"` +} + +func (c *CloudIdentityGroupsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupKey := strings.TrimSpace(c.Group) + if groupKey == "" { + return usage("group is required") + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + groupName, err := resolveGroupName(ctx, svc, groupKey) + if err != nil { + return err + } + + group, err := svc.Groups.Get(groupName).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get group %s: %w", groupKey, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, group) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Name: %s\n", group.Name) + if group.GroupKey != nil { + u.Out().Printf("Email: %s\n", group.GroupKey.Id) + } + if group.DisplayName != "" { + u.Out().Printf("Display Name: %s\n", group.DisplayName) + } + if group.Parent != "" { + u.Out().Printf("Parent: %s\n", group.Parent) + } + if len(group.Labels) > 0 { + labels := make([]string, 0, len(group.Labels)) + for key := range group.Labels { + labels = append(labels, key) + } + sort.Strings(labels) + u.Out().Printf("Labels: %s\n", strings.Join(labels, ", ")) + } + return nil +} + +type CloudIdentityGroupsCreateCmd struct { + Email string `name:"email" help:"Group email" required:""` + DisplayName string `name:"display-name" help:"Display name"` + Parent string `name:"parent" help:"Customer parent (default: customers/my_customer, override with GOG_CUSTOMER_ID env var)"` + DynamicQuery string `name:"dynamic-query" help:"CEL expression for dynamic membership (e.g., 'user.is_enrolled_in_2sv == true')"` +} + +func (c *CloudIdentityGroupsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + email := strings.TrimSpace(c.Email) + if email == "" { + return usage("--email is required") + } + + parent := strings.TrimSpace(c.Parent) + if parent == "" { + parent = cloudIdentityParent() + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + display := strings.TrimSpace(c.DisplayName) + if display == "" { + display = email + } + + labels := map[string]string{"cloudidentity.googleapis.com/groups.discussion_forum": ""} + group := &cloudidentity.Group{ + Parent: parent, + GroupKey: &cloudidentity.EntityKey{Id: email}, + DisplayName: display, + Labels: labels, + } + if strings.TrimSpace(c.DynamicQuery) != "" { + group.DynamicGroupMetadata = &cloudidentity.DynamicGroupMetadata{ + Queries: []*cloudidentity.DynamicGroupQuery{{ + Query: strings.TrimSpace(c.DynamicQuery), + ResourceType: "USER", + }}, + } + group.Labels = map[string]string{"cloudidentity.googleapis.com/groups.dynamic": ""} + } + + op, err := svc.Groups.Create(group).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create group: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Created group %s\n", email) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CloudIdentityGroupsUpdateCmd struct { + Group string `arg:"" name:"group" help:"Group email or resource name"` + DisplayName string `name:"display-name" help:"New display name"` +} + +func (c *CloudIdentityGroupsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupKey := strings.TrimSpace(c.Group) + if groupKey == "" { + return usage("group is required") + } + if strings.TrimSpace(c.DisplayName) == "" { + return usage("--display-name is required") + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + groupName, err := resolveGroupName(ctx, svc, groupKey) + if err != nil { + return err + } + + patch := &cloudidentity.Group{DisplayName: strings.TrimSpace(c.DisplayName)} + op, err := svc.Groups.Patch(groupName, patch).UpdateMask("displayName").Context(ctx).Do() + if err != nil { + return fmt.Errorf("update group %s: %w", groupKey, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Updated group %s\n", groupKey) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CloudIdentityGroupsDeleteCmd struct { + Group string `arg:"" name:"group" help:"Group email or resource name"` +} + +func (c *CloudIdentityGroupsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupKey := strings.TrimSpace(c.Group) + if groupKey == "" { + return usage("group is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", groupKey)); err != nil { + return err + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + groupName, err := resolveGroupName(ctx, svc, groupKey) + if err != nil { + return err + } + + op, err := svc.Groups.Delete(groupName).Context(ctx).Do() + if err != nil { + return fmt.Errorf("delete group %s: %w", groupKey, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Deleted group %s\n", groupKey) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CloudIdentityMembersCmd struct { + List CloudIdentityMembersListCmd `cmd:"" name:"list" aliases:"ls" help:"List group members"` + Add CloudIdentityMembersAddCmd `cmd:"" name:"add" help:"Add a member to a group"` + Remove CloudIdentityMembersRemoveCmd `cmd:"" name:"remove" aliases:"rm" help:"Remove a member from a group"` +} + +type CloudIdentityMembersListCmd struct { + Group string `arg:"" name:"group" help:"Group email or resource name"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *CloudIdentityMembersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupKey := strings.TrimSpace(c.Group) + if groupKey == "" { + return usage("group is required") + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + groupName, err := resolveGroupName(ctx, svc, groupKey) + if err != nil { + return err + } + + call := svc.Groups.Memberships.List(groupName).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list members: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Memberships) == 0 { + u.Err().Println("No members found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "MEMBER\tROLE\tRESOURCE") + for _, membership := range resp.Memberships { + if membership == nil { + continue + } + memberEmail := "" + if membership.PreferredMemberKey != nil { + memberEmail = membership.PreferredMemberKey.Id + } + roles := membershipRoleNames(membership.Roles) + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(memberEmail), + sanitizeTab(strings.Join(roles, ",")), + sanitizeTab(membership.Name), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type CloudIdentityMembersAddCmd struct { + Group string `arg:"" name:"group" help:"Group email or resource name"` + Email string `name:"email" help:"Member email" required:""` + Role string `name:"role" default:"MEMBER" enum:"MEMBER,MANAGER,OWNER" help:"Member role"` +} + +func (c *CloudIdentityMembersAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupKey := strings.TrimSpace(c.Group) + if groupKey == "" { + return usage("group is required") + } + memberEmail := strings.TrimSpace(c.Email) + if memberEmail == "" { + return usage("--email is required") + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + groupName, err := resolveGroupName(ctx, svc, groupKey) + if err != nil { + return err + } + + membership := &cloudidentity.Membership{ + PreferredMemberKey: &cloudidentity.EntityKey{Id: memberEmail}, + Roles: []*cloudidentity.MembershipRole{{Name: c.Role}}, + } + op, err := svc.Groups.Memberships.Create(groupName, membership).Context(ctx).Do() + if err != nil { + return fmt.Errorf("add member %s: %w", memberEmail, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Added %s to %s as %s\n", memberEmail, groupKey, c.Role) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CloudIdentityMembersRemoveCmd struct { + Group string `arg:"" name:"group" help:"Group email or resource name"` + Email string `name:"email" help:"Member email" required:""` +} + +func (c *CloudIdentityMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupKey := strings.TrimSpace(c.Group) + if groupKey == "" { + return usage("group is required") + } + memberEmail := strings.TrimSpace(c.Email) + if memberEmail == "" { + return usage("--email is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", memberEmail, groupKey)); err != nil { + return err + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + groupName, err := resolveGroupName(ctx, svc, groupKey) + if err != nil { + return err + } + + membershipName, err := resolveMembershipName(ctx, svc, groupName, memberEmail) + if err != nil { + return err + } + + op, err := svc.Groups.Memberships.Delete(membershipName).Context(ctx).Do() + if err != nil { + return fmt.Errorf("remove member %s: %w", memberEmail, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Removed %s from %s\n", memberEmail, groupKey) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type CloudIdentityPoliciesCmd struct { + List CloudIdentityPoliciesListCmd `cmd:"" name:"list" aliases:"ls" help:"List Cloud Identity policies"` +} + +type CloudIdentityPoliciesListCmd struct { + Filter string `name:"filter" help:"Filter expression"` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *CloudIdentityPoliciesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newCloudIdentityAdminService(ctx, account) + if err != nil { + return err + } + + call := svc.Policies.List().PageSize(c.Max) + if strings.TrimSpace(c.Filter) != "" { + call = call.Filter(strings.TrimSpace(c.Filter)) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list policies: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Policies) == 0 { + u.Err().Println("No policies found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tSETTING\tTYPE") + for _, policy := range resp.Policies { + if policy == nil { + continue + } + settingType := "" + if policy.Setting != nil { + settingType = policy.Setting.Type + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(policy.Name), + sanitizeTab(settingType), + sanitizeTab(policy.Type), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +func resolveGroupName(ctx context.Context, svc *cloudidentity.Service, groupKey string) (string, error) { + key := strings.TrimSpace(groupKey) + if key == "" { + return "", fmt.Errorf("group key is required") + } + if strings.HasPrefix(key, "groups/") { + return key, nil + } + lookup, err := svc.Groups.Lookup().GroupKeyId(key).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("lookup group %s: %w", key, err) + } + if lookup.Name == "" { + return "", fmt.Errorf("lookup group %s: empty response", key) + } + return lookup.Name, nil +} + +func resolveMembershipName(ctx context.Context, svc *cloudidentity.Service, groupName, memberEmail string) (string, error) { + lookup, err := svc.Groups.Memberships.Lookup(groupName).MemberKeyId(memberEmail).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("lookup membership %s: %w", memberEmail, err) + } + if lookup.Name == "" { + return "", fmt.Errorf("lookup membership %s: empty response", memberEmail) + } + return lookup.Name, nil +} + +func membershipRoleNames(roles []*cloudidentity.MembershipRole) []string { + if len(roles) == 0 { + return nil + } + out := make([]string, 0, len(roles)) + for _, role := range roles { + if role == nil || role.Name == "" { + continue + } + out = append(out, role.Name) + } + if len(out) == 0 { + return out + } + sort.Strings(out) + return out +} diff --git a/internal/cmd/cloudidentity_test.go b/internal/cmd/cloudidentity_test.go new file mode 100644 index 00000000..d1c317f8 --- /dev/null +++ b/internal/cmd/cloudidentity_test.go @@ -0,0 +1,549 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/cloudidentity/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func newCloudIdentityTestService(t *testing.T, handler http.HandlerFunc) (*cloudidentity.Service, func()) { + t.Helper() + + srv := httptest.NewServer(handler) + svc, err := cloudidentity.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + srv.Close() + t.Fatalf("NewService: %v", err) + } + return svc, srv.Close +} + +func stubCloudIdentityAdminService(t *testing.T, svc *cloudidentity.Service) { + t.Helper() + + orig := newCloudIdentityAdminService + t.Cleanup(func() { newCloudIdentityAdminService = orig }) + newCloudIdentityAdminService = func(context.Context, string) (*cloudidentity.Service, error) { + return svc, nil + } +} + +func TestCloudIdentityGroupsListCmd(t *testing.T) { + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v1/groups") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "groups": []map[string]any{{ + "name": "groups/123", + "groupKey": map[string]any{"id": "group@example.com"}, + "displayName": "Example Group", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "group@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityMembersAddCmd(t *testing.T) { + var gotEmail string + var gotRole string + + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/groups/123/memberships"): + var payload cloudidentity.Membership + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload.PreferredMemberKey != nil { + gotEmail = payload.PreferredMemberKey.Id + } + if len(payload.Roles) > 0 && payload.Roles[0] != nil { + gotRole = payload.Roles[0].Name + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityMembersAddCmd{Group: "group@example.com", Email: "user@example.com", Role: "MANAGER"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotEmail != "user@example.com" || gotRole != "MANAGER" { + t.Fatalf("unexpected membership payload: %s %s", gotEmail, gotRole) + } + if !strings.Contains(out, "Added") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityPoliciesListCmd(t *testing.T) { + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v1/policies") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "policies": []map[string]any{{ + "name": "policies/1", + "etag": "abc", + "setting": map[string]any{"type": "settings/gmail.service_status"}, + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityPoliciesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "policies/1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityGroupsGetCmd(t *testing.T) { + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups/123": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "groups/123", + "groupKey": map[string]any{"id": "group@example.com"}, + "displayName": "Example Group", + "parent": "customers/my_customer", + "labels": map[string]any{"cloudidentity.googleapis.com/groups.discussion_forum": ""}, + }) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsGetCmd{Group: "group@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "groups/123") { + t.Fatalf("expected name in output: %s", out) + } + if !strings.Contains(out, "group@example.com") { + t.Fatalf("expected email in output: %s", out) + } + if !strings.Contains(out, "Example Group") { + t.Fatalf("expected display name in output: %s", out) + } +} + +func TestCloudIdentityGroupsGetCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups/123": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "groups/123", + "groupKey": map[string]any{"id": "group@example.com"}, + "displayName": "Example Group", + }) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsGetCmd{Group: "group@example.com"} + + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["name"] != "groups/123" { + t.Fatalf("unexpected name in JSON: %v", result["name"]) + } +} + +func TestCloudIdentityGroupsCreateCmd(t *testing.T) { + var gotEmail string + var gotDisplayName string + + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/v1/groups") { + http.NotFound(w, r) + return + } + var payload cloudidentity.Group + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload.GroupKey != nil { + gotEmail = payload.GroupKey.Id + } + gotDisplayName = payload.DisplayName + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsCreateCmd{Email: "newgroup@example.com", DisplayName: "New Group"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotEmail != "newgroup@example.com" { + t.Fatalf("unexpected email: %s", gotEmail) + } + if gotDisplayName != "New Group" { + t.Fatalf("unexpected display name: %s", gotDisplayName) + } + if !strings.Contains(out, "Created group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityGroupsCreateCmd_WithDynamicQuery(t *testing.T) { + var gotDynamicQuery string + + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/v1/groups") { + http.NotFound(w, r) + return + } + var payload cloudidentity.Group + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload.DynamicGroupMetadata != nil && len(payload.DynamicGroupMetadata.Queries) > 0 { + gotDynamicQuery = payload.DynamicGroupMetadata.Queries[0].Query + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsCreateCmd{ + Email: "dynamic@example.com", + DynamicQuery: "user.is_enrolled_in_2sv == true", + } + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotDynamicQuery != "user.is_enrolled_in_2sv == true" { + t.Fatalf("unexpected dynamic query: %s", gotDynamicQuery) + } +} + +func TestCloudIdentityGroupsUpdateCmd(t *testing.T) { + var gotDisplayName string + + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodPatch && r.URL.Path == "/v1/groups/123": + var payload cloudidentity.Group + _ = json.NewDecoder(r.Body).Decode(&payload) + gotDisplayName = payload.DisplayName + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsUpdateCmd{Group: "group@example.com", DisplayName: "Updated Name"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotDisplayName != "Updated Name" { + t.Fatalf("unexpected display name: %s", gotDisplayName) + } + if !strings.Contains(out, "Updated group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityGroupsDeleteCmd(t *testing.T) { + var deleteCalled bool + + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodDelete && r.URL.Path == "/v1/groups/123": + deleteCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &CloudIdentityGroupsDeleteCmd{Group: "group@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("delete was not called") + } + if !strings.Contains(out, "Deleted group") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityMembersListCmd(t *testing.T) { + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/groups/123/memberships"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "memberships": []map[string]any{{ + "name": "groups/123/memberships/456", + "preferredMemberKey": map[string]any{"id": "member@example.com"}, + "roles": []map[string]any{{"name": "MEMBER"}, {"name": "MANAGER"}}, + }}, + }) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityMembersListCmd{Group: "group@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "member@example.com") { + t.Fatalf("expected member email in output: %s", out) + } + if !strings.Contains(out, "MANAGER") { + t.Fatalf("expected MANAGER role in output: %s", out) + } +} + +func TestCloudIdentityMembersRemoveCmd(t *testing.T) { + var deleteCalled bool + + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123"}) + return + case r.Method == http.MethodGet && r.URL.Path == "/v1/groups/123/memberships:lookup": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "groups/123/memberships/456"}) + return + case r.Method == http.MethodDelete && r.URL.Path == "/v1/groups/123/memberships/456": + deleteCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &CloudIdentityMembersRemoveCmd{Group: "group@example.com", Email: "member@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("delete was not called") + } + if !strings.Contains(out, "Removed") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestCloudIdentityGroupsListCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudIdentityTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "groups": []map[string]any{{ + "name": "groups/123", + "groupKey": map[string]any{"id": "group@example.com"}, + "displayName": "Example Group", + }}, + "nextPageToken": "npt123", + }) + })) + t.Cleanup(closeSrv) + stubCloudIdentityAdminService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &CloudIdentityGroupsListCmd{} + + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["nextPageToken"] != "npt123" { + t.Fatalf("unexpected nextPageToken in JSON: %v", result["nextPageToken"]) + } +} + +func TestMembershipRoleNames(t *testing.T) { + tests := []struct { + name string + roles []*cloudidentity.MembershipRole + want []string + }{ + {"nil", nil, nil}, + {"empty", []*cloudidentity.MembershipRole{}, nil}, + {"nil role", []*cloudidentity.MembershipRole{nil}, nil}, + {"single", []*cloudidentity.MembershipRole{{Name: "MEMBER"}}, []string{"MEMBER"}}, + {"multiple sorted", []*cloudidentity.MembershipRole{{Name: "OWNER"}, {Name: "MEMBER"}}, []string{"MEMBER", "OWNER"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := membershipRoleNames(tt.roles) + if len(got) != len(tt.want) { + t.Fatalf("membershipRoleNames() = %v, want %v", got, tt.want) + } + for i, v := range got { + if v != tt.want[i] { + t.Fatalf("membershipRoleNames() = %v, want %v", got, tt.want) + } + } + }) + } +} diff --git a/internal/cmd/config_cmd.go b/internal/cmd/config_cmd.go index 7fe9f6b3..818fe790 100644 --- a/internal/cmd/config_cmd.go +++ b/internal/cmd/config_cmd.go @@ -2,11 +2,11 @@ package cmd import ( "context" - "fmt" "os" "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" ) type ConfigCmd struct { @@ -41,7 +41,8 @@ func (c *ConfigGetCmd) Run(ctx context.Context) error { if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, outfmt.KeyValuePayload(key.String(), value)) } - fmt.Fprintln(os.Stdout, formatConfigValue(value, spec.EmptyHint)) + u := ui.FromContext(ctx) + u.Out().Println(formatConfigValue(value, spec.EmptyHint)) return nil } @@ -52,8 +53,9 @@ func (c *ConfigKeysCmd) Run(ctx context.Context) error { if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, outfmt.KeysPayload(keys)) } + u := ui.FromContext(ctx) for _, key := range keys { - fmt.Fprintln(os.Stdout, key) + u.Out().Println(key) } return nil } @@ -87,7 +89,8 @@ func (c *ConfigSetCmd) Run(ctx context.Context) error { payload["saved"] = true return outfmt.WriteJSON(os.Stdout, payload) } - fmt.Fprintf(os.Stdout, "Set %s = %s\n", c.Key, c.Value) + u := ui.FromContext(ctx) + u.Out().Printf("Set %s = %s\n", c.Key, c.Value) return nil } @@ -119,7 +122,8 @@ func (c *ConfigUnsetCmd) Run(ctx context.Context) error { payload["removed"] = true return outfmt.WriteJSON(os.Stdout, payload) } - fmt.Fprintf(os.Stdout, "Unset %s\n", c.Key) + u := ui.FromContext(ctx) + u.Out().Printf("Unset %s\n", c.Key) return nil } @@ -142,10 +146,11 @@ func (c *ConfigListCmd) Run(ctx context.Context) error { return outfmt.WriteJSON(os.Stdout, payload) } - fmt.Fprintf(os.Stdout, "Config file: %s\n", path) + u := ui.FromContext(ctx) + u.Out().Printf("Config file: %s\n", path) for _, key := range keys { value := config.GetValue(cfg, key) - fmt.Fprintf(os.Stdout, "%s: %s\n", key, formatConfigValue(value, func() string { return "(not set)" })) + u.Out().Printf("%s: %s\n", key, formatConfigValue(value, func() string { return "(not set)" })) } return nil } @@ -161,7 +166,8 @@ func (c *ConfigPathCmd) Run(ctx context.Context) error { if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, outfmt.PathPayload(path)) } - fmt.Fprintln(os.Stdout, path) + u := ui.FromContext(ctx) + u.Out().Println(path) return nil } diff --git a/internal/cmd/config_cmd_test.go b/internal/cmd/config_cmd_test.go index d99f29a0..9cccbade 100644 --- a/internal/cmd/config_cmd_test.go +++ b/internal/cmd/config_cmd_test.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "path/filepath" + "strings" "testing" "github.com/steipete/gogcli/internal/config" @@ -103,3 +104,387 @@ func TestConfigCmd_JSONEmptyValues(t *testing.T) { t.Fatalf("expected empty value, got %q", get.Value) } } + +func TestConfigGetCmd_TextOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cfg := config.File{DefaultTimezone: "America/New_York"} + if err := config.WriteConfig(cfg); err != nil { + t.Fatalf("write config: %v", err) + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "get", "timezone"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "America/New_York") { + t.Fatalf("expected output to contain timezone, got %q", out) + } +} + +func TestConfigGetCmd_EmptyValueShowsHint(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config-home")) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "get", "timezone"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "not set") { + t.Fatalf("expected hint for empty value, got %q", out) + } +} + +func TestConfigGetCmd_InvalidKey(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + err := Execute([]string{"config", "get", "invalid_key"}) + if err == nil { + t.Fatal("expected error for invalid key") + } + if !strings.Contains(err.Error(), "unknown config key") { + t.Fatalf("expected unknown config key error, got %v", err) + } +} + +func TestConfigSetCmd_ValidTimezone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "set", "timezone", "UTC"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "Set timezone = UTC") { + t.Fatalf("expected confirmation message, got %q", out) + } + + // Verify value was persisted + cfg, err := config.ReadConfig() + if err != nil { + t.Fatalf("read config: %v", err) + } + if cfg.DefaultTimezone != "UTC" { + t.Fatalf("expected timezone UTC, got %q", cfg.DefaultTimezone) + } +} + +func TestConfigSetCmd_JSONOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "config", "set", "timezone", "Europe/London"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var result struct { + Key string `json:"key"` + Value string `json:"value"` + Saved bool `json:"saved"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if result.Key != "timezone" || result.Value != "Europe/London" || !result.Saved { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestConfigSetCmd_InvalidTimezone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + err := Execute([]string{"config", "set", "timezone", "Invalid/Timezone"}) + if err == nil { + t.Fatal("expected error for invalid timezone") + } + if !strings.Contains(err.Error(), "invalid timezone") { + t.Fatalf("expected invalid timezone error, got %v", err) + } +} + +func TestConfigSetCmd_InvalidKey(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + err := Execute([]string{"config", "set", "invalid_key", "value"}) + if err == nil { + t.Fatal("expected error for invalid key") + } + if !strings.Contains(err.Error(), "unknown config key") { + t.Fatalf("expected unknown config key error, got %v", err) + } +} + +func TestConfigUnsetCmd_RemovesValue(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cfg := config.File{DefaultTimezone: "UTC"} + if err := config.WriteConfig(cfg); err != nil { + t.Fatalf("write config: %v", err) + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "unset", "timezone"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "Unset timezone") { + t.Fatalf("expected confirmation message, got %q", out) + } + + // Verify value was removed + cfg, err := config.ReadConfig() + if err != nil { + t.Fatalf("read config: %v", err) + } + if cfg.DefaultTimezone != "" { + t.Fatalf("expected empty timezone, got %q", cfg.DefaultTimezone) + } +} + +func TestConfigUnsetCmd_JSONOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cfg := config.File{DefaultTimezone: "UTC"} + if err := config.WriteConfig(cfg); err != nil { + t.Fatalf("write config: %v", err) + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "config", "unset", "timezone"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var result struct { + Key string `json:"key"` + Removed bool `json:"removed"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if result.Key != "timezone" || !result.Removed { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestConfigUnsetCmd_InvalidKey(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + err := Execute([]string{"config", "unset", "invalid_key"}) + if err == nil { + t.Fatal("expected error for invalid key") + } + if !strings.Contains(err.Error(), "unknown config key") { + t.Fatalf("expected unknown config key error, got %v", err) + } +} + +func TestConfigKeysCmd_TextOutput(t *testing.T) { + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "keys"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "timezone") { + t.Fatalf("expected timezone key in output, got %q", out) + } + if !strings.Contains(out, "keyring_backend") { + t.Fatalf("expected keyring_backend key in output, got %q", out) + } +} + +func TestConfigKeysCmd_JSONOutput(t *testing.T) { + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "config", "keys"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var result struct { + Keys []string `json:"keys"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + + hasTimezone := false + hasKeyring := false + for _, key := range result.Keys { + if key == "timezone" { + hasTimezone = true + } + if key == "keyring_backend" { + hasKeyring = true + } + } + if !hasTimezone || !hasKeyring { + t.Fatalf("expected timezone and keyring_backend keys, got %v", result.Keys) + } +} + +func TestConfigListCmd_TextOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cfg := config.File{ + DefaultTimezone: "Asia/Tokyo", + KeyringBackend: "file", + } + if err := config.WriteConfig(cfg); err != nil { + t.Fatalf("write config: %v", err) + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "list"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "Asia/Tokyo") { + t.Fatalf("expected timezone value in output, got %q", out) + } + if !strings.Contains(out, "file") { + t.Fatalf("expected keyring_backend value in output, got %q", out) + } + if !strings.Contains(out, "Config file:") { + t.Fatalf("expected config file path in output, got %q", out) + } +} + +func TestConfigListCmd_ShowsNotSetHint(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config-home")) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "list"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "not set") { + t.Fatalf("expected 'not set' hint in output, got %q", out) + } +} + +func TestConfigPathCmd_TextOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "path"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "config.json") { + t.Fatalf("expected config path to contain config.json, got %q", out) + } +} + +func TestConfigPathCmd_JSONOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "config", "path"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var result struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if !strings.Contains(result.Path, "config.json") { + t.Fatalf("expected path to contain config.json, got %q", result.Path) + } +} + +func TestConfigSetCmd_KeyringBackend(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"config", "set", "keyring_backend", "file"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "Set keyring_backend = file") { + t.Fatalf("expected confirmation message, got %q", out) + } + + cfg, err := config.ReadConfig() + if err != nil { + t.Fatalf("read config: %v", err) + } + if cfg.KeyringBackend != "file" { + t.Fatalf("expected keyring_backend file, got %q", cfg.KeyringBackend) + } +} + +func TestFormatConfigValue_WithValue(t *testing.T) { + result := formatConfigValue("test-value", func() string { return "hint" }) + if result != "test-value" { + t.Fatalf("expected test-value, got %q", result) + } +} + +func TestFormatConfigValue_EmptyWithHint(t *testing.T) { + result := formatConfigValue("", func() string { return "(custom hint)" }) + if result != "(custom hint)" { + t.Fatalf("expected (custom hint), got %q", result) + } +} + +func TestFormatConfigValue_EmptyNilHint(t *testing.T) { + result := formatConfigValue("", nil) + if result != "(not set)" { + t.Fatalf("expected (not set), got %q", result) + } +} diff --git a/internal/cmd/confirm_coverage_test.go b/internal/cmd/confirm_coverage_test.go new file mode 100644 index 00000000..856e2c9e --- /dev/null +++ b/internal/cmd/confirm_coverage_test.go @@ -0,0 +1,183 @@ +package cmd + +import ( + "context" + "errors" + "strings" + "testing" +) + +// ----------------------------------------------------------------------------- +// confirmDestructive additional coverage tests +// ----------------------------------------------------------------------------- + +func TestConfirmDestructive_ForceBypassesAll(t *testing.T) { + // Force flag should bypass all checks, including NoInput + flags := &RootFlags{Force: true, NoInput: true} + if err := confirmDestructive(context.Background(), flags, "delete everything"); err != nil { + t.Fatalf("expected nil with Force=true, got %v", err) + } +} + +func TestConfirmDestructive_NoInputReturnusageError(t *testing.T) { + flags := &RootFlags{NoInput: true} + err := confirmDestructive(context.Background(), flags, "wipe data") + if err == nil { + t.Fatalf("expected error for NoInput without Force") + } + + // Should be an ExitError with code 2 (usage error) + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if exitErr.Code != 2 { + t.Fatalf("expected exit code 2, got %d", exitErr.Code) + } + + // Should contain the action in the message + if !strings.Contains(err.Error(), "wipe data") { + t.Fatalf("error should mention the action: %v", err) + } + if !strings.Contains(err.Error(), "refusing") { + t.Fatalf("error should mention refusing: %v", err) + } + if !strings.Contains(err.Error(), "--force") { + t.Fatalf("error should mention --force: %v", err) + } +} + +func TestConfirmDestructive_ActionInMessage(t *testing.T) { + tests := []struct { + action string + }{ + {"delete user account"}, + {"remove all files"}, + {"destroy database"}, + {"reset configuration"}, + } + + for _, tc := range tests { + t.Run(tc.action, func(t *testing.T) { + flags := &RootFlags{NoInput: true} + err := confirmDestructive(context.Background(), flags, tc.action) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), tc.action) { + t.Fatalf("error message should contain action %q: %v", tc.action, err) + } + }) + } +} + +func TestConfirmDestructive_ExitErrorProperties(t *testing.T) { + flags := &RootFlags{NoInput: true} + err := confirmDestructive(context.Background(), flags, "test action") + + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T", err) + } + + // Verify ExitError properly wraps and exposes the underlying error + if exitErr.Err == nil { + t.Fatal("ExitError.Err should not be nil") + } + + // Error() should return the wrapped error message + errStr := err.Error() + if errStr == "" { + t.Fatal("Error() should return non-empty string") + } +} + +func TestConfirmDestructive_ContextPassedThrough(t *testing.T) { + // Test that context is passed through (though currently not used when Force=true) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // With Force=true, should still succeed even with cancelled context + flags := &RootFlags{Force: true} + if err := confirmDestructive(ctx, flags, "action"); err != nil { + t.Fatalf("Force=true should succeed regardless of context: %v", err) + } +} + +func TestConfirmDestructive_DefaultFlags(t *testing.T) { + // Default flags (no Force, no NoInput) in non-terminal environment + // This tests the term.IsTerminal check which will return false in tests + flags := &RootFlags{} + err := confirmDestructive(context.Background(), flags, "test") + + // In a test environment, stdin is not a terminal, so it should fail + if err == nil { + // If it doesn't fail, it means the stdin somehow is a terminal (unlikely in tests) + // This is acceptable as the behavior depends on the environment + t.Skip("stdin appears to be a terminal in this test environment") + } + + // Should fail with usage error indicating non-interactive mode + if !strings.Contains(err.Error(), "non-interactive") || !strings.Contains(err.Error(), "--force") { + t.Fatalf("expected non-interactive error with --force hint: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// ExitError tests +// ----------------------------------------------------------------------------- + +func TestExitError_Error(t *testing.T) { + err := &ExitError{Code: 1, Err: errors.New("something went wrong")} + if err.Error() != "something went wrong" { + t.Fatalf("unexpected error string: %s", err.Error()) + } +} + +func TestExitError_NilErr(t *testing.T) { + // ExitError with nil Err + err := &ExitError{Code: 1, Err: nil} + // Error() method should handle nil gracefully + result := err.Error() + if result != "" { + t.Fatalf("expected empty string for nil Err, got %q", result) + } +} + +func TestExitError_Unwrap(t *testing.T) { + innerErr := errors.New("inner error") + exitErr := &ExitError{Code: 1, Err: innerErr} + + // errors.Unwrap should return the inner error + unwrapped := errors.Unwrap(exitErr) + if !errors.Is(unwrapped, innerErr) { + t.Fatalf("Unwrap should return inner error, got %v", unwrapped) + } + + // errors.Is should work with the inner error + if !errors.Is(exitErr, innerErr) { + t.Fatal("errors.Is should find inner error") + } +} + +func TestExitError_ExitCodes(t *testing.T) { + tests := []struct { + code int + name string + }{ + {0, "success (unusual)"}, + {1, "general error"}, + {2, "usage error"}, + {126, "command not executable"}, + {127, "command not found"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := &ExitError{Code: tc.code} + if err.Code != tc.code { + t.Fatalf("expected code %d, got %d", tc.code, err.Code) + } + }) + } +} diff --git a/internal/cmd/contacts.go b/internal/cmd/contacts.go index 9b99da18..177ad2ca 100644 --- a/internal/cmd/contacts.go +++ b/internal/cmd/contacts.go @@ -21,6 +21,11 @@ type ContactsCmd struct { Delete ContactsDeleteCmd `cmd:"" name:"delete" help:"Delete a contact"` Directory ContactsDirectoryCmd `cmd:"" name:"directory" help:"Directory contacts"` Other ContactsOtherCmd `cmd:"" name:"other" help:"Other contacts"` + Delegates ContactsDelegatesCmd `cmd:"" name:"delegates" help:"Contact delegates"` + Domain ContactsDomainCmd `cmd:"" name:"domain" help:"Domain shared contacts"` + Import ContactsImportCmd `cmd:"" name:"import" help:"Import contacts from CSV"` + Export ContactsExportCmd `cmd:"" name:"export" help:"Export contacts to CSV"` + Dedup ContactsDedupCmd `cmd:"" name:"dedup" help:"Deduplicate contacts"` } type ContactsSearchCmd struct { diff --git a/internal/cmd/contacts_advanced_test.go b/internal/cmd/contacts_advanced_test.go new file mode 100644 index 00000000..4318b96e --- /dev/null +++ b/internal/cmd/contacts_advanced_test.go @@ -0,0 +1,1111 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/people/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func stubPeopleDirectoryService(t *testing.T, svc *people.Service) { + t.Helper() + orig := newPeopleDirectoryService + t.Cleanup(func() { newPeopleDirectoryService = orig }) + newPeopleDirectoryService = func(context.Context, string) (*people.Service, error) { return svc, nil } +} + +// testContextWithStderr creates a UI context that writes to os.Stderr for capturing stderr. +func testContextWithStderr(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func TestContactsDelegatesListCmd(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/delegates") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "delegates": []map[string]any{{"delegateEmail": "delegate@example.com", "verificationStatus": "accepted"}}, + }) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "delegate@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDomainListCmd(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people:listDirectoryPeople") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "people": []map[string]any{{ + "resourceName": "people/abc", + "names": []map[string]any{{"displayName": "Dir Contact"}}, + "emailAddresses": []map[string]any{{"value": "dir@example.com"}}, + }}, + }) + })) + t.Cleanup(closeSrv) + stubPeopleDirectoryService(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDomainListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "dir@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsImportCmd(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "people:batchCreateContacts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"createdPeople": []map[string]any{{"person": map[string]any{"resourceName": "people/c1"}}}}) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + csvPath := filepath.Join(t.TempDir(), "contacts.csv") + if err := os.WriteFile(csvPath, []byte("name,email,phone\nAlice,alice@example.com,123\n"), 0o600); err != nil { + t.Fatalf("write csv: %v", err) + } + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsImportCmd{File: csvPath} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Imported") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDedupCmd(t *testing.T) { + deleted := false + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "people/me/connections"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + {"resourceName": "people/c1", "emailAddresses": []map[string]any{{"value": "dup@example.com"}}}, + {"resourceName": "people/c2", "emailAddresses": []map[string]any{{"value": "dup@example.com"}}}, + }, + }) + return + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "people:batchDeleteContacts"): + deleted = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDedupCmd{Apply: true} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatalf("expected delete call") + } + if !strings.Contains(out, "Deleted") { + t.Fatalf("unexpected output: %s", out) + } +} + +// --- ContactsDelegatesAddCmd Tests --- + +func TestContactsDelegatesAddCmd(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/delegates") { + http.NotFound(w, r) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "delegateEmail": body["delegateEmail"], + "verificationStatus": "pending", + }) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesAddCmd{Delegate: "new-delegate@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Added delegate") || !strings.Contains(out, "new-delegate@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDelegatesAddCmd_JSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/delegates") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "delegateEmail": "new-delegate@example.com", + "verificationStatus": "pending", + }) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesAddCmd{Delegate: "new-delegate@example.com"} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["delegateEmail"] != "new-delegate@example.com" { + t.Fatalf("unexpected delegateEmail: %v", result["delegateEmail"]) + } +} + +func TestContactsDelegatesAddCmd_EmptyDelegate(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesAddCmd{Delegate: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "--delegate is required") { + t.Fatalf("expected delegate required error, got: %v", err) + } +} + +// --- ContactsDelegatesRemoveCmd Tests --- + +func TestContactsDelegatesRemoveCmd(t *testing.T) { + var deletedDelegate string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/delegates/") { + http.NotFound(w, r) + return + } + parts := strings.Split(r.URL.Path, "/delegates/") + if len(parts) > 1 { + deletedDelegate = parts[1] + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDelegatesRemoveCmd{Delegate: "remove-me@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if deletedDelegate != "remove-me@example.com" { + t.Fatalf("unexpected deleted delegate: %s", deletedDelegate) + } + if !strings.Contains(out, "Removed delegate") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDelegatesRemoveCmd_JSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/delegates/") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDelegatesRemoveCmd{Delegate: "remove-me@example.com"} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["removed"] != true { + t.Fatalf("expected removed=true, got %v", result["removed"]) + } + if result["delegate"] != "remove-me@example.com" { + t.Fatalf("unexpected delegate: %v", result["delegate"]) + } +} + +func TestContactsDelegatesRemoveCmd_EmptyDelegate(t *testing.T) { + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDelegatesRemoveCmd{Delegate: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "--delegate is required") { + t.Fatalf("expected delegate required error, got: %v", err) + } +} + +// --- ContactsDelegatesListCmd JSON Test --- + +func TestContactsDelegatesListCmd_JSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/delegates") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "delegates": []map[string]any{ + {"delegateEmail": "delegate1@example.com", "verificationStatus": "accepted"}, + {"delegateEmail": "delegate2@example.com", "verificationStatus": "pending"}, + }, + }) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesListCmd{} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + delegates, ok := result["delegates"].([]any) + if !ok || len(delegates) != 2 { + t.Fatalf("unexpected delegates: %v", result["delegates"]) + } +} + +func TestContactsDelegatesListCmd_Empty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/delegates") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"delegates": []map[string]any{}}) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesListCmd{} + + errOut := captureStderr(t, func() { + if err := cmd.Run(testContextWithStderr(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(errOut, "No delegates") { + t.Fatalf("expected 'No delegates' message, got: %s", errOut) + } +} + +// --- ContactsDomainCreateCmd Tests --- + +func TestContactsDomainCreateCmd(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "people:createContact") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c123", + "names": []map[string]any{{"displayName": "New Contact"}}, + "emailAddresses": []map[string]any{{"value": "new@example.com"}}, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDomainCreateCmd{Email: "new@example.com", Name: "New Contact"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created contact") || !strings.Contains(out, "people/c123") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDomainCreateCmd_JSON(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "people:createContact") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c456", + "names": []map[string]any{{"displayName": "JSON Contact"}}, + "emailAddresses": []map[string]any{{"value": "json@example.com"}}, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDomainCreateCmd{Email: "json@example.com", Name: "JSON Contact"} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["resourceName"] != "people/c456" { + t.Fatalf("unexpected resourceName: %v", result["resourceName"]) + } +} + +func TestContactsDomainCreateCmd_MissingFields(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + + cmd1 := &ContactsDomainCreateCmd{Email: "", Name: "Test"} + err1 := cmd1.Run(testContext(t), flags) + if err1 == nil || !strings.Contains(err1.Error(), "--email and --name are required") { + t.Fatalf("expected required fields error, got: %v", err1) + } + + cmd2 := &ContactsDomainCreateCmd{Email: "test@example.com", Name: ""} + err2 := cmd2.Run(testContext(t), flags) + if err2 == nil || !strings.Contains(err2.Error(), "--email and --name are required") { + t.Fatalf("expected required fields error, got: %v", err2) + } +} + +// --- ContactsDomainDeleteCmd Tests --- + +func TestContactsDomainDeleteCmd_ByResourceName(t *testing.T) { + var deletedResource string + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, ":deleteContact") { + http.NotFound(w, r) + return + } + parts := strings.Split(r.URL.Path, "/") + for i, p := range parts { + if strings.HasPrefix(p, "people") && i+1 < len(parts) { + deletedResource = "people/" + strings.Split(parts[i+1], ":")[0] + break + } + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDomainDeleteCmd{Email: "people/c789"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if deletedResource != "people/c789" { + t.Fatalf("unexpected deleted resource: %s", deletedResource) + } + if !strings.Contains(out, "Deleted contact") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDomainDeleteCmd_ByEmail(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "people:searchContacts"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"person": map[string]any{"resourceName": "people/found123"}}, + }, + }) + return + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, ":deleteContact"): + w.WriteHeader(http.StatusNoContent) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDomainDeleteCmd{Email: "search@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted contact") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestContactsDomainDeleteCmd_JSON(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, ":deleteContact") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDomainDeleteCmd{Email: "people/c999"} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["deleted"] != true { + t.Fatalf("expected deleted=true, got %v", result["deleted"]) + } +} + +func TestContactsDomainDeleteCmd_NotFound(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "people:searchContacts") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{}}) + return + } + http.NotFound(w, r) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDomainDeleteCmd{Email: "notfound@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "no contact found") { + t.Fatalf("expected not found error, got: %v", err) + } +} + +func TestContactsDomainDeleteCmd_EmptyIdentifier(t *testing.T) { + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDomainDeleteCmd{Email: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "email is required") { + t.Fatalf("expected email required error, got: %v", err) + } +} + +// --- ContactsDomainListCmd JSON and Empty Tests --- + +func TestContactsDomainListCmd_JSON(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people:listDirectoryPeople") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "people": []map[string]any{ + {"resourceName": "people/d1", "names": []map[string]any{{"displayName": "Dir1"}}}, + {"resourceName": "people/d2", "names": []map[string]any{{"displayName": "Dir2"}}}, + }, + "nextPageToken": "token123", + }) + })) + t.Cleanup(closeSrv) + stubPeopleDirectoryService(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDomainListCmd{} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["nextPageToken"] != "token123" { + t.Fatalf("unexpected nextPageToken: %v", result["nextPageToken"]) + } +} + +func TestContactsDomainListCmd_Empty(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people:listDirectoryPeople") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"people": []map[string]any{}}) + })) + t.Cleanup(closeSrv) + stubPeopleDirectoryService(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDomainListCmd{} + + errOut := captureStderr(t, func() { + if err := cmd.Run(testContextWithStderr(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(errOut, "No domain contacts") { + t.Fatalf("expected 'No domain contacts' message, got: %s", errOut) + } +} + +// --- ContactsImportCmd Additional Tests --- + +func TestContactsImportCmd_JSON(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "people:batchCreateContacts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "createdPeople": []map[string]any{ + {"person": map[string]any{"resourceName": "people/c1"}}, + {"person": map[string]any{"resourceName": "people/c2"}}, + }, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + csvPath := filepath.Join(t.TempDir(), "contacts.csv") + if err := os.WriteFile(csvPath, []byte("name,email,phone\nAlice,alice@example.com,123\nBob,bob@example.com,456\n"), 0o600); err != nil { + t.Fatalf("write csv: %v", err) + } + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsImportCmd{File: csvPath} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["createdPeople"] == nil { + t.Fatalf("expected createdPeople in output") + } +} + +func TestContactsImportCmd_EmptyCSV(t *testing.T) { + csvPath := filepath.Join(t.TempDir(), "empty.csv") + if err := os.WriteFile(csvPath, []byte(""), 0o600); err != nil { + t.Fatalf("write csv: %v", err) + } + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsImportCmd{File: csvPath} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "empty csv") { + t.Fatalf("expected empty csv error, got: %v", err) + } +} + +func TestContactsImportCmd_HeaderOnlyCSV(t *testing.T) { + csvPath := filepath.Join(t.TempDir(), "header_only.csv") + if err := os.WriteFile(csvPath, []byte("name,email,phone\n"), 0o600); err != nil { + t.Fatalf("write csv: %v", err) + } + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsImportCmd{File: csvPath} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "no contacts found") { + t.Fatalf("expected no contacts error, got: %v", err) + } +} + +func TestContactsImportCmd_FileNotFound(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsImportCmd{File: "/nonexistent/path/contacts.csv"} + + err := cmd.Run(testContext(t), flags) + if err == nil || !strings.Contains(err.Error(), "open csv") { + t.Fatalf("expected file not found error, got: %v", err) + } +} + +// --- ContactsExportCmd Tests --- + +func TestContactsExportCmd(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people/me/connections") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + { + "resourceName": "people/c1", + "names": []map[string]any{{"displayName": "Alice"}}, + "emailAddresses": []map[string]any{{"value": "alice@example.com"}}, + "phoneNumbers": []map[string]any{{"value": "+1234567890"}}, + }, + { + "resourceName": "people/c2", + "names": []map[string]any{{"displayName": "Bob"}}, + "emailAddresses": []map[string]any{{"value": "bob@example.com"}}, + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + csvPath := filepath.Join(t.TempDir(), "exported.csv") + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsExportCmd{File: csvPath} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Exported 2 contacts") { + t.Fatalf("unexpected output: %s", out) + } + + content, err := os.ReadFile(csvPath) + if err != nil { + t.Fatalf("read exported file: %v", err) + } + + if !strings.Contains(string(content), "Alice") || !strings.Contains(string(content), "alice@example.com") { + t.Fatalf("exported CSV missing expected data: %s", content) + } +} + +func TestContactsExportCmd_JSON(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people/me/connections") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + {"resourceName": "people/c1", "names": []map[string]any{{"displayName": "Alice"}}}, + }, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + csvPath := filepath.Join(t.TempDir(), "exported_json.csv") + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsExportCmd{File: csvPath} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["exported"].(float64) != 1 { + t.Fatalf("expected exported=1, got %v", result["exported"]) + } +} + +func TestContactsExportCmd_ToStdout(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people/me/connections") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + {"resourceName": "people/c1", "names": []map[string]any{{"displayName": "Alice"}}}, + }, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsExportCmd{File: "-"} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "name,email,phone") { + t.Fatalf("expected CSV header in stdout output: %s", out) + } +} + +// --- ContactsDedupCmd Additional Tests --- + +func TestContactsDedupCmd_NoDuplicates(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people/me/connections") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + {"resourceName": "people/c1", "emailAddresses": []map[string]any{{"value": "unique1@example.com"}}}, + {"resourceName": "people/c2", "emailAddresses": []map[string]any{{"value": "unique2@example.com"}}}, + }, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDedupCmd{} + + errOut := captureStderr(t, func() { + if err := cmd.Run(testContextWithStderr(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(errOut, "No duplicates") { + t.Fatalf("expected 'No duplicates' message, got: %s", errOut) + } +} + +func TestContactsDedupCmd_JSON(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "people/me/connections"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + {"resourceName": "people/c1", "emailAddresses": []map[string]any{{"value": "dup@example.com"}}}, + {"resourceName": "people/c2", "emailAddresses": []map[string]any{{"value": "dup@example.com"}}}, + }, + }) + return + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "people:batchDeleteContacts"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + default: + http.NotFound(w, r) + return + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &ContactsDedupCmd{Apply: true} + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json unmarshal: %v (output: %q)", err, out) + } + if result["deleted"].(float64) != 1 { + t.Fatalf("expected deleted=1, got %v", result["deleted"]) + } +} + +func TestContactsDedupCmd_DryRun(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "people/me/connections") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": []map[string]any{ + {"resourceName": "people/c1", "emailAddresses": []map[string]any{{"value": "dup@example.com"}}}, + {"resourceName": "people/c2", "emailAddresses": []map[string]any{{"value": "dup@example.com"}}}, + }, + }) + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDedupCmd{Apply: false} + + // The "Run with --apply" hint is written to stdout via ui.Printf in dry-run mode + // We verify the duplicates are shown + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // The dry-run output should contain duplicate info and the --apply hint + if !strings.Contains(out, "dup@example.com") && !strings.Contains(out, "duplicates") { + t.Fatalf("expected duplicate info in output: %s", out) + } +} + +// --- CSV Helper Tests --- + +func TestNormalizeCSVHeader(t *testing.T) { + input := []string{" Name ", "EMAIL", " Phone "} + expected := []string{"name", "email", "phone"} + + result := normalizeCSVHeader(input) + for i, v := range result { + if v != expected[i] { + t.Fatalf("expected %q at index %d, got %q", expected[i], i, v) + } + } +} + +func TestParseCSVRow(t *testing.T) { + header := []string{"name", "email", "phone"} + row := []string{" Alice ", "alice@example.com", " 123 "} + + result := parseCSVRow(header, row) + if result["name"] != "Alice" { + t.Fatalf("expected name='Alice', got %q", result["name"]) + } + if result["email"] != "alice@example.com" { + t.Fatalf("expected email='alice@example.com', got %q", result["email"]) + } + if result["phone"] != "123" { + t.Fatalf("expected phone='123', got %q", result["phone"]) + } +} + +func TestParseCSVRow_ShortRow(t *testing.T) { + header := []string{"name", "email", "phone"} + row := []string{"Alice"} + + result := parseCSVRow(header, row) + if result["name"] != "Alice" { + t.Fatalf("expected name='Alice', got %q", result["name"]) + } + if result["email"] != "" { + t.Fatalf("expected email='', got %q", result["email"]) + } +} + +func TestCsvPerson(t *testing.T) { + tests := []struct { + name string + entry map[string]string + expect bool + }{ + {"with name", map[string]string{"name": "Alice"}, true}, + {"with given", map[string]string{"given": "Alice"}, true}, + {"with family", map[string]string{"family": "Smith"}, true}, + {"with email", map[string]string{"email": "a@b.com"}, true}, + {"with phone", map[string]string{"phone": "123"}, true}, + {"empty", map[string]string{}, false}, + {"only whitespace", map[string]string{"name": "", "email": "", "phone": ""}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := csvPerson(tc.entry) + if tc.expect && result == nil { + t.Fatalf("expected non-nil person") + } + if !tc.expect && result != nil { + t.Fatalf("expected nil person") + } + }) + } +} + +func TestOpenCSVReader_EmptyPath(t *testing.T) { + _, _, err := openCSVReader(" ") + if err == nil || !strings.Contains(err.Error(), "file is required") { + t.Fatalf("expected file required error, got: %v", err) + } +} + +func TestOpenCSVWriter_EmptyPath(t *testing.T) { + _, _, err := openCSVWriter(" ") + if err == nil || !strings.Contains(err.Error(), "file is required") { + t.Fatalf("expected file required error, got: %v", err) + } +} + +// --- Delegates with User Override Tests --- + +func TestContactsDelegatesListCmd_WithUserOverride(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/delegates") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"delegates": []map[string]any{}}) + })) + t.Cleanup(srv.Close) + stubGmailService(t, srv) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &ContactsDelegatesListCmd{User: "other@example.com"} + + _ = captureStderr(t, func() { + _ = cmd.Run(testContextWithStderr(t), flags) + }) +} diff --git a/internal/cmd/contacts_delegates.go b/internal/cmd/contacts_delegates.go new file mode 100644 index 00000000..3f3d2f7f --- /dev/null +++ b/internal/cmd/contacts_delegates.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + "text/tabwriter" + + "google.golang.org/api/gmail/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// ContactsDelegatesCmd manages delegate access using Gmail delegation APIs. +type ContactsDelegatesCmd struct { + List ContactsDelegatesListCmd `cmd:"" name:"list" help:"List delegates (Gmail delegation)"` + Add ContactsDelegatesAddCmd `cmd:"" name:"add" help:"Add a delegate (Gmail delegation)"` + Remove ContactsDelegatesRemoveCmd `cmd:"" name:"remove" help:"Remove a delegate (Gmail delegation)"` +} + +type ContactsDelegatesListCmd struct { + User string `name:"user" help:"User email to list delegates for"` +} + +func (c *ContactsDelegatesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Users.Settings.Delegates.List("me").Context(ctx).Do() + if err != nil { + return fmt.Errorf("list delegates: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"delegates": resp.Delegates}) + } + + if len(resp.Delegates) == 0 { + u.Err().Println("No delegates") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "EMAIL\tSTATUS") + for _, d := range resp.Delegates { + fmt.Fprintf(tw, "%s\t%s\n", d.DelegateEmail, d.VerificationStatus) + } + _ = tw.Flush() + return nil +} + +type ContactsDelegatesAddCmd struct { + User string `name:"user" help:"User email to add delegate for"` + Delegate string `name:"delegate" help:"Delegate email" required:""` +} + +func (c *ContactsDelegatesAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + delegate := strings.TrimSpace(c.Delegate) + if delegate == "" { + return usage("--delegate is required") + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Users.Settings.Delegates.Create("me", &gmail.Delegate{DelegateEmail: delegate}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("add delegate: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Added delegate: %s\n", resp.DelegateEmail) + return nil +} + +type ContactsDelegatesRemoveCmd struct { + User string `name:"user" help:"User email to remove delegate for"` + Delegate string `name:"delegate" help:"Delegate email" required:""` +} + +func (c *ContactsDelegatesRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + delegate := strings.TrimSpace(c.Delegate) + if delegate == "" { + return usage("--delegate is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove delegate %s", delegate)); err != nil { + return err + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + if err := svc.Users.Settings.Delegates.Delete("me", delegate).Context(ctx).Do(); err != nil { + return fmt.Errorf("remove delegate: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"delegate": delegate, "removed": true}) + } + + u.Out().Printf("Removed delegate: %s\n", delegate) + return nil +} diff --git a/internal/cmd/contacts_domain.go b/internal/cmd/contacts_domain.go new file mode 100644 index 00000000..9046e828 --- /dev/null +++ b/internal/cmd/contacts_domain.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/people/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ContactsDomainCmd struct { + List ContactsDomainListCmd `cmd:"" name:"list" help:"List domain shared contacts"` + Create ContactsDomainCreateCmd `cmd:"" name:"create" help:"Create a domain shared contact"` + Delete ContactsDomainDeleteCmd `cmd:"" name:"delete" help:"Delete a domain shared contact"` +} + +type ContactsDomainListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ContactsDomainListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newPeopleDirectoryService(ctx, account) + if err != nil { + return err + } + + call := svc.People.ListDirectoryPeople().ReadMask(contactsReadMask).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "people": resp.People, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(resp.People) == 0 { + u.Err().Println("No domain contacts found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL\tPHONE") + for _, p := range resp.People { + if p == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(p.ResourceName), + sanitizeTab(primaryName(p)), + sanitizeTab(primaryEmail(p)), + sanitizeTab(primaryPhone(p)), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ContactsDomainCreateCmd struct { + Email string `name:"email" help:"Email address" required:""` + Name string `name:"name" help:"Contact name" required:""` +} + +func (c *ContactsDomainCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + email := strings.TrimSpace(c.Email) + name := strings.TrimSpace(c.Name) + if email == "" || name == "" { + return usage("--email and --name are required") + } + + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return err + } + + person := &people.Person{ + Names: []*people.Name{{DisplayName: name}}, + EmailAddresses: []*people.EmailAddress{{Value: email}}, + } + created, err := svc.People.CreateContact(person).Do() + if err != nil { + return fmt.Errorf("create domain contact: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created contact: %s\n", created.ResourceName) + return nil +} + +type ContactsDomainDeleteCmd struct { + Email string `arg:"" name:"email" help:"Email or resource name"` +} + +func (c *ContactsDomainDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + identifier := strings.TrimSpace(c.Email) + if identifier == "" { + return usage("email is required") + } + + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return err + } + + resource := identifier + if !strings.HasPrefix(resource, "people/") { + search, err := svc.People.SearchContacts().Query(identifier).ReadMask(contactsReadMask).PageSize(1).Do() + if err != nil { + return fmt.Errorf("search contact: %w", err) + } + if len(search.Results) == 0 || search.Results[0].Person == nil { + return fmt.Errorf("no contact found for %s", identifier) + } + resource = search.Results[0].Person.ResourceName + } + + if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete contact %s", resource)); err != nil { + return err + } + + if _, err := svc.People.DeleteContact(resource).Do(); err != nil { + return fmt.Errorf("delete contact: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"resource": resource, "deleted": true}) + } + + u.Out().Printf("Deleted contact: %s\n", resource) + return nil +} diff --git a/internal/cmd/contacts_import.go b/internal/cmd/contacts_import.go new file mode 100644 index 00000000..dab59421 --- /dev/null +++ b/internal/cmd/contacts_import.go @@ -0,0 +1,333 @@ +package cmd + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "os" + "strings" + + "google.golang.org/api/people/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ContactsImportCmd struct { + File string `name:"file" help:"CSV file path (or - for stdin)" required:""` + User string `name:"user" help:"User email to import contacts for"` +} + +func (c *ContactsImportCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + reader, closer, err := openCSVReader(c.File) + if err != nil { + return err + } + if closer != nil { + defer closer.Close() + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("read csv: %w", err) + } + if len(records) == 0 { + return fmt.Errorf("empty csv") + } + + header := normalizeCSVHeader(records[0]) + contacts := make([]*people.ContactToCreate, 0, len(records)-1) + + for _, row := range records[1:] { + if len(row) == 0 { + continue + } + entry := parseCSVRow(header, row) + person := csvPerson(entry) + if person == nil { + continue + } + contacts = append(contacts, &people.ContactToCreate{ContactPerson: person}) + } + + if len(contacts) == 0 { + return fmt.Errorf("no contacts found in csv") + } + + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.People.BatchCreateContacts(&people.BatchCreateContactsRequest{Contacts: contacts}).Do() + if err != nil { + return fmt.Errorf("import contacts: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Imported %d contacts\n", len(contacts)) + return nil +} + +type ContactsExportCmd struct { + File string `name:"file" help:"CSV output path (or - for stdout)" required:""` + User string `name:"user" help:"User email to export contacts for"` +} + +func (c *ContactsExportCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return err + } + + contacts := make([]*people.Person, 0) + pageToken := "" + for { + call := svc.People.Connections.List(peopleMeResource).PersonFields(contactsReadMask).PageSize(200) + if pageToken != "" { + call = call.PageToken(pageToken) + } + var resp *people.ListConnectionsResponse + resp, err = call.Do() + if err != nil { + return fmt.Errorf("list contacts: %w", err) + } + contacts = append(contacts, resp.Connections...) + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + writer, closeFn, err := openCSVWriter(c.File) + if err != nil { + return err + } + if closeFn != nil { + defer closeFn.Close() + } + + if err := writer.Write([]string{"name", "email", "phone"}); err != nil { + return fmt.Errorf("write csv header: %w", err) + } + for _, person := range contacts { + if person == nil { + continue + } + record := []string{primaryName(person), primaryEmail(person), primaryPhone(person)} + if err := writer.Write(record); err != nil { + return fmt.Errorf("write csv: %w", err) + } + } + writer.Flush() + if err := writer.Error(); err != nil { + return fmt.Errorf("write csv: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"exported": len(contacts), "file": c.File}) + } + + ui.FromContext(ctx).Out().Printf("Exported %d contacts to %s\n", len(contacts), c.File) + return nil +} + +type ContactsDedupCmd struct { + User string `name:"user" help:"User email to dedup contacts for"` + Apply bool `name:"apply" help:"Delete duplicate contacts"` +} + +func (c *ContactsDedupCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return err + } + + contacts := make([]*people.Person, 0) + pageToken := "" + for { + call := svc.People.Connections.List(peopleMeResource).PersonFields(contactsReadMask).PageSize(200) + if pageToken != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return fmt.Errorf("list contacts: %w", err) + } + contacts = append(contacts, resp.Connections...) + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + byEmail := make(map[string][]*people.Person) + for _, person := range contacts { + if person == nil { + continue + } + email := strings.ToLower(strings.TrimSpace(primaryEmail(person))) + if email == "" { + continue + } + byEmail[email] = append(byEmail[email], person) + } + + duplicates := make([]string, 0) + deleteTargets := make([]string, 0) + for email, peopleList := range byEmail { + if len(peopleList) <= 1 { + continue + } + duplicates = append(duplicates, email) + if c.Apply { + for _, person := range peopleList[1:] { + if person != nil { + deleteTargets = append(deleteTargets, person.ResourceName) + } + } + } + } + + if len(duplicates) == 0 { + u.Err().Println("No duplicates found") + return nil + } + + if c.Apply { + if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete %d duplicate contacts", len(deleteTargets))); err != nil { + return err + } + if _, err := svc.People.BatchDeleteContacts(&people.BatchDeleteContactsRequest{ResourceNames: deleteTargets}).Do(); err != nil { + return fmt.Errorf("delete duplicates: %w", err) + } + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "duplicates": duplicates, + "deleted": len(deleteTargets), + }) + } + + if c.Apply { + u.Out().Printf("Deleted %d duplicate contacts\n", len(deleteTargets)) + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "DUPLICATE EMAIL") + for _, email := range duplicates { + fmt.Fprintf(w, "%s\n", email) + } + u.Err().Println("Run with --apply to delete duplicates") + return nil +} + +func openCSVReader(path string) (*csv.Reader, io.Closer, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return nil, nil, fmt.Errorf("file is required") + } + if trimmed == "-" { + return csv.NewReader(os.Stdin), nil, nil + } + f, err := os.Open(trimmed) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return nil, nil, fmt.Errorf("open csv: %w", err) + } + return csv.NewReader(f), f, nil +} + +func openCSVWriter(path string) (*csv.Writer, io.Closer, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return nil, nil, fmt.Errorf("file is required") + } + if trimmed == "-" { + return csv.NewWriter(os.Stdout), nil, nil + } + f, err := os.Create(trimmed) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return nil, nil, fmt.Errorf("create csv: %w", err) + } + return csv.NewWriter(f), f, nil +} + +func normalizeCSVHeader(header []string) []string { + out := make([]string, len(header)) + for i, h := range header { + out[i] = strings.ToLower(strings.TrimSpace(h)) + } + return out +} + +func parseCSVRow(header []string, row []string) map[string]string { + out := make(map[string]string, len(header)) + for i, key := range header { + if i >= len(row) { + continue + } + out[key] = strings.TrimSpace(row[i]) + } + return out +} + +func csvPerson(entry map[string]string) *people.Person { + if len(entry) == 0 { + return nil + } + + name := entry["name"] + given := entry["given"] + family := entry["family"] + email := entry["email"] + phone := entry["phone"] + + person := &people.Person{} + if name != "" || given != "" || family != "" { + person.Names = []*people.Name{{DisplayName: name, GivenName: given, FamilyName: family}} + } + if email != "" { + person.EmailAddresses = []*people.EmailAddress{{Value: email}} + } + if phone != "" { + person.PhoneNumbers = []*people.PhoneNumber{{Value: phone}} + } + + if len(person.Names) == 0 && len(person.EmailAddresses) == 0 && len(person.PhoneNumbers) == 0 { + return nil + } + return person +} diff --git a/internal/cmd/contacts_output.go b/internal/cmd/contacts_output.go index 4f66a136..ba2a0fa0 100644 --- a/internal/cmd/contacts_output.go +++ b/internal/cmd/contacts_output.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "fmt" "os" "github.com/steipete/gogcli/internal/outfmt" @@ -13,11 +12,11 @@ func writeDeleteResult(ctx context.Context, u *ui.UI, resourceName string) error if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "resource": resourceName}) } - if u == nil { - _, _ = fmt.Fprintf(os.Stdout, "deleted\ttrue\nresource\t%s\n", resourceName) - return nil + out := u + if out == nil { + out = ui.FromContext(ctx) } - u.Out().Printf("deleted\ttrue") - u.Out().Printf("resource\t%s", resourceName) + out.Out().Printf("deleted\ttrue") + out.Out().Printf("resource\t%s", resourceName) return nil } diff --git a/internal/cmd/csv.go b/internal/cmd/csv.go index 47279ba1..5f764206 100644 --- a/internal/cmd/csv.go +++ b/internal/cmd/csv.go @@ -1,19 +1,82 @@ package cmd -import "strings" +import ( + "context" + "fmt" + "strings" -func splitCSV(s string) []string { - s = strings.TrimSpace(s) - if s == "" { + csvproc "github.com/steipete/gogcli/internal/csv" + "github.com/steipete/gogcli/internal/ui" +) + +type CSVCmd struct { + File string `arg:"" name:"file" help:"CSV file path"` + Command []string `arg:"" name:"command" help:"Command template to execute. Use ~field for substitution (e.g., 'users create ~email --first-name ~firstName')"` + Fields string `name:"fields" help:"Comma-separated list of fields to include"` + Match []string `name:"matchfield" help:"Only process rows where FIELD:REGEX matches (e.g., 'status:^active$')"` + Skip []string `name:"skipfield" help:"Skip rows where FIELD:REGEX matches (e.g., 'email:@test\\.com$')"` + SkipRows int `name:"skiprows" help:"Skip first N data rows"` + MaxRows int `name:"maxrows" help:"Max number of rows to process"` + DryRun bool `name:"dry-run" help:"Preview commands without executing"` +} + +func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + if strings.TrimSpace(c.File) == "" { + return usage("file is required") + } + if len(c.Command) == 0 { + return usage("command is required") + } + + matchFilters, err := csvproc.ParseFieldFilters(c.Match) + if err != nil { + return err + } + skipFilters, err := csvproc.ParseFieldFilters(c.Skip) + if err != nil { + return err + } + + fields := splitCSV(c.Fields) + processed := 0 + failed := 0 + + err = csvproc.Process(c.File, csvproc.Options{ + Fields: fields, + Match: matchFilters, + Skip: skipFilters, + SkipRows: c.SkipRows, + MaxRows: c.MaxRows, + }, func(row csvproc.Row) error { + processed++ + args, subErr := csvproc.SubstituteArgs(c.Command, row) + if subErr != nil { + failed++ + return fmt.Errorf("row %d: %w", row.Index, subErr) + } + if c.DryRun { + if u != nil { + u.Err().Printf("[dry-run] row %d: %s\n", row.Index, strings.Join(args, " ")) + } + return nil + } + if execErr := executeSubcommand(ctx, flags, args); execErr != nil { + failed++ + return fmt.Errorf("row %d: %w", row.Index, execErr) + } return nil + }) + if err != nil { + return err } - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) + + if u != nil { + if c.DryRun { + u.Err().Printf("CSV preview: processed=%d failed=%d (no commands executed)\n", processed, failed) + } else { + u.Err().Printf("CSV complete: processed=%d failed=%d\n", processed, failed) } } - return out + return nil } diff --git a/internal/cmd/csv_test.go b/internal/cmd/csv_test.go new file mode 100644 index 00000000..f711b81c --- /dev/null +++ b/internal/cmd/csv_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestCSVCmdSubstitutionAndFilters(t *testing.T) { + csvPath := filepath.Join(t.TempDir(), "users.csv") + data := "email,first,last,dept\n" + + "alice@example.com,Alice,Example,Sales\n" + + "bob@example.com,Bob,Example,HR\n" + if err := os.WriteFile(csvPath, []byte(data), 0o600); err != nil { + t.Fatalf("write csv: %v", err) + } + + orig := executeSubcommand + t.Cleanup(func() { executeSubcommand = orig }) + + var mu sync.Mutex + calls := [][]string{} + executeSubcommand = func(_ context.Context, _ *RootFlags, args []string) error { + mu.Lock() + defer mu.Unlock() + calls = append(calls, append([]string{}, args...)) + return nil + } + + cmd := &CSVCmd{ + File: csvPath, + Command: []string{"users", "create", "~email", "--first-name", "~~first~~", "--last-name", "~~last~~", "--alias", "~~email~!~@.*$~!~@alias.com~~"}, + Match: []string{"dept:^Sales$"}, + } + + if err := cmd.Run(testContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(calls) != 1 { + t.Fatalf("expected 1 command, got %d", len(calls)) + } + got := strings.Join(calls[0], " ") + if !strings.Contains(got, "alice@example.com") || !strings.Contains(got, "Alice") || !strings.Contains(got, "@alias.com") { + t.Fatalf("unexpected args: %s", got) + } +} + +func TestBatchCmdParsing(t *testing.T) { + batchPath := filepath.Join(t.TempDir(), "batch.txt") + content := "# comment\n" + + "users create \"user one@example.com\" --first-name \"User One\"\n" + + "users create user2@example.com --first-name User2\n" + if err := os.WriteFile(batchPath, []byte(content), 0o600); err != nil { + t.Fatalf("write batch: %v", err) + } + + orig := executeSubcommand + t.Cleanup(func() { executeSubcommand = orig }) + + var mu sync.Mutex + calls := [][]string{} + executeSubcommand = func(_ context.Context, _ *RootFlags, args []string) error { + mu.Lock() + defer mu.Unlock() + calls = append(calls, append([]string{}, args...)) + return nil + } + + cmd := &BatchCmd{File: batchPath, Parallel: 2} + if err := cmd.Run(testContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(calls) != 2 { + t.Fatalf("expected 2 commands, got %d", len(calls)) + } + + foundQuoted := false + for _, call := range calls { + for _, arg := range call { + if arg == "user one@example.com" { + foundQuoted = true + break + } + } + } + if !foundQuoted { + t.Fatalf("expected quoted arg to be preserved: %v", calls) + } +} + +func TestBatchCmdDryRun(t *testing.T) { + batchPath := filepath.Join(t.TempDir(), "batch.txt") + content := "# comment\n" + + "users create \"user one@example.com\" --first-name \"User One\"\n" + + "users create user2@example.com --first-name User2\n" + if err := os.WriteFile(batchPath, []byte(content), 0o600); err != nil { + t.Fatalf("write batch: %v", err) + } + + orig := executeSubcommand + t.Cleanup(func() { executeSubcommand = orig }) + + var mu sync.Mutex + calls := [][]string{} + executeSubcommand = func(_ context.Context, _ *RootFlags, args []string) error { + mu.Lock() + defer mu.Unlock() + calls = append(calls, append([]string{}, args...)) + return nil + } + + cmd := &BatchCmd{File: batchPath, DryRun: true} + if err := cmd.Run(testContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + // No commands should have been executed + if len(calls) != 0 { + t.Fatalf("expected 0 commands to be executed in dry-run, got %d", len(calls)) + } +} + +func TestCSVCmdDryRun(t *testing.T) { + csvPath := filepath.Join(t.TempDir(), "users.csv") + data := "email,first,last,dept\n" + + "alice@example.com,Alice,Example,Sales\n" + + "bob@example.com,Bob,Example,HR\n" + if err := os.WriteFile(csvPath, []byte(data), 0o600); err != nil { + t.Fatalf("write csv: %v", err) + } + + orig := executeSubcommand + t.Cleanup(func() { executeSubcommand = orig }) + + var mu sync.Mutex + calls := [][]string{} + executeSubcommand = func(_ context.Context, _ *RootFlags, args []string) error { + mu.Lock() + defer mu.Unlock() + calls = append(calls, append([]string{}, args...)) + return nil + } + + cmd := &CSVCmd{ + File: csvPath, + Command: []string{"users", "create", "~email", "--first-name", "~~first~~"}, + DryRun: true, + } + + if err := cmd.Run(testContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + // No commands should have been executed + if len(calls) != 0 { + t.Fatalf("expected 0 commands to be executed in dry-run, got %d", len(calls)) + } +} diff --git a/internal/cmd/domains.go b/internal/cmd/domains.go new file mode 100644 index 00000000..cf158d0f --- /dev/null +++ b/internal/cmd/domains.go @@ -0,0 +1,15 @@ +package cmd + +type DomainsCmd struct { + List DomainsListCmd `cmd:"" name:"list" aliases:"ls" help:"List domains"` + Get DomainsGetCmd `cmd:"" name:"get" help:"Get domain details"` + Create DomainsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create domain"` + Delete DomainsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete domain"` + Aliases DomainsAliasesCmd `cmd:"" name:"aliases" help:"Manage domain aliases"` +} + +type DomainsAliasesCmd struct { + List DomainsAliasesListCmd `cmd:"" name:"list" aliases:"ls" help:"List domain aliases"` + Create DomainsAliasesCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create domain alias"` + Delete DomainsAliasesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete domain alias"` +} diff --git a/internal/cmd/domains_aliases.go b/internal/cmd/domains_aliases.go new file mode 100644 index 00000000..be8f5964 --- /dev/null +++ b/internal/cmd/domains_aliases.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DomainsAliasesListCmd struct{} + +func (c *DomainsAliasesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + resp, err := svc.DomainAliases.List(adminCustomerID()).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list domain aliases: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.DomainAliases) == 0 { + u.Err().Println("No domain aliases found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ALIAS\tPARENT DOMAIN\tVERIFIED\tCREATED") + for _, alias := range resp.DomainAliases { + if alias == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%t\t%s\n", + sanitizeTab(alias.DomainAliasName), + sanitizeTab(alias.ParentDomainName), + alias.Verified, + formatUnixSeconds(alias.CreationTime), + ) + } + return nil +} + +type DomainsAliasesCreateCmd struct { + Alias string `arg:"" name:"alias" help:"Domain alias to create"` + Parent string `name:"parent" required:"" help:"Parent domain"` +} + +func (c *DomainsAliasesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + req := &admin.DomainAlias{ + DomainAliasName: c.Alias, + ParentDomainName: c.Parent, + } + created, err := svc.DomainAliases.Insert(adminCustomerID(), req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create domain alias %s: %w", c.Alias, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created domain alias: %s (parent: %s)\n", created.DomainAliasName, created.ParentDomainName) + return nil +} + +type DomainsAliasesDeleteCmd struct { + Alias string `arg:"" name:"alias" help:"Domain alias to delete"` +} + +func (c *DomainsAliasesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete domain alias %s", c.Alias)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.DomainAliases.Delete(adminCustomerID(), c.Alias).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete domain alias %s: %w", c.Alias, err) + } + + u.Out().Printf("Deleted domain alias: %s\n", c.Alias) + return nil +} diff --git a/internal/cmd/domains_create.go b/internal/cmd/domains_create.go new file mode 100644 index 00000000..ca35225e --- /dev/null +++ b/internal/cmd/domains_create.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DomainsCreateCmd struct { + Domain string `arg:"" name:"domain" help:"Domain name"` +} + +func (c *DomainsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + req := &admin.Domains{DomainName: c.Domain} + created, err := svc.Domains.Insert(adminCustomerID(), req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create domain %s: %w", c.Domain, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created domain: %s\n", created.DomainName) + return nil +} diff --git a/internal/cmd/domains_delete.go b/internal/cmd/domains_delete.go new file mode 100644 index 00000000..e4a5709f --- /dev/null +++ b/internal/cmd/domains_delete.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/steipete/gogcli/internal/ui" +) + +type DomainsDeleteCmd struct { + Domain string `arg:"" name:"domain" help:"Domain name"` +} + +func (c *DomainsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete domain %s", c.Domain)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Domains.Delete(adminCustomerID(), c.Domain).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete domain %s: %w", c.Domain, err) + } + + u.Out().Printf("Deleted domain: %s\n", c.Domain) + return nil +} diff --git a/internal/cmd/domains_get.go b/internal/cmd/domains_get.go new file mode 100644 index 00000000..889b4b0e --- /dev/null +++ b/internal/cmd/domains_get.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DomainsGetCmd struct { + Domain string `arg:"" name:"domain" help:"Domain name"` +} + +func (c *DomainsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + domain, err := svc.Domains.Get(adminCustomerID(), c.Domain).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get domain %s: %w", c.Domain, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, domain) + } + + u.Out().Printf("Domain: %s\n", domain.DomainName) + u.Out().Printf("Primary: %v\n", domain.IsPrimary) + u.Out().Printf("Verified: %v\n", domain.Verified) + u.Out().Printf("Created: %s\n", formatUnixSeconds(domain.CreationTime)) + if len(domain.DomainAliases) > 0 { + u.Out().Printf("Aliases: %d\n", len(domain.DomainAliases)) + for _, alias := range domain.DomainAliases { + if alias == nil { + continue + } + u.Out().Printf(" - %s\n", alias.DomainAliasName) + } + } + return nil +} diff --git a/internal/cmd/domains_list.go b/internal/cmd/domains_list.go new file mode 100644 index 00000000..9b19935f --- /dev/null +++ b/internal/cmd/domains_list.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DomainsListCmd struct { + ToDrive ToDriveFlags `embed:""` +} + +func (c *DomainsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Domains.List(adminCustomerID()).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list domains: %w", err) + } + + if len(resp.Domains) == 0 { + u.Err().Println("No domains found") + return nil + } + + rows := make([][]string, 0, len(resp.Domains)) + for _, domain := range resp.Domains { + if domain == nil { + continue + } + rows = append(rows, toDriveRow( + domain.DomainName, + toDriveBool(domain.IsPrimary), + toDriveBool(domain.Verified), + formatUnixSeconds(domain.CreationTime), + )) + } + + if ok, err := writeToDrive(ctx, flags, toDriveTitle("Domains", c.ToDrive), []string{"DOMAIN", "PRIMARY", "VERIFIED", "CREATED"}, rows, c.ToDrive); ok { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "DOMAIN\tPRIMARY\tVERIFIED\tCREATED") + for _, row := range rows { + if len(row) < 4 { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(row[0]), + sanitizeTab(row[1]), + sanitizeTab(row[2]), + sanitizeTab(row[3]), + ) + } + + return nil +} diff --git a/internal/cmd/domains_test.go b/internal/cmd/domains_test.go new file mode 100644 index 00000000..5cf41b22 --- /dev/null +++ b/internal/cmd/domains_test.go @@ -0,0 +1,711 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// ----------------------------------------------------------------------------- +// DomainsListCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domains") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domains": []map[string]any{ + {"domainName": "example.com", "isPrimary": true, "verified": true, "creationTime": "1700000000"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +// ----------------------------------------------------------------------------- +// DomainsGetCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domains/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainName": "example.com", + "isPrimary": true, + "verified": true, + "creationTime": "1704067200000", // string for ,string tag + "domainAliases": []map[string]any{ + {"domainAliasName": "alias1.example.com"}, + {"domainAliasName": "alias2.example.com"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsGetCmd{Domain: "example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "example.com") { + t.Fatalf("expected JSON output with domain, got: %s", out) + } + if !strings.Contains(out, "domainName") { + t.Fatalf("expected JSON output with domainName field, got: %s", out) + } +} + +func TestDomainsGetCmd_Plain(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domains/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainName": "example.com", + "isPrimary": true, + "verified": true, + "creationTime": "1704067200000", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsGetCmd{Domain: "example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Domain:") || !strings.Contains(out, "example.com") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Primary:") { + t.Fatalf("expected Primary field, got: %s", out) + } + if !strings.Contains(out, "Verified:") { + t.Fatalf("expected Verified field, got: %s", out) + } +} + +func TestDomainsGetCmd_WithAliases(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domains/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainName": "example.com", + "isPrimary": false, + "verified": true, + "creationTime": "1704067200000", + "domainAliases": []map[string]any{ + {"domainAliasName": "alias1.example.com"}, + nil, // test nil handling + {"domainAliasName": "alias2.example.com"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsGetCmd{Domain: "example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Aliases:") { + t.Fatalf("expected Aliases field, got: %s", out) + } + if !strings.Contains(out, "alias1.example.com") { + t.Fatalf("expected alias1, got: %s", out) + } + if !strings.Contains(out, "alias2.example.com") { + t.Fatalf("expected alias2, got: %s", out) + } +} + +func TestDomainsGetCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsGetCmd{Domain: "nonexistent.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for nonexistent domain") + } +} + +func TestDomainsGetCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DomainsGetCmd{Domain: "example.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for missing account") + } +} + +// ----------------------------------------------------------------------------- +// DomainsCreateCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsCreateCmd_JSON(t *testing.T) { + var gotDomain string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/domains") { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotDomain = payload["domainName"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainName": gotDomain, + "isPrimary": false, + "verified": false, + "creationTime": "1704067200000", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsCreateCmd{Domain: "newdomain.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotDomain != "newdomain.com" { + t.Fatalf("expected domain newdomain.com, got: %s", gotDomain) + } + if !strings.Contains(out, "newdomain.com") { + t.Fatalf("expected JSON output with domain, got: %s", out) + } +} + +func TestDomainsCreateCmd_Plain(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/domains") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainName": "newdomain.com", + "isPrimary": false, + "verified": false, + "creationTime": "1704067200000", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsCreateCmd{Domain: "newdomain.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created domain:") || !strings.Contains(out, "newdomain.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDomainsCreateCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 403, + "message": "insufficient permissions", + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsCreateCmd{Domain: "newdomain.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for failed create") + } +} + +func TestDomainsCreateCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DomainsCreateCmd{Domain: "newdomain.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for missing account") + } +} + +// ----------------------------------------------------------------------------- +// DomainsDeleteCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsDeleteCmd_Success(t *testing.T) { + var deletedDomain string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/domains/") { + parts := strings.Split(r.URL.Path, "/domains/") + if len(parts) > 1 { + deletedDomain = parts[1] + } + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &DomainsDeleteCmd{Domain: "deleteme.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if deletedDomain != "deleteme.com" { + t.Fatalf("expected domain deleteme.com to be deleted, got: %s", deletedDomain) + } + if !strings.Contains(out, "Deleted domain:") || !strings.Contains(out, "deleteme.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDomainsDeleteCmd_RequiresConfirmation(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &DomainsDeleteCmd{Domain: "deleteme.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for missing confirmation") + } +} + +func TestDomainsDeleteCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "domain not found", + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &DomainsDeleteCmd{Domain: "nonexistent.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for nonexistent domain") + } +} + +func TestDomainsDeleteCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DomainsDeleteCmd{Domain: "deleteme.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for missing account") + } +} + +// ----------------------------------------------------------------------------- +// DomainsAliasesListCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsAliasesListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domainaliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainAliases": []map[string]any{ + { + "domainAliasName": "alias1.example.com", + "parentDomainName": "example.com", + "verified": true, + "creationTime": "1704067200000", + }, + { + "domainAliasName": "alias2.example.com", + "parentDomainName": "example.com", + "verified": false, + "creationTime": "1704153600000", + }, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "domainAliases") { + t.Fatalf("expected JSON output with domainAliases, got: %s", out) + } + if !strings.Contains(out, "alias1.example.com") { + t.Fatalf("expected alias1.example.com in output, got: %s", out) + } +} + +func TestDomainsAliasesListCmd_Plain(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domainaliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainAliases": []map[string]any{ + { + "domainAliasName": "alias1.example.com", + "parentDomainName": "example.com", + "verified": true, + "creationTime": "1704067200000", + }, + nil, // test nil handling + { + "domainAliasName": "alias2.example.com", + "parentDomainName": "example.com", + "verified": false, + "creationTime": "1704153600000", + }, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ALIAS") || !strings.Contains(out, "PARENT DOMAIN") { + t.Fatalf("expected table headers, got: %s", out) + } + if !strings.Contains(out, "alias1.example.com") { + t.Fatalf("expected alias1.example.com in output, got: %s", out) + } + if !strings.Contains(out, "example.com") { + t.Fatalf("expected parent domain in output, got: %s", out) + } +} + +func TestDomainsAliasesListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/domainaliases") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainAliases": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesListCmd{} + + // Empty list should not error + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestDomainsAliasesListCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesListCmd{} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error") + } +} + +func TestDomainsAliasesListCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DomainsAliasesListCmd{} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for missing account") + } +} + +// ----------------------------------------------------------------------------- +// DomainsAliasesCreateCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsAliasesCreateCmd_JSON(t *testing.T) { + var gotAlias, gotParent string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/domainaliases") { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotAlias = payload["domainAliasName"].(string) + gotParent = payload["parentDomainName"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainAliasName": gotAlias, + "parentDomainName": gotParent, + "verified": false, + "creationTime": "1704067200000", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesCreateCmd{Alias: "newalias.example.com", Parent: "example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotAlias != "newalias.example.com" { + t.Fatalf("expected alias newalias.example.com, got: %s", gotAlias) + } + if gotParent != "example.com" { + t.Fatalf("expected parent example.com, got: %s", gotParent) + } + if !strings.Contains(out, "newalias.example.com") { + t.Fatalf("expected JSON output with alias, got: %s", out) + } +} + +func TestDomainsAliasesCreateCmd_Plain(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/domainaliases") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "domainAliasName": "newalias.example.com", + "parentDomainName": "example.com", + "verified": false, + "creationTime": "1704067200000", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesCreateCmd{Alias: "newalias.example.com", Parent: "example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created domain alias:") { + t.Fatalf("expected 'Created domain alias:' in output, got: %s", out) + } + if !strings.Contains(out, "newalias.example.com") { + t.Fatalf("expected alias in output, got: %s", out) + } + if !strings.Contains(out, "example.com") { + t.Fatalf("expected parent in output, got: %s", out) + } +} + +func TestDomainsAliasesCreateCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 400, + "message": "invalid domain alias", + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &DomainsAliasesCreateCmd{Alias: "invalid", Parent: "example.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for invalid alias") + } +} + +func TestDomainsAliasesCreateCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DomainsAliasesCreateCmd{Alias: "newalias.example.com", Parent: "example.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for missing account") + } +} + +// ----------------------------------------------------------------------------- +// DomainsAliasesDeleteCmd Tests +// ----------------------------------------------------------------------------- + +func TestDomainsAliasesDeleteCmd_Success(t *testing.T) { + var deletedAlias string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/domainaliases/") { + parts := strings.Split(r.URL.Path, "/domainaliases/") + if len(parts) > 1 { + deletedAlias = parts[1] + } + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &DomainsAliasesDeleteCmd{Alias: "deleteme.example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if deletedAlias != "deleteme.example.com" { + t.Fatalf("expected alias deleteme.example.com to be deleted, got: %s", deletedAlias) + } + if !strings.Contains(out, "Deleted domain alias:") || !strings.Contains(out, "deleteme.example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDomainsAliasesDeleteCmd_RequiresConfirmation(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &DomainsAliasesDeleteCmd{Alias: "deleteme.example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for missing confirmation") + } +} + +func TestDomainsAliasesDeleteCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "domain alias not found", + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &DomainsAliasesDeleteCmd{Alias: "nonexistent.example.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for nonexistent alias") + } +} + +func TestDomainsAliasesDeleteCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DomainsAliasesDeleteCmd{Alias: "deleteme.example.com"} + + if err := cmd.Run(testContext(t), flags); err == nil { + t.Fatalf("expected error for missing account") + } +} diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 2adac673..a5374684 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -40,6 +40,7 @@ const ( extPptx = ".pptx" extPNG = ".png" extTXT = ".txt" + roleReader = "reader" ) type DriveCmd struct { @@ -56,6 +57,12 @@ type DriveCmd struct { Share DriveShareCmd `cmd:"" name:"share" help:"Share a file or folder"` Unshare DriveUnshareCmd `cmd:"" name:"unshare" help:"Remove a permission from a file"` Permissions DrivePermissionsCmd `cmd:"" name:"permissions" help:"List permissions on a file"` + Orphans DriveOrphansCmd `cmd:"" name:"orphans" help:"Manage orphaned files"` + Cleanup DriveCleanupCmd `cmd:"" name:"cleanup" help:"Cleanup Drive content"` + Revisions DriveRevisionsCmd `cmd:"" name:"revisions" help:"Manage file revisions"` + Shortcuts DriveShortcutsCmd `cmd:"" name:"shortcuts" help:"Manage shortcuts"` + Activity DriveActivityCmd `cmd:"" name:"activity" help:"Show file activity"` + Transfer DriveTransferCmd `cmd:"" name:"transfer" help:"Transfer file ownership"` URL DriveURLCmd `cmd:"" name:"url" help:"Print web URLs for files"` Comments DriveCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` Drives DriveDrivesCmd `cmd:"" name:"drives" help:"List shared drives (Team Drives)"` @@ -594,9 +601,9 @@ func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error { } role := strings.TrimSpace(c.Role) if role == "" { - role = "reader" + role = roleReader } - if role != "reader" && role != "writer" { + if role != roleReader && role != "writer" { return usage("invalid --role (expected reader|writer)") } @@ -796,7 +803,7 @@ func (c *DriveURLCmd) Run(ctx context.Context, flags *RootFlags) error { func buildDriveListQuery(folderID string, userQuery string) string { q := strings.TrimSpace(userQuery) - parent := fmt.Sprintf("'%s' in parents", folderID) + parent := fmt.Sprintf("'%s' in parents", googleapi.EscapeDriveQueryValue(folderID)) if q != "" { q = q + " and " + parent } else { @@ -809,17 +816,10 @@ func buildDriveListQuery(folderID string, userQuery string) string { } func buildDriveSearchQuery(text string) string { - q := fmt.Sprintf("fullText contains '%s'", escapeDriveQueryString(text)) + q := fmt.Sprintf("fullText contains '%s'", googleapi.EscapeDriveQueryValue(text)) return q + " and trashed = false" } -func escapeDriveQueryString(s string) string { - // Escape backslashes first, then single quotes - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "'", "\\'") - return s -} - func driveType(mimeType string) string { if mimeType == "application/vnd.google-apps.folder" { return "folder" diff --git a/internal/cmd/drive_activity.go b/internal/cmd/drive_activity.go new file mode 100644 index 00000000..c1b84736 --- /dev/null +++ b/internal/cmd/drive_activity.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/driveactivity/v2" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newDriveActivityService = googleapi.NewDriveActivity + +type DriveActivityCmd struct { + FileID string `arg:"" name:"file-id" help:"Drive file ID"` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *DriveActivityCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + fileID := strings.TrimSpace(c.FileID) + if fileID == "" { + return usage("file-id is required") + } + + svc, err := newDriveActivityService(ctx, account) + if err != nil { + return err + } + + req := &driveactivity.QueryDriveActivityRequest{ + ItemName: "items/" + fileID, + PageSize: c.Max, + PageToken: func() string { + return c.Page + }(), + } + resp, err := svc.Activity.Query(req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("query activity: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Activities) == 0 { + u.Err().Println("No activity found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "TIME\tACTOR\tACTION") + for _, act := range resp.Activities { + if act == nil { + continue + } + time := act.Timestamp + if time == "" && act.TimeRange != nil { + time = act.TimeRange.EndTime + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(time), + sanitizeTab(activityActor(act.Actors)), + sanitizeTab(activityAction(act.PrimaryActionDetail)), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +func activityActor(actors []*driveactivity.Actor) string { + if len(actors) == 0 || actors[0] == nil { + return "" + } + actor := actors[0] + if actor.User != nil { + if actor.User.KnownUser != nil && actor.User.KnownUser.PersonName != "" { + return actor.User.KnownUser.PersonName + } + if actor.User.KnownUser != nil && actor.User.KnownUser.IsCurrentUser { + return "me" + } + } + if actor.Administrator != nil { + return "admin" + } + if actor.System != nil { + return "system" + } + if actor.Anonymous != nil { + return "anonymous" + } + return trackingUnknown +} + +func activityAction(detail *driveactivity.ActionDetail) string { + if detail == nil { + return "" + } + switch { + case detail.Create != nil: + return "create" + case detail.Edit != nil: + return "edit" + case detail.Move != nil: + return "move" + case detail.Rename != nil: + return "rename" + case detail.Delete != nil: + return "delete" + case detail.Restore != nil: + return "restore" + case detail.PermissionChange != nil: + return "permission" + case detail.Comment != nil: + return "comment" + case detail.DlpChange != nil: + return "dlp" + case detail.SettingsChange != nil: + return "settings" + case detail.Reference != nil: + return "reference" + default: + return "other" + } +} diff --git a/internal/cmd/drive_activity_test.go b/internal/cmd/drive_activity_test.go new file mode 100644 index 00000000..c504fb67 --- /dev/null +++ b/internal/cmd/drive_activity_test.go @@ -0,0 +1,521 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "google.golang.org/api/driveactivity/v2" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// ----------------------------------------------------------------------------- +// activityAction tests +// ----------------------------------------------------------------------------- + +func TestActivityAction_Nil(t *testing.T) { + if got := activityAction(nil); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestActivityAction_Create(t *testing.T) { + detail := &driveactivity.ActionDetail{Create: &driveactivity.Create{}} + if got := activityAction(detail); got != "create" { + t.Fatalf("expected create, got %q", got) + } +} + +func TestActivityAction_Edit(t *testing.T) { + detail := &driveactivity.ActionDetail{Edit: &driveactivity.Edit{}} + if got := activityAction(detail); got != "edit" { + t.Fatalf("expected edit, got %q", got) + } +} + +func TestActivityAction_Move(t *testing.T) { + detail := &driveactivity.ActionDetail{Move: &driveactivity.Move{}} + if got := activityAction(detail); got != "move" { + t.Fatalf("expected move, got %q", got) + } +} + +func TestActivityAction_Rename(t *testing.T) { + detail := &driveactivity.ActionDetail{Rename: &driveactivity.Rename{}} + if got := activityAction(detail); got != "rename" { + t.Fatalf("expected rename, got %q", got) + } +} + +func TestActivityAction_Delete(t *testing.T) { + detail := &driveactivity.ActionDetail{Delete: &driveactivity.Delete{}} + if got := activityAction(detail); got != "delete" { + t.Fatalf("expected delete, got %q", got) + } +} + +func TestActivityAction_Restore(t *testing.T) { + detail := &driveactivity.ActionDetail{Restore: &driveactivity.Restore{}} + if got := activityAction(detail); got != "restore" { + t.Fatalf("expected restore, got %q", got) + } +} + +func TestActivityAction_Permission(t *testing.T) { + detail := &driveactivity.ActionDetail{PermissionChange: &driveactivity.PermissionChange{}} + if got := activityAction(detail); got != "permission" { + t.Fatalf("expected permission, got %q", got) + } +} + +func TestActivityAction_Comment(t *testing.T) { + detail := &driveactivity.ActionDetail{Comment: &driveactivity.Comment{}} + if got := activityAction(detail); got != "comment" { + t.Fatalf("expected comment, got %q", got) + } +} + +func TestActivityAction_Dlp(t *testing.T) { + detail := &driveactivity.ActionDetail{DlpChange: &driveactivity.DataLeakPreventionChange{}} + if got := activityAction(detail); got != "dlp" { + t.Fatalf("expected dlp, got %q", got) + } +} + +func TestActivityAction_Settings(t *testing.T) { + detail := &driveactivity.ActionDetail{SettingsChange: &driveactivity.SettingsChange{}} + if got := activityAction(detail); got != "settings" { + t.Fatalf("expected settings, got %q", got) + } +} + +func TestActivityAction_Reference(t *testing.T) { + detail := &driveactivity.ActionDetail{Reference: &driveactivity.ApplicationReference{}} + if got := activityAction(detail); got != "reference" { + t.Fatalf("expected reference, got %q", got) + } +} + +func TestActivityAction_Other(t *testing.T) { + // Empty detail with no action type set + detail := &driveactivity.ActionDetail{} + if got := activityAction(detail); got != "other" { + t.Fatalf("expected other, got %q", got) + } +} + +// ----------------------------------------------------------------------------- +// activityActor tests +// ----------------------------------------------------------------------------- + +func TestActivityActor_Empty(t *testing.T) { + if got := activityActor(nil); got != "" { + t.Fatalf("expected empty, got %q", got) + } + if got := activityActor([]*driveactivity.Actor{}); got != "" { + t.Fatalf("expected empty for empty slice, got %q", got) + } + if got := activityActor([]*driveactivity.Actor{nil}); got != "" { + t.Fatalf("expected empty for nil actor, got %q", got) + } +} + +func TestActivityActor_KnownUserWithName(t *testing.T) { + actors := []*driveactivity.Actor{ + {User: &driveactivity.User{KnownUser: &driveactivity.KnownUser{PersonName: "John Doe"}}}, + } + if got := activityActor(actors); got != "John Doe" { + t.Fatalf("expected John Doe, got %q", got) + } +} + +func TestActivityActor_KnownUserCurrentUser(t *testing.T) { + actors := []*driveactivity.Actor{ + {User: &driveactivity.User{KnownUser: &driveactivity.KnownUser{IsCurrentUser: true}}}, + } + if got := activityActor(actors); got != "me" { + t.Fatalf("expected me, got %q", got) + } +} + +func TestActivityActor_Administrator(t *testing.T) { + actors := []*driveactivity.Actor{ + {Administrator: &driveactivity.Administrator{}}, + } + if got := activityActor(actors); got != "admin" { + t.Fatalf("expected admin, got %q", got) + } +} + +func TestActivityActor_System(t *testing.T) { + actors := []*driveactivity.Actor{ + {System: &driveactivity.SystemEvent{}}, + } + if got := activityActor(actors); got != "system" { + t.Fatalf("expected system, got %q", got) + } +} + +func TestActivityActor_Anonymous(t *testing.T) { + actors := []*driveactivity.Actor{ + {Anonymous: &driveactivity.AnonymousUser{}}, + } + if got := activityActor(actors); got != "anonymous" { + t.Fatalf("expected anonymous, got %q", got) + } +} + +func TestActivityActor_Unknown(t *testing.T) { + // Actor with no known type + actors := []*driveactivity.Actor{ + {}, + } + if got := activityActor(actors); got != "unknown" { + t.Fatalf("expected unknown, got %q", got) + } +} + +func TestActivityActor_UserWithNoKnownUser(t *testing.T) { + // User set but KnownUser is nil - falls through to unknown + actors := []*driveactivity.Actor{ + {User: &driveactivity.User{}}, + } + if got := activityActor(actors); got != "unknown" { + t.Fatalf("expected unknown for User with no KnownUser, got %q", got) + } +} + +// ----------------------------------------------------------------------------- +// DriveActivityCmd tests +// ----------------------------------------------------------------------------- + +func TestDriveActivityCmd_ValidationEmptyFileID(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for empty file-id") + } + if !strings.Contains(err.Error(), "file-id is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveActivityCmd_ValidationNoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &DriveActivityCmd{FileID: "file1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for missing account") + } +} + +func TestDriveActivityCmd_TableOutput(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{ + { + "timestamp": "2026-01-02T10:30:00Z", + "primaryActionDetail": map[string]any{ + "create": map[string]any{}, + }, + "actors": []map[string]any{ + {"user": map[string]any{"knownUser": map[string]any{"personName": "Alice Smith"}}}, + }, + }, + { + "timestamp": "2026-01-03T14:00:00Z", + "primaryActionDetail": map[string]any{ + "edit": map[string]any{}, + }, + "actors": []map[string]any{ + {"administrator": map[string]any{}}, + }, + }, + }, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "TIME") || !strings.Contains(out, "ACTOR") || !strings.Contains(out, "ACTION") { + t.Fatalf("missing table headers: %s", out) + } + if !strings.Contains(out, "create") { + t.Fatalf("missing create action: %s", out) + } + if !strings.Contains(out, "edit") { + t.Fatalf("missing edit action: %s", out) + } + if !strings.Contains(out, "Alice Smith") { + t.Fatalf("missing actor name: %s", out) + } + if !strings.Contains(out, "admin") { + t.Fatalf("missing admin actor: %s", out) + } +} + +func TestDriveActivityCmd_JSONOutput(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{ + { + "timestamp": "2026-01-02T10:30:00Z", + "primaryActionDetail": map[string]any{ + "delete": map[string]any{}, + }, + "actors": []map[string]any{ + {"system": map[string]any{}}, + }, + }, + }, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "activities") { + t.Fatalf("expected JSON activities output: %s", out) + } + if !strings.Contains(out, "delete") { + t.Fatalf("expected delete in JSON: %s", out) + } +} + +func TestDriveActivityCmd_NoActivities(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{}, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + // No activities should print to stderr + err := cmd.Run(testContext(t), flags) + if err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestDriveActivityCmd_TimeRange(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{ + { + // No direct timestamp, use timeRange instead + "timeRange": map[string]any{ + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-02T00:00:00Z", + }, + "primaryActionDetail": map[string]any{ + "move": map[string]any{}, + }, + "actors": []map[string]any{ + {"anonymous": map[string]any{}}, + }, + }, + }, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "2026-01-02") { + t.Fatalf("expected timeRange endTime in output: %s", out) + } + if !strings.Contains(out, "move") { + t.Fatalf("expected move action: %s", out) + } + if !strings.Contains(out, "anonymous") { + t.Fatalf("expected anonymous actor: %s", out) + } +} + +func TestDriveActivityCmd_NilActivity(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + // Return activities with one nil entry (API doesn't usually do this, but testing code path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{ + nil, + { + "timestamp": "2026-01-02T10:30:00Z", + "primaryActionDetail": map[string]any{ + "rename": map[string]any{}, + }, + "actors": []map[string]any{ + {"user": map[string]any{"knownUser": map[string]any{"isCurrentUser": true}}}, + }, + }, + }, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "rename") { + t.Fatalf("expected rename action: %s", out) + } + if !strings.Contains(out, "me") { + t.Fatalf("expected 'me' as current user: %s", out) + } +} + +func TestDriveActivityCmd_Pagination(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{ + { + "timestamp": "2026-01-02T10:30:00Z", + "primaryActionDetail": map[string]any{ + "permissionChange": map[string]any{}, + }, + "actors": []map[string]any{ + {"user": map[string]any{"knownUser": map[string]any{"personName": "Bob"}}}, + }, + }, + }, + "nextPageToken": "next-page-token-123", + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1", Max: 10} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "permission") { + t.Fatalf("expected permission action: %s", out) + } +} + +func TestDriveActivityCmd_AllActionTypes(t *testing.T) { + // Test all action types in a single response + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{ + {"timestamp": "2026-01-01T00:00:00Z", "primaryActionDetail": map[string]any{"restore": map[string]any{}}, "actors": []map[string]any{{}}}, + {"timestamp": "2026-01-02T00:00:00Z", "primaryActionDetail": map[string]any{"comment": map[string]any{}}, "actors": []map[string]any{{}}}, + {"timestamp": "2026-01-03T00:00:00Z", "primaryActionDetail": map[string]any{"dlpChange": map[string]any{}}, "actors": []map[string]any{{}}}, + {"timestamp": "2026-01-04T00:00:00Z", "primaryActionDetail": map[string]any{"settingsChange": map[string]any{}}, "actors": []map[string]any{{}}}, + {"timestamp": "2026-01-05T00:00:00Z", "primaryActionDetail": map[string]any{"reference": map[string]any{}}, "actors": []map[string]any{{}}}, + {"timestamp": "2026-01-06T00:00:00Z", "primaryActionDetail": map[string]any{}, "actors": []map[string]any{{}}}, + }, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + for _, expected := range []string{"restore", "comment", "dlp", "settings", "reference", "other"} { + if !strings.Contains(out, expected) { + t.Fatalf("missing %s action: %s", expected, out) + } + } +} + +func TestDriveActivityCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error": {"message": "forbidden"}}`, http.StatusForbidden) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "query activity") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cmd/drive_advanced_test.go b/internal/cmd/drive_advanced_test.go new file mode 100644 index 00000000..29fb8d12 --- /dev/null +++ b/internal/cmd/drive_advanced_test.go @@ -0,0 +1,361 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/driveactivity/v2" + "google.golang.org/api/option" +) + +func TestDriveOrphansListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "f1", "name": "Orphan", "mimeType": "text/plain"}, + }, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveOrphansListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Orphan") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveOrphansCollectCmd(t *testing.T) { + var updated bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{{"id": "f1", "parents": []string{}}}, + }) + case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/files/f1"): + updated = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "f1"}) + default: + http.NotFound(w, r) + } + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &DriveOrphansCollectCmd{Folder: "folder1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !updated { + t.Fatalf("expected update request") + } + if !strings.Contains(out, "Moved 1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveCleanupEmptyFoldersCmd(t *testing.T) { + var deleted bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files") && strings.Contains(q, "mimeType"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{{"id": "folder1", "name": "Empty"}}, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files") && strings.Contains(q, "'folder1' in parents"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{}}) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/files/folder1"): + deleted = true + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &DriveCleanupEmptyFoldersCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatalf("expected delete") + } + if !strings.Contains(out, "Deleted 1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveRevisionsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/file1/revisions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "revisions": []map[string]any{{"id": "1", "modifiedTime": "2026-01-01T00:00:00Z", "keepForever": false}}, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveRevisionsListCmd{FileID: "file1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "2026-01-01") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveRevisionsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/file1/revisions/rev1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "rev1", + "modifiedTime": "2026-01-15T10:30:00Z", + "mimeType": "text/plain", + "keepForever": true, + "lastModifyingUser": map[string]any{ + "emailAddress": "editor@example.com", + }, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveRevisionsGetCmd{FileID: "file1", RevisionID: "rev1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "rev1") || !strings.Contains(out, "2026-01-15") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveRevisionsDeleteCmd(t *testing.T) { + var deleted bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/files/file1/revisions/rev1") { + http.NotFound(w, r) + return + } + deleted = true + w.WriteHeader(http.StatusNoContent) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &DriveRevisionsDeleteCmd{FileID: "file1", RevisionID: "rev1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatalf("expected delete request") + } + if !strings.Contains(out, "Deleted revision") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveShortcutsCreateCmd(t *testing.T) { + var gotMime string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + var payload struct { + MimeType string `json:"mimeType"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + gotMime = payload.MimeType + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "shortcut1", "name": "Shortcut"}) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveShortcutsCreateCmd{Target: "file1", Name: "Shortcut"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotMime != driveMimeShortcut { + t.Fatalf("unexpected mime: %s", gotMime) + } + if !strings.Contains(out, "Created shortcut") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveShortcutsDeleteCmd(t *testing.T) { + var deleted bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/files/shortcut1") { + http.NotFound(w, r) + return + } + deleted = true + w.WriteHeader(http.StatusNoContent) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &DriveShortcutsDeleteCmd{ShortcutID: "shortcut1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatalf("expected delete request") + } + if !strings.Contains(out, "Deleted shortcut") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveActivityCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/activity:query") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "activities": []map[string]any{{"timestamp": "2026-01-02T00:00:00Z", "primaryActionDetail": map[string]any{"edit": map[string]any{}}, "actors": []map[string]any{{"administrator": map[string]any{}}}}}, + }) + }) + stubDriveActivity(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &DriveActivityCmd{FileID: "file1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "edit") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestDriveTransferCmd(t *testing.T) { + var permCreated bool + var permDeleted bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/file1/permissions"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "permissions": []map[string]any{{"id": "perm-old", "emailAddress": "old@example.com"}}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/files/file1/permissions"): + permCreated = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "perm-new"}) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{{"id": "file1"}}, + }) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/files/file1/permissions/perm-old"): + permDeleted = true + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &DriveTransferCmd{From: "old@example.com", To: "new@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !permCreated || !permDeleted { + t.Fatalf("expected permission changes, created=%v deleted=%v", permCreated, permDeleted) + } + if !strings.Contains(out, "Transferred 1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubDriveActivity(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newDriveActivityService + svc, err := driveactivity.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new driveactivity service: %v", err) + } + newDriveActivityService = func(context.Context, string) (*driveactivity.Service, error) { return svc, nil } + t.Cleanup(func() { + newDriveActivityService = orig + srv.Close() + }) +} diff --git a/internal/cmd/drive_cleanup.go b/internal/cmd/drive_cleanup.go new file mode 100644 index 00000000..4beedb2c --- /dev/null +++ b/internal/cmd/drive_cleanup.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DriveCleanupCmd struct { + EmptyFolders DriveCleanupEmptyFoldersCmd `cmd:"" name:"empty-folders" help:"Delete empty folders"` +} + +type DriveCleanupEmptyFoldersCmd struct { + User string `name:"user" help:"User email to clean up"` + Max int64 `name:"max" aliases:"limit" default:"200" help:"Max folders to scan per page"` + Parent string `name:"parent" help:"Only scan folders within this parent folder"` +} + +func (c *DriveCleanupEmptyFoldersCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + if err = confirmDestructive(ctx, flags, "delete empty Drive folders"); err != nil { + return err + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + query := "mimeType='application/vnd.google-apps.folder' and trashed=false" + if strings.TrimSpace(c.Parent) != "" { + query = fmt.Sprintf("%s and '%s' in parents", query, googleapi.EscapeDriveQueryValue(strings.TrimSpace(c.Parent))) + } + + deleted := 0 + pageToken := "" + for { + call := svc.Files.List(). + Q(query). + Fields("nextPageToken, files(id, name)"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list folders: %w", err) + } + + for _, folder := range resp.Files { + if folder == nil { + continue + } + hasChildren, err := driveFolderHasChildren(ctx, svc, folder.Id) + if err != nil { + return err + } + if hasChildren { + continue + } + if err := svc.Files.Delete(folder.Id).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete folder %s: %w", folder.Id, err) + } + deleted++ + } + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": deleted}) + } + + u.Out().Printf("Deleted %d empty folders\n", deleted) + return nil +} + +func driveFolderHasChildren(ctx context.Context, svc *drive.Service, folderID string) (bool, error) { + resp, err := svc.Files.List(). + Q(fmt.Sprintf("'%s' in parents and trashed=false", googleapi.EscapeDriveQueryValue(folderID))). + PageSize(1). + Fields("files(id)"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true). + Context(ctx). + Do() + if err != nil { + return false, fmt.Errorf("check folder %s: %w", folderID, err) + } + + return len(resp.Files) > 0, nil +} diff --git a/internal/cmd/drive_orphans.go b/internal/cmd/drive_orphans.go new file mode 100644 index 00000000..24530b6f --- /dev/null +++ b/internal/cmd/drive_orphans.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DriveOrphansCmd struct { + List DriveOrphansListCmd `cmd:"" name:"list" aliases:"ls" help:"List orphaned files. This finds files owned by the user that are not in root. Note: files whose parent folders were deleted may not be detected; use Drive's 'Organize files' UI for comprehensive orphan recovery."` + Collect DriveOrphansCollectCmd `cmd:"" name:"collect" help:"Move orphaned files into a folder. This finds files owned by the user that are not in root. Note: files whose parent folders were deleted may not be detected; use Drive's 'Organize files' UI for comprehensive orphan recovery."` +} + +type DriveOrphansListCmd struct { + User string `name:"user" help:"User email to list files for"` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *DriveOrphansListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + call := svc.Files.List(). + Q(driveOrphansQuery()). + Fields("nextPageToken, files(id, name, mimeType, owners(emailAddress), parents)"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list orphaned files: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Files) == 0 { + u.Err().Println("No orphaned files found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tTYPE\tOWNER") + for _, f := range resp.Files { + if f == nil { + continue + } + owner := "" + if len(f.Owners) > 0 { + owner = f.Owners[0].EmailAddress + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(f.Id), + sanitizeTab(f.Name), + sanitizeTab(f.MimeType), + sanitizeTab(owner), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type DriveOrphansCollectCmd struct { + User string `name:"user" help:"User email to collect files for"` + Folder string `name:"folder" help:"Destination folder ID (default: create 'Orphans')"` +} + +func (c *DriveOrphansCollectCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + if err = confirmDestructive(ctx, flags, "collect orphaned files into a folder"); err != nil { + return err + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + folderID := strings.TrimSpace(c.Folder) + if folderID == "" { + folder, err := svc.Files.Create(&drive.File{ + Name: "Orphans", + MimeType: "application/vnd.google-apps.folder", + }). + SupportsAllDrives(true). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("create orphans folder: %w", err) + } + folderID = folder.Id + } + + moved := 0 + pageToken := "" + for { + call := svc.Files.List(). + Q(driveOrphansQuery()). + Fields("nextPageToken, files(id, parents)"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true) + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list orphaned files: %w", err) + } + + for _, f := range resp.Files { + if f == nil { + continue + } + update := svc.Files.Update(f.Id, &drive.File{}). + AddParents(folderID). + SupportsAllDrives(true) + if len(f.Parents) > 0 { + update = update.RemoveParents(strings.Join(f.Parents, ",")) + } + if _, err := update.Context(ctx).Do(); err != nil { + return fmt.Errorf("move file %s: %w", f.Id, err) + } + moved++ + } + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "moved": moved, + "folder": folderID, + }) + } + + u.Out().Printf("Moved %d orphaned files to %s\n", moved, folderID) + return nil +} + +func driveOrphansQuery() string { + return "trashed=false and 'me' in owners and not 'root' in parents" +} diff --git a/internal/cmd/drive_revisions.go b/internal/cmd/drive_revisions.go new file mode 100644 index 00000000..4fd7bdcd --- /dev/null +++ b/internal/cmd/drive_revisions.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DriveRevisionsCmd struct { + List DriveRevisionsListCmd `cmd:"" name:"list" aliases:"ls" help:"List file revisions"` + Get DriveRevisionsGetCmd `cmd:"" name:"get" help:"Get a file revision"` + Delete DriveRevisionsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a file revision"` +} + +type DriveRevisionsListCmd struct { + FileID string `arg:"" name:"file-id" help:"File ID"` +} + +func (c *DriveRevisionsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + fileID := strings.TrimSpace(c.FileID) + if fileID == "" { + return usage("file-id is required") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Revisions.List(fileID). + Fields("revisions(id, modifiedTime, keepForever, mimeType, lastModifyingUser(emailAddress, displayName))"). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("list revisions: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Revisions) == 0 { + u.Err().Println("No revisions found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tMODIFIED\tKEEP\tMIME\tUSER") + for _, rev := range resp.Revisions { + if rev == nil { + continue + } + user := "" + if rev.LastModifyingUser != nil { + if rev.LastModifyingUser.DisplayName != "" { + user = rev.LastModifyingUser.DisplayName + } else { + user = rev.LastModifyingUser.EmailAddress + } + } + fmt.Fprintf(w, "%s\t%s\t%t\t%s\t%s\n", + sanitizeTab(rev.Id), + sanitizeTab(rev.ModifiedTime), + rev.KeepForever, + sanitizeTab(rev.MimeType), + sanitizeTab(user), + ) + } + return nil +} + +type DriveRevisionsGetCmd struct { + FileID string `arg:"" name:"file-id" help:"File ID"` + RevisionID string `arg:"" name:"revision-id" help:"Revision ID"` +} + +func (c *DriveRevisionsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + fileID := strings.TrimSpace(c.FileID) + revID := strings.TrimSpace(c.RevisionID) + if fileID == "" || revID == "" { + return usage("file-id and revision-id are required") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + rev, err := svc.Revisions.Get(fileID, revID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get revision: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, rev) + } + + u.Out().Printf("ID: %s\n", rev.Id) + u.Out().Printf("Modified: %s\n", rev.ModifiedTime) + u.Out().Printf("Mime: %s\n", rev.MimeType) + u.Out().Printf("Keep: %t\n", rev.KeepForever) + if rev.LastModifyingUser != nil { + u.Out().Printf("User: %s\n", rev.LastModifyingUser.EmailAddress) + } + return nil +} + +type DriveRevisionsDeleteCmd struct { + FileID string `arg:"" name:"file-id" help:"File ID"` + RevisionID string `arg:"" name:"revision-id" help:"Revision ID"` +} + +func (c *DriveRevisionsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + fileID := strings.TrimSpace(c.FileID) + revID := strings.TrimSpace(c.RevisionID) + if fileID == "" || revID == "" { + return usage("file-id and revision-id are required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete revision %s for file %s", revID, fileID)); err != nil { + return err + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + if err := svc.Revisions.Delete(fileID, revID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete revision: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"fileId": fileID, "revisionId": revID, "deleted": true}) + } + + u.Out().Printf("Deleted revision %s for %s\n", revID, fileID) + return nil +} diff --git a/internal/cmd/drive_shortcuts.go b/internal/cmd/drive_shortcuts.go new file mode 100644 index 00000000..0e775fc4 --- /dev/null +++ b/internal/cmd/drive_shortcuts.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +const driveMimeShortcut = "application/vnd.google-apps.shortcut" + +type DriveShortcutsCmd struct { + Create DriveShortcutsCreateCmd `cmd:"" name:"create" help:"Create a shortcut"` + Delete DriveShortcutsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a shortcut"` +} + +type DriveShortcutsCreateCmd struct { + Target string `name:"target" help:"Target file ID" required:""` + Parent string `name:"parent" help:"Parent folder ID"` + Name string `name:"name" help:"Shortcut name"` +} + +func (c *DriveShortcutsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + target := strings.TrimSpace(c.Target) + if target == "" { + return usage("--target is required") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + file := &drive.File{ + MimeType: driveMimeShortcut, + Name: strings.TrimSpace(c.Name), + ShortcutDetails: &drive.FileShortcutDetails{ + TargetId: target, + }, + } + if strings.TrimSpace(c.Parent) != "" { + file.Parents = []string{strings.TrimSpace(c.Parent)} + } + + created, err := svc.Files.Create(file). + SupportsAllDrives(true). + Fields("id, name, shortcutDetails"). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("create shortcut: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created shortcut: %s (%s)\n", created.Name, created.Id) + return nil +} + +type DriveShortcutsDeleteCmd struct { + ShortcutID string `arg:"" name:"shortcut-id" help:"Shortcut file ID"` +} + +func (c *DriveShortcutsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + shortcutID := strings.TrimSpace(c.ShortcutID) + if shortcutID == "" { + return usage("shortcut-id is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete shortcut %s", shortcutID)); err != nil { + return err + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + if err := svc.Files.Delete(shortcutID).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete shortcut: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"shortcutId": shortcutID, "deleted": true}) + } + + u.Out().Printf("Deleted shortcut: %s\n", shortcutID) + return nil +} diff --git a/internal/cmd/drive_test.go b/internal/cmd/drive_test.go index a61077e2..c93e066e 100644 --- a/internal/cmd/drive_test.go +++ b/internal/cmd/drive_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "testing" + + "github.com/steipete/gogcli/internal/googleapi" +) func TestBuildDriveListQuery(t *testing.T) { t.Run("adds parent and trashed", func(t *testing.T) { @@ -33,7 +37,7 @@ func TestBuildDriveSearchQuery(t *testing.T) { } func TestEscapeDriveQueryString(t *testing.T) { - got := escapeDriveQueryString("a'b") + got := googleapi.EscapeDriveQueryValue("a'b") if got != "a\\'b" { t.Fatalf("unexpected: %q", got) } diff --git a/internal/cmd/drive_transfer.go b/internal/cmd/drive_transfer.go new file mode 100644 index 00000000..a4f4dac0 --- /dev/null +++ b/internal/cmd/drive_transfer.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type DriveTransferCmd struct { + From string `name:"from" help:"Current owner email" required:""` + To string `name:"to" help:"New owner email" required:""` + RetainPermissions bool `name:"retain-permissions" help:"Keep existing owner permission"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max files per page"` +} + +func (c *DriveTransferCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + from := strings.TrimSpace(c.From) + to := strings.TrimSpace(c.To) + if from == "" || to == "" { + return usage("--from and --to are required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("transfer Drive ownership from %s to %s", from, to)); err != nil { + return err + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + moved := 0 + pageToken := "" + for { + call := svc.Files.List(). + Q(fmt.Sprintf("'%s' in owners and trashed=false", googleapi.EscapeDriveQueryValue(from))). + Fields("nextPageToken, files(id, name, owners(emailAddress), permissions(id,emailAddress))"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list files: %w", err) + } + + for _, f := range resp.Files { + if f == nil { + continue + } + perm := &drive.Permission{ + Type: "user", + Role: "owner", + EmailAddress: to, + } + if _, err := svc.Permissions.Create(f.Id, perm). + TransferOwnership(true). + SendNotificationEmail(false). + SupportsAllDrives(true). + UseDomainAdminAccess(true). + Context(ctx). + Do(); err != nil { + return fmt.Errorf("transfer %s: %w", f.Id, err) + } + + if !c.RetainPermissions { + if err := removeDrivePermission(ctx, svc, f.Id, from); err != nil { + return err + } + } + moved++ + } + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"transferred": moved, "from": from, "to": to}) + } + + u.Out().Printf("Transferred %d files from %s to %s\n", moved, from, to) + return nil +} + +func removeDrivePermission(ctx context.Context, svc *drive.Service, fileID string, email string) error { + perms, err := svc.Permissions.List(fileID). + Fields("permissions(id,emailAddress)"). + SupportsAllDrives(true). + UseDomainAdminAccess(true). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("list permissions for %s: %w", fileID, err) + } + + for _, perm := range perms.Permissions { + if perm == nil { + continue + } + if strings.EqualFold(perm.EmailAddress, email) { + if err := svc.Permissions.Delete(fileID, perm.Id). + SupportsAllDrives(true). + UseDomainAdminAccess(true). + Context(ctx). + Do(); err != nil { + return fmt.Errorf("remove permission %s: %w", perm.Id, err) + } + break + } + } + + return nil +} diff --git a/internal/cmd/exec_helpers.go b/internal/cmd/exec_helpers.go new file mode 100644 index 00000000..21bd203b --- /dev/null +++ b/internal/cmd/exec_helpers.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "fmt" + "strings" +) + +var executeSubcommand = runSubcommand + +func runSubcommand(ctx context.Context, flags *RootFlags, args []string) error { + parser, cli, err := newParser(helpDescription()) + if err != nil { + return err + } + + kctx, err := parser.Parse(args) + if err != nil { + if parsedErr := wrapParseError(err); parsedErr != nil { + return parsedErr + } + return err + } + + if ctx != nil { + kctx.BindTo(ctx, (*context.Context)(nil)) + } + if flags != nil { + cli.RootFlags = *flags + kctx.Bind(flags) + } + + if err := enforceEnabledCommands(kctx, cli.EnableCommands); err != nil { + return err + } + + return kctx.Run() +} + +func splitCommandLine(line string) ([]string, error) { + args := []string{} + var buf strings.Builder + inSingle := false + inDouble := false + escaped := false + + flush := func() { + if buf.Len() > 0 { + args = append(args, buf.String()) + buf.Reset() + } + } + + for _, r := range line { + switch { + case escaped: + buf.WriteRune(r) + escaped = false + case r == '\\' && !inSingle: + escaped = true + case r == '\'' && !inDouble: + inSingle = !inSingle + case r == '"' && !inSingle: + inDouble = !inDouble + case (r == ' ' || r == '\t') && !inSingle && !inDouble: + flush() + default: + buf.WriteRune(r) + } + } + + if escaped { + return nil, fmt.Errorf("unterminated escape") + } + if inSingle || inDouble { + return nil, fmt.Errorf("unterminated quote") + } + flush() + return args, nil +} diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go new file mode 100644 index 00000000..fbcb6fa6 --- /dev/null +++ b/internal/cmd/forms.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/forms/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newFormsService = googleapi.NewForms + +type FormsCmd struct { + List FormsListCmd `cmd:"" name:"list" aliases:"ls" help:"List forms"` + Get FormsGetCmd `cmd:"" name:"get" help:"Get form"` + Create FormsCreateCmd `cmd:"" name:"create" help:"Create form"` + Responses FormsResponsesCmd `cmd:"" name:"responses" help:"List form responses"` +} + +type FormsListCmd struct { + User string `name:"user" help:"User email to list forms for"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *FormsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + query := "mimeType='application/vnd.google-apps.form' and trashed=false" + call := svc.Files.List().Q(query).Fields("files(id,name,owners(emailAddress),createdTime),nextPageToken") + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list forms: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Files) == 0 { + u.Err().Println("No forms found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tOWNER\tCREATED") + for _, file := range resp.Files { + if file == nil { + continue + } + owner := "" + if len(file.Owners) > 0 { + owner = file.Owners[0].EmailAddress + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(file.Id), + sanitizeTab(file.Name), + sanitizeTab(owner), + sanitizeTab(file.CreatedTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type FormsGetCmd struct { + FormID string `arg:"" name:"form-id" help:"Form ID"` +} + +func (c *FormsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + formID := strings.TrimSpace(c.FormID) + if formID == "" { + return usage("form ID is required") + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + form, err := svc.Forms.Get(formID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get form %s: %w", formID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, form) + } + + title := "" + if form.Info != nil { + title = form.Info.Title + } + u.Out().Printf("ID: %s\n", form.FormId) + u.Out().Printf("Title: %s\n", title) + if form.ResponderUri != "" { + u.Out().Printf("Responder URL: %s\n", form.ResponderUri) + } + return nil +} + +type FormsCreateCmd struct { + Title string `name:"title" help:"Form title" required:""` +} + +func (c *FormsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + title := strings.TrimSpace(c.Title) + if title == "" { + return usage("--title is required") + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + form := &forms.Form{Info: &forms.Info{Title: title}} + created, err := svc.Forms.Create(form).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create form: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created form: %s (%s)\n", created.FormId, title) + return nil +} + +type FormsResponsesCmd struct { + FormID string `arg:"" name:"form-id" help:"Form ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *FormsResponsesCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + formID := strings.TrimSpace(c.FormID) + if formID == "" { + return usage("form ID is required") + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + call := svc.Forms.Responses.List(formID) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list responses: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Responses) == 0 { + u.Err().Println("No responses found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "RESPONSE ID\tEMAIL\tCREATED\tLAST SUBMITTED") + for _, response := range resp.Responses { + if response == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(response.ResponseId), + sanitizeTab(response.RespondentEmail), + sanitizeTab(response.CreateTime), + sanitizeTab(response.LastSubmittedTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} diff --git a/internal/cmd/forms_test.go b/internal/cmd/forms_test.go new file mode 100644 index 00000000..3ed5d729 --- /dev/null +++ b/internal/cmd/forms_test.go @@ -0,0 +1,585 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/drive/v3" + "google.golang.org/api/forms/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestFormsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "f1", "name": "Survey", "createdTime": "2026-01-01T00:00:00Z", "owners": []map[string]any{{"emailAddress": "owner@example.com"}}}, + }, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Survey") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "f1", "name": "Survey", "createdTime": "2026-01-01T00:00:00Z", "owners": []map[string]any{{"emailAddress": "owner@example.com"}}}, + }, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "files") || !strings.Contains(out, "Survey") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestFormsListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{}, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsListCmd{} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestFormsListCmd_Pagination(t *testing.T) { + var gotPageToken, gotPageSize string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + gotPageToken = r.URL.Query().Get("pageToken") + gotPageSize = r.URL.Query().Get("pageSize") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "f1", "name": "Form 1", "createdTime": "2026-01-01T00:00:00Z", "owners": []map[string]any{{"emailAddress": "owner@example.com"}}}, + }, + "nextPageToken": "next-token", + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsListCmd{Max: 10, Page: "token123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageToken != "token123" { + t.Errorf("expected page token 'token123', got %q", gotPageToken) + } + if gotPageSize != "10" { + t.Errorf("expected page size '10', got %q", gotPageSize) + } + if !strings.Contains(out, "Form 1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsListCmd_WithUser(t *testing.T) { + var usedAccount string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "f1", "name": "User Form", "createdTime": "2026-01-01T00:00:00Z", "owners": []map[string]any{{"emailAddress": "user2@example.com"}}}, + }, + }) + }) + + srv := httptest.NewServer(h) + orig := newDriveService + svc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new drive service: %v", err) + } + newDriveService = func(_ context.Context, account string) (*drive.Service, error) { + usedAccount = account + return svc, nil + } + t.Cleanup(func() { + newDriveService = orig + srv.Close() + }) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &FormsListCmd{User: "user2@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if usedAccount != "user2@example.com" { + t.Errorf("expected account 'user2@example.com', got %q", usedAccount) + } + if !strings.Contains(out, "User Form") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/forms/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form-1", + "info": map[string]any{"title": "Test Form"}, + "responderUri": "https://docs.google.com/forms/d/e/form-1/viewform", + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsGetCmd{FormID: "form-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Test Form") || !strings.Contains(out, "ID:") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/forms/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form-1", + "info": map[string]any{"title": "Test Form"}, + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsGetCmd{FormID: "form-1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "formId") || !strings.Contains(out, "form-1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestFormsGetCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsGetCmd{FormID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty form ID") + } + if !strings.Contains(err.Error(), "form ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFormsGetCmd_NoTitle(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/forms/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form-1", + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsGetCmd{FormID: "form-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ID:") || !strings.Contains(out, "form-1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsCreateCmd(t *testing.T) { + var gotTitle string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v1/forms") { + http.NotFound(w, r) + return + } + var payload struct { + Info struct { + Title string `json:"title"` + } `json:"info"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotTitle = payload.Info.Title + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form-1", + "info": map[string]any{"title": payload.Info.Title}, + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsCreateCmd{Title: "Survey"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotTitle != "Survey" { + t.Fatalf("unexpected title: %q", gotTitle) + } + if !strings.Contains(out, "Created form") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v1/forms") { + http.NotFound(w, r) + return + } + var payload struct { + Info struct { + Title string `json:"title"` + } `json:"info"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form-1", + "info": map[string]any{"title": payload.Info.Title}, + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsCreateCmd{Title: "Survey"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "formId") || !strings.Contains(out, "form-1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestFormsCreateCmd_MissingTitle(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsCreateCmd{Title: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing title") + } + if !strings.Contains(err.Error(), "--title is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFormsCreateCmd_WhitespaceTitle(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsCreateCmd{Title: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for whitespace-only title") + } + if !strings.Contains(err.Error(), "--title is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFormsResponsesCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/responses") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responses": []map[string]any{ + { + "responseId": "resp-1", + "respondentEmail": "respondent@example.com", + "createTime": "2026-01-01T10:00:00Z", + "lastSubmittedTime": "2026-01-01T10:05:00Z", + }, + }, + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsResponsesCmd{FormID: "form-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "resp-1") || !strings.Contains(out, "respondent@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestFormsResponsesCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/responses") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responses": []map[string]any{ + { + "responseId": "resp-1", + "respondentEmail": "respondent@example.com", + "createTime": "2026-01-01T10:00:00Z", + "lastSubmittedTime": "2026-01-01T10:05:00Z", + }, + }, + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsResponsesCmd{FormID: "form-1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "responses") || !strings.Contains(out, "resp-1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestFormsResponsesCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/responses") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responses": []map[string]any{}, + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsResponsesCmd{FormID: "form-1"} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestFormsResponsesCmd_EmptyFormID(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsResponsesCmd{FormID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty form ID") + } + if !strings.Contains(err.Error(), "form ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFormsResponsesCmd_Pagination(t *testing.T) { + var gotPageToken, gotPageSize string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/responses") { + http.NotFound(w, r) + return + } + gotPageToken = r.URL.Query().Get("pageToken") + gotPageSize = r.URL.Query().Get("pageSize") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responses": []map[string]any{ + { + "responseId": "resp-1", + "respondentEmail": "user1@example.com", + "createTime": "2026-01-01T10:00:00Z", + "lastSubmittedTime": "2026-01-01T10:05:00Z", + }, + }, + "nextPageToken": "next-token", + }) + }) + stubForms(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &FormsResponsesCmd{FormID: "form-1", Max: 5, Page: "token-abc"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageToken != "token-abc" { + t.Errorf("expected page token 'token-abc', got %q", gotPageToken) + } + if gotPageSize != "5" { + t.Errorf("expected page size '5', got %q", gotPageSize) + } + if !strings.Contains(out, "resp-1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubForms(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newFormsService + svc, err := forms.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new forms service: %v", err) + } + newFormsService = func(context.Context, string) (*forms.Service, error) { return svc, nil } + t.Cleanup(func() { + newFormsService = orig + srv.Close() + }) +} + +func stubDrive(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newDriveService + svc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new drive service: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil } + t.Cleanup(func() { + newDriveService = orig + srv.Close() + }) +} diff --git a/internal/cmd/gmail_advanced_test.go b/internal/cmd/gmail_advanced_test.go new file mode 100644 index 00000000..6586abc1 --- /dev/null +++ b/internal/cmd/gmail_advanced_test.go @@ -0,0 +1,1474 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/ui" +) + +var errUnexpectedServiceCreation = errors.New("unexpected service creation") + +// ==================== gmail_attachments.go tests ==================== + +func TestAttachmentOutputFromInfo(t *testing.T) { + info := attachmentInfo{ + Filename: "test.pdf", + Size: 1024, + MimeType: "application/pdf", + AttachmentID: "att123", + } + out := attachmentOutputFromInfo(info) + + if out.Filename != "test.pdf" { + t.Fatalf("expected filename test.pdf, got %s", out.Filename) + } + if out.Size != 1024 { + t.Fatalf("expected size 1024, got %d", out.Size) + } + if out.SizeHuman != "1.0 KB" { + t.Fatalf("expected 1.0 KB, got %s", out.SizeHuman) + } + if out.MimeType != "application/pdf" { + t.Fatalf("expected application/pdf, got %s", out.MimeType) + } + if out.AttachmentID != "att123" { + t.Fatalf("expected att123, got %s", out.AttachmentID) + } +} + +func TestAttachmentOutputs_Empty(t *testing.T) { + result := attachmentOutputs(nil) + if result != nil { + t.Fatalf("expected nil for empty input, got %v", result) + } + + result = attachmentOutputs([]attachmentInfo{}) + if result != nil { + t.Fatalf("expected nil for empty slice, got %v", result) + } +} + +func TestAttachmentOutputs_Multiple(t *testing.T) { + infos := []attachmentInfo{ + {Filename: "a.txt", Size: 100, MimeType: "text/plain", AttachmentID: "a1"}, + {Filename: "b.pdf", Size: 2048, MimeType: "application/pdf", AttachmentID: "b2"}, + } + result := attachmentOutputs(infos) + + if len(result) != 2 { + t.Fatalf("expected 2 outputs, got %d", len(result)) + } + if result[0].Filename != "a.txt" || result[1].Filename != "b.pdf" { + t.Fatalf("unexpected filenames: %v", result) + } +} + +func TestAttachmentOutputsFromDownloads_Empty(t *testing.T) { + result := attachmentOutputsFromDownloads(nil) + if result != nil { + t.Fatalf("expected nil for empty input, got %v", result) + } + + result = attachmentOutputsFromDownloads([]attachmentDownloadOutput{}) + if result != nil { + t.Fatalf("expected nil for empty slice, got %v", result) + } +} + +func TestAttachmentOutputsFromDownloads_Multiple(t *testing.T) { + downloads := []attachmentDownloadOutput{ + { + MessageID: "m1", + attachmentOutput: attachmentOutput{ + Filename: "a.txt", + Size: 100, + SizeHuman: "100 B", + MimeType: "text/plain", + AttachmentID: "a1", + }, + Path: "/tmp/a.txt", + Cached: false, + }, + { + MessageID: "m2", + attachmentOutput: attachmentOutput{ + Filename: "b.pdf", + Size: 2048, + SizeHuman: "2.0 KB", + MimeType: "application/pdf", + AttachmentID: "b2", + }, + Path: "/tmp/b.pdf", + Cached: true, + }, + } + result := attachmentOutputsFromDownloads(downloads) + + if len(result) != 2 { + t.Fatalf("expected 2 outputs, got %d", len(result)) + } + if result[0].Filename != "a.txt" || result[1].Filename != "b.pdf" { + t.Fatalf("unexpected filenames: %v", result) + } + // Verify it extracts the embedded attachmentOutput correctly + if result[0].Size != 100 || result[1].Size != 2048 { + t.Fatalf("unexpected sizes: %v", result) + } +} + +func TestAttachmentDownloadOutputsFromInfo_Empty(t *testing.T) { + result := attachmentDownloadOutputsFromInfo("m1", nil) + if result != nil { + t.Fatalf("expected nil for empty input, got %v", result) + } + + result = attachmentDownloadOutputsFromInfo("m1", []attachmentInfo{}) + if result != nil { + t.Fatalf("expected nil for empty slice, got %v", result) + } +} + +func TestAttachmentDownloadOutputsFromInfo_Multiple(t *testing.T) { + infos := []attachmentInfo{ + {Filename: "a.txt", Size: 100, MimeType: "text/plain", AttachmentID: "a1"}, + {Filename: "b.pdf", Size: 2048, MimeType: "application/pdf", AttachmentID: "b2"}, + } + result := attachmentDownloadOutputsFromInfo("msg123", infos) + + if len(result) != 2 { + t.Fatalf("expected 2 outputs, got %d", len(result)) + } + if result[0].MessageID != "msg123" || result[1].MessageID != "msg123" { + t.Fatalf("expected message ID msg123, got %v", result) + } + if result[0].Filename != "a.txt" || result[1].Filename != "b.pdf" { + t.Fatalf("unexpected filenames: %v", result) + } +} + +func TestAttachmentDownloadSummaries_Empty(t *testing.T) { + result := attachmentDownloadSummaries(nil) + if result != nil { + t.Fatalf("expected nil for empty input, got %v", result) + } + + result = attachmentDownloadSummaries([]attachmentDownloadOutput{}) + if result != nil { + t.Fatalf("expected nil for empty slice, got %v", result) + } +} + +func TestAttachmentDownloadSummaries_Multiple(t *testing.T) { + downloads := []attachmentDownloadOutput{ + { + MessageID: "m1", + attachmentOutput: attachmentOutput{ + Filename: "a.txt", + Size: 100, + SizeHuman: "100 B", + MimeType: "text/plain", + AttachmentID: "a1", + }, + Path: "/tmp/a.txt", + Cached: false, + }, + { + MessageID: "m2", + attachmentOutput: attachmentOutput{ + Filename: "b.pdf", + Size: 2048, + SizeHuman: "2.0 KB", + MimeType: "application/pdf", + AttachmentID: "b2", + }, + Path: "/tmp/b.pdf", + Cached: true, + }, + } + result := attachmentDownloadSummaries(downloads) + + if len(result) != 2 { + t.Fatalf("expected 2 summaries, got %d", len(result)) + } + if result[0].MessageID != "m1" || result[1].MessageID != "m2" { + t.Fatalf("unexpected message IDs: %v", result) + } + if result[0].Path != "/tmp/a.txt" || result[1].Path != "/tmp/b.pdf" { + t.Fatalf("unexpected paths: %v", result) + } + if result[0].Cached != false || result[1].Cached != true { + t.Fatalf("unexpected cached flags: %v", result) + } +} + +func TestAttachmentDownloadDraftOutputs_Empty(t *testing.T) { + result := attachmentDownloadDraftOutputs(nil) + if result != nil { + t.Fatalf("expected nil for empty input, got %v", result) + } + + result = attachmentDownloadDraftOutputs([]attachmentDownloadOutput{}) + if result != nil { + t.Fatalf("expected nil for empty slice, got %v", result) + } +} + +func TestAttachmentDownloadDraftOutputs_Multiple(t *testing.T) { + downloads := []attachmentDownloadOutput{ + { + MessageID: "m1", + attachmentOutput: attachmentOutput{ + Filename: "a.txt", + Size: 100, + SizeHuman: "100 B", + MimeType: "text/plain", + AttachmentID: "a1", + }, + Path: "/tmp/a.txt", + Cached: false, + }, + { + MessageID: "m2", + attachmentOutput: attachmentOutput{ + Filename: "b.pdf", + Size: 2048, + SizeHuman: "2.0 KB", + MimeType: "application/pdf", + AttachmentID: "b2", + }, + Path: "/tmp/b.pdf", + Cached: true, + }, + } + result := attachmentDownloadDraftOutputs(downloads) + + if len(result) != 2 { + t.Fatalf("expected 2 draft outputs, got %d", len(result)) + } + if result[0].MessageID != "m1" || result[1].MessageID != "m2" { + t.Fatalf("unexpected message IDs: %v", result) + } + if result[0].Filename != "a.txt" || result[1].Filename != "b.pdf" { + t.Fatalf("unexpected filenames: %v", result) + } +} + +func TestAttachmentLine_Advanced(t *testing.T) { + out := attachmentOutput{ + Filename: "test.pdf", + SizeHuman: "1.0 KB", + MimeType: "application/pdf", + AttachmentID: "att123", + } + line := attachmentLine(out) + expected := "attachment\ttest.pdf\t1.0 KB\tapplication/pdf\tatt123" + if line != expected { + t.Fatalf("expected %q, got %q", expected, line) + } +} + +func TestFormatBytes_EdgeCases(t *testing.T) { + tests := []struct { + bytes int64 + expected string + }{ + {0, "0 B"}, + {1023, "1023 B"}, + {1536, "1.5 KB"}, + {1024*1024*1024 + 512*1024*1024, "1.5 GB"}, + } + + for _, tt := range tests { + result := formatBytes(tt.bytes) + if result != tt.expected { + t.Errorf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected) + } + } +} + +func TestCollectAttachments_Nil(t *testing.T) { + result := collectAttachments(nil) + if result != nil { + t.Fatalf("expected nil for nil input, got %v", result) + } +} + +func TestCollectAttachments_NoAttachments(t *testing.T) { + part := &gmail.MessagePart{ + MimeType: "text/plain", + Body: &gmail.MessagePartBody{Data: "SGVsbG8="}, + } + result := collectAttachments(part) + if len(result) != 0 { + t.Fatalf("expected 0 attachments, got %d", len(result)) + } +} + +func TestCollectAttachments_SingleAttachment(t *testing.T) { + part := &gmail.MessagePart{ + MimeType: "application/pdf", + Filename: "document.pdf", + Body: &gmail.MessagePartBody{ + AttachmentId: "att123", + Size: 1024, + }, + } + result := collectAttachments(part) + if len(result) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(result)) + } + if result[0].Filename != "document.pdf" { + t.Fatalf("expected document.pdf, got %s", result[0].Filename) + } + if result[0].AttachmentID != "att123" { + t.Fatalf("expected att123, got %s", result[0].AttachmentID) + } +} + +func TestCollectAttachments_EmptyFilename(t *testing.T) { + part := &gmail.MessagePart{ + MimeType: "application/pdf", + Filename: "", + Body: &gmail.MessagePartBody{ + AttachmentId: "att123", + Size: 1024, + }, + } + result := collectAttachments(part) + if len(result) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(result)) + } + if result[0].Filename != "attachment" { + t.Fatalf("expected default 'attachment', got %s", result[0].Filename) + } +} + +func TestCollectAttachments_WhitespaceFilename(t *testing.T) { + part := &gmail.MessagePart{ + MimeType: "application/pdf", + Filename: " ", + Body: &gmail.MessagePartBody{ + AttachmentId: "att123", + Size: 1024, + }, + } + result := collectAttachments(part) + if len(result) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(result)) + } + if result[0].Filename != "attachment" { + t.Fatalf("expected default 'attachment', got %s", result[0].Filename) + } +} + +func TestCollectAttachments_NestedParts(t *testing.T) { + part := &gmail.MessagePart{ + MimeType: "multipart/mixed", + Parts: []*gmail.MessagePart{ + { + MimeType: "text/plain", + Body: &gmail.MessagePartBody{Data: "SGVsbG8="}, + }, + { + MimeType: "application/pdf", + Filename: "doc1.pdf", + Body: &gmail.MessagePartBody{ + AttachmentId: "att1", + Size: 1024, + }, + }, + { + MimeType: "multipart/alternative", + Parts: []*gmail.MessagePart{ + { + MimeType: "image/png", + Filename: "image.png", + Body: &gmail.MessagePartBody{ + AttachmentId: "att2", + Size: 2048, + }, + }, + }, + }, + }, + } + result := collectAttachments(part) + if len(result) != 2 { + t.Fatalf("expected 2 attachments, got %d", len(result)) + } + filenames := make(map[string]bool) + for _, a := range result { + filenames[a.Filename] = true + } + if !filenames["doc1.pdf"] || !filenames["image.png"] { + t.Fatalf("expected doc1.pdf and image.png, got %v", result) + } +} + +// ==================== gmail.go helper tests ==================== + +func TestHasHeaderName(t *testing.T) { + headers := []string{"From", "To", "Subject"} + + if !hasHeaderName(headers, "from") { + t.Fatalf("expected to find 'from'") + } + if !hasHeaderName(headers, "FROM") { + t.Fatalf("expected to find 'FROM' (case insensitive)") + } + if !hasHeaderName(headers, "To") { + t.Fatalf("expected to find 'To'") + } + if hasHeaderName(headers, "Bcc") { + t.Fatalf("expected not to find 'Bcc'") + } + if hasHeaderName(nil, "From") { + t.Fatalf("expected not to find in nil slice") + } + if hasHeaderName([]string{}, "From") { + t.Fatalf("expected not to find in empty slice") + } + // Test with whitespace + if !hasHeaderName([]string{" From "}, "From") { + t.Fatalf("expected to find 'From' with trimmed whitespace") + } +} + +func TestParseListUnsubscribe_Empty(t *testing.T) { + result := parseListUnsubscribe("") + if result != nil { + t.Fatalf("expected nil for empty string, got %v", result) + } + + result = parseListUnsubscribe(" ") + if result != nil { + t.Fatalf("expected nil for whitespace-only string, got %v", result) + } +} + +func TestParseListUnsubscribe_SingleHTTPS(t *testing.T) { + result := parseListUnsubscribe("") + if len(result) != 1 || result[0] != "https://example.com/unsub" { + t.Fatalf("expected single HTTPS link, got %v", result) + } +} + +func TestParseListUnsubscribe_SingleMailto(t *testing.T) { + result := parseListUnsubscribe("") + if len(result) != 1 || result[0] != "mailto:unsub@example.com" { + t.Fatalf("expected single mailto link, got %v", result) + } +} + +func TestParseListUnsubscribe_Multiple(t *testing.T) { + result := parseListUnsubscribe(", ") + if len(result) != 2 { + t.Fatalf("expected 2 links, got %d", len(result)) + } +} + +func TestParseListUnsubscribe_NoBrackets(t *testing.T) { + result := parseListUnsubscribe("https://example.com/unsub, mailto:unsub@example.com") + if len(result) != 2 { + t.Fatalf("expected 2 links, got %d: %v", len(result), result) + } +} + +func TestParseListUnsubscribe_InvalidLinks(t *testing.T) { + result := parseListUnsubscribe("not a link, also not a link") + if len(result) != 0 { + t.Fatalf("expected no valid links, got %v", result) + } +} + +func TestParseListUnsubscribe_Deduplication(t *testing.T) { + result := parseListUnsubscribe(", ") + if len(result) != 1 { + t.Fatalf("expected 1 deduplicated link, got %d", len(result)) + } +} + +func TestIsUnsubscribeLink(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"https://example.com/unsub", true}, + {"http://example.com/unsub", true}, + {"mailto:unsub@example.com", true}, + {"HTTPS://EXAMPLE.COM/UNSUB", true}, + {"HTTP://EXAMPLE.COM/UNSUB", true}, + {"MAILTO:UNSUB@EXAMPLE.COM", true}, + {"ftp://example.com/unsub", false}, + {"not a link", false}, + {"", false}, + {" ", false}, + } + + for _, tt := range tests { + result := isUnsubscribeLink(tt.input) + if result != tt.expected { + t.Errorf("isUnsubscribeLink(%q) = %v, want %v", tt.input, result, tt.expected) + } + } +} + +// ==================== gmail_thread.go helper tests ==================== + +func TestStripHTMLTags_Advanced(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"

Hello

", "Hello"}, + {"

Hello

", "Hello"}, + {"Hello", "Hello"}, + {"Hello", "Hello"}, + {"Hello World", "Hello World"}, + {"Link", "Link"}, + {"", ""}, + } + + for _, tt := range tests { + result := stripHTMLTags(tt.input) + if result != tt.expected { + t.Errorf("stripHTMLTags(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestBestBodyText_Nil(t *testing.T) { + result := bestBodyText(nil) + if result != "" { + t.Fatalf("expected empty string for nil, got %q", result) + } +} + +func TestBestBodyForDisplay_Nil(t *testing.T) { + body, isHTML := bestBodyForDisplay(nil) + if body != "" || isHTML { + t.Fatalf("expected empty string and false for nil, got %q, %v", body, isHTML) + } +} + +func TestMimeTypeMatches_Advanced(t *testing.T) { + tests := []struct { + partType string + want string + expected bool + }{ + {"text/plain", "text/plain", true}, + {"TEXT/PLAIN", "text/plain", true}, + {"text/plain; charset=utf-8", "text/plain", true}, + {"text/html", "text/plain", false}, + {"", "", true}, + {"", "text/plain", false}, + } + + for _, tt := range tests { + result := mimeTypeMatches(tt.partType, tt.want) + if result != tt.expected { + t.Errorf("mimeTypeMatches(%q, %q) = %v, want %v", tt.partType, tt.want, result, tt.expected) + } + } +} + +func TestNormalizeMimeType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"text/plain", "text/plain"}, + {"TEXT/PLAIN", "text/plain"}, + {"text/plain; charset=utf-8", "text/plain"}, + {"text/html;charset=ISO-8859-1", "text/html"}, + {"", ""}, + {" text/plain ", "text/plain"}, + } + + for _, tt := range tests { + result := normalizeMimeType(tt.input) + if result != tt.expected { + t.Errorf("normalizeMimeType(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestLooksLikeHTML(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"", true}, + {"", true}, + {"", true}, + {"", true}, + {"", true}, + {"", true}, + {"", true}, + {"Hello World", false}, + {"

Hello

", false}, // Not a clear HTML document marker + {"", false}, + {" ", false}, + {"Some text with embedded", true}, + } + + for _, tt := range tests { + result := looksLikeHTML(tt.input) + if result != tt.expected { + t.Errorf("looksLikeHTML(%q) = %v, want %v", tt.input, result, tt.expected) + } + } +} + +func TestLooksLikeBase64(t *testing.T) { + tests := []struct { + input []byte + expected bool + }{ + {[]byte("SGVsbG8gV29ybGQ="), true}, // "Hello World" base64 encoded + {[]byte("SGVsbG8gV29ybGQ"), true}, // Without padding + {[]byte("SGVs bG8g V29y bGQ="), true}, // With spaces + {[]byte("SGVs\nbG8g\nV29y\nbGQ="), true}, // With newlines + {[]byte("!@#$%^&*()"), false}, // Special characters + {[]byte("Hello World with special chars!"), false}, // Regular text + {[]byte(""), false}, + {[]byte(" "), false}, + } + + for _, tt := range tests { + result := looksLikeBase64(tt.input) + if result != tt.expected { + t.Errorf("looksLikeBase64(%q) = %v, want %v", string(tt.input), result, tt.expected) + } + } +} + +func TestDecodeAnyBase64(t *testing.T) { + input := "SGVsbG8gV29ybGQ=" // "Hello World" in standard base64 + result, err := decodeAnyBase64([]byte(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result) != "Hello World" { + t.Fatalf("expected 'Hello World', got %q", string(result)) + } + + // Test raw URL encoding + inputURLRaw := "SGVsbG8gV29ybGQ" // Without padding + result, err = decodeAnyBase64([]byte(inputURLRaw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result) != "Hello World" { + t.Fatalf("expected 'Hello World', got %q", string(result)) + } +} + +func TestStripBase64Whitespace(t *testing.T) { + input := []byte("SGVs bG8g\nV29y\tbGQ=") + expected := []byte("SGVsbG8gV29ybGQ=") + result := stripBase64Whitespace(input) + if string(result) != string(expected) { + t.Fatalf("expected %q, got %q", string(expected), string(result)) + } +} + +func TestDecodeBase64URL_Advanced(t *testing.T) { + // Test standard base64 URL encoding + input := base64.RawURLEncoding.EncodeToString([]byte("Hello World")) + result, err := decodeBase64URL(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "Hello World" { + t.Fatalf("expected 'Hello World', got %q", result) + } +} + +func TestDecodeBase64URLBytes(t *testing.T) { + // Test with padding + input := base64.URLEncoding.EncodeToString([]byte("Hello World")) + result, err := decodeBase64URLBytes(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result) != "Hello World" { + t.Fatalf("expected 'Hello World', got %q", string(result)) + } + + // Test without padding + inputRaw := base64.RawURLEncoding.EncodeToString([]byte("Hello World")) + result, err = decodeBase64URLBytes(inputRaw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result) != "Hello World" { + t.Fatalf("expected 'Hello World', got %q", string(result)) + } +} + +// ==================== Command execution tests ==================== + +func TestGmailThreadGetCmd_EmptyThreadID(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + // No server needed since validation happens before API call + newGmailService = func(context.Context, string) (*gmail.Service, error) { + t.Fatalf("should not create service for empty thread ID") + return nil, errUnexpectedServiceCreation + } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailThreadGetCmd{ThreadID: ""} + err := runKong(t, cmd, []string{""}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "empty threadId") { + t.Fatalf("expected empty threadId error, got: %v", err) + } +} + +func TestGmailThreadModifyCmd_EmptyThreadID(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + newGmailService = func(context.Context, string) (*gmail.Service, error) { + t.Fatalf("should not create service for empty thread ID") + return nil, errUnexpectedServiceCreation + } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailThreadModifyCmd{ThreadID: "", Add: "INBOX"} + err := runKong(t, cmd, []string{"", "--add", "INBOX"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "empty threadId") { + t.Fatalf("expected empty threadId error, got: %v", err) + } +} + +func TestGmailThreadModifyCmd_NoLabels(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + newGmailService = func(context.Context, string) (*gmail.Service, error) { + t.Fatalf("should not create service when no labels specified") + return nil, errUnexpectedServiceCreation + } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailThreadModifyCmd{ThreadID: "t1"} + err := runKong(t, cmd, []string{"t1"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "--add and/or --remove") { + t.Fatalf("expected add/remove error, got: %v", err) + } +} + +func TestGmailThreadAttachmentsCmd_EmptyThreadID(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + newGmailService = func(context.Context, string) (*gmail.Service, error) { + t.Fatalf("should not create service for empty thread ID") + return nil, errUnexpectedServiceCreation + } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailThreadAttachmentsCmd{ThreadID: ""} + err := runKong(t, cmd, []string{""}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "empty threadId") { + t.Fatalf("expected empty threadId error, got: %v", err) + } +} + +func TestGmailAttachmentCmd_MissingArgs(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + newGmailService = func(context.Context, string) (*gmail.Service, error) { + t.Fatalf("should not create service for missing args") + return nil, errUnexpectedServiceCreation + } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailAttachmentCmd{MessageID: "", AttachmentID: ""} + err := runKong(t, cmd, []string{"", ""}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "messageId/attachmentId required") { + t.Fatalf("expected required args error, got: %v", err) + } +} + +func TestGmailURLCmd_JSON_MultipleThreads(t *testing.T) { + flags := &RootFlags{Account: "test@example.com"} + + out := captureStdout(t, func() { + ctx := testContextJSON(t) + cmd := &GmailURLCmd{ThreadIDs: []string{"t1", "t2"}} + if err := runKong(t, cmd, []string{"t1", "t2"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + var parsed struct { + URLs []struct { + ID string `json:"id"` + URL string `json:"url"` + } `json:"urls"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if len(parsed.URLs) != 2 { + t.Fatalf("expected 2 URLs, got %d", len(parsed.URLs)) + } + if parsed.URLs[0].ID != "t1" || parsed.URLs[1].ID != "t2" { + t.Fatalf("unexpected IDs: %v", parsed.URLs) + } + if !strings.Contains(parsed.URLs[0].URL, "test%40example.com") { + t.Fatalf("expected URL-encoded account in URL, got %s", parsed.URLs[0].URL) + } +} + +func TestGmailURLCmd_Text_Encoded(t *testing.T) { + flags := &RootFlags{Account: "test@example.com"} + + out := captureStdout(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailURLCmd{ThreadIDs: []string{"t1"}} + if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + if !strings.Contains(out, "t1") || !strings.Contains(out, "mail.google.com") { + t.Fatalf("unexpected text output: %q", out) + } +} + +func TestGmailThreadAttachmentsCmd_EmptyThread_JSON(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []any{}, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + ctx := testContextJSON(t) + cmd := &GmailThreadAttachmentsCmd{} + if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + var parsed struct { + ThreadID string `json:"threadId"` + Attachments []any `json:"attachments"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if parsed.ThreadID != "t1" { + t.Fatalf("expected threadId t1, got %s", parsed.ThreadID) + } + if len(parsed.Attachments) != 0 { + t.Fatalf("expected 0 attachments, got %d", len(parsed.Attachments)) + } +} + +func TestGmailThreadAttachmentsCmd_WithAttachments_JSON(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []map[string]any{ + { + "id": "m1", + "threadId": "t1", + "payload": map[string]any{ + "mimeType": "multipart/mixed", + "parts": []map[string]any{ + { + "mimeType": "text/plain", + "body": map[string]any{"data": "SGVsbG8="}, + }, + { + "mimeType": "application/pdf", + "filename": "test.pdf", + "body": map[string]any{ + "attachmentId": "att1", + "size": 1024, + }, + }, + }, + }, + }, + }, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + ctx := testContextJSON(t) + cmd := &GmailThreadAttachmentsCmd{} + if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + var parsed struct { + ThreadID string `json:"threadId"` + Attachments []struct { + MessageID string `json:"messageId"` + Filename string `json:"filename"` + Size int64 `json:"size"` + AttachmentID string `json:"attachmentId"` + } `json:"attachments"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if parsed.ThreadID != "t1" { + t.Fatalf("expected threadId t1, got %s", parsed.ThreadID) + } + if len(parsed.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments)) + } + if parsed.Attachments[0].Filename != "test.pdf" { + t.Fatalf("expected filename test.pdf, got %s", parsed.Attachments[0].Filename) + } +} + +func TestGmailThreadAttachmentsCmd_Download_JSON(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + attachmentData := []byte("test data") + attachmentEncoded := base64.RawURLEncoding.EncodeToString(attachmentData) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []map[string]any{ + { + "id": "m1", + "threadId": "t1", + "payload": map[string]any{ + "mimeType": "multipart/mixed", + "parts": []map[string]any{ + { + "mimeType": "application/pdf", + "filename": "download.pdf", + "body": map[string]any{ + "attachmentId": "att1", + "size": int64(len(attachmentData)), + }, + }, + }, + }, + }, + }, + }) + return + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1/attachments/att1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": attachmentEncoded}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + outDir := t.TempDir() + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + ctx := testContextJSON(t) + cmd := &GmailThreadAttachmentsCmd{Download: true, OutputDir: OutputDirFlag{Dir: outDir}} + if runErr := runKong(t, cmd, []string{"t1", "--download", "--out-dir", outDir}, ctx, flags); runErr != nil { + t.Fatalf("execute: %v", runErr) + } + }) + + var parsed struct { + ThreadID string `json:"threadId"` + Attachments []struct { + Path string `json:"path"` + Cached bool `json:"cached"` + } `json:"attachments"` + } + if err = json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if len(parsed.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments)) + } + if !strings.Contains(parsed.Attachments[0].Path, outDir) { + t.Fatalf("expected path in %s, got %s", outDir, parsed.Attachments[0].Path) + } + + // Verify file was written + data, err := os.ReadFile(parsed.Attachments[0].Path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != string(attachmentData) { + t.Fatalf("expected %q, got %q", string(attachmentData), string(data)) + } +} + +func TestGmailThreadGetCmd_JSON_EmptyThread(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []any{}, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + ctx := testContextJSON(t) + cmd := &GmailThreadGetCmd{} + if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + var parsed struct { + Thread struct { + ID string `json:"id"` + } `json:"thread"` + Downloaded []any `json:"downloaded"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if parsed.Thread.ID != "t1" { + t.Fatalf("expected thread id t1, got %s", parsed.Thread.ID) + } +} + +func TestGmailThreadGetCmd_Text_WithMessages(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + bodyData := base64.RawURLEncoding.EncodeToString([]byte("Hello World")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []map[string]any{ + { + "id": "m1", + "threadId": "t1", + "payload": map[string]any{ + "mimeType": "text/plain", + "headers": []map[string]any{ + {"name": "From", "value": "sender@example.com"}, + {"name": "To", "value": "recipient@example.com"}, + {"name": "Subject", "value": "Test Subject"}, + {"name": "Date", "value": "Mon, 02 Jan 2006 15:04:05 -0700"}, + }, + "body": map[string]any{"data": bodyData}, + }, + }, + }, + }) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailThreadGetCmd{} + if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + if !strings.Contains(out, "sender@example.com") { + t.Fatalf("expected From in output: %q", out) + } + if !strings.Contains(out, "Test Subject") { + t.Fatalf("expected Subject in output: %q", out) + } + if !strings.Contains(out, "Hello World") { + t.Fatalf("expected body in output: %q", out) + } +} + +func TestGmailThreadModifyCmd_Success_JSON(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/labels") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{ + {"id": "INBOX", "name": "INBOX"}, + {"id": "Label_123", "name": "MyLabel"}, + {"id": "Label_456", "name": "OldLabel"}, + }, + }) + return + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1/modify") && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + ctx := testContextJSON(t) + cmd := &GmailThreadModifyCmd{} + if err := runKong(t, cmd, []string{"t1", "--add", "MyLabel", "--remove", "OldLabel"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + var parsed struct { + Modified string `json:"modified"` + AddedLabels []string `json:"addedLabels"` + RemovedLabels []string `json:"removedLabels"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if parsed.Modified != "t1" { + t.Fatalf("expected modified t1, got %s", parsed.Modified) + } +} + +// ==================== downloadAttachment tests ==================== + +func TestDownloadAttachment_MissingMessageID(t *testing.T) { + _, _, err := downloadAttachment(context.Background(), nil, "", attachmentInfo{AttachmentID: "a1"}, ".") + if err == nil || !strings.Contains(err.Error(), "missing messageID/attachmentID") { + t.Fatalf("expected missing messageID error, got: %v", err) + } +} + +func TestDownloadAttachment_MissingAttachmentID(t *testing.T) { + _, _, err := downloadAttachment(context.Background(), nil, "m1", attachmentInfo{AttachmentID: ""}, ".") + if err == nil || !strings.Contains(err.Error(), "missing messageID/attachmentID") { + t.Fatalf("expected missing attachmentID error, got: %v", err) + } +} + +func TestDownloadAttachment_DefaultDir(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + attachmentData := []byte("test attachment data") + attachmentEncoded := base64.RawURLEncoding.EncodeToString(attachmentData) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1/attachments/att1") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": attachmentEncoded}) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + // Change to temp dir so "." works correctly + origDir, _ := os.Getwd() + tempDir := t.TempDir() + _ = os.Chdir(tempDir) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + info := attachmentInfo{ + Filename: "test.txt", + AttachmentID: "att1", + Size: int64(len(attachmentData)), + } + path, cached, err := downloadAttachment(context.Background(), svc, "m1", info, "") + if err != nil { + t.Fatalf("downloadAttachment: %v", err) + } + if cached { + t.Fatalf("expected not cached") + } + if !strings.Contains(path, "test.txt") { + t.Fatalf("expected test.txt in path, got %s", path) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != string(attachmentData) { + t.Fatalf("expected %q, got %q", string(attachmentData), string(data)) + } +} + +func TestDownloadAttachment_PathTraversalPrevention(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + attachmentData := []byte("test") + attachmentEncoded := base64.RawURLEncoding.EncodeToString(attachmentData) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1/attachments/att1") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": attachmentEncoded}) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + tempDir := t.TempDir() + + // Test path traversal attack filename + info := attachmentInfo{ + Filename: "../../../etc/passwd", + AttachmentID: "att1", + Size: int64(len(attachmentData)), + } + path, _, err := downloadAttachment(context.Background(), svc, "m1", info, tempDir) + if err != nil { + t.Fatalf("downloadAttachment: %v", err) + } + // Verify the file was saved in the expected directory, not traversed + if !strings.HasPrefix(path, tempDir) { + t.Fatalf("expected path to be within %s, got %s", tempDir, path) + } + if strings.Contains(path, "..") { + t.Fatalf("path should not contain path traversal: %s", path) + } +} + +func TestDownloadAttachment_InvalidFilenames(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + attachmentData := []byte("test") + attachmentEncoded := base64.RawURLEncoding.EncodeToString(attachmentData) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1/attachments/att1") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": attachmentEncoded}) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + tempDir := t.TempDir() + + tests := []struct { + filename string + expectedInPath string + }{ + {".", "attachment"}, + {"..", "attachment"}, + {"", "attachment"}, + } + + for _, tt := range tests { + info := attachmentInfo{ + Filename: tt.filename, + AttachmentID: "att1", + Size: int64(len(attachmentData)), + } + path, _, err := downloadAttachment(context.Background(), svc, "m1", info, tempDir) + if err != nil { + t.Fatalf("downloadAttachment for %q: %v", tt.filename, err) + } + if !strings.Contains(filepath.Base(path), tt.expectedInPath) { + t.Fatalf("expected %q in path for filename %q, got %s", tt.expectedInPath, tt.filename, path) + } + } +} diff --git a/internal/cmd/gmail_attachments.go b/internal/cmd/gmail_attachments.go index ac003cb9..874124cd 100644 --- a/internal/cmd/gmail_attachments.go +++ b/internal/cmd/gmail_attachments.go @@ -10,6 +10,8 @@ import ( "github.com/steipete/gogcli/internal/ui" ) +const defaultAttachmentName = "attachment" + type attachmentInfo struct { Filename string Size int64 @@ -181,7 +183,7 @@ func collectAttachments(p *gmail.MessagePart) []attachmentInfo { if p.Body != nil && p.Body.AttachmentId != "" { filename := p.Filename if strings.TrimSpace(filename) == "" { - filename = "attachment" + filename = defaultAttachmentName } out = append(out, attachmentInfo{ Filename: filename, diff --git a/internal/cmd/gmail_history.go b/internal/cmd/gmail_history.go index 3336cae1..6cd42b42 100644 --- a/internal/cmd/gmail_history.go +++ b/internal/cmd/gmail_history.go @@ -44,7 +44,9 @@ func (c *GmailHistoryCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - ids := collectHistoryMessageIDs(resp) + historyIDs := collectHistoryMessageIDs(resp) + // Since this command only requests messageAdded, FetchIDs contains all relevant IDs + ids := historyIDs.FetchIDs if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{ "historyId": formatHistoryID(resp.HistoryId), diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 053ba1af..dc7681d1 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -4,8 +4,10 @@ import ( "context" "encoding/base64" "fmt" + "html" "net/mail" "os" + "regexp" "strings" "google.golang.org/api/gmail/v1" @@ -16,6 +18,9 @@ import ( "github.com/steipete/gogcli/internal/ui" ) +// maxSignatureFileSize is the maximum allowed size for a --signature-file (1 MB). +const maxSignatureFileSize = 1 << 20 + type GmailSendCmd struct { To string `name:"to" help:"Recipients (comma-separated; required unless --reply-all is used)"` Cc string `name:"cc" help:"CC recipients (comma-separated)"` @@ -30,6 +35,9 @@ type GmailSendCmd struct { ReplyTo string `name:"reply-to" help:"Reply-To header address"` Attach []string `name:"attach" help:"Attachment file path (repeatable)"` From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"` + Signature bool `name:"signature" help:"Append the default Gmail signature to the message body"` + SignatureName string `name:"signature-name" help:"Append signature from this send-as alias (uses Gmail signature HTML)"` + SignatureFile string `name:"signature-file" help:"Append signature from a local file path"` Track bool `name:"track" help:"Enable open tracking (requires tracking setup)"` TrackSplit bool `name:"track-split" help:"Send tracked messages separately per recipient"` } @@ -60,6 +68,11 @@ type sendMessageOptions struct { TrackingCfg *tracking.Config } +type signatureContent struct { + Plain string + HTML string +} + func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) account, err := requireAccount(flags) @@ -98,6 +111,10 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { return usage("--track-split requires --track") } + if err = validateSignatureFlags(c.Signature, c.SignatureName, c.SignatureFile); err != nil { + return err + } + svc, err := newGmailService(ctx, account) if err != nil { return err @@ -132,6 +149,22 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { // If lookup fails, we just use the plain email address (no error) } + bodyHTML := c.BodyHTML + if c.Signature || strings.TrimSpace(c.SignatureName) != "" || strings.TrimSpace(c.SignatureFile) != "" { + sig, sigErr := c.resolveSignature(ctx, svc, u, sendingEmail) + if sigErr != nil { + return sigErr + } + if sig != nil { + if strings.TrimSpace(body) != "" { + body = appendSignaturePlain(body, sig.Plain) + } + if strings.TrimSpace(bodyHTML) != "" { + bodyHTML = appendSignatureHTML(bodyHTML, sig.HTML) + } + } + } + // Fetch reply info (includes recipient headers for reply-all) replyInfo, err := fetchReplyInfo(ctx, svc, replyToMessageID, threadID) if err != nil { @@ -183,7 +216,7 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { ReplyTo: c.ReplyTo, Subject: c.Subject, Body: body, - BodyHTML: c.BodyHTML, + BodyHTML: bodyHTML, ReplyInfo: replyInfo, Attachments: atts, Track: c.Track, @@ -217,6 +250,81 @@ func (c *GmailSendCmd) resolveTrackingConfig(account string, toRecipients, ccRec return trackingCfg, nil } +func validateSignatureFlags(signature bool, signatureName, signatureFile string) error { + count := 0 + if signature { + count++ + } + if strings.TrimSpace(signatureName) != "" { + count++ + } + if strings.TrimSpace(signatureFile) != "" { + count++ + } + if count > 1 { + return usage("use only one of --signature, --signature-name, or --signature-file") + } + return nil +} + +func (c *GmailSendCmd) resolveSignature(ctx context.Context, svc *gmail.Service, u *ui.UI, sendingEmail string) (*signatureContent, error) { + sigName := strings.TrimSpace(c.SignatureName) + sigFile := strings.TrimSpace(c.SignatureFile) + switch { + case sigFile != "": + path, err := config.ExpandPath(sigFile) + if err != nil { + return nil, err + } + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("signature file not found: %s", path) + } + return nil, fmt.Errorf("stat signature file %q: %w", path, err) + } + if info.Size() > maxSignatureFileSize { + return nil, fmt.Errorf("signature file too large (%d bytes, max %d)", info.Size(), maxSignatureFileSize) + } + b, err := os.ReadFile(path) //nolint:gosec // user-provided path + if err != nil { + return nil, fmt.Errorf("read signature file %q: %w", path, err) + } + content := string(b) + if strings.TrimSpace(content) == "" { + u.Err().Printf("Warning: signature file is empty (%s)", path) + } + if isLikelyHTML(content) { + return &signatureContent{ + Plain: signatureHTMLToPlain(content), + HTML: content, + }, nil + } + return &signatureContent{ + Plain: content, + HTML: plainTextToHTML(content), + }, nil + case sigName != "" || c.Signature: + sendAsEmail := sendingEmail + if sigName != "" { + sendAsEmail = sigName + } + sa, err := svc.Users.Settings.SendAs.Get("me", sendAsEmail).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("fetch signature for %q: %w", sendAsEmail, err) + } + if strings.TrimSpace(sa.Signature) == "" { + u.Err().Printf("Warning: signature not set for %s", sendAsEmail) + } + return &signatureContent{ + Plain: signatureHTMLToPlain(sa.Signature), + HTML: sa.Signature, + }, nil + default: + return nil, nil //nolint:nilnil // nil result + nil error means no signature was requested + } +} + func buildSendBatches(toRecipients, ccRecipients, bccRecipients []string, track, trackSplit bool) []sendBatch { totalRecipients := len(toRecipients) + len(ccRecipients) + len(bccRecipients) if track && trackSplit && totalRecipients > 1 { @@ -243,6 +351,94 @@ func buildSendBatches(toRecipients, ccRecipients, bccRecipients []string, track, }} } +func appendSignaturePlain(body, signature string) string { + if strings.TrimSpace(signature) == "" { + return body + } + return body + "\n\n--\n" + signature +} + +func appendSignatureHTML(bodyHTML, signatureHTML string) string { + if strings.TrimSpace(signatureHTML) == "" { + return bodyHTML + } + wrapped := `
` + signatureHTML + `
` + return bodyHTML + "\n\n" + wrapped +} + +// sigAnchorRe matches text and captures href and link text. +var sigAnchorRe = regexp.MustCompile(`(?i)]*href=["']([^"']+)["'][^>]*>(.*?)`) + +// sigMultiNewlineRe collapses three or more consecutive newlines to two. +var sigMultiNewlineRe = regexp.MustCompile(`\n{3,}`) + +func signatureHTMLToPlain(signatureHTML string) string { + if strings.TrimSpace(signatureHTML) == "" { + return "" + } + + s := signatureHTML + + // Convert text to "text (URL)" before stripping tags. + s = sigAnchorRe.ReplaceAllStringFunc(s, func(match string) string { + sub := sigAnchorRe.FindStringSubmatch(match) + if len(sub) < 3 { + return match + } + href := sub[1] + text := strings.TrimSpace(stripHTML(sub[2])) + if text == "" { + return href + } + if text == href { + return href + } + return text + " (" + href + ")" + }) + + // Convert block-closing tags to newlines. + for _, tag := range []string{"

", "", ""} { + s = strings.ReplaceAll(s, tag, "\n") + s = strings.ReplaceAll(s, strings.ToUpper(tag), "\n") + } + + // Convert
variants to newlines. + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + + // Strip remaining HTML tags. + s = stripHTML(s) + + // Collapse runs of 3+ newlines to 2. + s = sigMultiNewlineRe.ReplaceAllString(s, "\n\n") + + // Trim trailing whitespace from each line, then trim the whole string. + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + s = strings.Join(lines, "\n") + + return strings.TrimSpace(s) +} + +// htmlTagRe matches an opening HTML tag like
,
, . +var htmlTagRe = regexp.MustCompile(`<[a-zA-Z][^>]*>`) + +// isLikelyHTML reports whether s appears to contain HTML markup. +func isLikelyHTML(s string) bool { + return htmlTagRe.MatchString(s) +} + +// plainTextToHTML converts plain text to a simple HTML representation. +// It escapes HTML entities and converts newlines to
tags. +func plainTextToHTML(s string) string { + s = html.EscapeString(s) + s = strings.ReplaceAll(s, "\n", "
\n") + return s +} + func sendGmailBatches(ctx context.Context, svc *gmail.Service, opts sendMessageOptions, batches []sendBatch) ([]sendResult, error) { reply := replyInfo{} if opts.ReplyInfo != nil { diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go new file mode 100644 index 00000000..b67bdee3 --- /dev/null +++ b/internal/cmd/gmail_send_signature_test.go @@ -0,0 +1,704 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/ui" +) + +func TestGmailSendCmd_SignaturePlain(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotRaw string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") + switch { + case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": "a@b.com", + "signature": "
Sig
", + "verificationStatus": "accepted", + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + var payload struct { + Raw string `json:"raw"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + gotRaw = string(decoded) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailSendCmd{ + To: "a@example.com", + Subject: "Hello", + Body: "Body", + Signature: true, + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotRaw == "" { + t.Fatalf("expected raw message") + } + if !strings.Contains(gotRaw, "Body\r\n\r\n--\r\nSig") { + t.Fatalf("expected signature in body, got: %q", gotRaw) + } +} + +func TestGmailSendCmd_SignatureHTML(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotRaw string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") + switch { + case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": "a@b.com", + "signature": "
Sig
", + "verificationStatus": "accepted", + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + var payload struct { + Raw string `json:"raw"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + gotRaw = string(decoded) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailSendCmd{ + To: "a@example.com", + Subject: "Hello", + BodyHTML: "

Hello

", + Signature: true, + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotRaw == "" { + t.Fatalf("expected raw message") + } + if !strings.Contains(gotRaw, "

Hello

\r\n\r\n
Sig
") { + t.Fatalf("expected HTML signature wrapper, got: %q", gotRaw) + } +} + +func TestGmailSendCmd_SignatureFileMissing(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication()) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailSendCmd{ + To: "a@example.com", + Subject: "Hello", + Body: "Body", + SignatureFile: "/no/such/signature.txt", + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err == nil || !strings.Contains(err.Error(), "signature file") { + t.Fatalf("expected signature file error, got: %v", err) + } +} + +func TestGmailSendCmd_SignatureFileTooLarge(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication()) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + // Create a temp file larger than maxSignatureFileSize (1 MB). + tmp := filepath.Join(t.TempDir(), "big_sig.html") + data := make([]byte, maxSignatureFileSize+1) + if writeErr := os.WriteFile(tmp, data, 0o600); writeErr != nil { + t.Fatalf("write temp file: %v", writeErr) + } + + cmd := &GmailSendCmd{ + To: "a@example.com", + Subject: "Hello", + Body: "Body", + SignatureFile: tmp, + } + err = cmd.Run(ctx, &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error for oversized signature file, got nil") + } + if !strings.Contains(err.Error(), "signature file too large") { + t.Fatalf("expected 'signature file too large' error, got: %v", err) + } +} + +func TestIsLikelyHTML(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {name: "empty", in: "", want: false}, + {name: "plain text", in: "Hello world", want: false}, + {name: "angle brackets no tag", in: "1 < 2 > 0", want: false}, + {name: "div tag", in: "
hello
", want: true}, + {name: "br tag", in: "line
break", want: true}, + {name: "p tag", in: "

paragraph

", want: true}, + {name: "anchor tag", in: `
link`, want: true}, + {name: "self-closing br", in: "text
more", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isLikelyHTML(tt.in) + if got != tt.want { + t.Errorf("isLikelyHTML(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestPlainTextToHTML(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "empty", in: "", want: ""}, + {name: "simple text", in: "Hello", want: "Hello"}, + {name: "newlines", in: "Line1\nLine2\nLine3", want: "Line1
\nLine2
\nLine3"}, + {name: "escapes ampersand", in: "A & B", want: "A & B"}, + {name: "escapes angle brackets", in: "1 < 2 > 0", want: "1 < 2 > 0"}, + {name: "combined escapes and newlines", in: "A & B\nC < D", want: "A & B
\nC < D"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := plainTextToHTML(tt.in) + if got != tt.want { + t.Errorf("plainTextToHTML(%q)\ngot: %q\nwant: %q", tt.in, got, tt.want) + } + }) + } +} + +func TestSignatureFileContentType_PlainText(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotRaw string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") + switch { + case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": "a@b.com", + "verificationStatus": "accepted", + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + var payload struct { + Raw string `json:"raw"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + gotRaw = string(decoded) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + // Write a plain-text signature file with special characters and newlines. + tmp := filepath.Join(t.TempDir(), "sig.txt") + if writeErr := os.WriteFile(tmp, []byte("Best & regards\nJohn Doe\n© 2024"), 0o600); writeErr != nil { + t.Fatalf("write temp file: %v", writeErr) + } + + cmd := &GmailSendCmd{ + To: "a@example.com", + Subject: "Hello", + Body: "Body", + BodyHTML: "

Body

", + SignatureFile: tmp, + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotRaw == "" { + t.Fatal("expected raw message") + } + + // Plain part should contain the file verbatim (no HTML escaping). + if !strings.Contains(gotRaw, "Best & regards") { + t.Errorf("plain body should contain original plain text, got: %q", gotRaw) + } + + // HTML part should have escaped entities and
for newlines. + if !strings.Contains(gotRaw, "Best & regards
") { + t.Errorf("HTML body should contain escaped entities and
, got: %q", gotRaw) + } + if !strings.Contains(gotRaw, "© 2024") { + t.Errorf("HTML body should contain the copyright line, got: %q", gotRaw) + } +} + +func TestSignatureFileContentType_HTML(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotRaw string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") + switch { + case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/a@b.com": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": "a@b.com", + "verificationStatus": "accepted", + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + var payload struct { + Raw string `json:"raw"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + gotRaw = string(decoded) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + // Write an HTML signature file. + tmp := filepath.Join(t.TempDir(), "sig.html") + htmlSig := `
John Doe
` + if writeErr := os.WriteFile(tmp, []byte(htmlSig), 0o600); writeErr != nil { + t.Fatalf("write temp file: %v", writeErr) + } + + cmd := &GmailSendCmd{ + To: "a@example.com", + Subject: "Hello", + Body: "Body", + BodyHTML: "

Body

", + SignatureFile: tmp, + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotRaw == "" { + t.Fatal("expected raw message") + } + + // HTML part should contain the file content verbatim (as-is). + if !strings.Contains(gotRaw, htmlSig) { + t.Errorf("HTML body should contain original HTML signature, got: %q", gotRaw) + } + + // Plain part should have gone through signatureHTMLToPlain. + // signatureHTMLToPlain converts tags to "text (URL)" format. + if !strings.Contains(gotRaw, "My Site (https://example.com)") { + t.Errorf("plain body should contain converted anchor tag, got: %q", gotRaw) + } + if !strings.Contains(gotRaw, "John Doe") { + t.Errorf("plain body should contain text from div, got: %q", gotRaw) + } +} + +func TestAppendSignaturePlain(t *testing.T) { + tests := []struct { + name string + body string + signature string + want string + }{ + {name: "normal case", body: "Hello", signature: "-- John", want: "Hello\n\n--\n-- John"}, + {name: "empty signature", body: "Hello", signature: "", want: "Hello"}, + {name: "whitespace-only signature", body: "Hello", signature: " ", want: "Hello"}, + {name: "empty body with signature", body: "", signature: "Sig", want: "\n\n--\nSig"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendSignaturePlain(tt.body, tt.signature) + if got != tt.want { + t.Errorf("appendSignaturePlain(%q, %q)\ngot: %q\nwant: %q", tt.body, tt.signature, got, tt.want) + } + }) + } +} + +func TestAppendSignatureHTML(t *testing.T) { + tests := []struct { + name string + body string + signature string + want string + }{ + { + name: "normal case", + body: "

Hi

", + signature: "
Sig
", + want: "

Hi

\n\n" + `
Sig
`, + }, + {name: "empty signature", body: "

Hi

", signature: "", want: "

Hi

"}, + {name: "whitespace-only signature", body: "

Hi

", signature: " ", want: "

Hi

"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendSignatureHTML(tt.body, tt.signature) + if got != tt.want { + t.Errorf("appendSignatureHTML(%q, %q)\ngot: %q\nwant: %q", tt.body, tt.signature, got, tt.want) + } + }) + } +} + +func TestValidateSignatureFlags(t *testing.T) { + tests := []struct { + name string + signature bool + signatureName string + signatureFile string + wantErr bool + }{ + {name: "no flags set", signature: false, signatureName: "", signatureFile: "", wantErr: false}, + {name: "only --signature", signature: true, signatureName: "", signatureFile: "", wantErr: false}, + {name: "only --signature-name", signature: false, signatureName: "alias@example.com", signatureFile: "", wantErr: false}, + {name: "only --signature-file", signature: false, signatureName: "", signatureFile: "/tmp/sig.html", wantErr: false}, + {name: "--signature + --signature-name", signature: true, signatureName: "alias@example.com", signatureFile: "", wantErr: true}, + {name: "--signature + --signature-file", signature: true, signatureName: "", signatureFile: "/tmp/sig.html", wantErr: true}, + {name: "--signature-name + --signature-file", signature: false, signatureName: "alias@example.com", signatureFile: "/tmp/sig.html", wantErr: true}, + {name: "all three", signature: true, signatureName: "alias@example.com", signatureFile: "/tmp/sig.html", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSignatureFlags(tt.signature, tt.signatureName, tt.signatureFile) + if (err != nil) != tt.wantErr { + t.Errorf("validateSignatureFlags(%v, %q, %q) error = %v, wantErr = %v", + tt.signature, tt.signatureName, tt.signatureFile, err, tt.wantErr) + } + }) + } +} + +func TestGmailSendCmd_SignatureNameAlias(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotRaw string + var gotSendAsGetPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/users/me/settings/sendAs/"): + gotSendAsGetPath = path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": "alias@example.com", + "signature": "
Alias Sig
", + "verificationStatus": "accepted", + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + var payload struct { + Raw string `json:"raw"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + decoded, err := base64.RawURLEncoding.DecodeString(payload.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + gotRaw = string(decoded) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "m1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailSendCmd{ + To: "recipient@example.com", + Subject: "Hello", + Body: "Body", + SignatureName: "alias@example.com", + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + // Verify the SendAs.Get call used alias@example.com, not the account email a@b.com. + wantPath := "/users/me/settings/sendAs/alias@example.com" + if gotSendAsGetPath != wantPath { + t.Errorf("expected SendAs.Get path %q, got %q", wantPath, gotSendAsGetPath) + } + + if gotRaw == "" { + t.Fatal("expected raw message") + } + // The signature HTML should be converted to plain text "Alias Sig" and appended. + if !strings.Contains(gotRaw, "Body\r\n\r\n--\r\nAlias Sig") { + t.Errorf("expected alias signature in plain body, got: %q", gotRaw) + } +} + +func TestSignatureHTMLToPlain(t *testing.T) { + tests := []struct { + name string + html string + want string + }{ + { + name: "empty string", + html: "", + want: "", + }, + { + name: "whitespace only", + html: " \t\n ", + want: "", + }, + { + name: "simple div", + html: "
Sig
", + want: "Sig", + }, + { + name: "p tags with line breaks", + html: "

Line1

Line2

", + want: "Line1\nLine2", + }, + { + name: "anchor tag converted to text with URL", + html: `
My Site`, + want: "My Site (https://example.com)", + }, + { + name: "anchor tag where text equals URL", + html: `https://example.com`, + want: "https://example.com", + }, + { + name: "anchor tag with no text", + html: ``, + want: "https://example.com", + }, + { + name: "table with rows", + html: "
Row1
Row2
", + want: "Row1\nRow2", + }, + { + name: "br tag", + html: "Line1
Line2", + want: "Line1\nLine2", + }, + { + name: "br self-closing no space", + html: "Line1
Line2", + want: "Line1\nLine2", + }, + { + name: "br self-closing with space", + html: "Line1
Line2", + want: "Line1\nLine2", + }, + { + name: "multiple consecutive newlines collapsed", + html: "

A

B

", + want: "A\n\nB", + }, + { + name: "mixed tags", + html: `
John Doe
555-1234
`, + want: "John Doe\nexample.com (https://example.com)\n555-1234", + }, + { + name: "trailing whitespace trimmed", + html: "
Hello
", + want: "Hello", + }, + { + name: "plain text passthrough", + html: "Just plain text", + want: "Just plain text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := signatureHTMLToPlain(tt.html) + if got != tt.want { + t.Errorf("signatureHTMLToPlain(%q)\ngot: %q\nwant: %q", tt.html, got, tt.want) + } + }) + } +} diff --git a/internal/cmd/gmail_send_validation_more_test.go b/internal/cmd/gmail_send_validation_more_test.go index eda2e09b..e9c89ae8 100644 --- a/internal/cmd/gmail_send_validation_more_test.go +++ b/internal/cmd/gmail_send_validation_more_test.go @@ -30,6 +30,7 @@ func TestGmailSendCmd_ValidationErrors(t *testing.T) { {To: "a@b.com", Body: "B"}, {To: "a@b.com", Subject: "S"}, {To: "a@b.com", Subject: "S", Body: "B", TrackSplit: true}, + {To: "a@b.com", Subject: "S", Body: "B", Signature: true, SignatureName: "alias@example.com"}, } for _, cmd := range cases { diff --git a/internal/cmd/gmail_thread.go b/internal/cmd/gmail_thread.go index 52ccac96..1dbce5d6 100644 --- a/internal/cmd/gmail_thread.go +++ b/internal/cmd/gmail_thread.go @@ -573,7 +573,7 @@ func downloadAttachment(ctx context.Context, svc *gmail.Service, messageID strin // Sanitize filename to prevent path traversal attacks safeFilename := filepath.Base(a.Filename) if safeFilename == "" || safeFilename == "." || safeFilename == ".." { - safeFilename = "attachment" + safeFilename = defaultAttachmentName } filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, safeFilename) outPath := filepath.Join(dir, filename) diff --git a/internal/cmd/gmail_thread_helpers_test.go b/internal/cmd/gmail_thread_helpers_test.go index 73443d6e..1a80e520 100644 --- a/internal/cmd/gmail_thread_helpers_test.go +++ b/internal/cmd/gmail_thread_helpers_test.go @@ -18,7 +18,7 @@ func TestStripHTMLTags_More(t *testing.T) { } } -func TestFormatBytes(t *testing.T) { +func TestFormatBytes_Helpers(t *testing.T) { if got := formatBytes(500); got != "500 B" { t.Fatalf("unexpected bytes format: %q", got) } @@ -66,7 +66,7 @@ func TestCollectAttachments_More(t *testing.T) { } } -func TestAttachmentLine(t *testing.T) { +func TestAttachmentLine_Helpers(t *testing.T) { att := attachmentOutput{ Filename: "file.txt", Size: 12, @@ -186,7 +186,7 @@ func TestDecodeBodyCharset_ISO88591(t *testing.T) { } } -func TestMimeTypeMatches(t *testing.T) { +func TestMimeTypeMatches_Helpers(t *testing.T) { if !mimeTypeMatches("Text/Plain; charset=UTF-8", "text/plain") { t.Fatalf("expected mime match") } diff --git a/internal/cmd/gmail_thread_test.go b/internal/cmd/gmail_thread_test.go index 57c0528d..60be0b80 100644 --- a/internal/cmd/gmail_thread_test.go +++ b/internal/cmd/gmail_thread_test.go @@ -68,7 +68,7 @@ func TestBestBodyText_MimeTypeWithParams(t *testing.T) { } } -func TestDecodeBase64URL(t *testing.T) { +func TestDecodeBase64URL_Thread(t *testing.T) { got, err := decodeBase64URL(base64.RawURLEncoding.EncodeToString([]byte("ok"))) if err != nil { t.Fatalf("err: %v", err) @@ -88,7 +88,7 @@ func TestDecodeBase64URL(t *testing.T) { } } -func TestStripHTMLTags(t *testing.T) { +func TestStripHTMLTags_Thread(t *testing.T) { tests := []struct { name string input string diff --git a/internal/cmd/gmail_url_test.go b/internal/cmd/gmail_url_test.go index 9b269cb5..6dbbd167 100644 --- a/internal/cmd/gmail_url_test.go +++ b/internal/cmd/gmail_url_test.go @@ -11,7 +11,7 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -func TestGmailURLCmd_JSON(t *testing.T) { +func TestGmailURLCmd_JSON_URL(t *testing.T) { u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) if err != nil { t.Fatalf("ui.New: %v", err) @@ -35,7 +35,7 @@ func TestGmailURLCmd_JSON(t *testing.T) { } } -func TestGmailURLCmd_Text(t *testing.T) { +func TestGmailURLCmd_Text_URL(t *testing.T) { cmd := GmailURLCmd{ThreadIDs: []string{"t1"}} out := captureStdout(t, func() { u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go index fdeafbc1..4121ba0b 100644 --- a/internal/cmd/gmail_watch_cmds.go +++ b/internal/cmd/gmail_watch_cmds.go @@ -191,20 +191,21 @@ func (c *GmailWatchStopCmd) Run(ctx context.Context, flags *RootFlags) error { } type GmailWatchServeCmd struct { - Bind string `name:"bind" help:"Bind address" default:"127.0.0.1"` - Port int `name:"port" help:"Listen port" default:"8788"` - Path string `name:"path" help:"Push handler path" default:"/gmail-pubsub"` - Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: local"` - Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` - VerifyOIDC bool `name:"verify-oidc" help:"Verify Pub/Sub OIDC tokens"` - OIDCEmail string `name:"oidc-email" help:"Expected service account email"` - OIDCAudience string `name:"oidc-audience" help:"Expected OIDC audience"` - SharedToken string `name:"token" help:"Shared token for x-gog-token or ?token="` - HookURL string `name:"hook-url" help:"Webhook URL to forward messages"` - HookToken string `name:"hook-token" help:"Webhook bearer token"` - IncludeBody bool `name:"include-body" help:"Include text/plain body in hook payload"` - MaxBytes int `name:"max-bytes" help:"Max bytes of body to include" default:"20000"` - SaveHook bool `name:"save-hook" help:"Persist hook settings to watch state"` + Bind string `name:"bind" help:"Bind address" default:"127.0.0.1"` + Port int `name:"port" help:"Listen port" default:"8788"` + Path string `name:"path" help:"Push handler path" default:"/gmail-pubsub"` + Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: local"` + Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` + VerifyOIDC bool `name:"verify-oidc" help:"Verify Pub/Sub OIDC tokens"` + OIDCEmail string `name:"oidc-email" help:"Expected service account email"` + OIDCAudience string `name:"oidc-audience" help:"Expected OIDC audience"` + SharedToken string `name:"token" help:"Shared token for x-gog-token or ?token="` + HookURL string `name:"hook-url" help:"Webhook URL to forward messages"` + HookToken string `name:"hook-token" help:"Webhook bearer token"` + IncludeBody bool `name:"include-body" help:"Include text/plain body in hook payload"` + MaxBytes int `name:"max-bytes" help:"Max bytes of body to include" default:"20000"` + HistoryTypes []string `name:"history-types" help:"History types to include (repeatable, comma-separated: messageAdded,messageDeleted,labelAdded,labelRemoved). Default: messageAdded"` + SaveHook bool `name:"save-hook" help:"Persist hook settings to watch state"` } func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { @@ -234,6 +235,11 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags return err } + historyTypes, err := parseHistoryTypes(c.HistoryTypes) + if err != nil { + return err + } + store, err := loadGmailWatchStore(account) if err != nil { return err @@ -297,6 +303,7 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags HookTimeout: defaultHookRequestTimeoutSec * time.Second, HistoryMax: defaultHistoryMaxResults, ResyncMax: defaultHistoryResyncMax, + HistoryTypes: historyTypes, AllowNoHook: hook == nil, IncludeBody: includeBody, MaxBodyBytes: maxBytes, diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go index f58de88b..bfcc61aa 100644 --- a/internal/cmd/gmail_watch_server.go +++ b/internal/cmd/gmail_watch_server.go @@ -177,7 +177,9 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl } historyCall := svc.Users.History.List("me").StartHistoryId(startID).MaxResults(s.cfg.HistoryMax) - historyCall.HistoryTypes("messageAdded") + if len(s.cfg.HistoryTypes) > 0 { + historyCall.HistoryTypes(s.cfg.HistoryTypes...) + } historyResp, err := historyCall.Do() if err != nil { @@ -187,38 +189,36 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl return nil, err } - messageIDs := collectHistoryMessageIDs(historyResp) - msgs, err := s.fetchMessages(ctx, svc, messageIDs) - if err != nil { - return nil, err - } - nextHistoryID := payload.HistoryID if historyResp != nil && historyResp.HistoryId != 0 { nextHistoryID = formatHistoryID(historyResp.HistoryId) } - if err := store.Update(func(state *gmailWatchState) error { - shouldUpdate, err := shouldUpdateHistoryID(state.HistoryID, nextHistoryID) - if err != nil { - return err - } - if shouldUpdate { - state.HistoryID = nextHistoryID - } - if payload.MessageID != "" { - state.LastPushMessageID = payload.MessageID + if len(s.cfg.HistoryTypes) > 0 && (historyResp == nil || len(historyResp.History) == 0) { + if updateErr := store.Update(func(state *gmailWatchState) error { + return updateStateAfterHistory(state, nextHistoryID, payload.MessageID) + }); updateErr != nil { + s.warnf("watch: failed to update state: %v", updateErr) } - state.UpdatedAtMs = time.Now().UnixMilli() - return nil - }); err != nil { - s.warnf("watch: failed to update state: %v", err) + return nil, errNoNewMessages + } + + historyIDs := collectHistoryMessageIDs(historyResp) + msgs, err := s.fetchMessages(ctx, svc, historyIDs.FetchIDs) + if err != nil { + return nil, err + } + if updateErr := store.Update(func(state *gmailWatchState) error { + return updateStateAfterHistory(state, nextHistoryID, payload.MessageID) + }); updateErr != nil { + s.warnf("watch: failed to update state: %v", updateErr) } return &gmailHookPayload{ - Source: "gmail", - Account: s.cfg.Account, - HistoryID: nextHistoryID, - Messages: msgs, + Source: "gmail", + Account: s.cfg.Account, + HistoryID: nextHistoryID, + Messages: msgs, + DeletedMessageIDs: historyIDs.DeletedIDs, }, nil } @@ -238,21 +238,10 @@ func (s *gmailWatchServer) resyncHistory(ctx context.Context, svc *gmail.Service return nil, err } - if err := s.store.Update(func(state *gmailWatchState) error { - shouldUpdate, err := shouldUpdateHistoryID(state.HistoryID, historyID) - if err != nil { - return err - } - if shouldUpdate { - state.HistoryID = historyID - } - if messageID != "" { - state.LastPushMessageID = messageID - } - state.UpdatedAtMs = time.Now().UnixMilli() - return nil - }); err != nil { - s.warnf("watch: failed to update state after resync: %v", err) + if updateErr := s.store.Update(func(state *gmailWatchState) error { + return updateStateAfterHistory(state, historyID, messageID) + }); updateErr != nil { + s.warnf("watch: failed to update state after resync: %v", updateErr) } return &gmailHookPayload{ @@ -441,6 +430,23 @@ func pathMatches(expected, actual string) bool { return strings.HasPrefix(actual, expected+"/") } +// updateStateAfterHistory updates the stored state with the new history ID and push message ID. +// This is a common operation after processing history, whether messages were found or not. +func updateStateAfterHistory(state *gmailWatchState, historyID, pushMessageID string) error { + shouldUpdate, err := shouldUpdateHistoryID(state.HistoryID, historyID) + if err != nil { + return err + } + if shouldUpdate { + state.HistoryID = historyID + } + if pushMessageID != "" { + state.LastPushMessageID = pushMessageID + } + state.UpdatedAtMs = time.Now().UnixMilli() + return nil +} + func isStaleHistoryError(err error) bool { var gerr *googleapi.Error if errors.As(err, &gerr) { @@ -473,12 +479,58 @@ func isNotFoundAPIError(err error) bool { return false } -func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string { +// historyMessageIDs holds the result of collecting message IDs from history. +// FetchIDs contains messages that should be fetched (added, label changes, etc.). +// DeletedIDs contains messages that were deleted and cannot be fetched. +type historyMessageIDs struct { + FetchIDs []string + DeletedIDs []string +} + +func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) historyMessageIDs { if resp == nil || len(resp.History) == 0 { - return nil + return historyMessageIDs{} + } + seenFetch := make(map[string]struct{}) + seenDeleted := make(map[string]struct{}) + var result historyMessageIDs + + addFetch := func(id string) { + if strings.TrimSpace(id) == "" { + return + } + if _, ok := seenFetch[id]; ok { + return + } + // If already marked as deleted, don't add to fetch list + if _, ok := seenDeleted[id]; ok { + return + } + seenFetch[id] = struct{}{} + result.FetchIDs = append(result.FetchIDs, id) + } + + addDeleted := func(id string) { + if strings.TrimSpace(id) == "" { + return + } + if _, ok := seenDeleted[id]; ok { + return + } + // Remove from fetch list if previously added + if _, ok := seenFetch[id]; ok { + delete(seenFetch, id) + for i, fid := range result.FetchIDs { + if fid == id { + result.FetchIDs = append(result.FetchIDs[:i], result.FetchIDs[i+1:]...) + break + } + } + } + seenDeleted[id] = struct{}{} + result.DeletedIDs = append(result.DeletedIDs, id) } - seen := make(map[string]struct{}) - out := make([]string, 0) + for _, h := range resp.History { if h == nil { continue @@ -487,22 +539,32 @@ func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string { if added == nil || added.Message == nil || added.Message.Id == "" { continue } - if _, ok := seen[added.Message.Id]; ok { + addFetch(added.Message.Id) + } + for _, deleted := range h.MessagesDeleted { + if deleted == nil || deleted.Message == nil || deleted.Message.Id == "" { continue } - seen[added.Message.Id] = struct{}{} - out = append(out, added.Message.Id) + addDeleted(deleted.Message.Id) } - for _, msg := range h.Messages { - if msg == nil || msg.Id == "" { + for _, added := range h.LabelsAdded { + if added == nil || added.Message == nil || added.Message.Id == "" { + continue + } + addFetch(added.Message.Id) + } + for _, removed := range h.LabelsRemoved { + if removed == nil || removed.Message == nil || removed.Message.Id == "" { continue } - if _, ok := seen[msg.Id]; ok { + addFetch(removed.Message.Id) + } + for _, msg := range h.Messages { + if msg == nil || msg.Id == "" { continue } - seen[msg.Id] = struct{}{} - out = append(out, msg.Id) + addFetch(msg.Id) } } - return out + return result } diff --git a/internal/cmd/gmail_watch_server_helpers_test.go b/internal/cmd/gmail_watch_server_helpers_test.go index 6bbdc7bd..4e6cdcee 100644 --- a/internal/cmd/gmail_watch_server_helpers_test.go +++ b/internal/cmd/gmail_watch_server_helpers_test.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -50,6 +51,15 @@ func TestCollectHistoryMessageIDs(t *testing.T) { {Message: &gmail.Message{Id: "m1"}}, nil, }, + MessagesDeleted: []*gmail.HistoryMessageDeleted{ + {Message: &gmail.Message{Id: "m4"}}, + }, + LabelsAdded: []*gmail.HistoryLabelAdded{ + {Message: &gmail.Message{Id: "m5"}}, + }, + LabelsRemoved: []*gmail.HistoryLabelRemoved{ + {Message: &gmail.Message{Id: "m6"}}, + }, Messages: []*gmail.Message{ {Id: "m2"}, {Id: ""}, @@ -60,10 +70,148 @@ func TestCollectHistoryMessageIDs(t *testing.T) { }, }, } - ids := collectHistoryMessageIDs(resp) - joined := strings.Join(ids, ",") - if !strings.Contains(joined, "m1") || !strings.Contains(joined, "m2") || !strings.Contains(joined, "m3") { - t.Fatalf("unexpected ids: %v", ids) + result := collectHistoryMessageIDs(resp) + + // Check fetch IDs contain added, labels, and general messages + fetchJoined := strings.Join(result.FetchIDs, ",") + if !strings.Contains(fetchJoined, "m1") || + !strings.Contains(fetchJoined, "m2") || + !strings.Contains(fetchJoined, "m3") || + !strings.Contains(fetchJoined, "m5") || + !strings.Contains(fetchJoined, "m6") { + t.Fatalf("unexpected fetch ids: %v", result.FetchIDs) + } + + // Deleted message m4 should NOT be in fetch IDs + if strings.Contains(fetchJoined, "m4") { + t.Fatalf("deleted message m4 should not be in fetch ids: %v", result.FetchIDs) + } + + // Verify exact count: 5 unique fetch IDs (m1, m2, m3, m5, m6) + if len(result.FetchIDs) != 5 { + t.Fatalf("expected 5 unique fetch ids, got %d: %v", len(result.FetchIDs), result.FetchIDs) + } + + // Deleted message m4 should be in deleted IDs + if len(result.DeletedIDs) != 1 || result.DeletedIDs[0] != "m4" { + t.Fatalf("expected deleted ids [m4], got: %v", result.DeletedIDs) + } +} + +func TestCollectHistoryMessageIDs_DeletedRemovesFromFetch(t *testing.T) { + // Test that if a message is added then deleted, it ends up only in DeletedIDs + resp := &gmail.ListHistoryResponse{ + History: []*gmail.History{ + { + MessagesAdded: []*gmail.HistoryMessageAdded{ + {Message: &gmail.Message{Id: "m1"}}, + }, + }, + { + MessagesDeleted: []*gmail.HistoryMessageDeleted{ + {Message: &gmail.Message{Id: "m1"}}, // Same message deleted later + }, + }, + }, + } + result := collectHistoryMessageIDs(resp) + + // m1 should not be in fetch IDs since it was deleted + for _, id := range result.FetchIDs { + if id == "m1" { + t.Fatalf("deleted message m1 should not be in fetch ids: %v", result.FetchIDs) + } + } + + // m1 should be in deleted IDs + found := false + for _, id := range result.DeletedIDs { + if id == "m1" { + found = true + break + } + } + if !found { + t.Fatalf("deleted message m1 should be in deleted ids: %v", result.DeletedIDs) + } +} + +func TestCollectHistoryMessageIDs_EmptyResponse(t *testing.T) { + result := collectHistoryMessageIDs(nil) + if len(result.FetchIDs) != 0 || len(result.DeletedIDs) != 0 { + t.Fatalf("expected empty result for nil response") + } + + result = collectHistoryMessageIDs(&gmail.ListHistoryResponse{}) + if len(result.FetchIDs) != 0 || len(result.DeletedIDs) != 0 { + t.Fatalf("expected empty result for empty response") + } +} + +func TestParseHistoryTypes(t *testing.T) { + got, err := parseHistoryTypes([]string{"messageAdded,labelRemoved", "labelsAdded", "messagesDeleted", "messageAdded"}) + if err != nil { + t.Fatalf("parseHistoryTypes: %v", err) + } + want := []string{"messageAdded", "labelRemoved", "labelAdded", "messageDeleted"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected types: %v", got) + } + if _, err := parseHistoryTypes([]string{"nope"}); err == nil { + t.Fatalf("expected error for invalid history type") + } +} + +func TestParseHistoryTypes_DefaultsToMessageAdded(t *testing.T) { + // When no history types are provided, default to messageAdded for backward compatibility. + got, err := parseHistoryTypes(nil) + if err != nil { + t.Fatalf("parseHistoryTypes(nil): %v", err) + } + want := []string{"messageAdded"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected default %v, got %v", want, got) + } + + // Empty slice should also default to messageAdded. + got, err = parseHistoryTypes([]string{}) + if err != nil { + t.Fatalf("parseHistoryTypes([]string{}): %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected default %v, got %v", want, got) + } +} + +func TestParseHistoryTypes_EmptyStringsInInput(t *testing.T) { + // Test that empty strings between commas are handled correctly. + got, err := parseHistoryTypes([]string{"messageAdded,,labelAdded"}) + if err != nil { + t.Fatalf("parseHistoryTypes with empty strings: %v", err) + } + want := []string{"messageAdded", "labelAdded"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + + // Test leading/trailing empty parts. + got, err = parseHistoryTypes([]string{",messageAdded,", ",labelRemoved,"}) + if err != nil { + t.Fatalf("parseHistoryTypes with leading/trailing empty: %v", err) + } + want = []string{"messageAdded", "labelRemoved"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } + + // Test whitespace-only parts. + got, err = parseHistoryTypes([]string{"messageAdded, ,labelAdded"}) + if err != nil { + t.Fatalf("parseHistoryTypes with whitespace: %v", err) + } + want = []string{"messageAdded", "labelAdded"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) } } diff --git a/internal/cmd/gmail_watch_server_more_test.go b/internal/cmd/gmail_watch_server_more_test.go index 4c445d3e..da233f19 100644 --- a/internal/cmd/gmail_watch_server_more_test.go +++ b/internal/cmd/gmail_watch_server_more_test.go @@ -141,6 +141,93 @@ func TestGmailWatchServer_ServeHTTP_AllowNoHook(t *testing.T) { } } +func TestGmailWatchServer_ServeHTTP_HistoryTypes_NoMatch(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + store, err := newGmailWatchStore("a@b.com") + if err != nil { + t.Fatalf("store: %v", err) + } + // Seed state so StartHistoryID returns non-zero. + if updateErr := store.Update(func(s *gmailWatchState) error { + s.Account = "a@b.com" + s.HistoryID = "100" + return nil + }); updateErr != nil { + t.Fatalf("seed: %v", updateErr) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "historyId": "200", + "history": []map[string]any{}, + }) + return + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + gsvc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + s := &gmailWatchServer{ + cfg: gmailWatchServeConfig{ + Account: "a@b.com", + Path: "/gmail-pubsub", + SharedToken: "tok", + AllowNoHook: true, + HistoryMax: 100, + ResyncMax: 10, + HistoryTypes: []string{"messageAdded"}, + }, + store: store, + newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil }, + hookClient: srv.Client(), + logf: func(string, ...any) {}, + warnf: func(string, ...any) {}, + } + + push := pubsubPushEnvelope{} + push.Message.Data = base64.StdEncoding.EncodeToString([]byte(`{"emailAddress":"a@b.com","historyId":"200"}`)) + body, _ := json.Marshal(push) + + req := httptest.NewRequest(http.MethodPost, "/gmail-pubsub?token=tok", bytes.NewReader(body)) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + if rr.Code != http.StatusAccepted { + t.Fatalf("status: %d body=%q", rr.Code, rr.Body.String()) + } + if rr.Body.Len() != 0 { + t.Fatalf("expected empty body, got %q", rr.Body.String()) + } + + st := store.Get() + if st.HistoryID != "200" { + t.Fatalf("expected history updated, got %q", st.HistoryID) + } +} + func TestGmailWatchHelpers(t *testing.T) { if got := bearerToken(&http.Request{Header: http.Header{"Authorization": []string{"Bearer tok"}}}); got != "tok" { t.Fatalf("bearer: %q", got) diff --git a/internal/cmd/gmail_watch_state.go b/internal/cmd/gmail_watch_state.go index f67ca689..9b895936 100644 --- a/internal/cmd/gmail_watch_state.go +++ b/internal/cmd/gmail_watch_state.go @@ -33,7 +33,7 @@ func gmailWatchStatePath(account string) (string, error) { func sanitizeAccountForPath(account string) string { clean := strings.TrimSpace(strings.ToLower(account)) if clean == "" { - return "unknown" + return trackingUnknown } var b strings.Builder b.Grow(len(clean)) diff --git a/internal/cmd/gmail_watch_types.go b/internal/cmd/gmail_watch_types.go index 67e7e613..4ca3abad 100644 --- a/internal/cmd/gmail_watch_types.go +++ b/internal/cmd/gmail_watch_types.go @@ -54,6 +54,7 @@ type gmailWatchServeConfig struct { MaxBodyBytes int HistoryMax int64 ResyncMax int64 + HistoryTypes []string HookTimeout time.Duration DateLocation *time.Location PersistHook bool @@ -61,6 +62,51 @@ type gmailWatchServeConfig struct { VerboseOutput bool } +var gmailHistoryTypeAliases = map[string]string{ + // Canonical forms (for robustness when users provide exact casing) + "messageAdded": "messageAdded", + "messageDeleted": "messageDeleted", + "labelAdded": "labelAdded", + "labelRemoved": "labelRemoved", + // Lowercase aliases + "messageadded": "messageAdded", + "messagesadded": "messageAdded", + "messagedeleted": "messageDeleted", + "messagesdeleted": "messageDeleted", + "labeladded": "labelAdded", + "labelsadded": "labelAdded", + "labelremoved": "labelRemoved", + "labelsremoved": "labelRemoved", +} + +func parseHistoryTypes(values []string) ([]string, error) { + if len(values) == 0 { + // Default to messageAdded for backward compatibility. + // Previously this was hardcoded; returning nil would fetch ALL types. + return []string{"messageAdded"}, nil + } + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}) + for _, raw := range values { + for _, part := range strings.Split(raw, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + normalized, ok := gmailHistoryTypeAliases[strings.ToLower(trimmed)] + if !ok { + return nil, usage("--history-types must be one of messageAdded,messageDeleted,labelAdded,labelRemoved") + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + } + return out, nil +} + type pubsubPushEnvelope struct { Message struct { Data string `json:"data"` @@ -119,8 +165,9 @@ type gmailHookMessage struct { } type gmailHookPayload struct { - Source string `json:"source"` - Account string `json:"account"` - HistoryID string `json:"historyId"` - Messages []gmailHookMessage `json:"messages"` + Source string `json:"source"` + Account string `json:"account"` + HistoryID string `json:"historyId"` + Messages []gmailHookMessage `json:"messages"` + DeletedMessageIDs []string `json:"deletedMessageIds,omitempty"` } diff --git a/internal/cmd/groups.go b/internal/cmd/groups.go index a398a057..1e99766e 100644 --- a/internal/cmd/groups.go +++ b/internal/cmd/groups.go @@ -24,8 +24,12 @@ const ( ) type GroupsCmd struct { - List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"` - Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"` + List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"` + Members GroupsMembersCmd `cmd:"" name:"members" help:"List or manage group members"` + Create GroupsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create a group (admin)"` + Update GroupsUpdateCmd `cmd:"" name:"update" help:"Update a group (admin)"` + Delete GroupsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a group (admin)"` + Settings GroupsSettingsCmd `cmd:"" name:"settings" help:"Get or update group settings (admin)"` } type GroupsListCmd struct { @@ -132,9 +136,13 @@ func getRelationType(relationType string) string { } type GroupsMembersCmd struct { - GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@company.com)"` - Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` - Page string `name:"page" help:"Page token"` + Action string `arg:"" name:"action" help:"Action: add/remove/sync (default: list)" optional:""` + GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@company.com)" optional:""` + MemberEmail string `arg:"" name:"memberEmail" help:"Member email (for add/remove)" optional:""` + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"` + Page string `name:"page" help:"Page token"` + Role string `name:"role" default:"MEMBER" enum:"MEMBER,MANAGER,OWNER" help:"Member role (add only)"` + File string `name:"file" help:"CSV file with member emails (sync only)"` } func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -144,9 +152,57 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + action := strings.ToLower(strings.TrimSpace(c.Action)) groupEmail := strings.TrimSpace(c.GroupEmail) - if groupEmail == "" { - return usage("group email required") + memberEmail := strings.TrimSpace(c.MemberEmail) + + switch action { + case "", "list", "add", "remove", "sync": + default: + if groupEmail != "" { + return usage("unknown action (expected add, remove, sync)") + } + groupEmail = c.Action + action = "" + } + + switch action { + case "": + if groupEmail == "" { + return usage("group email required") + } + case "list": + if groupEmail == "" { + return usage("group email required") + } + case "add": + if groupEmail == "" || memberEmail == "" { + return usage("group and member email required") + } + return (&GroupsMembersAddCmd{ + Group: groupEmail, + Email: memberEmail, + Role: c.Role, + }).Run(ctx, flags) + case "remove": + if groupEmail == "" || memberEmail == "" { + return usage("group and member email required") + } + return (&GroupsMembersRemoveCmd{ + Group: groupEmail, + Email: memberEmail, + }).Run(ctx, flags) + case "sync": + if groupEmail == "" { + return usage("group email required") + } + if strings.TrimSpace(c.File) == "" { + return usage("--file is required") + } + return (&GroupsMembersSyncCmd{ + Group: groupEmail, + File: c.File, + }).Run(ctx, flags) } svc, err := newCloudIdentityService(ctx, account) diff --git a/internal/cmd/groups_admin.go b/internal/cmd/groups_admin.go new file mode 100644 index 00000000..13cb8f24 --- /dev/null +++ b/internal/cmd/groups_admin.go @@ -0,0 +1,435 @@ +package cmd + +import ( + "context" + "encoding/csv" + "fmt" + "os" + "sort" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/groupssettings/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newGroupsSettings = googleapi.NewGroupsSettings + +// Admin group management + +type GroupsCreateCmd struct { + Email string `arg:"" name:"email" help:"Group email address"` + Name string `name:"name" required:"" help:"Display name"` + Description string `name:"description" help:"Description"` +} + +func (c *GroupsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + req := &admin.Group{ + Email: c.Email, + Name: c.Name, + Description: c.Description, + } + created, err := svc.Groups.Insert(req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create group %s: %w", c.Email, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created group: %s\n", created.Email) + return nil +} + +type GroupsUpdateCmd struct { + Group string `arg:"" name:"group" help:"Group email or ID"` + Name *string `name:"name" help:"New display name"` + Description *string `name:"description" help:"Description"` +} + +func (c *GroupsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + group := &admin.Group{} + hasUpdates := false + if c.Name != nil { + group.Name = *c.Name + hasUpdates = true + } + if c.Description != nil { + group.Description = *c.Description + if *c.Description == "" { + group.ForceSendFields = append(group.ForceSendFields, "Description") + } + hasUpdates = true + } + if !hasUpdates { + return usage("no updates specified") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + updated, err := svc.Groups.Update(c.Group, group).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update group %s: %w", c.Group, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated group: %s\n", updated.Email) + return nil +} + +type GroupsDeleteCmd struct { + Group string `arg:"" name:"group" help:"Group email or ID"` +} + +func (c *GroupsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", c.Group)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Groups.Delete(c.Group).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete group %s: %w", c.Group, err) + } + + u.Out().Printf("Deleted group: %s\n", c.Group) + return nil +} + +type GroupsSettingsCmd struct { + Group string `arg:"" name:"group" help:"Group email"` + WhoCanJoin *string `name:"who-can-join" help:"Who can join (e.g., ANYONE_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN)"` + WhoCanPost *string `name:"who-can-post" help:"Who can post (e.g., ANYONE_CAN_POST, ALL_IN_DOMAIN_CAN_POST, OWNERS_ONLY, NONE_CAN_POST)"` + WhoCanViewGroup *string `name:"who-can-view-group" help:"Who can view group (e.g., ANYONE_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW)"` + WhoCanViewMembers *string `name:"who-can-view-membership" help:"Who can view membership (e.g., ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, OWNERS_ONLY)"` +} + +func (c *GroupsSettingsCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newGroupsSettings(ctx, account) + if err != nil { + return err + } + + hasUpdates := c.WhoCanJoin != nil || c.WhoCanPost != nil || c.WhoCanViewGroup != nil || c.WhoCanViewMembers != nil + if !hasUpdates { + settings, getErr := svc.Groups.Get(c.Group).Context(ctx).Do() + if getErr != nil { + return fmt.Errorf("get group settings %s: %w", c.Group, getErr) + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, settings) + } + u.Out().Printf("Group: %s\n", settings.Email) + u.Out().Printf("WhoCanJoin: %s\n", settings.WhoCanJoin) + u.Out().Printf("WhoCanPostMessage: %s\n", settings.WhoCanPostMessage) + u.Out().Printf("WhoCanViewGroup: %s\n", settings.WhoCanViewGroup) + u.Out().Printf("WhoCanViewMembers: %s\n", settings.WhoCanViewMembership) + return nil + } + + req := &groupssettings.Groups{} + if c.WhoCanJoin != nil { + req.WhoCanJoin = *c.WhoCanJoin + req.ForceSendFields = append(req.ForceSendFields, "WhoCanJoin") + } + if c.WhoCanPost != nil { + req.WhoCanPostMessage = *c.WhoCanPost + req.ForceSendFields = append(req.ForceSendFields, "WhoCanPostMessage") + } + if c.WhoCanViewGroup != nil { + req.WhoCanViewGroup = *c.WhoCanViewGroup + req.ForceSendFields = append(req.ForceSendFields, "WhoCanViewGroup") + } + if c.WhoCanViewMembers != nil { + req.WhoCanViewMembership = *c.WhoCanViewMembers + req.ForceSendFields = append(req.ForceSendFields, "WhoCanViewMembership") + } + + updated, err := svc.Groups.Update(c.Group, req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update group settings %s: %w", c.Group, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated settings for group: %s\n", updated.Email) + return nil +} + +type GroupsMembersAddCmd struct { + Group string `arg:"" name:"group" help:"Group email or ID"` + Email string `arg:"" name:"email" help:"Member email"` + Role string `name:"role" default:"MEMBER" enum:"MEMBER,MANAGER,OWNER" help:"Member role"` +} + +func (c *GroupsMembersAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + role, err := normalizeGroupRole(c.Role) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + member := &admin.Member{Email: c.Email, Role: role} + created, err := svc.Members.Insert(c.Group, member).Context(ctx).Do() + if err != nil { + return fmt.Errorf("add member %s to %s: %w", c.Email, c.Group, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Added %s to %s as %s\n", created.Email, c.Group, created.Role) + return nil +} + +type GroupsMembersRemoveCmd struct { + Group string `arg:"" name:"group" help:"Group email or ID"` + Email string `arg:"" name:"email" help:"Member email"` +} + +func (c *GroupsMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", c.Email, c.Group)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Members.Delete(c.Group, c.Email).Context(ctx).Do(); err != nil { + return fmt.Errorf("remove member %s from %s: %w", c.Email, c.Group, err) + } + + u.Out().Printf("Removed %s from %s\n", c.Email, c.Group) + return nil +} + +type GroupsMembersSyncCmd struct { + Group string `arg:"" name:"group" help:"Group email or ID"` + File string `name:"file" required:"" help:"CSV file with member emails"` +} + +func (c *GroupsMembersSyncCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + desiredEmails, err := readCSVEmails(c.File) + if err != nil { + return err + } + if len(desiredEmails) == 0 { + return usage("no member emails found in CSV") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + currentMembers, err := listGroupMembers(ctx, svc, c.Group) + if err != nil { + return err + } + + desired := make(map[string]struct{}, len(desiredEmails)) + for _, email := range desiredEmails { + desired[strings.ToLower(email)] = struct{}{} + } + + toAdd := make([]string, 0) + for email := range desired { + if _, ok := currentMembers[email]; !ok { + toAdd = append(toAdd, email) + } + } + toRemove := make([]string, 0) + for email := range currentMembers { + if _, ok := desired[email]; !ok { + toRemove = append(toRemove, email) + } + } + + sort.Strings(toAdd) + sort.Strings(toRemove) + + if len(toAdd) == 0 && len(toRemove) == 0 { + u.Out().Printf("Group %s already in sync (%d members)\n", c.Group, len(currentMembers)) + return nil + } + + if len(toRemove) > 0 { + if err := confirmDestructive(ctx, flags, fmt.Sprintf("sync members for %s (add %d, remove %d)", c.Group, len(toAdd), len(toRemove))); err != nil { + return err + } + } + + for _, email := range toAdd { + member := &admin.Member{Email: email, Role: "MEMBER"} + if _, err := svc.Members.Insert(c.Group, member).Context(ctx).Do(); err != nil { + return fmt.Errorf("add member %s: %w", email, err) + } + } + for _, email := range toRemove { + if err := svc.Members.Delete(c.Group, email).Context(ctx).Do(); err != nil { + return fmt.Errorf("remove member %s: %w", email, err) + } + } + + u.Out().Printf("Synced group %s: added %d, removed %d\n", c.Group, len(toAdd), len(toRemove)) + return nil +} + +func normalizeGroupRole(role string) (string, error) { + role = strings.TrimSpace(strings.ToUpper(role)) + switch role { + case "MEMBER", "MANAGER", "OWNER": + return role, nil + default: + return "", usage("invalid role (expected MEMBER, MANAGER, OWNER)") + } +} + +func readCSVEmails(path string) ([]string, error) { + f, err := os.Open(path) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return nil, fmt.Errorf("open CSV: %w", err) + } + defer f.Close() + + reader := csv.NewReader(f) + reader.FieldsPerRecord = -1 + + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + header := records[0] + idx := findEmailColumn(header) + start := 0 + if idx >= 0 { + start = 1 + } else { + idx = 0 + } + + seen := make(map[string]struct{}) + out := make([]string, 0, len(records)-start) + for _, row := range records[start:] { + if idx >= len(row) { + continue + } + email := strings.TrimSpace(row[idx]) + if email == "" { + continue + } + key := strings.ToLower(email) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, email) + } + return out, nil +} + +func findEmailColumn(header []string) int { + for i, col := range header { + name := strings.TrimSpace(strings.ToLower(col)) + switch name { + case "email", "emailaddress", "member", "member_email": + return i + } + } + return -1 +} + +func listGroupMembers(ctx context.Context, svc *admin.Service, group string) (map[string]struct{}, error) { + members := make(map[string]struct{}) + call := svc.Members.List(group).MaxResults(200) + for { + resp, err := call.Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("list members for %s: %w", group, err) + } + for _, member := range resp.Members { + if member == nil || member.Email == "" { + continue + } + members[strings.ToLower(member.Email)] = struct{}{} + } + if resp.NextPageToken == "" { + break + } + call = call.PageToken(resp.NextPageToken) + } + return members, nil +} diff --git a/internal/cmd/labels.go b/internal/cmd/labels.go new file mode 100644 index 00000000..ddf93e44 --- /dev/null +++ b/internal/cmd/labels.go @@ -0,0 +1,358 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drivelabels/v2" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newDriveLabelsService = googleapi.NewDriveLabels + +type LabelsCmd struct { + List LabelsListCmd `cmd:"" name:"list" aliases:"ls" help:"List Drive labels"` + Get LabelsGetCmd `cmd:"" name:"get" help:"Get a Drive label"` + Create LabelsCreateCmd `cmd:"" name:"create" help:"Create a Drive label"` + Update LabelsUpdateCmd `cmd:"" name:"update" help:"Update a Drive label"` + Delete LabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a Drive label"` + Publish LabelsPublishCmd `cmd:"" name:"publish" help:"Publish a Drive label"` + Disable LabelsDisableCmd `cmd:"" name:"disable" help:"Disable a Drive label"` +} + +type LabelsListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *LabelsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + call := svc.Labels.List() + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list labels: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Labels) == 0 { + u.Err().Println("No labels found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tTITLE\tTYPE\tSTATE") + for _, label := range resp.Labels { + if label == nil { + continue + } + title := "" + if label.Properties != nil { + title = label.Properties.Title + } + state := "" + if label.Lifecycle != nil { + state = label.Lifecycle.State + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(label.Name), + sanitizeTab(title), + sanitizeTab(label.LabelType), + sanitizeTab(state), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type LabelsGetCmd struct { + LabelID string `arg:"" name:"label-id" help:"Label ID or name"` +} + +func (c *LabelsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + label := strings.TrimSpace(c.LabelID) + if label == "" { + return usage("label-id is required") + } + label = normalizeLabelName(label) + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Labels.Get(label).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get label %s: %w", label, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + title := "" + if resp.Properties != nil { + title = resp.Properties.Title + } + u.Out().Printf("Name: %s\n", resp.Name) + u.Out().Printf("Title: %s\n", title) + u.Out().Printf("Type: %s\n", resp.LabelType) + if resp.Lifecycle != nil { + u.Out().Printf("State: %s\n", resp.Lifecycle.State) + } + return nil +} + +type LabelsCreateCmd struct { + Name string `name:"name" help:"Label title" required:""` + Type string `name:"type" help:"Label type: ADMIN|SHARED" default:"ADMIN"` +} + +func (c *LabelsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("--name is required") + } + + labelType := strings.ToUpper(strings.TrimSpace(c.Type)) + switch labelType { + case "ADMIN", "SHARED": + default: + return usage("invalid --type (expected ADMIN|SHARED)") + } + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + label := &drivelabels.GoogleAppsDriveLabelsV2Label{ + LabelType: labelType, + Properties: &drivelabels.GoogleAppsDriveLabelsV2LabelProperties{ + Title: name, + }, + } + created, err := svc.Labels.Create(label).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create label: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created label: %s\n", created.Name) + return nil +} + +type LabelsUpdateCmd struct { + LabelID string `arg:"" name:"label-id" help:"Label ID or name"` + Name *string `name:"name" help:"New label title"` +} + +func (c *LabelsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + label := strings.TrimSpace(c.LabelID) + if label == "" { + return usage("label-id is required") + } + label = normalizeLabelName(label) + + if c.Name == nil { + return usage("no updates specified") + } + newTitle := strings.TrimSpace(*c.Name) + if newTitle == "" { + return usage("--name cannot be empty") + } + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + req := &drivelabels.GoogleAppsDriveLabelsV2DeltaUpdateLabelRequest{ + Requests: []*drivelabels.GoogleAppsDriveLabelsV2DeltaUpdateLabelRequestRequest{ + { + UpdateLabel: &drivelabels.GoogleAppsDriveLabelsV2DeltaUpdateLabelRequestUpdateLabelPropertiesRequest{ + Properties: &drivelabels.GoogleAppsDriveLabelsV2LabelProperties{Title: newTitle}, + UpdateMask: "title", + }, + }, + }, + } + + updated, err := svc.Labels.Delta(label, req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update label: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated label: %s\n", label) + return nil +} + +type LabelsDeleteCmd struct { + LabelID string `arg:"" name:"label-id" help:"Label ID or name"` +} + +func (c *LabelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + label := strings.TrimSpace(c.LabelID) + if label == "" { + return usage("label-id is required") + } + label = normalizeLabelName(label) + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete label %s", label)); err != nil { + return err + } + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Labels.Delete(label).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete label: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"label": label, "deleted": true}) + } + + u.Out().Printf("Deleted label: %s\n", label) + return nil +} + +type LabelsPublishCmd struct { + LabelID string `arg:"" name:"label-id" help:"Label ID or name"` +} + +func (c *LabelsPublishCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + label := strings.TrimSpace(c.LabelID) + if label == "" { + return usage("label-id is required") + } + label = normalizeLabelName(label) + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Labels.Publish(label, &drivelabels.GoogleAppsDriveLabelsV2PublishLabelRequest{}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("publish label: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Published label: %s\n", label) + return nil +} + +type LabelsDisableCmd struct { + LabelID string `arg:"" name:"label-id" help:"Label ID or name"` +} + +func (c *LabelsDisableCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + label := strings.TrimSpace(c.LabelID) + if label == "" { + return usage("label-id is required") + } + label = normalizeLabelName(label) + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("disable label %s", label)); err != nil { + return err + } + + svc, err := newDriveLabelsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Labels.Disable(label, &drivelabels.GoogleAppsDriveLabelsV2DisableLabelRequest{}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("disable label: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Disabled label: %s\n", label) + return nil +} + +func normalizeLabelName(input string) string { + trimmed := strings.TrimSpace(input) + if strings.HasPrefix(trimmed, "labels/") { + return trimmed + } + return "labels/" + trimmed +} diff --git a/internal/cmd/labels_test.go b/internal/cmd/labels_test.go new file mode 100644 index 00000000..aab5bddc --- /dev/null +++ b/internal/cmd/labels_test.go @@ -0,0 +1,800 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/drivelabels/v2" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// ========== LabelsListCmd Tests ========== + +func TestLabelsListCmd_Text(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{ + {"name": "labels/label1", "labelType": "ADMIN", "properties": map[string]any{"title": "Confidential"}, "lifecycle": map[string]any{"state": "PUBLISHED"}}, + {"name": "labels/label2", "labelType": "SHARED", "properties": map[string]any{"title": "Public"}, "lifecycle": map[string]any{"state": "DRAFT"}}, + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Confidential") { + t.Fatalf("expected output to contain 'Confidential', got: %s", out) + } + if !strings.Contains(out, "Public") { + t.Fatalf("expected output to contain 'Public', got: %s", out) + } + if !strings.Contains(out, "ADMIN") { + t.Fatalf("expected output to contain 'ADMIN', got: %s", out) + } + if !strings.Contains(out, "PUBLISHED") { + t.Fatalf("expected output to contain 'PUBLISHED', got: %s", out) + } +} + +func TestLabelsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{ + {"name": "labels/label1", "labelType": "ADMIN", "properties": map[string]any{"title": "Confidential"}}, + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsListCmd{} + + ctx := outfmt.WithMode(testContext(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON output: %v, output: %s", err, out) + } + + labels, ok := result["labels"].([]any) + if !ok { + t.Fatalf("expected labels array in JSON output, got: %v", result) + } + if len(labels) != 1 { + t.Fatalf("expected 1 label, got %d", len(labels)) + } +} + +func TestLabelsListCmd_EmptyResults(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{}, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsListCmd{} + + stderr := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStderr(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + }) + + if !strings.Contains(stderr, "No labels") { + t.Fatalf("expected 'No labels' message in stderr, got: %s", stderr) + } +} + +func TestLabelsListCmd_Pagination(t *testing.T) { + var gotPageSize int64 + var gotPageToken string + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + + // Extract query params + if ps := r.URL.Query().Get("pageSize"); ps != "" { + var v int64 + if err := json.Unmarshal([]byte(ps), &v); err == nil { + gotPageSize = v + } + } + gotPageToken = r.URL.Query().Get("pageToken") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{ + {"name": "labels/label1", "labelType": "ADMIN", "properties": map[string]any{"title": "Test"}}, + }, + "nextPageToken": "next-token-123", + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsListCmd{Max: 10, Page: "prev-token"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageSize != 10 { + t.Fatalf("expected pageSize 10, got %d", gotPageSize) + } + if gotPageToken != "prev-token" { + t.Fatalf("expected pageToken 'prev-token', got %q", gotPageToken) + } +} + +func TestLabelsListCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 500, + "message": "Internal server error", + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "list labels") { + t.Fatalf("expected error to contain 'list labels', got: %v", err) + } +} + +// ========== LabelsGetCmd Tests ========== + +func TestLabelsGetCmd_Text(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels/label1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "labels/label1", + "labelType": "ADMIN", + "properties": map[string]any{"title": "Confidential"}, + "lifecycle": map[string]any{"state": "PUBLISHED"}, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsGetCmd{LabelID: "label1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "labels/label1") { + t.Fatalf("expected output to contain 'labels/label1', got: %s", out) + } + if !strings.Contains(out, "Confidential") { + t.Fatalf("expected output to contain 'Confidential', got: %s", out) + } + if !strings.Contains(out, "ADMIN") { + t.Fatalf("expected output to contain 'ADMIN', got: %s", out) + } + if !strings.Contains(out, "PUBLISHED") { + t.Fatalf("expected output to contain 'PUBLISHED', got: %s", out) + } +} + +func TestLabelsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/labels/label1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "labels/label1", + "labelType": "ADMIN", + "properties": map[string]any{"title": "Confidential"}, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsGetCmd{LabelID: "label1"} + + ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON output: %v, output: %s", err, out) + } + + if result["name"] != "labels/label1" { + t.Fatalf("expected name 'labels/label1', got: %v", result["name"]) + } +} + +func TestLabelsGetCmd_NormalizesLabelName(t *testing.T) { + var gotPath string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "labels/my-label", + "labelType": "ADMIN", + "properties": map[string]any{"title": "Test"}, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + // Pass label without "labels/" prefix - should be normalized + cmd := &LabelsGetCmd{LabelID: "my-label"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // The path should contain "labels/my-label" + if !strings.Contains(gotPath, "labels/my-label") { + t.Fatalf("expected path to contain 'labels/my-label', got: %s", gotPath) + } +} + +func TestLabelsGetCmd_EmptyLabelID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsGetCmd{LabelID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty label-id, got nil") + } + if !strings.Contains(err.Error(), "label-id is required") { + t.Fatalf("expected error to contain 'label-id is required', got: %v", err) + } +} + +func TestLabelsGetCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "Label not found", + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsGetCmd{LabelID: "nonexistent"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "get label") { + t.Fatalf("expected error to contain 'get label', got: %v", err) + } +} + +// ========== LabelsCreateCmd Tests ========== + +func TestLabelsCreateCmd_Text(t *testing.T) { + var gotTitle string + var gotType string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + var payload struct { + LabelType string `json:"labelType"` + Properties struct { + Title string `json:"title"` + } `json:"properties"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotType = payload.LabelType + gotTitle = payload.Properties.Title + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "labels/label1", "labelType": payload.LabelType, "properties": map[string]any{"title": payload.Properties.Title}}) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsCreateCmd{Name: "Confidential", Type: "ADMIN"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotTitle != "Confidential" || gotType != "ADMIN" { + t.Fatalf("unexpected payload: title=%q type=%q", gotTitle, gotType) + } + if !strings.Contains(out, "Created label") { + t.Fatalf("expected output to contain 'Created label', got: %s", out) + } +} + +func TestLabelsCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "labels/new-label", + "labelType": "SHARED", + "properties": map[string]any{"title": "My New Label"}, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsCreateCmd{Name: "My New Label", Type: "SHARED"} + + ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON output: %v, output: %s", err, out) + } + + if result["name"] != "labels/new-label" { + t.Fatalf("expected name 'labels/new-label', got: %v", result["name"]) + } +} + +func TestLabelsCreateCmd_EmptyName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsCreateCmd{Name: " ", Type: "ADMIN"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name, got nil") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Fatalf("expected error to contain '--name is required', got: %v", err) + } +} + +func TestLabelsCreateCmd_InvalidType(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsCreateCmd{Name: "Test Label", Type: "INVALID"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for invalid type, got nil") + } + if !strings.Contains(err.Error(), "invalid --type") { + t.Fatalf("expected error to contain 'invalid --type', got: %v", err) + } +} + +func TestLabelsCreateCmd_TypeCaseInsensitive(t *testing.T) { + var gotType string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels") { + http.NotFound(w, r) + return + } + var payload struct { + LabelType string `json:"labelType"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + gotType = payload.LabelType + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "labels/label1"}) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + // Test lowercase input + cmd := &LabelsCreateCmd{Name: "Test", Type: "shared"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotType != "SHARED" { + t.Fatalf("expected type to be normalized to 'SHARED', got: %s", gotType) + } +} + +func TestLabelsCreateCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 403, + "message": "Insufficient permissions", + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsCreateCmd{Name: "Test", Type: "ADMIN"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create label") { + t.Fatalf("expected error to contain 'create label', got: %v", err) + } +} + +// ========== LabelsDeleteCmd Tests ========== + +func TestLabelsDeleteCmd_Text(t *testing.T) { + var deleteCalled bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/v2/labels/label1") { + http.NotFound(w, r) + return + } + deleteCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LabelsDeleteCmd{LabelID: "label1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("expected delete API to be called") + } + if !strings.Contains(out, "Deleted label") { + t.Fatalf("expected output to contain 'Deleted label', got: %s", out) + } +} + +func TestLabelsDeleteCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/v2/labels/label1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LabelsDeleteCmd{LabelID: "label1"} + + ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON output: %v, output: %s", err, out) + } + + if result["deleted"] != true { + t.Fatalf("expected deleted=true, got: %v", result["deleted"]) + } + if result["label"] != "labels/label1" { + t.Fatalf("expected label='labels/label1', got: %v", result["label"]) + } +} + +func TestLabelsDeleteCmd_EmptyLabelID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LabelsDeleteCmd{LabelID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty label-id, got nil") + } + if !strings.Contains(err.Error(), "label-id is required") { + t.Fatalf("expected error to contain 'label-id is required', got: %v", err) + } +} + +func TestLabelsDeleteCmd_NormalizesLabelName(t *testing.T) { + var gotPath string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + // Pass label without "labels/" prefix - should be normalized + cmd := &LabelsDeleteCmd{LabelID: "my-label-to-delete"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(gotPath, "labels/my-label-to-delete") { + t.Fatalf("expected path to contain 'labels/my-label-to-delete', got: %s", gotPath) + } +} + +func TestLabelsDeleteCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "Label not found", + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LabelsDeleteCmd{LabelID: "nonexistent"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "delete label") { + t.Fatalf("expected error to contain 'delete label', got: %v", err) + } +} + +// ========== LabelsUpdateCmd Tests ========== + +func TestLabelsUpdateCmd_Text(t *testing.T) { + var gotTitle string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels/label1:delta") { + http.NotFound(w, r) + return + } + var payload struct { + Requests []struct { + UpdateLabel struct { + Properties struct { + Title string `json:"title"` + } `json:"properties"` + UpdateMask string `json:"updateMask"` + } `json:"updateLabel"` + } `json:"requests"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if len(payload.Requests) > 0 { + gotTitle = payload.Requests[0].UpdateLabel.Properties.Title + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"updated": true}) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + name := "Renamed" + cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &name} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotTitle != "Renamed" { + t.Fatalf("unexpected title: %q", gotTitle) + } + if !strings.Contains(out, "Updated label") { + t.Fatalf("expected output to contain 'Updated label', got: %s", out) + } +} + +func TestLabelsUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/labels/label1:delta") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responses": []map[string]any{{"updateLabel": map[string]any{}}}, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + name := "New Title" + cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &name} + + ctx := outfmt.WithMode(testContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON output: %v, output: %s", err, out) + } + + if _, ok := result["responses"]; !ok { + t.Fatalf("expected 'responses' in JSON output, got: %v", result) + } +} + +func TestLabelsUpdateCmd_EmptyLabelID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + name := "New Title" + cmd := &LabelsUpdateCmd{LabelID: " ", Name: &name} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty label-id, got nil") + } + if !strings.Contains(err.Error(), "label-id is required") { + t.Fatalf("expected error to contain 'label-id is required', got: %v", err) + } +} + +func TestLabelsUpdateCmd_NoUpdates(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LabelsUpdateCmd{LabelID: "label1", Name: nil} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates, got nil") + } + if !strings.Contains(err.Error(), "no updates specified") { + t.Fatalf("expected error to contain 'no updates specified', got: %v", err) + } +} + +func TestLabelsUpdateCmd_EmptyNameValue(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + emptyName := " " + cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &emptyName} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name value, got nil") + } + if !strings.Contains(err.Error(), "--name cannot be empty") { + t.Fatalf("expected error to contain '--name cannot be empty', got: %v", err) + } +} + +func TestLabelsUpdateCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 500, + "message": "Update failed", + }, + }) + }) + stubDriveLabels(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + name := "New Title" + cmd := &LabelsUpdateCmd{LabelID: "label1", Name: &name} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "update label") { + t.Fatalf("expected error to contain 'update label', got: %v", err) + } +} + +// ========== Helper Functions ========== + +func stubDriveLabels(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newDriveLabelsService + svc, err := drivelabels.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new drive labels service: %v", err) + } + newDriveLabelsService = func(context.Context, string) (*drivelabels.Service, error) { return svc, nil } + t.Cleanup(func() { + newDriveLabelsService = orig + srv.Close() + }) +} diff --git a/internal/cmd/licenses.go b/internal/cmd/licenses.go new file mode 100644 index 00000000..a33058f2 --- /dev/null +++ b/internal/cmd/licenses.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/licensing/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newLicensingService = googleapi.NewLicensing + +type LicensesCmd struct { + List LicensesListCmd `cmd:"" name:"list" aliases:"ls" help:"List license assignments"` + Get LicensesGetCmd `cmd:"" name:"get" help:"Get a license assignment"` + Assign LicensesAssignCmd `cmd:"" name:"assign" help:"Assign a license"` + Revoke LicensesRevokeCmd `cmd:"" name:"revoke" help:"Revoke a license"` + Products LicensesProductsCmd `cmd:"" name:"products" help:"List available products and SKUs"` +} + +type LicensesListCmd struct { + Product string `name:"product" help:"Product ID"` + SKU string `name:"sku" help:"SKU ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *LicensesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + product := strings.TrimSpace(c.Product) + if product == "" { + return usage("--product is required") + } + + svc, err := newLicensingService(ctx, account) + if err != nil { + return err + } + + customer := adminCustomerID() + var resp *licensing.LicenseAssignmentList + if strings.TrimSpace(c.SKU) != "" { + call := svc.LicenseAssignments.ListForProductAndSku(product, c.SKU, customer) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err = call.Context(ctx).Do() + } else { + call := svc.LicenseAssignments.ListForProduct(product, customer) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err = call.Context(ctx).Do() + } + if err != nil { + return fmt.Errorf("list licenses: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Items) == 0 { + u.Err().Println("No licenses found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "USER\tPRODUCT\tSKU") + for _, item := range resp.Items { + if item == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(item.UserId), + sanitizeTab(item.ProductId), + sanitizeTab(item.SkuId), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type LicensesGetCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + Product string `name:"product" help:"Product ID" required:""` + SKU string `name:"sku" help:"SKU ID" required:""` +} + +func (c *LicensesGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + user := strings.TrimSpace(c.User) + if user == "" { + return usage("user is required") + } + + svc, err := newLicensingService(ctx, account) + if err != nil { + return err + } + + assignment, err := svc.LicenseAssignments.Get(c.Product, c.SKU, user).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get license for %s: %w", user, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, assignment) + } + + u.Out().Printf("User: %s\n", assignment.UserId) + u.Out().Printf("Product: %s\n", assignment.ProductId) + if assignment.ProductName != "" { + u.Out().Printf("Product Name: %s\n", assignment.ProductName) + } + u.Out().Printf("SKU: %s\n", assignment.SkuId) + if assignment.SkuName != "" { + u.Out().Printf("SKU Name: %s\n", assignment.SkuName) + } + return nil +} + +type LicensesAssignCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + Product string `name:"product" help:"Product ID" required:""` + SKU string `name:"sku" help:"SKU ID" required:""` +} + +func (c *LicensesAssignCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + user := strings.TrimSpace(c.User) + if user == "" { + return usage("user is required") + } + + svc, err := newLicensingService(ctx, account) + if err != nil { + return err + } + + insert := &licensing.LicenseAssignmentInsert{UserId: user} + assignment, err := svc.LicenseAssignments.Insert(c.Product, c.SKU, insert).Context(ctx).Do() + if err != nil { + return fmt.Errorf("assign license to %s: %w", user, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, assignment) + } + + u.Out().Printf("Assigned license %s/%s to %s\n", assignment.ProductId, assignment.SkuId, assignment.UserId) + return nil +} + +type LicensesRevokeCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + Product string `name:"product" help:"Product ID" required:""` + SKU string `name:"sku" help:"SKU ID" required:""` +} + +func (c *LicensesRevokeCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + user := strings.TrimSpace(c.User) + if user == "" { + return usage("user is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("revoke license %s/%s for %s", c.Product, c.SKU, user)); err != nil { + return err + } + + svc, err := newLicensingService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.LicenseAssignments.Delete(c.Product, c.SKU, user).Context(ctx).Do(); err != nil { + return fmt.Errorf("revoke license for %s: %w", user, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"user": user, "product": c.Product, "sku": c.SKU, "revoked": true}) + } + + u.Out().Printf("Revoked license %s/%s for %s\n", c.Product, c.SKU, user) + return nil +} + +type LicensesProductsCmd struct{} + +func (c *LicensesProductsCmd) Run(ctx context.Context, _ *RootFlags) error { + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, licenseProducts) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PRODUCT ID\tPRODUCT NAME\tSKU ID\tSKU NAME") + for _, product := range licenseProducts { + for _, sku := range product.SKUs { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(product.ID), + sanitizeTab(product.Name), + sanitizeTab(sku.ID), + sanitizeTab(sku.Name), + ) + } + } + return nil +} + +type licenseSKU struct { + ID string `json:"id"` + Name string `json:"name"` + UnarchivalProduct string `json:"unarchivalProduct,omitempty"` + UnarchivalSKU string `json:"unarchivalSku,omitempty"` + UnarchivalSKUName string `json:"unarchivalSkuName,omitempty"` + UnarchivalProdName string `json:"unarchivalProductName,omitempty"` +} + +type licenseProduct struct { + ID string `json:"id"` + Name string `json:"name"` + SKUs []licenseSKU `json:"skus"` +} + +var licenseProducts = []licenseProduct{ + { + ID: "Google-Apps", + Name: "Google Workspace", + SKUs: []licenseSKU{ + {ID: "1010020027", Name: "Google Workspace Business Starter"}, + {ID: "1010020028", Name: "Google Workspace Business Standard"}, + {ID: "1010020025", Name: "Google Workspace Business Plus"}, + {ID: "1010060003", Name: "Google Workspace Enterprise Essentials"}, + {ID: "1010020029", Name: "Google Workspace Enterprise Starter"}, + {ID: "1010020026", Name: "Google Workspace Enterprise Standard"}, + {ID: "1010020020", Name: "Google Workspace Enterprise Plus (formerly G Suite Enterprise)"}, + {ID: "1010060001", Name: "Google Workspace Essentials (formerly G Suite Essentials)"}, + {ID: "1010060005", Name: "Google Workspace Enterprise Essentials Plus"}, + {ID: "1010020030", Name: "Google Workspace Frontline Starter"}, + {ID: "1010020031", Name: "Google Workspace Frontline Standard"}, + {ID: "1010020034", Name: "Google Workspace Frontline Plus"}, + {ID: "1010020035", Name: "Google Workspace Business Continuity"}, + {ID: "1010020036", Name: "Google Workspace Business Continuity Plus"}, + {ID: "Google-Apps-Unlimited", Name: "G Suite Business"}, + {ID: "Google-Apps-For-Business", Name: "G Suite Basic"}, + {ID: "Google-Apps-Lite", Name: "G Suite Lite"}, + {ID: "Google-Apps-For-Postini", Name: "Google Apps Message Security"}, + {ID: "Google-Apps-For-Education", Name: "Google Workspace for Education - Fundamentals"}, + {ID: "1010070001", Name: "Google Workspace for Education Fundamentals"}, + {ID: "1010070004", Name: "Google Workspace for Education Gmail Only"}, + }, + }, + { + ID: "101047", + Name: "Google AI", + SKUs: []licenseSKU{ + {ID: "1010470008", Name: "Google AI Ultra for Business"}, + {ID: "1010470004", Name: "Google AI Pro for Education"}, + {ID: "1010470005", Name: "Gemini Education Premium"}, + }, + }, + { + ID: "101031", + Name: "Google Workspace for Education", + SKUs: []licenseSKU{ + {ID: "1010310005", Name: "Google Workspace for Education Standard"}, + {ID: "1010310006", Name: "Google Workspace for Education Standard (Staff)"}, + {ID: "1010310007", Name: "Google Workspace for Education Standard (Extra Student)"}, + {ID: "1010310008", Name: "Google Workspace for Education Plus"}, + {ID: "1010310009", Name: "Google Workspace for Education Plus (Staff)"}, + {ID: "1010310010", Name: "Google Workspace for Education Plus (Extra Student)"}, + {ID: "1010310002", Name: "Google Workspace for Education Plus - Legacy"}, + {ID: "1010310003", Name: "Google Workspace for Education Plus - Legacy (Student)"}, + }, + }, + { + ID: "101037", + Name: "Google Workspace for Education: Teaching and Learning Upgrade", + SKUs: []licenseSKU{ + {ID: "1010370001", Name: "Google Workspace for Education: Teaching and Learning Upgrade"}, + }, + }, + { + ID: "101038", + Name: "AppSheet", + SKUs: []licenseSKU{ + {ID: "1010380001", Name: "AppSheet Core"}, + {ID: "1010380002", Name: "AppSheet Enterprise Standard"}, + {ID: "1010380003", Name: "AppSheet Enterprise Plus"}, + }, + }, + { + ID: "Google-Vault", + Name: "Google Vault", + SKUs: []licenseSKU{ + {ID: "Google-Vault", Name: "Google Vault"}, + {ID: "Google-Vault-Former-Employee", Name: "Google Vault - Former Employee"}, + }, + }, + { + ID: "101001", + Name: "Cloud Identity", + SKUs: []licenseSKU{ + {ID: "1010010001", Name: "Cloud Identity"}, + }, + }, + { + ID: "101005", + Name: "Cloud Identity Premium", + SKUs: []licenseSKU{ + {ID: "1010050001", Name: "Cloud Identity Premium"}, + }, + }, + { + ID: "101033", + Name: "Google Voice", + SKUs: []licenseSKU{ + {ID: "1010330003", Name: "Google Voice Starter"}, + {ID: "1010330004", Name: "Google Voice Standard"}, + {ID: "1010330002", Name: "Google Voice Premier"}, + }, + }, + { + ID: "101034", + Name: "Google Workspace Archived User", + SKUs: []licenseSKU{ + {ID: "1010340007", Name: "Google Workspace for Education Fundamentals - Archived User"}, + {ID: "1010340004", Name: "Google Workspace Enterprise Standard - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020026"}, + {ID: "1010340001", Name: "Google Workspace Enterprise Plus - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020020"}, + {ID: "1010340005", Name: "Google Workspace Business Starter - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020027"}, + {ID: "1010340006", Name: "Google Workspace Business Standard - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020028"}, + {ID: "1010340003", Name: "Google Workspace Business Plus - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "1010020025"}, + {ID: "1010340002", Name: "G Suite Business - Archived User", UnarchivalProduct: "Google-Apps", UnarchivalSKU: "Google-Apps-Unlimited"}, + }, + }, +} diff --git a/internal/cmd/licenses_test.go b/internal/cmd/licenses_test.go new file mode 100644 index 00000000..9cdeab5f --- /dev/null +++ b/internal/cmd/licenses_test.go @@ -0,0 +1,480 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/licensing/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestLicensesListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/licensing/v1/product/Google-Apps/sku/1010020027/users") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"userId": "user@example.com", "productId": "Google-Apps", "skuId": "1010020027"}, + }, + }) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesListCmd{Product: "Google-Apps", SKU: "1010020027"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "user@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLicensesListCmd_ProductOnly(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Should call ListForProduct, not ListForProductAndSku + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/licensing/v1/product/Google-Apps/users") { + http.NotFound(w, r) + return + } + // Ensure it's not calling the SKU-specific endpoint + if strings.Contains(r.URL.Path, "/sku/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"userId": "user1@example.com", "productId": "Google-Apps", "skuId": "1010020027"}, + {"userId": "user2@example.com", "productId": "Google-Apps", "skuId": "1010020028"}, + }, + }) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesListCmd{Product: "Google-Apps"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "user1@example.com") || !strings.Contains(out, "user2@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLicensesListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"userId": "user@example.com", "productId": "Google-Apps", "skuId": "1010020027"}, + }, + }) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesListCmd{Product: "Google-Apps", SKU: "1010020027"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"items"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestLicensesListCmd_MissingProduct(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing product") + } + if !strings.Contains(err.Error(), "--product is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLicensesGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if !strings.Contains(r.URL.Path, "/user@example.com") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "userId": "user@example.com", + "productId": "Google-Apps", + "productName": "Google Workspace", + "skuId": "1010020027", + "skuName": "Google Workspace Business Starter", + }) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesGetCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "User:") || !strings.Contains(out, "user@example.com") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Product:") || !strings.Contains(out, "Google-Apps") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "SKU:") || !strings.Contains(out, "1010020027") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLicensesGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "userId": "user@example.com", + "productId": "Google-Apps", + "skuId": "1010020027", + }) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesGetCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"userId"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestLicensesGetCmd_MissingUser(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesGetCmd{User: "", Product: "Google-Apps", SKU: "1010020027"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing user") + } + if !strings.Contains(err.Error(), "user is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLicensesGetCmd_WithProductAndSkuName(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "userId": "user@example.com", + "productId": "Google-Apps", + "productName": "Google Workspace", + "skuId": "1010020027", + "skuName": "Business Starter", + }) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesGetCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Product Name:") || !strings.Contains(out, "Google Workspace") { + t.Fatalf("expected product name, got: %s", out) + } + if !strings.Contains(out, "SKU Name:") || !strings.Contains(out, "Business Starter") { + t.Fatalf("expected sku name, got: %s", out) + } +} + +func TestLicensesAssignCmd(t *testing.T) { + var gotUser string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/apps/licensing/v1/product/Google-Apps/sku/1010020027/user"): + var payload struct { + UserId string `json:"userId"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotUser = payload.UserId + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "userId": payload.UserId, + "productId": "Google-Apps", + "skuId": "1010020027", + }) + return + default: + http.NotFound(w, r) + } + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesAssignCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotUser != "user@example.com" { + t.Fatalf("unexpected user: %q", gotUser) + } + if !strings.Contains(out, "Assigned license") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLicensesAssignCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "userId": "user@example.com", + "productId": "Google-Apps", + "skuId": "1010020027", + }) + return + } + http.NotFound(w, r) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesAssignCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"userId"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestLicensesAssignCmd_MissingUser(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &LicensesAssignCmd{User: " ", Product: "Google-Apps", SKU: "1010020027"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing user") + } + if !strings.Contains(err.Error(), "user is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLicensesRevokeCmd(t *testing.T) { + var deleteCalled bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/user@example.com") { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LicensesRevokeCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("delete was not called") + } + if !strings.Contains(out, "Revoked license") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLicensesRevokeCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubLicensing(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LicensesRevokeCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"revoked"`) || !strings.Contains(out, `true`) { + t.Fatalf("expected JSON output with revoked: true, got: %s", out) + } +} + +func TestLicensesRevokeCmd_MissingUser(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &LicensesRevokeCmd{User: "", Product: "Google-Apps", SKU: "1010020027"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing user") + } + if !strings.Contains(err.Error(), "user is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLicensesRevokeCmd_RequiresConfirmation(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: false, NoInput: true} + cmd := &LicensesRevokeCmd{User: "user@example.com", Product: "Google-Apps", SKU: "1010020027"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when confirmation is required but not provided") + } + if !strings.Contains(err.Error(), "refusing") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLicensesProductsCmd(t *testing.T) { + cmd := &LicensesProductsCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Google-Apps") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLicensesProductsCmd_JSON(t *testing.T) { + cmd := &LicensesProductsCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"id"`) || !strings.Contains(out, `"skus"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestLicensesProductsCmd_ContainsExpectedProducts(t *testing.T) { + cmd := &LicensesProductsCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + expectedProducts := []string{ + "Google-Apps", + "Google Workspace", + "Cloud Identity", + "Google Vault", + } + for _, p := range expectedProducts { + if !strings.Contains(out, p) { + t.Errorf("expected output to contain %q", p) + } + } +} + +func stubLicensing(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newLicensingService + svc, err := licensing.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new licensing service: %v", err) + } + newLicensingService = func(context.Context, string) (*licensing.Service, error) { return svc, nil } + t.Cleanup(func() { + newLicensingService = orig + srv.Close() + }) +} diff --git a/internal/cmd/lookerstudio.go b/internal/cmd/lookerstudio.go new file mode 100644 index 00000000..2b139fa6 --- /dev/null +++ b/internal/cmd/lookerstudio.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// LookerStudioCmd manages Looker Studio assets via Drive permissions. +type LookerStudioCmd struct { + Permissions LookerStudioPermissionsCmd `cmd:"" name:"permissions" help:"Manage Looker Studio permissions"` +} + +type LookerStudioPermissionsCmd struct { + List LookerStudioPermissionsListCmd `cmd:"" name:"list" help:"List permissions for a Looker Studio asset"` + Add LookerStudioPermissionsAddCmd `cmd:"" name:"add" help:"Add a permission to a Looker Studio asset"` + Remove LookerStudioPermissionsRemoveCmd `cmd:"" name:"remove" help:"Remove a permission from a Looker Studio asset"` +} + +type LookerStudioPermissionsListCmd struct { + AssetID string `arg:"" name:"asset-id" help:"Looker Studio asset ID (Drive file ID)"` + User string `name:"user" help:"User email to list permissions as"` +} + +func (c *LookerStudioPermissionsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + assetID := strings.TrimSpace(c.AssetID) + if assetID == "" { + return usage("asset-id is required") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Permissions.List(assetID).SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list permissions: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Permissions) == 0 { + u.Err().Println("No permissions found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL") + for _, perm := range resp.Permissions { + if perm == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(perm.Id), + sanitizeTab(perm.Type), + sanitizeTab(perm.Role), + sanitizeTab(perm.EmailAddress), + ) + } + return nil +} + +type LookerStudioPermissionsAddCmd struct { + AssetID string `arg:"" name:"asset-id" help:"Looker Studio asset ID (Drive file ID)"` + User string `name:"user" help:"User email to apply permission as"` + Email string `name:"email" help:"User email to grant access" required:""` + Role string `name:"role" help:"Permission role: VIEWER|EDITOR" default:"VIEWER"` +} + +func (c *LookerStudioPermissionsAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + assetID := strings.TrimSpace(c.AssetID) + if assetID == "" { + return usage("asset-id is required") + } + email := strings.TrimSpace(c.Email) + if email == "" { + return usage("--email is required") + } + + role := strings.ToUpper(strings.TrimSpace(c.Role)) + var driveRole string + switch role { + case "VIEWER": + driveRole = "reader" + case "EDITOR": + driveRole = "writer" + default: + return usage("invalid --role (expected VIEWER|EDITOR)") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + perm := &drive.Permission{Type: "user", Role: driveRole, EmailAddress: email} + created, err := svc.Permissions.Create(assetID, perm). + SupportsAllDrives(true). + SendNotificationEmail(false). + Fields("id, type, role, emailAddress"). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("add permission: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Added permission: %s (%s)\n", created.Id, email) + return nil +} + +type LookerStudioPermissionsRemoveCmd struct { + AssetID string `arg:"" name:"asset-id" help:"Looker Studio asset ID (Drive file ID)"` + PermissionID string `arg:"" name:"permission-id" help:"Permission ID"` + User string `name:"user" help:"User email to apply permission as"` +} + +func (c *LookerStudioPermissionsRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + assetID := strings.TrimSpace(c.AssetID) + permissionID := strings.TrimSpace(c.PermissionID) + if assetID == "" || permissionID == "" { + return usage("asset-id and permission-id are required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from asset %s", permissionID, assetID)); err != nil { + return err + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + if err := svc.Permissions.Delete(assetID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + return fmt.Errorf("remove permission: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"removed": true, "permissionId": permissionID}) + } + + u.Out().Printf("Removed permission: %s\n", permissionID) + return nil +} diff --git a/internal/cmd/lookerstudio_test.go b/internal/cmd/lookerstudio_test.go new file mode 100644 index 00000000..93e66174 --- /dev/null +++ b/internal/cmd/lookerstudio_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestLookerStudioPermissionsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/asset1/permissions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "permissions": []map[string]any{ + {"id": "perm1", "type": "user", "role": "reader", "emailAddress": "viewer@example.com"}, + }, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &LookerStudioPermissionsListCmd{AssetID: "asset1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "viewer@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLookerStudioPermissionsAddCmd(t *testing.T) { + var gotRole string + var gotEmail string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/files/asset1/permissions") { + http.NotFound(w, r) + return + } + var payload struct { + Role string `json:"role"` + Type string `json:"type"` + Email string `json:"emailAddress"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotRole = payload.Role + gotEmail = payload.Email + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "perm1", "role": payload.Role, "emailAddress": payload.Email}) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &LookerStudioPermissionsAddCmd{AssetID: "asset1", Email: "editor@example.com", Role: "EDITOR"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotRole != "writer" || gotEmail != "editor@example.com" { + t.Fatalf("unexpected permission payload: role=%q email=%q", gotRole, gotEmail) + } + if !strings.Contains(out, "Added permission") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLookerStudioPermissionsRemoveCmd(t *testing.T) { + var deleted bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/files/asset1/permissions/perm1") { + http.NotFound(w, r) + return + } + deleted = true + w.WriteHeader(http.StatusNoContent) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &LookerStudioPermissionsRemoveCmd{AssetID: "asset1", PermissionID: "perm1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleted { + t.Fatalf("expected delete request") + } + if !strings.Contains(out, "Removed permission") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestLookerStudioPermissionsAddCmd_BadRole(t *testing.T) { + cmd := &LookerStudioPermissionsAddCmd{AssetID: "asset1", Email: "user@example.com", Role: "OWNER"} + if err := cmd.Run(context.Background(), &RootFlags{Account: "user@example.com"}); err == nil { + t.Fatalf("expected error") + } +} diff --git a/internal/cmd/meet.go b/internal/cmd/meet.go new file mode 100644 index 00000000..3be2ba77 --- /dev/null +++ b/internal/cmd/meet.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newMeetService = googleapi.NewMeet + +type MeetCmd struct { + Spaces MeetSpacesCmd `cmd:"" name:"spaces" help:"Manage Meet spaces"` +} + +type MeetSpacesCmd struct { + List MeetSpacesListCmd `cmd:"" name:"list" aliases:"ls" help:"List meeting spaces"` + Get MeetSpacesGetCmd `cmd:"" name:"get" help:"Get meeting space"` + Create MeetSpacesCreateCmd `cmd:"" name:"create" help:"Create meeting space"` + End MeetSpacesEndCmd `cmd:"" name:"end" help:"End active conference in space"` +} + +type MeetSpacesListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *MeetSpacesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newMeetService(ctx, account) + if err != nil { + return err + } + + call := svc.ConferenceRecords.List() + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list conference records: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.ConferenceRecords) == 0 { + u.Err().Println("No meeting spaces found") + return nil + } + + latest := make(map[string]*meet.ConferenceRecord) + for _, record := range resp.ConferenceRecords { + if record == nil || record.Space == "" { + continue + } + if cur, ok := latest[record.Space]; !ok || cur.StartTime < record.StartTime { + latest[record.Space] = record + } + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "SPACE\tLAST START\tLAST END") + for space, record := range latest { + endTime := "" + if record != nil { + endTime = record.EndTime + } + fmt.Fprintf(w, "%s\t%s\t%s\n", sanitizeTab(space), sanitizeTab(record.StartTime), sanitizeTab(endTime)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type MeetSpacesGetCmd struct { + Space string `arg:"" name:"space" help:"Space name or meeting code"` +} + +func (c *MeetSpacesGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + space := strings.TrimSpace(c.Space) + if space == "" { + return usage("space is required") + } + if !strings.HasPrefix(space, "spaces/") { + space = "spaces/" + space + } + + svc, err := newMeetService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Spaces.Get(space).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get space %s: %w", space, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Name: %s\n", resp.Name) + u.Out().Printf("Meeting Code: %s\n", resp.MeetingCode) + u.Out().Printf("Meeting URI: %s\n", resp.MeetingUri) + if resp.Config != nil { + u.Out().Printf("Access Type: %s\n", resp.Config.AccessType) + } + return nil +} + +type MeetSpacesCreateCmd struct { + AccessType string `name:"access-type" help:"Access type: OPEN|TRUSTED|RESTRICTED"` +} + +func (c *MeetSpacesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + var cfg *meet.SpaceConfig + if strings.TrimSpace(c.AccessType) != "" { + accessType := strings.ToUpper(strings.TrimSpace(c.AccessType)) + switch accessType { + case "OPEN", "TRUSTED", "RESTRICTED": + cfg = &meet.SpaceConfig{AccessType: accessType} + default: + return usage("invalid --access-type (expected OPEN|TRUSTED|RESTRICTED)") + } + } + + svc, err := newMeetService(ctx, account) + if err != nil { + return err + } + + space := &meet.Space{Config: cfg} + created, err := svc.Spaces.Create(space).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create space: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created space: %s (%s)\n", created.Name, created.MeetingUri) + return nil +} + +type MeetSpacesEndCmd struct { + Space string `arg:"" name:"space" help:"Space name or meeting code"` +} + +func (c *MeetSpacesEndCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + space := strings.TrimSpace(c.Space) + if space == "" { + return usage("space is required") + } + if !strings.HasPrefix(space, "spaces/") { + space = "spaces/" + space + } + + svc, err := newMeetService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Spaces.EndActiveConference(space, &meet.EndActiveConferenceRequest{}).Context(ctx).Do(); err != nil { + return fmt.Errorf("end active conference: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"space": space, "ended": true}) + } + + u.Out().Printf("Ended active conference in %s\n", space) + return nil +} diff --git a/internal/cmd/meet_test.go b/internal/cmd/meet_test.go new file mode 100644 index 00000000..28a2ac18 --- /dev/null +++ b/internal/cmd/meet_test.go @@ -0,0 +1,638 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/meet/v2" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/ui" +) + +// Helper functions + +func testMeetContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func testMeetContextWithStdout(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func stubMeet(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newMeetService + svc, err := meet.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new meet service: %v", err) + } + newMeetService = func(context.Context, string) (*meet.Service, error) { return svc, nil } + t.Cleanup(func() { + newMeetService = orig + srv.Close() + }) +} + +// MeetSpacesListCmd tests + +func TestMeetSpacesListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{ + {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T00:00:00Z"}, + }, + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextJSON(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed struct { + ConferenceRecords []struct { + Name string `json:"name"` + Space string `json:"space"` + StartTime string `json:"startTime"` + } `json:"conferenceRecords"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.ConferenceRecords) != 1 { + t.Fatalf("expected 1 record, got %d", len(parsed.ConferenceRecords)) + } + if parsed.ConferenceRecords[0].Space != "spaces/space1" { + t.Fatalf("unexpected space: %s", parsed.ConferenceRecords[0].Space) + } +} + +func TestMeetSpacesListCmd_EmptyResults(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{}, + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesListCmd{} + + // Empty results should not error, just show message on stderr + if err := cmd.Run(testMeetContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestMeetSpacesListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{ + {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T00:00:00Z"}, + }, + "nextPageToken": "token123", + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesListCmd{} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["nextPageToken"] != "token123" { + t.Fatalf("unexpected nextPageToken: %v", parsed["nextPageToken"]) + } +} + +func TestMeetSpacesListCmd_WithPaging(t *testing.T) { + var gotPageSize, gotPageToken string + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") { + http.NotFound(w, r) + return + } + gotPageSize = r.URL.Query().Get("pageSize") + gotPageToken = r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{ + {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T00:00:00Z"}, + }, + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesListCmd{Max: 25, Page: "mytoken"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testMeetContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageSize != "25" { + t.Fatalf("unexpected pageSize: %q", gotPageSize) + } + if gotPageToken != "mytoken" { + t.Fatalf("unexpected pageToken: %q", gotPageToken) + } +} + +func TestMeetSpacesListCmd_DeduplicatesSpaces(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/conferenceRecords") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{ + {"name": "conferenceRecords/1", "space": "spaces/space1", "startTime": "2026-01-01T10:00:00Z", "endTime": "2026-01-01T11:00:00Z"}, + {"name": "conferenceRecords/2", "space": "spaces/space1", "startTime": "2026-01-02T10:00:00Z", "endTime": "2026-01-02T11:00:00Z"}, + {"name": "conferenceRecords/3", "space": "spaces/space2", "startTime": "2026-01-01T10:00:00Z"}, + }, + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testMeetContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should show each space once with the latest record + space1Count := strings.Count(out, "spaces/space1") + space2Count := strings.Count(out, "spaces/space2") + if space1Count != 1 || space2Count != 1 { + t.Fatalf("expected each space once, got space1=%d space2=%d in output=%q", space1Count, space2Count, out) + } + + // Should show the later date for space1 (2026-01-02) + if !strings.Contains(out, "2026-01-02") { + t.Fatalf("expected newer date for space1, got=%q", out) + } +} + +// MeetSpacesGetCmd tests + +func TestMeetSpacesGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/spaces/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/abc123", + "meetingCode": "abc-defg-hij", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "config": map[string]any{ + "accessType": "TRUSTED", + }, + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesGetCmd{Space: "abc123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "spaces/abc123") || !strings.Contains(out, "abc-defg-hij") || !strings.Contains(out, "TRUSTED") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestMeetSpacesGetCmd_EmptySpace(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesGetCmd{Space: ""} + + err := cmd.Run(testMeetContext(t), flags) + if err == nil { + t.Fatal("expected error for empty space") + } + if !strings.Contains(err.Error(), "space is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMeetSpacesGetCmd_WithSpacePrefix(t *testing.T) { + var gotPath string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/spaces/") { + http.NotFound(w, r) + return + } + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/abc123", + "meetingCode": "abc-defg-hij", + "meetingUri": "https://meet.google.com/abc-defg-hij", + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesGetCmd{Space: "spaces/abc123"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should not double the prefix + if strings.Contains(gotPath, "spaces/spaces/") { + t.Fatalf("unexpected double prefix in path: %q", gotPath) + } +} + +func TestMeetSpacesGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v2/spaces/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/abc123", + "meetingCode": "abc-defg-hij", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "config": map[string]any{ + "accessType": "OPEN", + }, + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesGetCmd{Space: "abc123"} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["name"] != "spaces/abc123" { + t.Fatalf("unexpected name: %v", parsed["name"]) + } +} + +// MeetSpacesCreateCmd tests + +func TestMeetSpacesCreateCmd(t *testing.T) { + var gotAccess string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") { + http.NotFound(w, r) + return + } + var payload struct { + Config struct { + AccessType string `json:"accessType"` + } `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotAccess = payload.Config.AccessType + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "spaces/space1", "meetingUri": "https://meet.google.com/abc-defg-hij"}) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesCreateCmd{AccessType: "OPEN"} + + out := captureStdout(t, func() { + if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotAccess != "OPEN" { + t.Fatalf("unexpected access type: %q", gotAccess) + } + if !strings.Contains(out, "Created space") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestMeetSpacesCreateCmd_NoAccessType(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/newspace", + "meetingCode": "new-meet-ing", + "meetingUri": "https://meet.google.com/new-meet-ing", + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesCreateCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextJSON(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed struct { + Name string `json:"name"` + MeetingCode string `json:"meetingCode"` + MeetingURI string `json:"meetingUri"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.Name != "spaces/newspace" { + t.Fatalf("unexpected name: %s", parsed.Name) + } +} + +func TestMeetSpacesCreateCmd_InvalidAccessType(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesCreateCmd{AccessType: "INVALID"} + + err := cmd.Run(testMeetContext(t), flags) + if err == nil { + t.Fatal("expected error for invalid access type") + } + if !strings.Contains(err.Error(), "invalid --access-type") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMeetSpacesCreateCmd_AllAccessTypes(t *testing.T) { + for _, accessType := range []string{"OPEN", "TRUSTED", "RESTRICTED", "open", "trusted", "restricted"} { + t.Run(accessType, func(t *testing.T) { + var gotAccess string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") { + http.NotFound(w, r) + return + } + var payload struct { + Config struct { + AccessType string `json:"accessType"` + } `json:"config"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + gotAccess = payload.Config.AccessType + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/space1", + "meetingUri": "https://meet.google.com/abc-defg-hij", + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesCreateCmd{AccessType: accessType} + + _ = captureStdout(t, func() { + if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + expected := strings.ToUpper(accessType) + if gotAccess != expected { + t.Fatalf("expected access type %q, got %q", expected, gotAccess) + } + }) + } +} + +func TestMeetSpacesCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "spaces/newspace", + "meetingCode": "new-meet-ing", + "meetingUri": "https://meet.google.com/new-meet-ing", + }) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesCreateCmd{} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["name"] != "spaces/newspace" { + t.Fatalf("unexpected name: %v", parsed["name"]) + } +} + +// MeetSpacesEndCmd tests + +func TestMeetSpacesEndCmd(t *testing.T) { + var ended bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v2/spaces/space1:endActiveConference") { + http.NotFound(w, r) + return + } + ended = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesEndCmd{Space: "space1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !ended { + t.Fatalf("expected end request") + } + if !strings.Contains(out, "Ended active conference") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestMeetSpacesEndCmd_EmptySpace(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesEndCmd{Space: ""} + + err := cmd.Run(testMeetContext(t), flags) + if err == nil { + t.Fatal("expected error for empty space") + } + if !strings.Contains(err.Error(), "space is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMeetSpacesEndCmd_WhitespaceOnlySpace(t *testing.T) { + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesEndCmd{Space: " "} + + err := cmd.Run(testMeetContext(t), flags) + if err == nil { + t.Fatal("expected error for whitespace-only space") + } + if !strings.Contains(err.Error(), "space is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMeetSpacesEndCmd_WithSpacePrefix(t *testing.T) { + var gotPath string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":endActiveConference") { + http.NotFound(w, r) + return + } + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesEndCmd{Space: "spaces/abc123"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should not double the prefix + if strings.Contains(gotPath, "spaces/spaces/") { + t.Fatalf("unexpected double prefix in path: %q", gotPath) + } +} + +func TestMeetSpacesEndCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":endActiveConference") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubMeet(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &MeetSpacesEndCmd{Space: "abc123"} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["space"] != "spaces/abc123" { + t.Fatalf("unexpected space: %v", parsed["space"]) + } + if parsed["ended"] != true { + t.Fatalf("unexpected ended: %v", parsed["ended"]) + } +} diff --git a/internal/cmd/orgunits.go b/internal/cmd/orgunits.go new file mode 100644 index 00000000..68841497 --- /dev/null +++ b/internal/cmd/orgunits.go @@ -0,0 +1,9 @@ +package cmd + +type OrgunitsCmd struct { + List OrgunitsListCmd `cmd:"" name:"list" aliases:"ls" help:"List organizational units"` + Get OrgunitsGetCmd `cmd:"" name:"get" help:"Get organizational unit"` + Create OrgunitsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create organizational unit"` + Update OrgunitsUpdateCmd `cmd:"" name:"update" help:"Update organizational unit"` + Delete OrgunitsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete organizational unit"` +} diff --git a/internal/cmd/orgunits_create.go b/internal/cmd/orgunits_create.go new file mode 100644 index 00000000..cf68c475 --- /dev/null +++ b/internal/cmd/orgunits_create.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type OrgunitsCreateCmd struct { + Name string `arg:"" name:"name" help:"Org unit name"` + Parent string `name:"parent" help:"Parent org unit path"` + Description string `name:"description" help:"Description"` +} + +func (c *OrgunitsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + parent := strings.TrimSpace(c.Parent) + if parent == "" { + parent = "/" + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + org := &admin.OrgUnit{ + Name: c.Name, + ParentOrgUnitPath: parent, + Description: c.Description, + } + + created, err := svc.Orgunits.Insert(adminCustomerID(), org).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create org unit: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created org unit: %s (%s)\n", created.Name, created.OrgUnitPath) + return nil +} diff --git a/internal/cmd/orgunits_delete.go b/internal/cmd/orgunits_delete.go new file mode 100644 index 00000000..8f1d8bcd --- /dev/null +++ b/internal/cmd/orgunits_delete.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/steipete/gogcli/internal/ui" +) + +type OrgunitsDeleteCmd struct { + Path string `arg:"" name:"path" help:"Org unit path or ID"` +} + +func (c *OrgunitsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete org unit %s", c.Path)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Orgunits.Delete(adminCustomerID(), c.Path).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete org unit %s: %w", c.Path, err) + } + + u.Out().Printf("Deleted org unit: %s\n", c.Path) + return nil +} diff --git a/internal/cmd/orgunits_get.go b/internal/cmd/orgunits_get.go new file mode 100644 index 00000000..27b15540 --- /dev/null +++ b/internal/cmd/orgunits_get.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type OrgunitsGetCmd struct { + Path string `arg:"" name:"path" help:"Org unit path or ID"` +} + +func (c *OrgunitsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + ou, err := svc.Orgunits.Get(adminCustomerID(), c.Path).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get org unit %s: %w", c.Path, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, ou) + } + + u.Out().Printf("Name: %s\n", ou.Name) + u.Out().Printf("Path: %s\n", ou.OrgUnitPath) + u.Out().Printf("ID: %s\n", ou.OrgUnitId) + if ou.ParentOrgUnitPath != "" { + u.Out().Printf("Parent Path: %s\n", ou.ParentOrgUnitPath) + } + if ou.ParentOrgUnitId != "" { + u.Out().Printf("Parent ID: %s\n", ou.ParentOrgUnitId) + } + if ou.Description != "" { + u.Out().Printf("Description: %s\n", ou.Description) + } + return nil +} diff --git a/internal/cmd/orgunits_list.go b/internal/cmd/orgunits_list.go new file mode 100644 index 00000000..27f87099 --- /dev/null +++ b/internal/cmd/orgunits_list.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type OrgunitsListCmd struct { + Parent string `name:"parent" help:"Parent org unit path (default: /)"` + Type string `name:"type" default:"children" enum:"all,children,allIncludingParent" help:"Whether to return all descendants or immediate children"` + ToDrive ToDriveFlags `embed:""` +} + +func (c *OrgunitsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + parent := strings.TrimSpace(c.Parent) + if parent == "" { + parent = "/" + } + + resp, err := svc.Orgunits.List(adminCustomerID()). + OrgUnitPath(parent). + Type(c.Type). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("list org units: %w", err) + } + + if len(resp.OrganizationUnits) == 0 { + u.Err().Println("No organizational units found") + return nil + } + + rows := make([][]string, 0, len(resp.OrganizationUnits)) + for _, ou := range resp.OrganizationUnits { + if ou == nil { + continue + } + rows = append(rows, toDriveRow( + ou.OrgUnitPath, + ou.Name, + ou.OrgUnitId, + ou.Description, + )) + } + + if ok, err := writeToDrive(ctx, flags, toDriveTitle("Org Units", c.ToDrive), []string{"PATH", "NAME", "ID", "DESCRIPTION"}, rows, c.ToDrive); ok { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PATH\tNAME\tID\tDESCRIPTION") + for _, row := range rows { + if len(row) < 4 { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(row[0]), + sanitizeTab(row[1]), + sanitizeTab(row[2]), + sanitizeTab(row[3]), + ) + } + return nil +} diff --git a/internal/cmd/orgunits_test.go b/internal/cmd/orgunits_test.go new file mode 100644 index 00000000..865b32da --- /dev/null +++ b/internal/cmd/orgunits_test.go @@ -0,0 +1,805 @@ +package cmd + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestOrgunitsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "organizationUnits": []map[string]any{ + {"name": "Sales", "orgUnitPath": "/Sales", "orgUnitId": "ou-1"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Sales") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestOrgunitsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "organizationUnits": []map[string]any{ + {"name": "Engineering", "orgUnitPath": "/Engineering", "orgUnitId": "ou-2", "description": "Engineering team"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "organizationUnits") { + t.Fatalf("expected JSON output, got: %s", out) + } + if !strings.Contains(out, "Engineering") { + t.Fatalf("expected Engineering in output, got: %s", out) + } +} + +func TestOrgunitsListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "organizationUnits": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsListCmd{} + + // Should not error on empty results + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestOrgunitsListCmd_WithParent(t *testing.T) { + var capturedPath string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + capturedPath = r.URL.Query().Get("orgUnitPath") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "organizationUnits": []map[string]any{ + {"name": "West", "orgUnitPath": "/Sales/West", "orgUnitId": "ou-3"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsListCmd{Parent: "/Sales"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if capturedPath != "/Sales" { + t.Errorf("expected parent path /Sales, got %s", capturedPath) + } + if !strings.Contains(out, "West") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestOrgunitsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Sales", + "orgUnitPath": "/Sales", + "orgUnitId": "ou-1", + "parentOrgUnitPath": "/", + "parentOrgUnitId": "root-ou", + "description": "Sales department", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsGetCmd{Path: "/Sales"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Name:") || !strings.Contains(out, "Sales") { + t.Fatalf("expected Name: Sales in output, got: %s", out) + } + if !strings.Contains(out, "Path:") || !strings.Contains(out, "/Sales") { + t.Fatalf("expected Path: /Sales in output, got: %s", out) + } + if !strings.Contains(out, "Description:") || !strings.Contains(out, "Sales department") { + t.Fatalf("expected Description in output, got: %s", out) + } +} + +func TestOrgunitsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Engineering", + "orgUnitPath": "/Engineering", + "orgUnitId": "ou-2", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsGetCmd{Path: "/Engineering"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"orgUnitPath"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestOrgunitsGetCmd_MinimalFields(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + // Response with minimal fields - no parent, no description + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Simple", + "orgUnitPath": "/Simple", + "orgUnitId": "ou-simple", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsGetCmd{Path: "/Simple"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Simple") { + t.Fatalf("unexpected output: %s", out) + } + // Should NOT contain description if empty + if strings.Contains(out, "Description:") { + t.Errorf("should not show empty description, got: %s", out) + } +} + +func TestOrgunitsGetCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsGetCmd{Path: "/NonExistent"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for non-existent org unit") + } + if !strings.Contains(err.Error(), "get org unit") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOrgunitsCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "NewUnit", + "orgUnitPath": "/NewUnit", + "orgUnitId": "ou-new", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsCreateCmd{Name: "NewUnit"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created org unit") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "NewUnit") { + t.Fatalf("expected NewUnit in output, got: %s", out) + } +} + +func TestOrgunitsCreateCmd_WithParent(t *testing.T) { + var capturedBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "SubUnit", + "orgUnitPath": "/Sales/SubUnit", + "orgUnitId": "ou-sub", + "parentOrgUnitPath": "/Sales", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsCreateCmd{Name: "SubUnit", Parent: "/Sales"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if capturedBody["parentOrgUnitPath"] != "/Sales" { + t.Errorf("expected parent /Sales, got %v", capturedBody["parentOrgUnitPath"]) + } + if !strings.Contains(out, "SubUnit") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestOrgunitsCreateCmd_WithDescription(t *testing.T) { + var capturedBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Marketing", + "orgUnitPath": "/Marketing", + "orgUnitId": "ou-mkt", + "description": "Marketing department", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsCreateCmd{Name: "Marketing", Description: "Marketing department"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if capturedBody["description"] != "Marketing department" { + t.Errorf("expected description to be passed, got %v", capturedBody["description"]) + } + if !strings.Contains(out, "Marketing") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestOrgunitsCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "JSONUnit", + "orgUnitPath": "/JSONUnit", + "orgUnitId": "ou-json", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsCreateCmd{Name: "JSONUnit"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"orgUnitPath"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestOrgunitsCreateCmd_DefaultParent(t *testing.T) { + var capturedBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "RootChild", + "orgUnitPath": "/RootChild", + "orgUnitId": "ou-root-child", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsCreateCmd{Name: "RootChild", Parent: ""} // Empty parent + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + // Should default to root "/" + if capturedBody["parentOrgUnitPath"] != "/" { + t.Errorf("expected default parent /, got %v", capturedBody["parentOrgUnitPath"]) + } +} + +func TestOrgunitsCreateCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "already exists", http.StatusConflict) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsCreateCmd{Name: "Existing"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for conflict") + } + if !strings.Contains(err.Error(), "create org unit") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOrgunitsUpdateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "UpdatedName", + "orgUnitPath": "/Sales", + "orgUnitId": "ou-1", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "UpdatedName" + cmd := &OrgunitsUpdateCmd{Path: "/Sales", Name: &newName} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated org unit") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "UpdatedName") { + t.Fatalf("expected UpdatedName in output, got: %s", out) + } +} + +func TestOrgunitsUpdateCmd_WithDescription(t *testing.T) { + var capturedBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Sales", + "orgUnitPath": "/Sales", + "orgUnitId": "ou-1", + "description": "Updated description", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + desc := "Updated description" + cmd := &OrgunitsUpdateCmd{Path: "/Sales", Description: &desc} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if capturedBody["description"] != "Updated description" { + t.Errorf("expected description update, got %v", capturedBody["description"]) + } +} + +func TestOrgunitsUpdateCmd_WithParent(t *testing.T) { + var capturedBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "West", + "orgUnitPath": "/Marketing/West", + "orgUnitId": "ou-west", + "parentOrgUnitPath": "/Marketing", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newParent := "/Marketing" + cmd := &OrgunitsUpdateCmd{Path: "/Sales/West", Parent: &newParent} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if capturedBody["parentOrgUnitPath"] != "/Marketing" { + t.Errorf("expected parent update, got %v", capturedBody["parentOrgUnitPath"]) + } +} + +func TestOrgunitsUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "JSONUpdate", + "orgUnitPath": "/JSONUpdate", + "orgUnitId": "ou-json", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "JSONUpdate" + cmd := &OrgunitsUpdateCmd{Path: "/OldName", Name: &newName} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"orgUnitPath"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestOrgunitsUpdateCmd_NoUpdates(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Should not be called + t.Error("API should not be called when no updates specified") + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsUpdateCmd{Path: "/Sales"} // No Name, Parent, or Description + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when no updates specified") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 2 { + t.Errorf("expected usage error (exit code 2), got %v", err) + } +} + +func TestOrgunitsUpdateCmd_ClearDescription(t *testing.T) { + var capturedBody map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + _ = json.NewDecoder(r.Body).Decode(&capturedBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Sales", + "orgUnitPath": "/Sales", + "orgUnitId": "ou-1", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + emptyDesc := "" + cmd := &OrgunitsUpdateCmd{Path: "/Sales", Description: &emptyDesc} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + // The empty string should be explicitly sent + if capturedBody["description"] != "" { + t.Errorf("expected empty description to be sent, got %v", capturedBody["description"]) + } +} + +func TestOrgunitsUpdateCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "NewName" + cmd := &OrgunitsUpdateCmd{Path: "/NonExistent", Name: &newName} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for non-existent org unit") + } + if !strings.Contains(err.Error(), "update org unit") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOrgunitsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &OrgunitsDeleteCmd{Path: "/OldUnit"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted org unit") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "/OldUnit") { + t.Fatalf("expected path in output, got: %s", out) + } +} + +func TestOrgunitsDeleteCmd_RequiresForce(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Should not be called without Force flag + t.Error("API should not be called without Force flag") + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", NoInput: true} // No Force, non-interactive + cmd := &OrgunitsDeleteCmd{Path: "/OldUnit"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error without --force") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Errorf("expected ExitError, got %v", err) + } +} + +func TestOrgunitsDeleteCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &OrgunitsDeleteCmd{Path: "/NonExistent"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for non-existent org unit") + } + if !strings.Contains(err.Error(), "delete org unit") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOrgunitsDeleteCmd_ByID(t *testing.T) { + var capturedPath string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + // Capture the path part after /orgunits/ + parts := strings.Split(r.URL.Path, "/orgunits/") + if len(parts) > 1 { + capturedPath = parts[1] + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &OrgunitsDeleteCmd{Path: "id:ou-12345"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // The path should be passed through (org unit ID format) + if !strings.Contains(capturedPath, "id:ou-12345") && !strings.Contains(capturedPath, "ou-12345") { + t.Errorf("expected org unit ID to be passed, got path: %s", capturedPath) + } + if !strings.Contains(out, "Deleted org unit") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestOrgunitsListCmd_Error(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for permission denied") + } + if !strings.Contains(err.Error(), "list org units") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOrgunitsListCmd_WithType(t *testing.T) { + var capturedType string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits") { + http.NotFound(w, r) + return + } + capturedType = r.URL.Query().Get("type") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "organizationUnits": []map[string]any{ + {"name": "All1", "orgUnitPath": "/All1", "orgUnitId": "ou-all1"}, + {"name": "All2", "orgUnitPath": "/All1/All2", "orgUnitId": "ou-all2"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &OrgunitsListCmd{Type: "all"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if capturedType != "all" { + t.Errorf("expected type=all, got %s", capturedType) + } + if !strings.Contains(out, "All1") { + t.Fatalf("unexpected output: %s", out) + } +} + +// Test that requireAccount returns error when no account is provided +func TestOrgunitsCmd_RequiresAccount(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Should not be called + t.Error("API should not be called without account") + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{} // No account specified + cmd := &OrgunitsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when account not specified") + } +} diff --git a/internal/cmd/orgunits_update.go b/internal/cmd/orgunits_update.go new file mode 100644 index 00000000..38b02c8e --- /dev/null +++ b/internal/cmd/orgunits_update.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type OrgunitsUpdateCmd struct { + Path string `arg:"" name:"path" help:"Org unit path or ID"` + Name *string `name:"name" help:"New org unit name"` + Parent *string `name:"parent" help:"New parent org unit path"` + Description *string `name:"description" help:"Description"` +} + +func (c *OrgunitsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + org := &admin.OrgUnit{} + hasUpdates := false + + if c.Name != nil { + org.Name = *c.Name + hasUpdates = true + } + if c.Parent != nil { + org.ParentOrgUnitPath = *c.Parent + hasUpdates = true + } + if c.Description != nil { + org.Description = *c.Description + if *c.Description == "" { + org.ForceSendFields = append(org.ForceSendFields, "Description") + } + hasUpdates = true + } + + if !hasUpdates { + return usage("no updates specified") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + updated, err := svc.Orgunits.Update(adminCustomerID(), c.Path, org).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update org unit %s: %w", c.Path, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated org unit: %s (%s)\n", updated.Name, updated.OrgUnitPath) + return nil +} diff --git a/internal/cmd/printers.go b/internal/cmd/printers.go new file mode 100644 index 00000000..e4395a96 --- /dev/null +++ b/internal/cmd/printers.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type PrintersCmd struct { + List PrintersListCmd `cmd:"" name:"list" aliases:"ls" help:"List printers"` + Get PrintersGetCmd `cmd:"" name:"get" help:"Get printer"` + Create PrintersCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create printer"` + Update PrintersUpdateCmd `cmd:"" name:"update" help:"Update printer"` + Delete PrintersDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete printer"` +} + +type PrintersListCmd struct { + OrgUnit string `name:"org-unit" aliases:"ou" help:"Filter by org unit path or ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *PrintersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + parent := printerParent() + call := svc.Customers.Chrome.Printers.List(parent) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + if strings.TrimSpace(c.OrgUnit) != "" { + orgUnit := strings.TrimSpace(c.OrgUnit) + orgUnit = strings.TrimPrefix(orgUnit, "orgUnits/") + var orgUnitID string + orgUnitID, err = resolveOrgUnitID(ctx, svc, orgUnit) + if err != nil { + return err + } + call = call.OrgUnitId(orgUnitID) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list printers: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Printers) == 0 { + u.Err().Println("No printers found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tURI\tORG UNIT") + for _, printer := range resp.Printers { + if printer == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(printer.Id), + sanitizeTab(printer.DisplayName), + sanitizeTab(printer.Uri), + sanitizeTab(printer.OrgUnitId), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type PrintersGetCmd struct { + PrinterID string `arg:"" name:"printer-id" help:"Printer ID"` +} + +func (c *PrintersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + printerID := strings.TrimSpace(c.PrinterID) + if printerID == "" { + return usage("printer ID is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + name := printerResourceName(printerID) + printer, err := svc.Customers.Chrome.Printers.Get(name).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get printer %s: %w", printerID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, printer) + } + + u.Out().Printf("ID: %s\n", printer.Id) + u.Out().Printf("Name: %s\n", printer.DisplayName) + u.Out().Printf("URI: %s\n", printer.Uri) + if printer.OrgUnitId != "" { + u.Out().Printf("Org Unit: %s\n", printer.OrgUnitId) + } + if printer.Description != "" { + u.Out().Printf("Desc: %s\n", printer.Description) + } + return nil +} + +type PrintersCreateCmd struct { + Name string `name:"name" help:"Printer name" required:""` + URI string `name:"uri" help:"Printer URI" required:""` + OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path or ID"` +} + +func (c *PrintersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + uri := strings.TrimSpace(c.URI) + if name == "" || uri == "" { + return usage("--name and --uri are required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + printer := &admin.Printer{ + DisplayName: name, + Uri: uri, + } + if strings.TrimSpace(c.OrgUnit) != "" { + orgUnit := strings.TrimSpace(c.OrgUnit) + orgUnit = strings.TrimPrefix(orgUnit, "orgUnits/") + var orgUnitID string + orgUnitID, err = resolveOrgUnitID(ctx, svc, orgUnit) + if err != nil { + return err + } + printer.OrgUnitId = orgUnitID + } + + created, err := svc.Customers.Chrome.Printers.Create(printerParent(), printer).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create printer %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created printer: %s (%s)\n", created.DisplayName, created.Id) + return nil +} + +type PrintersUpdateCmd struct { + PrinterID string `arg:"" name:"printer-id" help:"Printer ID"` + Name *string `name:"name" help:"Printer name"` + URI *string `name:"uri" help:"Printer URI"` +} + +func (c *PrintersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + printerID := strings.TrimSpace(c.PrinterID) + if printerID == "" { + return usage("printer ID is required") + } + if c.Name == nil && c.URI == nil { + return usage("no updates specified") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + patch := &admin.Printer{} + updateMask := make([]string, 0, 2) + if c.Name != nil { + patch.DisplayName = strings.TrimSpace(*c.Name) + updateMask = append(updateMask, "displayName") + } + if c.URI != nil { + patch.Uri = strings.TrimSpace(*c.URI) + updateMask = append(updateMask, "uri") + } + + updated, err := svc.Customers.Chrome.Printers.Patch(printerResourceName(printerID), patch). + UpdateMask(strings.Join(updateMask, ",")). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("update printer %s: %w", printerID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated printer: %s (%s)\n", updated.DisplayName, updated.Id) + return nil +} + +type PrintersDeleteCmd struct { + PrinterID string `arg:"" name:"printer-id" help:"Printer ID"` +} + +func (c *PrintersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + printerID := strings.TrimSpace(c.PrinterID) + if printerID == "" { + return usage("printer ID is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete printer %s", printerID)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Customers.Chrome.Printers.Delete(printerResourceName(printerID)).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete printer %s: %w", printerID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"printerId": printerID, "deleted": true}) + } + + u.Out().Printf("Deleted printer: %s\n", printerID) + return nil +} + +func printerParent() string { + return fmt.Sprintf("customers/%s", adminCustomerID()) +} + +func printerResourceName(id string) string { + id = strings.TrimSpace(id) + if strings.HasPrefix(id, "customers/") { + return id + } + return fmt.Sprintf("customers/%s/chrome/printers/%s", adminCustomerID(), id) +} diff --git a/internal/cmd/printers_test.go b/internal/cmd/printers_test.go new file mode 100644 index 00000000..9e798ab5 --- /dev/null +++ b/internal/cmd/printers_test.go @@ -0,0 +1,476 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestPrintersListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "printers": []map[string]any{ + {"id": "p1", "displayName": "HQ Printer", "uri": "ipp://printer"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "HQ Printer") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestPrintersListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "printers": []map[string]any{ + {"id": "p1", "displayName": "HQ Printer", "uri": "ipp://printer", "orgUnitId": "ou1"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "printers") || !strings.Contains(out, "HQ Printer") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestPrintersListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "printers": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersListCmd{} + + // Should not return error even with empty list + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestPrintersListCmd_Pagination(t *testing.T) { + var gotPageToken, gotPageSize string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers") { + http.NotFound(w, r) + return + } + gotPageToken = r.URL.Query().Get("pageToken") + gotPageSize = r.URL.Query().Get("pageSize") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "printers": []map[string]any{ + {"id": "p1", "displayName": "Printer 1", "uri": "ipp://p1"}, + }, + "nextPageToken": "next-token", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersListCmd{Max: 10, Page: "token123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageToken != "token123" { + t.Errorf("expected page token 'token123', got %q", gotPageToken) + } + if gotPageSize != "10" { + t.Errorf("expected page size '10', got %q", gotPageSize) + } + if !strings.Contains(out, "Printer 1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestPrintersGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "p1", + "displayName": "Office Printer", + "uri": "ipp://office", + "orgUnitId": "ou1", + "description": "Main office printer", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersGetCmd{PrinterID: "p1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Office Printer") || !strings.Contains(out, "ID:") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestPrintersGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/chrome/printers/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "p1", + "displayName": "Office Printer", + "uri": "ipp://office", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersGetCmd{PrinterID: "p1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "displayName") || !strings.Contains(out, "Office Printer") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestPrintersGetCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersGetCmd{PrinterID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty printer ID") + } + if !strings.Contains(err.Error(), "printer ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrintersCreateCmd(t *testing.T) { + var gotName string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/chrome/printers") { + http.NotFound(w, r) + return + } + var payload struct { + DisplayName string `json:"displayName"` + Uri string `json:"uri"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotName = payload.DisplayName + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "p2", + "displayName": payload.DisplayName, + "uri": payload.Uri, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersCreateCmd{Name: "Lab Printer", URI: "ipp://lab"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotName != "Lab Printer" { + t.Fatalf("unexpected name: %q", gotName) + } + if !strings.Contains(out, "Created printer") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestPrintersCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/chrome/printers") { + http.NotFound(w, r) + return + } + var payload struct { + DisplayName string `json:"displayName"` + Uri string `json:"uri"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "p2", + "displayName": payload.DisplayName, + "uri": payload.Uri, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersCreateCmd{Name: "Lab Printer", URI: "ipp://lab"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "id") || !strings.Contains(out, "p2") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestPrintersCreateCmd_MissingName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersCreateCmd{Name: "", URI: "ipp://test"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "--name and --uri are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrintersCreateCmd_MissingURI(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersCreateCmd{Name: "Test", URI: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing URI") + } + if !strings.Contains(err.Error(), "--name and --uri are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrintersUpdateCmd(t *testing.T) { + var gotName, gotURI string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/chrome/printers/") { + http.NotFound(w, r) + return + } + var payload struct { + DisplayName string `json:"displayName"` + Uri string `json:"uri"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotName = payload.DisplayName + gotURI = payload.Uri + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "p1", + "displayName": payload.DisplayName, + "uri": payload.Uri, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated Printer" + newURI := "ipp://updated" + cmd := &PrintersUpdateCmd{PrinterID: "p1", Name: &newName, URI: &newURI} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotName != "Updated Printer" { + t.Errorf("unexpected name: %q", gotName) + } + if gotURI != "ipp://updated" { + t.Errorf("unexpected URI: %q", gotURI) + } + if !strings.Contains(out, "Updated printer") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestPrintersUpdateCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + newName := "Test" + cmd := &PrintersUpdateCmd{PrinterID: " ", Name: &newName} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty printer ID") + } + if !strings.Contains(err.Error(), "printer ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrintersUpdateCmd_NoUpdates(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &PrintersUpdateCmd{PrinterID: "p1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates") + } + if !strings.Contains(err.Error(), "no updates specified") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrintersDeleteCmd(t *testing.T) { + var deletedID string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/chrome/printers/") { + http.NotFound(w, r) + return + } + parts := strings.Split(r.URL.Path, "/chrome/printers/") + if len(parts) > 1 { + deletedID = parts[1] + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &PrintersDeleteCmd{PrinterID: "p1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if deletedID != "p1" { + t.Errorf("unexpected deleted ID: %q", deletedID) + } + if !strings.Contains(out, "Deleted printer") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestPrintersDeleteCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &PrintersDeleteCmd{PrinterID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty printer ID") + } + if !strings.Contains(err.Error(), "printer ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrintersDeleteCmd_RequiresConfirmation(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &PrintersDeleteCmd{PrinterID: "p1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when --force not set and non-interactive") + } + if !strings.Contains(err.Error(), "without --force") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPrinterResourceName(t *testing.T) { + tests := []struct { + id string + expected string + }{ + {"p1", "customers/my_customer/chrome/printers/p1"}, + {"customers/my_customer/chrome/printers/p2", "customers/my_customer/chrome/printers/p2"}, + {" p3 ", "customers/my_customer/chrome/printers/p3"}, + } + + for _, tt := range tests { + got := printerResourceName(tt.id) + if got != tt.expected { + t.Errorf("printerResourceName(%q) = %q, want %q", tt.id, got, tt.expected) + } + } +} + +func TestPrinterParent(t *testing.T) { + expected := "customers/my_customer" + got := printerParent() + if got != expected { + t.Errorf("printerParent() = %q, want %q", got, expected) + } +} diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go new file mode 100644 index 00000000..30adb924 --- /dev/null +++ b/internal/cmd/projects.go @@ -0,0 +1,215 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/cloudresourcemanager/v3" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newCloudResourceService = googleapi.NewCloudResourceManager + +type ProjectsCmd struct { + List ProjectsListCmd `cmd:"" name:"list" aliases:"ls" help:"List GCP projects"` + Get ProjectsGetCmd `cmd:"" name:"get" help:"Get a GCP project"` + Create ProjectsCreateCmd `cmd:"" name:"create" help:"Create a GCP project"` + Delete ProjectsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a GCP project"` +} + +type ProjectsListCmd struct { + Parent string `name:"parent" help:"Parent resource (organizations/ID or folders/ID)" required:""` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + ShowDeleted bool `name:"show-deleted" help:"Include deleted projects"` +} + +func (c *ProjectsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + parent := strings.TrimSpace(c.Parent) + if parent == "" { + return usage("--parent is required") + } + + svc, err := newCloudResourceService(ctx, account) + if err != nil { + return err + } + + call := svc.Projects.List().Parent(parent).PageSize(c.Max).ShowDeleted(c.ShowDeleted) + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list projects: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Projects) == 0 { + u.Err().Println("No projects found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PROJECT ID\tNAME\tSTATE") + for _, project := range resp.Projects { + if project == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(project.ProjectId), + sanitizeTab(project.DisplayName), + sanitizeTab(project.State), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ProjectsGetCmd struct { + Project string `arg:"" name:"project" help:"Project ID or resource name"` +} + +func (c *ProjectsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + project := strings.TrimSpace(c.Project) + if project == "" { + return usage("project is required") + } + + svc, err := newCloudResourceService(ctx, account) + if err != nil { + return err + } + + name := normalizeProjectName(project) + resp, err := svc.Projects.Get(name).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get project %s: %w", project, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Project ID: %s\n", resp.ProjectId) + u.Out().Printf("Name: %s\n", resp.DisplayName) + u.Out().Printf("State: %s\n", resp.State) + u.Out().Printf("Parent: %s\n", resp.Parent) + return nil +} + +type ProjectsCreateCmd struct { + ID string `name:"id" help:"Project ID" required:""` + Name string `name:"name" help:"Display name" required:""` + Parent string `name:"parent" help:"Parent resource (organizations/ID or folders/ID)" required:""` +} + +func (c *ProjectsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + projectID := strings.TrimSpace(c.ID) + name := strings.TrimSpace(c.Name) + parent := strings.TrimSpace(c.Parent) + if projectID == "" || name == "" || parent == "" { + return usage("--id, --name, and --parent are required") + } + + svc, err := newCloudResourceService(ctx, account) + if err != nil { + return err + } + + project := &cloudresourcemanager.Project{ + ProjectId: projectID, + DisplayName: name, + Parent: parent, + } + op, err := svc.Projects.Create(project).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create project: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Requested creation of project %s\n", projectID) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type ProjectsDeleteCmd struct { + Project string `arg:"" name:"project" help:"Project ID or resource name"` +} + +func (c *ProjectsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + project := strings.TrimSpace(c.Project) + if project == "" { + return usage("project is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete project %s", project)); err != nil { + return err + } + + svc, err := newCloudResourceService(ctx, account) + if err != nil { + return err + } + + name := normalizeProjectName(project) + op, err := svc.Projects.Delete(name).Context(ctx).Do() + if err != nil { + return fmt.Errorf("delete project %s: %w", project, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Requested deletion of project %s\n", project) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +func normalizeProjectName(project string) string { + if strings.HasPrefix(project, "projects/") { + return project + } + return "projects/" + project +} diff --git a/internal/cmd/projects_test.go b/internal/cmd/projects_test.go new file mode 100644 index 00000000..4ad2dd55 --- /dev/null +++ b/internal/cmd/projects_test.go @@ -0,0 +1,884 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/cloudresourcemanager/v3" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func newCloudResourceServiceStub(t *testing.T, handler http.HandlerFunc) (*cloudresourcemanager.Service, func()) { + t.Helper() + + srv := httptest.NewServer(handler) + svc, err := cloudresourcemanager.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + srv.Close() + t.Fatalf("NewService: %v", err) + } + return svc, srv.Close +} + +func stubCloudResourceService(t *testing.T, svc *cloudresourcemanager.Service) { + t.Helper() + orig := newCloudResourceService + t.Cleanup(func() { newCloudResourceService = orig }) + newCloudResourceService = func(context.Context, string) (*cloudresourcemanager.Service, error) { return svc, nil } +} + +func stubCloudResourceServiceError(t *testing.T, err error) { + t.Helper() + orig := newCloudResourceService + t.Cleanup(func() { newCloudResourceService = orig }) + newCloudResourceService = func(context.Context, string) (*cloudresourcemanager.Service, error) { return nil, err } +} + +func projectsTestContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func projectsTestContextWithStdout(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func projectsTestContextJSON(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + return outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) +} + +// ----------------------------------------------------------------------------- +// normalizeProjectName helper tests +// ----------------------------------------------------------------------------- + +func TestNormalizeProjectName(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "bare project ID", + input: "my-project", + want: "projects/my-project", + }, + { + name: "already prefixed", + input: "projects/my-project", + want: "projects/my-project", + }, + { + name: "empty string", + input: "", + want: "projects/", + }, + { + name: "project ID with numbers", + input: "test-project-123", + want: "projects/test-project-123", + }, + { + name: "project ID with underscores", + input: "my_project_name", + want: "projects/my_project_name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeProjectName(tt.input) + if got != tt.want { + t.Errorf("normalizeProjectName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// ----------------------------------------------------------------------------- +// ProjectsListCmd tests +// ----------------------------------------------------------------------------- + +func TestProjectsListCmd(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v3/projects") { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("parent") == "" { + http.Error(w, "missing parent", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projects": []map[string]any{{ + "projectId": "p1", + "displayName": "Project One", + "state": "ACTIVE", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Project One") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestProjectsListCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v3/projects") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projects": []map[string]any{{ + "projectId": "p1", + "displayName": "Project One", + "state": "ACTIVE", + }}, + "nextPageToken": "npt123", + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["nextPageToken"] != "npt123" { + t.Fatalf("unexpected nextPageToken in JSON: %v", result["nextPageToken"]) + } +} + +func TestProjectsListCmd_EmptyParent(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: ""} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for empty parent") + } + if !strings.Contains(err.Error(), "--parent is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsListCmd_WhitespaceParent(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: " "} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for whitespace-only parent") + } + if !strings.Contains(err.Error(), "--parent is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsListCmd_NoProjects(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projects": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123"} + + // No projects should not return an error, just a message to stderr + err := cmd.Run(projectsTestContext(t), flags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsListCmd_WithPagination(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("pageToken") != "page2" { + http.Error(w, "expected page token", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projects": []map[string]any{{ + "projectId": "p2", + "displayName": "Project Two", + "state": "ACTIVE", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123", Page: "page2"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Project Two") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestProjectsListCmd_WithShowDeleted(t *testing.T) { + var gotShowDeleted bool + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotShowDeleted = r.URL.Query().Get("showDeleted") == "true" + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projects": []map[string]any{{ + "projectId": "p1", + "displayName": "Project One", + "state": "DELETE_REQUESTED", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123", ShowDeleted: true} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !gotShowDeleted { + t.Fatal("showDeleted was not passed to API") + } + if !strings.Contains(out, "DELETE_REQUESTED") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestProjectsListCmd_APIError(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "list projects") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsListCmd_ServiceError(t *testing.T) { + stubCloudResourceServiceError(t, errors.New("service unavailable")) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for service creation failure") + } + if !strings.Contains(err.Error(), "service unavailable") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsListCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{Account: ""} + cmd := &ProjectsListCmd{Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestProjectsListCmd_FolderParent(t *testing.T) { + var gotParent string + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotParent = r.URL.Query().Get("parent") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projects": []map[string]any{{ + "projectId": "p1", + "displayName": "Project One", + "state": "ACTIVE", + }}, + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsListCmd{Parent: "folders/456"} + + _ = captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotParent != "folders/456" { + t.Fatalf("unexpected parent: %s", gotParent) + } +} + +// ----------------------------------------------------------------------------- +// ProjectsGetCmd tests +// ----------------------------------------------------------------------------- + +func TestProjectsGetCmd(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v3/projects/my-project" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "projects/my-project", + "projectId": "my-project", + "displayName": "My Project", + "state": "ACTIVE", + "parent": "organizations/123", + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: "my-project"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "my-project") { + t.Fatalf("expected project ID in output: %s", out) + } + if !strings.Contains(out, "My Project") { + t.Fatalf("expected display name in output: %s", out) + } + if !strings.Contains(out, "ACTIVE") { + t.Fatalf("expected state in output: %s", out) + } + if !strings.Contains(out, "organizations/123") { + t.Fatalf("expected parent in output: %s", out) + } +} + +func TestProjectsGetCmd_WithPrefix(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/projects/my-project" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "projectId": "my-project", + "displayName": "My Project", + "state": "ACTIVE", + "parent": "organizations/123", + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: "projects/my-project"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "my-project") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestProjectsGetCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "projects/my-project", + "projectId": "my-project", + "displayName": "My Project", + "state": "ACTIVE", + "parent": "organizations/123", + }) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: "my-project"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["projectId"] != "my-project" { + t.Fatalf("unexpected projectId in JSON: %v", result["projectId"]) + } + if result["state"] != "ACTIVE" { + t.Fatalf("unexpected state in JSON: %v", result["state"]) + } +} + +func TestProjectsGetCmd_EmptyProject(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: ""} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for empty project") + } + if !strings.Contains(err.Error(), "project is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsGetCmd_WhitespaceProject(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: " "} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for whitespace-only project") + } + if !strings.Contains(err.Error(), "project is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsGetCmd_NotFound(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "project not found", http.StatusNotFound) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: "nonexistent"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for not found project") + } + if !strings.Contains(err.Error(), "get project") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsGetCmd_ServiceError(t *testing.T) { + stubCloudResourceServiceError(t, errors.New("service unavailable")) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsGetCmd{Project: "my-project"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for service creation failure") + } + if !strings.Contains(err.Error(), "service unavailable") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsGetCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{Account: ""} + cmd := &ProjectsGetCmd{Project: "my-project"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +// ----------------------------------------------------------------------------- +// ProjectsCreateCmd tests +// ----------------------------------------------------------------------------- + +func TestProjectsCreateCmd(t *testing.T) { + var gotID string + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/v3/projects") { + http.NotFound(w, r) + return + } + var payload cloudresourcemanager.Project + _ = json.NewDecoder(r.Body).Decode(&payload) + gotID = payload.ProjectId + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotID != "p1" { + t.Fatalf("unexpected project id: %s", gotID) + } + if !strings.Contains(out, "Requested creation") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestProjectsCreateCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1", "done": false}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["name"] != "operations/op1" { + t.Fatalf("unexpected operation name in JSON: %v", result["name"]) + } +} + +func TestProjectsCreateCmd_Payload(t *testing.T) { + var gotPayload cloudresourcemanager.Project + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotPayload) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/op1"}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "test-project-id", Name: "Test Project Name", Parent: "folders/789"} + + _ = captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPayload.ProjectId != "test-project-id" { + t.Fatalf("unexpected project ID in payload: %s", gotPayload.ProjectId) + } + if gotPayload.DisplayName != "Test Project Name" { + t.Fatalf("unexpected display name in payload: %s", gotPayload.DisplayName) + } + if gotPayload.Parent != "folders/789" { + t.Fatalf("unexpected parent in payload: %s", gotPayload.Parent) + } +} + +func TestProjectsCreateCmd_MissingID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "", Name: "Project One", Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for missing ID") + } + if !strings.Contains(err.Error(), "--id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsCreateCmd_MissingName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "p1", Name: "", Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "--name") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsCreateCmd_MissingParent(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: ""} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for missing parent") + } + if !strings.Contains(err.Error(), "--parent") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsCreateCmd_APIError(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "quota exceeded", http.StatusTooManyRequests) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "create project") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsCreateCmd_ServiceError(t *testing.T) { + stubCloudResourceServiceError(t, errors.New("service unavailable")) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for service creation failure") + } +} + +// ----------------------------------------------------------------------------- +// ProjectsDeleteCmd tests +// ----------------------------------------------------------------------------- + +func TestProjectsDeleteCmd(t *testing.T) { + var deleteCalled bool + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/v3/projects/my-project" { + http.NotFound(w, r) + return + } + deleteCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/delete-op"}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: "my-project"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("delete was not called") + } + if !strings.Contains(out, "Requested deletion") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestProjectsDeleteCmd_WithPrefix(t *testing.T) { + var gotPath string + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/delete-op"}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: "projects/my-project"} + + _ = captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPath != "/v3/projects/my-project" { + t.Fatalf("unexpected path: %s", gotPath) + } +} + +func TestProjectsDeleteCmd_JSON(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/delete-op", "done": false}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: "my-project"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextJSON(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["name"] != "operations/delete-op" { + t.Fatalf("unexpected operation name in JSON: %v", result["name"]) + } +} + +func TestProjectsDeleteCmd_EmptyProject(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: ""} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for empty project") + } + if !strings.Contains(err.Error(), "project is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsDeleteCmd_APIError(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: "my-project"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "delete project") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectsDeleteCmd_ServiceError(t *testing.T) { + stubCloudResourceServiceError(t, errors.New("service unavailable")) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: "my-project"} + + err := cmd.Run(projectsTestContext(t), flags) + if err == nil { + t.Fatal("expected error for service creation failure") + } +} + +func TestProjectsDeleteCmd_WithOperationName(t *testing.T) { + svc, closeSrv := newCloudResourceServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": "operations/long-running-delete"}) + })) + t.Cleanup(closeSrv) + stubCloudResourceService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ProjectsDeleteCmd{Project: "my-project"} + + out := captureStdout(t, func() { + if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Operation:") { + t.Fatalf("expected operation name in output: %s", out) + } + if !strings.Contains(out, "operations/long-running-delete") { + t.Fatalf("expected full operation name in output: %s", out) + } +} diff --git a/internal/cmd/reports.go b/internal/cmd/reports.go new file mode 100644 index 00000000..2050a362 --- /dev/null +++ b/internal/cmd/reports.go @@ -0,0 +1,409 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + reports "google.golang.org/api/admin/reports/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newReportsService = googleapi.NewReports + +type ReportsCmd struct { + User ReportsUserCmd `cmd:"" name:"user" help:"User activity reports"` + Admin ReportsAdminCmd `cmd:"" name:"admin" help:"Admin activity reports"` + Login ReportsLoginCmd `cmd:"" name:"login" help:"Login activity reports"` + Drive ReportsDriveCmd `cmd:"" name:"drive" help:"Drive activity reports"` + Usage ReportsUsageCmd `cmd:"" name:"usage" help:"Customer usage reports"` + Accounts ReportsAccountsCmd `cmd:"" name:"accounts" help:"Account usage reports"` + EmailLog ReportsEmailLogCmd `cmd:"" name:"email-log" help:"Email log search"` +} + +type ReportsUserCmd struct { + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + User string `name:"user" help:"User email or ID (default: all)"` + Filters string `name:"filters" help:"Filters query"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + ToDrive ToDriveFlags `embed:""` +} + +func (c *ReportsUserCmd) Run(ctx context.Context, flags *RootFlags) error { + return runActivityReport(ctx, flags, activityReportOptions{ + Application: "user", + Date: c.Date, + User: c.User, + Filters: c.Filters, + Max: c.Max, + Page: c.Page, + ToDrive: c.ToDrive, + }) +} + +type ReportsAdminCmd struct { + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + Event string `name:"event" help:"Event name filter"` + Filters string `name:"filters" help:"Filters query"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + ToDrive ToDriveFlags `embed:""` +} + +func (c *ReportsAdminCmd) Run(ctx context.Context, flags *RootFlags) error { + return runActivityReport(ctx, flags, activityReportOptions{ + Application: "admin", + Date: c.Date, + Event: c.Event, + Filters: c.Filters, + Max: c.Max, + Page: c.Page, + ToDrive: c.ToDrive, + }) +} + +type ReportsLoginCmd struct { + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + User string `name:"user" help:"User email or ID (default: all)"` + Filters string `name:"filters" help:"Filters query"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + ToDrive ToDriveFlags `embed:""` +} + +func (c *ReportsLoginCmd) Run(ctx context.Context, flags *RootFlags) error { + return runActivityReport(ctx, flags, activityReportOptions{ + Application: "login", + Date: c.Date, + User: c.User, + Filters: c.Filters, + Max: c.Max, + Page: c.Page, + ToDrive: c.ToDrive, + }) +} + +type ReportsDriveCmd struct { + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + User string `name:"user" help:"User email or ID (default: all)"` + Filters string `name:"filters" help:"Filters query"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + ToDrive ToDriveFlags `embed:""` +} + +func (c *ReportsDriveCmd) Run(ctx context.Context, flags *RootFlags) error { + return runActivityReport(ctx, flags, activityReportOptions{ + Application: "drive", + Date: c.Date, + User: c.User, + Filters: c.Filters, + Max: c.Max, + Page: c.Page, + ToDrive: c.ToDrive, + }) +} + +type ReportsUsageCmd struct { + Application string `arg:"" name:"application" help:"Application name"` + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + Parameters string `name:"parameters" help:"Comma-separated parameters"` + Page string `name:"page" help:"Page token"` +} + +func (c *ReportsUsageCmd) Run(ctx context.Context, flags *RootFlags) error { + if strings.TrimSpace(c.Application) == "" { + return usage("application required") + } + return runUsageReport(ctx, flags, c.Application, c.Date, c.Parameters, c.Page) +} + +type ReportsAccountsCmd struct { + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + Page string `name:"page" help:"Page token"` +} + +func (c *ReportsAccountsCmd) Run(ctx context.Context, flags *RootFlags) error { + return runUsageReport(ctx, flags, "accounts", c.Date, "", c.Page) +} + +type ReportsEmailLogCmd struct { + Date string `name:"date" help:"Report date (YYYY-MM-DD)"` + Recipient string `name:"recipient" help:"Recipient email filter"` + Filters string `name:"filters" help:"Filters query"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ReportsEmailLogCmd) Run(ctx context.Context, flags *RootFlags) error { + filters := strings.TrimSpace(c.Filters) + if c.Recipient != "" { + recipientFilter := fmt.Sprintf("recipient==%s", c.Recipient) + if filters == "" { + filters = recipientFilter + } else { + filters = filters + "," + recipientFilter + } + } + return runActivityReport(ctx, flags, activityReportOptions{ + Application: "email", + Date: c.Date, + User: "all", + Filters: filters, + Max: c.Max, + Page: c.Page, + }) +} + +type activityReportOptions struct { + Application string + Date string + User string + Event string + Filters string + Max int64 + Page string + ToDrive ToDriveFlags +} + +func runActivityReport(ctx context.Context, flags *RootFlags, opts activityReportOptions) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newReportsService(ctx, account) + if err != nil { + return err + } + + userKey := strings.TrimSpace(opts.User) + if userKey == "" { + userKey = "all" + } + + call := svc.Activities.List(userKey, opts.Application) + if date := reportDate(opts.Date); date != "" { + start, end := reportDateRange(date) + call = call.StartTime(start).EndTime(end) + } + if opts.Event != "" { + call = call.EventName(opts.Event) + } + if opts.Filters != "" { + call = call.Filters(opts.Filters) + } + if opts.Max > 0 { + call = call.MaxResults(opts.Max) + } + if opts.Page != "" { + call = call.PageToken(opts.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("fetch %s report: %w", opts.Application, err) + } + + if len(resp.Items) == 0 { + u.Err().Println("No events found") + return nil + } + + rows := make([][]string, 0, len(resp.Items)) + for _, item := range resp.Items { + if item == nil { + continue + } + timeStr := formatActivityTime(item.Id) + actor := "" + if item.Actor != nil { + actor = item.Actor.Email + } + events := activityEventNames(item.Events) + rows = append(rows, toDriveRow( + timeStr, + actor, + item.IpAddress, + events, + )) + } + + reportTitle := fmt.Sprintf("Reports %s", cases.Title(language.English).String(opts.Application)) + if ok, err := writeToDrive(ctx, flags, toDriveTitle(reportTitle, opts.ToDrive), []string{"TIME", "ACTOR", "IP", "EVENTS"}, rows, opts.ToDrive); ok { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "TIME\tACTOR\tIP\tEVENTS") + for _, row := range rows { + if len(row) < 4 { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(row[0]), + sanitizeTab(row[1]), + sanitizeTab(row[2]), + sanitizeTab(row[3]), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +func runUsageReport(ctx context.Context, flags *RootFlags, application, date, parameters, page string) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newReportsService(ctx, account) + if err != nil { + return err + } + + date = reportDate(date) + + call := svc.CustomerUsageReports.Get(date).CustomerId(adminCustomerID()) + params := strings.TrimSpace(parameters) + if params == "" { + params = application + } else if !strings.Contains(params, ":") { + params = fmt.Sprintf("%s:%s", application, params) + } + if params != "" { + call = call.Parameters(params) + } + if page != "" { + call = call.PageToken(page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("fetch usage report: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.UsageReports) == 0 { + u.Err().Println("No usage reports found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "DATE\tENTITY\tPARAMETERS") + for _, report := range resp.UsageReports { + if report == nil { + continue + } + entity := "" + if report.Entity != nil { + entity = report.Entity.Type + if report.Entity.EntityId != "" { + entity = entity + ":" + report.Entity.EntityId + } + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(report.Date), + sanitizeTab(entity), + sanitizeTab(formatUsageParameters(report.Parameters)), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +func reportDate(date string) string { + date = strings.TrimSpace(date) + if date != "" { + return date + } + return time.Now().UTC().Format("2006-01-02") +} + +func reportDateRange(date string) (string, string) { + t, err := time.Parse("2006-01-02", date) + if err != nil { + return date, date + } + start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC) + return start.Format(time.RFC3339), end.Format(time.RFC3339) +} + +func formatActivityTime(id *reports.ActivityId) string { + if id == nil || id.Time == "" { + return "" + } + sec, err := strconv.ParseInt(id.Time, 10, 64) + if err != nil { + return id.Time + } + return time.Unix(sec, 0).UTC().Format(time.RFC3339) +} + +func activityEventNames(events []*reports.ActivityEvents) string { + if len(events) == 0 { + return "" + } + out := make([]string, 0, len(events)) + for _, ev := range events { + if ev == nil || ev.Name == "" { + continue + } + out = append(out, ev.Name) + } + return strings.Join(out, ",") +} + +func formatUsageParameters(params []*reports.UsageReportParameters) string { + if len(params) == 0 { + return "" + } + out := make([]string, 0, len(params)) + for _, p := range params { + if p == nil { + continue + } + value := "" + switch { + case p.StringValue != "": + value = p.StringValue + case p.DatetimeValue != "": + value = p.DatetimeValue + default: + if p.IntValue != 0 { + value = strconv.FormatInt(p.IntValue, 10) + } else if p.BoolValue { + value = strconv.FormatBool(p.BoolValue) + } + } + if p.Name != "" { + if value != "" { + out = append(out, fmt.Sprintf("%s=%s", p.Name, value)) + } else { + out = append(out, p.Name) + } + } + } + return strings.Join(out, ",") +} diff --git a/internal/cmd/reports_test.go b/internal/cmd/reports_test.go new file mode 100644 index 00000000..e6669450 --- /dev/null +++ b/internal/cmd/reports_test.go @@ -0,0 +1,1649 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + reports "google.golang.org/api/admin/reports/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestReportsUserCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "sam@example.com"}, + "ipAddress": "1.2.3.4", + "events": []map[string]any{ + {"name": "login"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "sam@example.com") || !strings.Contains(out, "login") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUserCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "json@example.com"}, + "ipAddress": "5.6.7.8", + "events": []map[string]any{ + {"name": "user_event"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "json@example.com") || !strings.Contains(out, "items") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsUserCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + // No error expected, just "no events" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsUserCmd_WithFilters(t *testing.T) { + var gotFilters string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + gotFilters = r.URL.Query().Get("filters") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "filtered@example.com"}, + "ipAddress": "1.1.1.1", + "events": []map[string]any{ + {"name": "filtered_event"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02", Filters: "event_name==login"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotFilters != "event_name==login" { + t.Fatalf("expected filters event_name==login, got %q", gotFilters) + } + if !strings.Contains(out, "filtered@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsAdminCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + // Verify application is admin + if !strings.Contains(r.URL.Path, "/admin") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "admin@example.com"}, + "ipAddress": "10.0.0.1", + "events": []map[string]any{ + {"name": "CREATE_USER"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAdminCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "admin@example.com") || !strings.Contains(out, "CREATE_USER") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsAdminCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "admin-json@example.com"}, + "ipAddress": "10.0.0.2", + "events": []map[string]any{ + {"name": "DELETE_USER"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAdminCmd{Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "admin-json@example.com") || !strings.Contains(out, "items") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsAdminCmd_WithEvent(t *testing.T) { + var gotEventName string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + gotEventName = r.URL.Query().Get("eventName") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "admin@example.com"}, + "ipAddress": "10.0.0.1", + "events": []map[string]any{ + {"name": "CREATE_USER"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAdminCmd{Date: "2026-01-02", Event: "CREATE_USER"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotEventName != "CREATE_USER" { + t.Fatalf("expected event name CREATE_USER, got %q", gotEventName) + } + if !strings.Contains(out, "CREATE_USER") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsAdminCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAdminCmd{Date: "2026-01-02"} + + // No error expected, just "no events" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsLoginCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + // Verify application is login + if !strings.Contains(r.URL.Path, "/login") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "user@example.com"}, + "ipAddress": "192.168.1.1", + "events": []map[string]any{ + {"name": "login_success"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsLoginCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "user@example.com") || !strings.Contains(out, "login_success") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsLoginCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "login-json@example.com"}, + "ipAddress": "192.168.1.2", + "events": []map[string]any{ + {"name": "login_failure"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsLoginCmd{Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "login-json@example.com") || !strings.Contains(out, "items") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsLoginCmd_WithUser(t *testing.T) { + var gotUserKey string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + // Extract user key from path + parts := strings.Split(r.URL.Path, "/") + for i, part := range parts { + if part == "users" && i+1 < len(parts) { + gotUserKey = parts[i+1] + break + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "specific@example.com"}, + "ipAddress": "192.168.1.3", + "events": []map[string]any{ + {"name": "login_success"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsLoginCmd{Date: "2026-01-02", User: "specific@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotUserKey != "specific@example.com" { + t.Fatalf("expected user key specific@example.com, got %q", gotUserKey) + } + if !strings.Contains(out, "specific@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsLoginCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsLoginCmd{Date: "2026-01-02"} + + // No error expected, just "no events" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsDriveCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + // Verify application is drive + if !strings.Contains(r.URL.Path, "/drive") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "drive@example.com"}, + "ipAddress": "172.16.0.1", + "events": []map[string]any{ + {"name": "view"}, + {"name": "download"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsDriveCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "drive@example.com") || !strings.Contains(out, "view") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsDriveCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "drive-json@example.com"}, + "ipAddress": "172.16.0.2", + "events": []map[string]any{ + {"name": "edit"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsDriveCmd{Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "drive-json@example.com") || !strings.Contains(out, "items") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsDriveCmd_WithUser(t *testing.T) { + var gotUserKey string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + // Extract user key from path + parts := strings.Split(r.URL.Path, "/") + for i, part := range parts { + if part == "users" && i+1 < len(parts) { + gotUserKey = parts[i+1] + break + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "driveuser@example.com"}, + "ipAddress": "172.16.0.3", + "events": []map[string]any{ + {"name": "create"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsDriveCmd{Date: "2026-01-02", User: "driveuser@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotUserKey != "driveuser@example.com" { + t.Fatalf("expected user key driveuser@example.com, got %q", gotUserKey) + } + if !strings.Contains(out, "driveuser@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsDriveCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsDriveCmd{Date: "2026-01-02"} + + // No error expected, just "no events" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsUsageCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + "customerId": "my_customer", + }, + "parameters": []map[string]any{ + {"name": "num_users", "intValue": "42"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02", Parameters: "num_users"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "num_users=42") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUsageCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + }, + "parameters": []map[string]any{ + {"name": "storage_used", "intValue": "1024"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "drive", Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "usageReports") || !strings.Contains(out, "storage_used") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsUsageCmd_RequiresApplication(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "", Date: "2026-01-02"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when Application is empty") + } +} + +func TestReportsUsageCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"} + + // No error expected, just "no reports" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsAccountsCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "USER", + "entityId": "user123", + }, + "parameters": []map[string]any{ + {"name": "accounts:total_accounts", "intValue": "100"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAccountsCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "2026-01-02") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsAccountsCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "USER", + }, + "parameters": []map[string]any{ + {"name": "accounts:active_accounts", "intValue": "85"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAccountsCmd{Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "usageReports") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsAccountsCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsAccountsCmd{Date: "2026-01-02"} + + // No error expected, just "no reports" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsEmailLogCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + // Verify application is email + if !strings.Contains(r.URL.Path, "/email") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "sender@example.com"}, + "ipAddress": "203.0.113.1", + "events": []map[string]any{ + {"name": "email_sent"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsEmailLogCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "sender@example.com") || !strings.Contains(out, "email_sent") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsEmailLogCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "email-json@example.com"}, + "ipAddress": "203.0.113.2", + "events": []map[string]any{ + {"name": "email_received"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsEmailLogCmd{Date: "2026-01-02"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "email-json@example.com") || !strings.Contains(out, "items") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestReportsEmailLogCmd_WithRecipient(t *testing.T) { + var gotFilters string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + gotFilters = r.URL.Query().Get("filters") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "sender@example.com"}, + "ipAddress": "203.0.113.3", + "events": []map[string]any{ + {"name": "email_sent"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsEmailLogCmd{Date: "2026-01-02", Recipient: "recipient@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(gotFilters, "recipient==recipient@example.com") { + t.Fatalf("expected recipient filter, got %q", gotFilters) + } + if !strings.Contains(out, "sender@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsEmailLogCmd_WithFiltersAndRecipient(t *testing.T) { + var gotFilters string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + gotFilters = r.URL.Query().Get("filters") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "sender@example.com"}, + "ipAddress": "203.0.113.4", + "events": []map[string]any{ + {"name": "email_sent"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsEmailLogCmd{ + Date: "2026-01-02", + Recipient: "recipient@example.com", + Filters: "message_size>1000", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should contain both filters combined + if !strings.Contains(gotFilters, "message_size>1000") { + t.Fatalf("expected message_size filter, got %q", gotFilters) + } + if !strings.Contains(gotFilters, "recipient==recipient@example.com") { + t.Fatalf("expected recipient filter, got %q", gotFilters) + } + if !strings.Contains(out, "sender@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsEmailLogCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsEmailLogCmd{Date: "2026-01-02"} + + // No error expected, just "no events" message + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestReportsActivityCmd_Pagination(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "page@example.com"}, + "ipAddress": "1.1.1.1", + "events": []map[string]any{ + {"name": "event1"}, + }, + }, + }, + "nextPageToken": "next_page_token_123", + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "page@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsActivityCmd_MaxResults(t *testing.T) { + var gotMaxResults string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + gotMaxResults = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "max@example.com"}, + "ipAddress": "1.1.1.1", + "events": []map[string]any{ + {"name": "event1"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02", Max: 50} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotMaxResults != "50" { + t.Fatalf("expected maxResults 50, got %q", gotMaxResults) + } + if !strings.Contains(out, "max@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsActivityCmd_PageToken(t *testing.T) { + var gotPageToken string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + gotPageToken = r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "paged@example.com"}, + "ipAddress": "1.1.1.1", + "events": []map[string]any{ + {"name": "event1"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02", Page: "my_page_token"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageToken != "my_page_token" { + t.Fatalf("expected pageToken my_page_token, got %q", gotPageToken) + } + if !strings.Contains(out, "paged@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsActivityCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 500, + "message": "Internal server error", + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "fetch user report") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReportsUsageCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 403, + "message": "Access denied", + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "fetch usage report") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReportsActivityCmd_NilActor(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "ipAddress": "1.1.1.1", + "events": []map[string]any{ + {"name": "system_event"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "system_event") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsActivityCmd_MultipleEvents(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/activity/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": map[string]any{"time": "1700000000"}, + "actor": map[string]any{"email": "multi@example.com"}, + "ipAddress": "1.1.1.1", + "events": []map[string]any{ + {"name": "event1"}, + {"name": "event2"}, + {"name": "event3"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUserCmd{Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Events should be comma-separated + if !strings.Contains(out, "event1,event2,event3") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUsageCmd_WithParameters(t *testing.T) { + var gotParams string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + gotParams = r.URL.Query().Get("parameters") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + }, + "parameters": []map[string]any{ + {"name": "accounts:num_users", "intValue": "100"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "accounts", Date: "2026-01-02", Parameters: "num_users"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Parameters should be prefixed with application + if gotParams != "accounts:num_users" { + t.Fatalf("expected parameters accounts:num_users, got %q", gotParams) + } + if !strings.Contains(out, "num_users=100") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUsageCmd_FullyQualifiedParameters(t *testing.T) { + var gotParams string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + gotParams = r.URL.Query().Get("parameters") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + }, + "parameters": []map[string]any{ + {"name": "drive:total_storage", "intValue": "5000"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "accounts", Date: "2026-01-02", Parameters: "drive:total_storage"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Fully qualified parameters should not be prefixed again + if gotParams != "drive:total_storage" { + t.Fatalf("expected parameters drive:total_storage, got %q", gotParams) + } + if !strings.Contains(out, "total_storage=5000") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUsageCmd_StringParameter(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + }, + "parameters": []map[string]any{ + {"name": "version", "stringValue": "v2.0"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "version=v2.0") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUsageCmd_DatetimeParameter(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + }, + "parameters": []map[string]any{ + {"name": "last_sync", "datetimeValue": "2026-01-02T10:00:00Z"}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "last_sync=2026-01-02T10:00:00Z") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportsUsageCmd_BoolParameter(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/usage/dates/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "usageReports": []map[string]any{ + { + "date": "2026-01-02", + "entity": map[string]any{ + "type": "CUSTOMER", + }, + "parameters": []map[string]any{ + {"name": "enabled", "boolValue": true}, + }, + }, + }, + }) + }) + stubReports(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "enabled=true") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestReportDate(t *testing.T) { + // Test with empty date - should return today's date + got := reportDate("") + if got == "" { + t.Fatal("expected non-empty date") + } + + // Test with explicit date + got = reportDate("2026-05-15") + if got != "2026-05-15" { + t.Fatalf("expected 2026-05-15, got %s", got) + } + + // Test with whitespace + got = reportDate(" 2026-05-15 ") + if got != "2026-05-15" { + t.Fatalf("expected 2026-05-15, got %s", got) + } +} + +func TestReportDateRange(t *testing.T) { + start, end := reportDateRange("2026-05-15") + if start != "2026-05-15T00:00:00Z" { + t.Fatalf("expected start 2026-05-15T00:00:00Z, got %s", start) + } + if end != "2026-05-15T23:59:59Z" { + t.Fatalf("expected end 2026-05-15T23:59:59Z, got %s", end) + } + + // Test with invalid date + start, end = reportDateRange("invalid") + if start != "invalid" || end != "invalid" { + t.Fatalf("expected invalid for both, got start=%s end=%s", start, end) + } +} + +func TestFormatActivityTime(t *testing.T) { + // Test with nil + got := formatActivityTime(nil) + if got != "" { + t.Fatalf("expected empty string, got %s", got) + } + + // Test with empty time + got = formatActivityTime(&reports.ActivityId{Time: ""}) + if got != "" { + t.Fatalf("expected empty string, got %s", got) + } + + // Test with valid unix timestamp + got = formatActivityTime(&reports.ActivityId{Time: "1700000000"}) + if got == "" || got == "1700000000" { + t.Fatalf("expected formatted time, got %s", got) + } + + // Test with invalid timestamp + got = formatActivityTime(&reports.ActivityId{Time: "not_a_number"}) + if got != "not_a_number" { + t.Fatalf("expected original value, got %s", got) + } +} + +func TestActivityEventNames(t *testing.T) { + // Test with nil + got := activityEventNames(nil) + if got != "" { + t.Fatalf("expected empty string, got %s", got) + } + + // Test with empty slice + got = activityEventNames([]*reports.ActivityEvents{}) + if got != "" { + t.Fatalf("expected empty string, got %s", got) + } + + // Test with single event + got = activityEventNames([]*reports.ActivityEvents{ + {Name: "event1"}, + }) + if got != "event1" { + t.Fatalf("expected event1, got %s", got) + } + + // Test with multiple events + got = activityEventNames([]*reports.ActivityEvents{ + {Name: "event1"}, + {Name: "event2"}, + {Name: "event3"}, + }) + if got != "event1,event2,event3" { + t.Fatalf("expected event1,event2,event3, got %s", got) + } + + // Test with nil event in slice + got = activityEventNames([]*reports.ActivityEvents{ + {Name: "event1"}, + nil, + {Name: "event3"}, + }) + if got != "event1,event3" { + t.Fatalf("expected event1,event3, got %s", got) + } + + // Test with empty name + got = activityEventNames([]*reports.ActivityEvents{ + {Name: "event1"}, + {Name: ""}, + {Name: "event3"}, + }) + if got != "event1,event3" { + t.Fatalf("expected event1,event3, got %s", got) + } +} + +func TestFormatUsageParameters(t *testing.T) { + // Test with nil + got := formatUsageParameters(nil) + if got != "" { + t.Fatalf("expected empty string, got %s", got) + } + + // Test with empty slice + got = formatUsageParameters([]*reports.UsageReportParameters{}) + if got != "" { + t.Fatalf("expected empty string, got %s", got) + } + + // Test with string value + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "version", StringValue: "v1.0"}, + }) + if got != "version=v1.0" { + t.Fatalf("expected version=v1.0, got %s", got) + } + + // Test with int value + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "count", IntValue: 42}, + }) + if got != "count=42" { + t.Fatalf("expected count=42, got %s", got) + } + + // Test with bool value + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "enabled", BoolValue: true}, + }) + if got != "enabled=true" { + t.Fatalf("expected enabled=true, got %s", got) + } + + // Test with datetime value + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "timestamp", DatetimeValue: "2026-01-01T00:00:00Z"}, + }) + if got != "timestamp=2026-01-01T00:00:00Z" { + t.Fatalf("expected timestamp=2026-01-01T00:00:00Z, got %s", got) + } + + // Test with multiple parameters + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "param1", StringValue: "value1"}, + {Name: "param2", IntValue: 100}, + }) + if got != "param1=value1,param2=100" { + t.Fatalf("expected param1=value1,param2=100, got %s", got) + } + + // Test with nil parameter in slice + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "param1", StringValue: "value1"}, + nil, + {Name: "param3", IntValue: 300}, + }) + if got != "param1=value1,param3=300" { + t.Fatalf("expected param1=value1,param3=300, got %s", got) + } + + // Test with name only (no value) + got = formatUsageParameters([]*reports.UsageReportParameters{ + {Name: "feature_enabled"}, + }) + if got != "feature_enabled" { + t.Fatalf("expected feature_enabled, got %s", got) + } +} + +func stubReports(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newReportsService + svc, err := reports.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new reports service: %v", err) + } + newReportsService = func(context.Context, string) (*reports.Service, error) { return svc, nil } + t.Cleanup(func() { + newReportsService = orig + srv.Close() + }) +} diff --git a/internal/cmd/reseller.go b/internal/cmd/reseller.go new file mode 100644 index 00000000..8d3a316d --- /dev/null +++ b/internal/cmd/reseller.go @@ -0,0 +1,379 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "google.golang.org/api/reseller/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newResellerService = googleapi.NewReseller + +type ResellerCmd struct { + Customers ResellerCustomersCmd `cmd:"" name:"customers" help:"Reseller customers"` + Subscriptions ResellerSubscriptionsCmd `cmd:"" name:"subscriptions" help:"Reseller subscriptions"` +} + +type ResellerCustomersCmd struct { + List ResellerCustomersListCmd `cmd:"" name:"list" aliases:"ls" help:"List reseller customers"` + Get ResellerCustomersGetCmd `cmd:"" name:"get" help:"Get reseller customer"` + Create ResellerCustomersCreateCmd `cmd:"" name:"create" help:"Create reseller customer"` +} + +type ResellerCustomersListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + Prefix string `name:"prefix" help:"Customer name prefix filter"` +} + +func (c *ResellerCustomersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newResellerService(ctx, account) + if err != nil { + return err + } + + call := svc.Subscriptions.List().MaxResults(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + if strings.TrimSpace(c.Prefix) != "" { + call = call.CustomerNamePrefix(strings.TrimSpace(c.Prefix)) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list customers: %w", err) + } + + type customer struct { + ID string `json:"id"` + Domain string `json:"domain"` + } + seen := make(map[string]customer) + for _, sub := range resp.Subscriptions { + if sub == nil || sub.CustomerId == "" { + continue + } + if _, ok := seen[sub.CustomerId]; ok { + continue + } + seen[sub.CustomerId] = customer{ID: sub.CustomerId, Domain: sub.CustomerDomain} + } + customers := make([]customer, 0, len(seen)) + for _, cust := range seen { + customers = append(customers, cust) + } + sort.Slice(customers, func(i, j int) bool { + if customers[i].Domain == customers[j].Domain { + return customers[i].ID < customers[j].ID + } + return customers[i].Domain < customers[j].Domain + }) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "customers": customers, + "nextPageToken": resp.NextPageToken, + }) + } + + if len(customers) == 0 { + u.Err().Println("No customers found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "CUSTOMER ID\tDOMAIN") + for _, cust := range customers { + fmt.Fprintf(w, "%s\t%s\n", sanitizeTab(cust.ID), sanitizeTab(cust.Domain)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ResellerCustomersGetCmd struct { + Customer string `arg:"" name:"customer" help:"Customer ID or domain"` +} + +func (c *ResellerCustomersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + customer := strings.TrimSpace(c.Customer) + if customer == "" { + return usage("customer is required") + } + + svc, err := newResellerService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Customers.Get(customer).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get customer %s: %w", customer, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Customer ID: %s\n", resp.CustomerId) + u.Out().Printf("Domain: %s\n", resp.CustomerDomain) + if resp.CustomerType != "" { + u.Out().Printf("Type: %s\n", resp.CustomerType) + } + if resp.PrimaryAdmin != nil && resp.PrimaryAdmin.PrimaryEmail != "" { + u.Out().Printf("Primary Admin: %s\n", resp.PrimaryAdmin.PrimaryEmail) + } + return nil +} + +type ResellerCustomersCreateCmd struct { + Domain string `name:"domain" help:"Customer domain" required:""` + AdminEmail string `name:"admin-email" help:"Primary admin email" required:""` + AlternateEmail string `name:"alternate-email" help:"Alternate email"` + Type string `name:"type" default:"domain" enum:"domain,team" help:"Customer type"` + Phone string `name:"phone" help:"Phone number"` +} + +func (c *ResellerCustomersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + domain := strings.TrimSpace(c.Domain) + adminEmail := strings.TrimSpace(c.AdminEmail) + if domain == "" || adminEmail == "" { + return usage("--domain and --admin-email are required") + } + + svc, err := newResellerService(ctx, account) + if err != nil { + return err + } + + customer := &reseller.Customer{ + CustomerDomain: domain, + CustomerType: c.Type, + PrimaryAdmin: &reseller.PrimaryAdmin{PrimaryEmail: adminEmail}, + } + if strings.TrimSpace(c.AlternateEmail) != "" { + customer.AlternateEmail = strings.TrimSpace(c.AlternateEmail) + } + if strings.TrimSpace(c.Phone) != "" { + customer.PhoneNumber = strings.TrimSpace(c.Phone) + } + + resp, err := svc.Customers.Insert(customer).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create customer: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Created customer %s (%s)\n", resp.CustomerId, resp.CustomerDomain) + return nil +} + +type ResellerSubscriptionsCmd struct { + List ResellerSubscriptionsListCmd `cmd:"" name:"list" aliases:"ls" help:"List reseller subscriptions"` + Get ResellerSubscriptionsGetCmd `cmd:"" name:"get" help:"Get reseller subscription"` + Create ResellerSubscriptionsCreateCmd `cmd:"" name:"create" help:"Create reseller subscription"` +} + +type ResellerSubscriptionsListCmd struct { + Customer string `name:"customer" help:"Customer ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` + Prefix string `name:"prefix" help:"Customer name prefix filter"` +} + +func (c *ResellerSubscriptionsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newResellerService(ctx, account) + if err != nil { + return err + } + + call := svc.Subscriptions.List().MaxResults(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + if strings.TrimSpace(c.Customer) != "" { + call = call.CustomerId(strings.TrimSpace(c.Customer)) + } + if strings.TrimSpace(c.Prefix) != "" { + call = call.CustomerNamePrefix(strings.TrimSpace(c.Prefix)) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list subscriptions: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Subscriptions) == 0 { + u.Err().Println("No subscriptions found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "CUSTOMER\tSUBSCRIPTION\tSKU\tPLAN\tSTATUS") + for _, sub := range resp.Subscriptions { + if sub == nil { + continue + } + plan := "" + if sub.Plan != nil { + plan = sub.Plan.PlanName + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(sub.CustomerId), + sanitizeTab(sub.SubscriptionId), + sanitizeTab(sub.SkuId), + sanitizeTab(plan), + sanitizeTab(sub.Status), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ResellerSubscriptionsGetCmd struct { + Customer string `arg:"" name:"customer" help:"Customer ID"` + Subscription string `arg:"" name:"subscription" help:"Subscription ID"` +} + +func (c *ResellerSubscriptionsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + customer := strings.TrimSpace(c.Customer) + subscription := strings.TrimSpace(c.Subscription) + if customer == "" || subscription == "" { + return usage("customer and subscription are required") + } + + svc, err := newResellerService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Subscriptions.Get(customer, subscription).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get subscription %s: %w", subscription, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Customer: %s\n", resp.CustomerId) + u.Out().Printf("Subscription: %s\n", resp.SubscriptionId) + u.Out().Printf("SKU: %s\n", resp.SkuId) + if resp.Plan != nil { + u.Out().Printf("Plan: %s\n", resp.Plan.PlanName) + } + if resp.Status != "" { + u.Out().Printf("Status: %s\n", resp.Status) + } + return nil +} + +type ResellerSubscriptionsCreateCmd struct { + Customer string `name:"customer" help:"Customer ID" required:""` + Plan string `name:"plan" help:"Plan name (FLEXIBLE, ANNUAL_MONTHLY_PAY, ANNUAL_YEARLY_PAY, TRIAL, FREE)" required:""` + SKU string `name:"sku" help:"SKU ID" required:""` + Seats int64 `name:"seats" help:"Number of seats for annual plans"` + MaxSeats int64 `name:"max-seats" help:"Maximum seats for flexible/trial plans"` +} + +func (c *ResellerSubscriptionsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + customer := strings.TrimSpace(c.Customer) + plan := strings.ToUpper(strings.TrimSpace(c.Plan)) + sku := strings.TrimSpace(c.SKU) + if customer == "" || plan == "" || sku == "" { + return usage("--customer, --plan, and --sku are required") + } + + svc, err := newResellerService(ctx, account) + if err != nil { + return err + } + + seats := &reseller.Seats{} + if plan == "FLEXIBLE" || plan == "TRIAL" || plan == "FREE" { + if c.MaxSeats > 0 { + seats.MaximumNumberOfSeats = c.MaxSeats + } else if c.Seats > 0 { + seats.MaximumNumberOfSeats = c.Seats + } + } else { + if c.Seats > 0 { + seats.NumberOfSeats = c.Seats + } else if c.MaxSeats > 0 { + seats.NumberOfSeats = c.MaxSeats + } + } + if seats.NumberOfSeats == 0 && seats.MaximumNumberOfSeats == 0 { + return usage("--seats or --max-seats is required") + } + + subscription := &reseller.Subscription{ + SkuId: sku, + Plan: &reseller.SubscriptionPlan{PlanName: plan}, + Seats: seats, + } + + resp, err := svc.Subscriptions.Insert(customer, subscription).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create subscription: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Created subscription %s for %s\n", resp.SubscriptionId, resp.CustomerId) + return nil +} diff --git a/internal/cmd/reseller_test.go b/internal/cmd/reseller_test.go new file mode 100644 index 00000000..c1c7f0f1 --- /dev/null +++ b/internal/cmd/reseller_test.go @@ -0,0 +1,723 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/reseller/v1" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func newResellerServiceStub(t *testing.T, handler http.HandlerFunc) (*reseller.Service, func()) { + t.Helper() + + srv := httptest.NewServer(handler) + svc, err := reseller.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + srv.Close() + t.Fatalf("NewService: %v", err) + } + return svc, srv.Close +} + +func stubResellerService(t *testing.T, svc *reseller.Service) { + t.Helper() + orig := newResellerService + t.Cleanup(func() { newResellerService = orig }) + newResellerService = func(context.Context, string) (*reseller.Service, error) { return svc, nil } +} + +func TestResellerCustomersListCmd(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{{ + "customerId": "C123", + "customerDomain": "example.com", + }}, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResellerSubscriptionsCreateCmd(t *testing.T) { + var gotPlan string + var gotSeats int64 + + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C1/subscriptions") { + http.NotFound(w, r) + return + } + var payload reseller.Subscription + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload.Plan != nil { + gotPlan = payload.Plan.PlanName + } + if payload.Seats != nil { + gotSeats = payload.Seats.NumberOfSeats + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptionId": "sub1", + "customerId": "C1", + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsCreateCmd{ + Customer: "C1", + Plan: "ANNUAL_MONTHLY_PAY", + SKU: "sku1", + Seats: 5, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPlan != "ANNUAL_MONTHLY_PAY" || gotSeats != 5 { + t.Fatalf("unexpected payload: plan=%s seats=%d", gotPlan, gotSeats) + } + if !strings.Contains(out, "Created subscription") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResellerCustomersGetCmd(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customerId": "C123", + "customerDomain": "example.com", + "customerType": "domain", + "primaryAdmin": map[string]any{"primaryEmail": "admin@example.com"}, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersGetCmd{Customer: "C123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "C123") { + t.Fatalf("expected customer ID in output, got: %s", out) + } + if !strings.Contains(out, "example.com") { + t.Fatalf("expected domain in output, got: %s", out) + } +} + +func TestResellerCustomersGetCmd_JSON(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customerId": "C123", + "customerDomain": "example.com", + "customerType": "domain", + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersGetCmd{Customer: "C123"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"customerId"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResellerCustomersGetCmd_EmptyCustomer(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersGetCmd{Customer: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty customer") + } + if !strings.Contains(err.Error(), "customer is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResellerCustomersGetCmd_APIError(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "Customer not found", + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersGetCmd{Customer: "NOTFOUND"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "get customer") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResellerCustomersGetCmd_WithPrimaryAdmin(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customerId": "C123", + "customerDomain": "example.com", + "customerType": "team", + "primaryAdmin": map[string]any{"primaryEmail": "admin@example.com"}, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersGetCmd{Customer: "C123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Primary Admin:") || !strings.Contains(out, "admin@example.com") { + t.Fatalf("expected primary admin in output, got: %s", out) + } + if !strings.Contains(out, "Type:") || !strings.Contains(out, "team") { + t.Fatalf("expected type in output, got: %s", out) + } +} + +func TestResellerSubscriptionsListCmd(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{ + { + "customerId": "C123", + "subscriptionId": "sub1", + "skuId": "Google-Apps-Unlimited", + "plan": map[string]any{"planName": "ANNUAL"}, + "status": "ACTIVE", + }, + { + "customerId": "C456", + "subscriptionId": "sub2", + "skuId": "Google-Vault", + "plan": map[string]any{"planName": "FLEXIBLE"}, + "status": "ACTIVE", + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "C123") || !strings.Contains(out, "sub1") { + t.Fatalf("expected subscription data in output, got: %s", out) + } + if !strings.Contains(out, "ANNUAL") { + t.Fatalf("expected plan name in output, got: %s", out) + } +} + +func TestResellerSubscriptionsListCmd_JSON(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{ + { + "customerId": "C123", + "subscriptionId": "sub1", + "skuId": "Google-Apps-Unlimited", + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"subscriptions"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResellerSubscriptionsListCmd_Empty(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsListCmd{} + + stderr := captureStderr(t, func() { + if err := cmd.Run(testContextWithStderr(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(stderr, "No subscriptions found") { + t.Fatalf("expected 'No subscriptions found' message, got: %s", stderr) + } +} + +func TestResellerSubscriptionsListCmd_WithFilters(t *testing.T) { + var gotCustomer, gotPrefix string + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + gotCustomer = r.URL.Query().Get("customerId") + gotPrefix = r.URL.Query().Get("customerNamePrefix") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{ + { + "customerId": "C123", + "subscriptionId": "sub1", + "skuId": "Google-Apps-Unlimited", + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsListCmd{Customer: "C123", Prefix: "test"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotCustomer != "C123" { + t.Fatalf("expected customerId filter, got: %s", gotCustomer) + } + if gotPrefix != "test" { + t.Fatalf("expected prefix filter, got: %s", gotPrefix) + } +} + +func TestResellerSubscriptionsListCmd_APIError(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 500, + "message": "Internal error", + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "list subscriptions") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResellerSubscriptionsGetCmd(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123/subscriptions/sub1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customerId": "C123", + "subscriptionId": "sub1", + "skuId": "Google-Apps-Unlimited", + "plan": map[string]any{"planName": "ANNUAL"}, + "status": "ACTIVE", + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "sub1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Customer:") || !strings.Contains(out, "C123") { + t.Fatalf("expected customer in output, got: %s", out) + } + if !strings.Contains(out, "Subscription:") || !strings.Contains(out, "sub1") { + t.Fatalf("expected subscription in output, got: %s", out) + } + if !strings.Contains(out, "Plan:") || !strings.Contains(out, "ANNUAL") { + t.Fatalf("expected plan in output, got: %s", out) + } +} + +func TestResellerSubscriptionsGetCmd_JSON(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123/subscriptions/sub1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customerId": "C123", + "subscriptionId": "sub1", + "skuId": "Google-Apps-Unlimited", + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "sub1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"subscriptionId"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResellerSubscriptionsGetCmd_EmptyCustomer(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsGetCmd{Customer: " ", Subscription: "sub1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty customer") + } + if !strings.Contains(err.Error(), "customer and subscription are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResellerSubscriptionsGetCmd_EmptySubscription(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty subscription") + } + if !strings.Contains(err.Error(), "customer and subscription are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResellerSubscriptionsGetCmd_APIError(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "Subscription not found", + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "NOTFOUND"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "get subscription") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResellerSubscriptionsGetCmd_WithStatus(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/customers/C123/subscriptions/sub1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "customerId": "C123", + "subscriptionId": "sub1", + "skuId": "Google-Apps-Unlimited", + "status": "SUSPENDED", + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerSubscriptionsGetCmd{Customer: "C123", Subscription: "sub1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Status:") || !strings.Contains(out, "SUSPENDED") { + t.Fatalf("expected status in output, got: %s", out) + } +} + +func TestResellerCustomersListCmd_JSON(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{{ + "customerId": "C123", + "customerDomain": "example.com", + }}, + "nextPageToken": "token123", + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"customers"`) { + t.Fatalf("expected JSON output with customers, got: %s", out) + } + if !strings.Contains(out, `"nextPageToken"`) { + t.Fatalf("expected nextPageToken in JSON output, got: %s", out) + } +} + +func TestResellerCustomersListCmd_Empty(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersListCmd{} + + stderr := captureStderr(t, func() { + if err := cmd.Run(testContextWithStderr(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(stderr, "No customers found") { + t.Fatalf("expected 'No customers found' message, got: %s", stderr) + } +} + +func TestResellerCustomersListCmd_WithPrefix(t *testing.T) { + var gotPrefix string + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + gotPrefix = r.URL.Query().Get("customerNamePrefix") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{{ + "customerId": "C123", + "customerDomain": "example.com", + }}, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersListCmd{Prefix: "test"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPrefix != "test" { + t.Fatalf("expected prefix filter, got: %s", gotPrefix) + } +} + +func TestResellerCustomersListCmd_DedupesCustomers(t *testing.T) { + svc, closeSrv := newResellerServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/apps/reseller/v1/subscriptions") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + // Return multiple subscriptions for the same customer + _ = json.NewEncoder(w).Encode(map[string]any{ + "subscriptions": []map[string]any{ + {"customerId": "C123", "customerDomain": "example.com", "skuId": "sku1"}, + {"customerId": "C123", "customerDomain": "example.com", "skuId": "sku2"}, + {"customerId": "C456", "customerDomain": "other.com", "skuId": "sku3"}, + }, + }) + })) + t.Cleanup(closeSrv) + stubResellerService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResellerCustomersListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should only have 2 customers, not 3 rows + lines := strings.Split(strings.TrimSpace(out), "\n") + // Header + 2 customers = 3 lines + if len(lines) != 3 { + t.Fatalf("expected 3 lines (header + 2 customers), got %d: %s", len(lines), out) + } +} diff --git a/internal/cmd/resources.go b/internal/cmd/resources.go new file mode 100644 index 00000000..450b9a7b --- /dev/null +++ b/internal/cmd/resources.go @@ -0,0 +1,7 @@ +package cmd + +type ResourcesCmd struct { + Buildings ResourcesBuildingsCmd `cmd:"" name:"buildings" help:"Manage resource buildings"` + Calendars ResourcesCalendarsCmd `cmd:"" name:"calendars" help:"Manage resource calendars"` + Features ResourcesFeaturesCmd `cmd:"" name:"features" help:"Manage resource features"` +} diff --git a/internal/cmd/resources_buildings.go b/internal/cmd/resources_buildings.go new file mode 100644 index 00000000..074f0ddb --- /dev/null +++ b/internal/cmd/resources_buildings.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ResourcesBuildingsCmd struct { + List ResourcesBuildingsListCmd `cmd:"" name:"list" aliases:"ls" help:"List buildings"` + Get ResourcesBuildingsGetCmd `cmd:"" name:"get" help:"Get building"` + Create ResourcesBuildingsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create building"` + Update ResourcesBuildingsUpdateCmd `cmd:"" name:"update" help:"Update building"` + Delete ResourcesBuildingsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete building"` +} + +type ResourcesBuildingsListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ResourcesBuildingsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + call := svc.Resources.Buildings.List(adminCustomerID()) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list buildings: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Buildings) == 0 { + u.Err().Println("No buildings found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tFLOORS\tDESCRIPTION") + for _, building := range resp.Buildings { + if building == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(building.BuildingId), + sanitizeTab(building.BuildingName), + sanitizeTab(strings.Join(building.FloorNames, ", ")), + sanitizeTab(building.Description), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ResourcesBuildingsGetCmd struct { + BuildingID string `arg:"" name:"building-id" help:"Building ID"` +} + +func (c *ResourcesBuildingsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + buildingID := strings.TrimSpace(c.BuildingID) + if buildingID == "" { + return usage("building ID is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + building, err := svc.Resources.Buildings.Get(adminCustomerID(), buildingID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get building %s: %w", buildingID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, building) + } + + u.Out().Printf("ID: %s\n", building.BuildingId) + u.Out().Printf("Name: %s\n", building.BuildingName) + if building.Description != "" { + u.Out().Printf("Description: %s\n", building.Description) + } + if len(building.FloorNames) > 0 { + u.Out().Printf("Floors: %s\n", strings.Join(building.FloorNames, ", ")) + } + return nil +} + +type ResourcesBuildingsCreateCmd struct { + Name string `name:"name" help:"Building name" required:""` + Description string `name:"description" help:"Building description"` + Floors string `name:"floors" help:"Comma-separated floor names"` +} + +func (c *ResourcesBuildingsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("--name is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + building := &admin.Building{ + BuildingName: name, + Description: c.Description, + } + if floors := splitCSV(c.Floors); len(floors) > 0 { + building.FloorNames = floors + } + + created, err := svc.Resources.Buildings.Insert(adminCustomerID(), building).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create building %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created building: %s (%s)\n", created.BuildingName, created.BuildingId) + return nil +} + +type ResourcesBuildingsUpdateCmd struct { + BuildingID string `arg:"" name:"building-id" help:"Building ID"` + Name *string `name:"name" help:"Building name"` + Description *string `name:"description" help:"Building description"` +} + +func (c *ResourcesBuildingsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + buildingID := strings.TrimSpace(c.BuildingID) + if buildingID == "" { + return usage("building ID is required") + } + + if c.Name == nil && c.Description == nil { + return usage("no updates specified") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + patch := &admin.Building{} + if c.Name != nil { + patch.BuildingName = strings.TrimSpace(*c.Name) + } + if c.Description != nil { + patch.Description = strings.TrimSpace(*c.Description) + } + + updated, err := svc.Resources.Buildings.Patch(adminCustomerID(), buildingID, patch).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update building %s: %w", buildingID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated building: %s (%s)\n", updated.BuildingName, updated.BuildingId) + return nil +} + +type ResourcesBuildingsDeleteCmd struct { + BuildingID string `arg:"" name:"building-id" help:"Building ID"` +} + +func (c *ResourcesBuildingsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + buildingID := strings.TrimSpace(c.BuildingID) + if buildingID == "" { + return usage("building ID is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete building %s", buildingID)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Resources.Buildings.Delete(adminCustomerID(), buildingID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete building %s: %w", buildingID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"buildingId": buildingID, "deleted": true}) + } + + u.Out().Printf("Deleted building: %s\n", buildingID) + return nil +} diff --git a/internal/cmd/resources_calendars.go b/internal/cmd/resources_calendars.go new file mode 100644 index 00000000..c28e16d0 --- /dev/null +++ b/internal/cmd/resources_calendars.go @@ -0,0 +1,276 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ResourcesCalendarsCmd struct { + List ResourcesCalendarsListCmd `cmd:"" name:"list" aliases:"ls" help:"List calendar resources"` + Get ResourcesCalendarsGetCmd `cmd:"" name:"get" help:"Get calendar resource"` + Create ResourcesCalendarsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create calendar resource"` + Update ResourcesCalendarsUpdateCmd `cmd:"" name:"update" help:"Update calendar resource"` + Delete ResourcesCalendarsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete calendar resource"` +} + +type ResourcesCalendarsListCmd struct { + Building string `name:"building" help:"Filter by building ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ResourcesCalendarsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + call := svc.Resources.Calendars.List(adminCustomerID()) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list calendar resources: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + items := resp.Items + if building := strings.TrimSpace(c.Building); building != "" { + filtered := make([]*admin.CalendarResource, 0, len(items)) + for _, item := range items { + if item != nil && item.BuildingId == building { + filtered = append(filtered, item) + } + } + items = filtered + } + + if len(items) == 0 { + u.Err().Println("No calendar resources found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "RESOURCE ID\tNAME\tEMAIL\tCATEGORY\tBUILDING\tFLOOR\tCAPACITY") + for _, resource := range items { + if resource == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%d\n", + sanitizeTab(resource.ResourceId), + sanitizeTab(resource.ResourceName), + sanitizeTab(resource.ResourceEmail), + sanitizeTab(resource.ResourceCategory), + sanitizeTab(resource.BuildingId), + sanitizeTab(resource.FloorName), + resource.Capacity, + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ResourcesCalendarsGetCmd struct { + ResourceID string `arg:"" name:"resource-id" help:"Resource ID"` +} + +func (c *ResourcesCalendarsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + resourceID := strings.TrimSpace(c.ResourceID) + if resourceID == "" { + return usage("resource ID is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + resource, err := svc.Resources.Calendars.Get(adminCustomerID(), resourceID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get calendar resource %s: %w", resourceID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resource) + } + + u.Out().Printf("ID: %s\n", resource.ResourceId) + u.Out().Printf("Name: %s\n", resource.ResourceName) + u.Out().Printf("Email: %s\n", resource.ResourceEmail) + u.Out().Printf("Category: %s\n", resource.ResourceCategory) + if resource.ResourceDescription != "" { + u.Out().Printf("Description: %s\n", resource.ResourceDescription) + } + if resource.UserVisibleDescription != "" { + u.Out().Printf("User Desc: %s\n", resource.UserVisibleDescription) + } + if resource.BuildingId != "" { + u.Out().Printf("Building: %s\n", resource.BuildingId) + } + if resource.FloorName != "" { + u.Out().Printf("Floor: %s\n", resource.FloorName) + } + if resource.Capacity != 0 { + u.Out().Printf("Capacity: %d\n", resource.Capacity) + } + return nil +} + +type ResourcesCalendarsCreateCmd struct { + Name string `name:"name" help:"Resource name" required:""` + Type string `name:"type" help:"Resource category: CONFERENCE_ROOM|OTHER" enum:"CONFERENCE_ROOM,OTHER" required:""` + Building string `name:"building" help:"Building ID"` + Floor string `name:"floor" help:"Floor name"` + Capacity int64 `name:"capacity" help:"Capacity"` +} + +func (c *ResourcesCalendarsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("--name is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + resource := &admin.CalendarResource{ + ResourceName: name, + ResourceCategory: c.Type, + BuildingId: strings.TrimSpace(c.Building), + FloorName: strings.TrimSpace(c.Floor), + Capacity: c.Capacity, + } + + created, err := svc.Resources.Calendars.Insert(adminCustomerID(), resource).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create calendar resource %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created calendar resource: %s (%s)\n", created.ResourceName, created.ResourceId) + return nil +} + +type ResourcesCalendarsUpdateCmd struct { + ResourceID string `arg:"" name:"resource-id" help:"Resource ID"` + Name *string `name:"name" help:"Resource name"` + Capacity *int64 `name:"capacity" help:"Capacity"` +} + +func (c *ResourcesCalendarsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + resourceID := strings.TrimSpace(c.ResourceID) + if resourceID == "" { + return usage("resource ID is required") + } + + if c.Name == nil && c.Capacity == nil { + return usage("no updates specified") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + patch := &admin.CalendarResource{} + if c.Name != nil { + patch.ResourceName = strings.TrimSpace(*c.Name) + } + if c.Capacity != nil { + patch.Capacity = *c.Capacity + } + + updated, err := svc.Resources.Calendars.Patch(adminCustomerID(), resourceID, patch).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update calendar resource %s: %w", resourceID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated calendar resource: %s (%s)\n", updated.ResourceName, updated.ResourceId) + return nil +} + +type ResourcesCalendarsDeleteCmd struct { + ResourceID string `arg:"" name:"resource-id" help:"Resource ID"` +} + +func (c *ResourcesCalendarsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + resourceID := strings.TrimSpace(c.ResourceID) + if resourceID == "" { + return usage("resource ID is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete calendar resource %s", resourceID)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Resources.Calendars.Delete(adminCustomerID(), resourceID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete calendar resource %s: %w", resourceID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"resourceId": resourceID, "deleted": true}) + } + + u.Out().Printf("Deleted calendar resource: %s\n", resourceID) + return nil +} diff --git a/internal/cmd/resources_features.go b/internal/cmd/resources_features.go new file mode 100644 index 00000000..c6aba53d --- /dev/null +++ b/internal/cmd/resources_features.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type ResourcesFeaturesCmd struct { + List ResourcesFeaturesListCmd `cmd:"" name:"list" aliases:"ls" help:"List resource features"` + Create ResourcesFeaturesCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create resource feature"` + Delete ResourcesFeaturesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete resource feature"` +} + +type ResourcesFeaturesListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ResourcesFeaturesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + call := svc.Resources.Features.List(adminCustomerID()) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list features: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Features) == 0 { + u.Err().Println("No features found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME") + for _, feature := range resp.Features { + if feature == nil { + continue + } + fmt.Fprintf(w, "%s\n", sanitizeTab(feature.Name)) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ResourcesFeaturesCreateCmd struct { + Name string `name:"name" help:"Feature name" required:""` +} + +func (c *ResourcesFeaturesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("--name is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + feature := &admin.Feature{Name: name} + created, err := svc.Resources.Features.Insert(adminCustomerID(), feature).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create feature %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created feature: %s\n", created.Name) + return nil +} + +type ResourcesFeaturesDeleteCmd struct { + Name string `arg:"" name:"name" help:"Feature name"` +} + +func (c *ResourcesFeaturesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("feature name is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete feature %s", name)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Resources.Features.Delete(adminCustomerID(), name).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete feature %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"name": name, "deleted": true}) + } + + u.Out().Printf("Deleted feature: %s\n", name) + return nil +} diff --git a/internal/cmd/resources_test.go b/internal/cmd/resources_test.go new file mode 100644 index 00000000..231d2fff --- /dev/null +++ b/internal/cmd/resources_test.go @@ -0,0 +1,1274 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// ============================================================================= +// Buildings Tests +// ============================================================================= + +func TestResourcesBuildingsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildings": []map[string]any{ + {"buildingId": "b1", "buildingName": "HQ", "floorNames": []string{"1", "2"}}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "HQ") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesBuildingsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildings": []map[string]any{ + {"buildingId": "b1", "buildingName": "HQ", "floorNames": []string{"1", "2"}}, + {"buildingId": "b2", "buildingName": "Annex", "description": "Secondary building"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "buildings") || !strings.Contains(out, "b1") { + t.Fatalf("expected JSON buildings output, got: %s", out) + } +} + +func TestResourcesBuildingsListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildings": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsListCmd{} + + // Should not error on empty list + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestResourcesBuildingsListCmd_Pagination(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") { + http.NotFound(w, r) + return + } + // Check pagination parameters + if r.URL.Query().Get("maxResults") != "10" { + t.Errorf("expected maxResults=10, got %s", r.URL.Query().Get("maxResults")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildings": []map[string]any{ + {"buildingId": "b1", "buildingName": "HQ"}, + }, + "nextPageToken": "token123", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsListCmd{Max: 10} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestResourcesBuildingsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings/b1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildingId": "b1", + "buildingName": "Headquarters", + "description": "Main office building", + "floorNames": []string{"Ground", "1", "2"}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsGetCmd{BuildingID: "b1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ID:") || !strings.Contains(out, "b1") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Name:") || !strings.Contains(out, "Headquarters") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesBuildingsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings/b1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildingId": "b1", + "buildingName": "Headquarters", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsGetCmd{BuildingID: "b1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "buildingId") || !strings.Contains(out, "b1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesBuildingsGetCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsGetCmd{BuildingID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty building ID") + } + if !strings.Contains(err.Error(), "building ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesBuildingsCreateCmd(t *testing.T) { + var gotPayload struct { + BuildingName string `json:"buildingName"` + Description string `json:"description"` + FloorNames []string `json:"floorNames"` + } + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/buildings") { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildingId": "b-new", + "buildingName": gotPayload.BuildingName, + "description": gotPayload.Description, + "floorNames": gotPayload.FloorNames, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsCreateCmd{ + Name: "New Building", + Description: "A new office building", + Floors: "Ground, 1, 2, 3", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPayload.BuildingName != "New Building" { + t.Errorf("expected name 'New Building', got %q", gotPayload.BuildingName) + } + if gotPayload.Description != "A new office building" { + t.Errorf("expected description 'A new office building', got %q", gotPayload.Description) + } + if len(gotPayload.FloorNames) != 4 { + t.Errorf("expected 4 floors, got %d", len(gotPayload.FloorNames)) + } + if !strings.Contains(out, "Created building") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesBuildingsCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/buildings") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildingId": "b-new", + "buildingName": "New Building", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsCreateCmd{Name: "New Building"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "buildingId") || !strings.Contains(out, "b-new") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesBuildingsCreateCmd_EmptyName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsCreateCmd{Name: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesBuildingsUpdateCmd(t *testing.T) { + var gotPayload struct { + BuildingName string `json:"buildingName"` + Description string `json:"description"` + } + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/buildings/b1") { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildingId": "b1", + "buildingName": gotPayload.BuildingName, + "description": gotPayload.Description, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated HQ" + newDesc := "Updated description" + cmd := &ResourcesBuildingsUpdateCmd{ + BuildingID: "b1", + Name: &newName, + Description: &newDesc, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPayload.BuildingName != "Updated HQ" { + t.Errorf("expected name 'Updated HQ', got %q", gotPayload.BuildingName) + } + if gotPayload.Description != "Updated description" { + t.Errorf("expected description 'Updated description', got %q", gotPayload.Description) + } + if !strings.Contains(out, "Updated building") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesBuildingsUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/buildings/b1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "buildingId": "b1", + "buildingName": "Updated HQ", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated HQ" + cmd := &ResourcesBuildingsUpdateCmd{BuildingID: "b1", Name: &newName} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "buildingId") || !strings.Contains(out, "b1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesBuildingsUpdateCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated" + cmd := &ResourcesBuildingsUpdateCmd{BuildingID: " ", Name: &newName} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty building ID") + } + if !strings.Contains(err.Error(), "building ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesBuildingsUpdateCmd_NoUpdates(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesBuildingsUpdateCmd{BuildingID: "b1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates") + } + if !strings.Contains(err.Error(), "no updates specified") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesBuildingsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/buildings/b1") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesBuildingsDeleteCmd{BuildingID: "b1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted building") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesBuildingsDeleteCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/buildings/b1") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesBuildingsDeleteCmd{BuildingID: "b1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") || !strings.Contains(out, "true") { + t.Fatalf("expected JSON output with deleted:true, got: %s", out) + } +} + +func TestResourcesBuildingsDeleteCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesBuildingsDeleteCmd{BuildingID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty building ID") + } + if !strings.Contains(err.Error(), "building ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ============================================================================= +// Calendars Tests +// ============================================================================= + +func TestResourcesCalendarsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "resourceId": "r1", + "resourceName": "Conference Room A", + "resourceEmail": "room-a@example.com", + "resourceCategory": "CONFERENCE_ROOM", + "buildingId": "b1", + "floorName": "1", + "capacity": 10, + }, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Conference Room A") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesCalendarsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"resourceId": "r1", "resourceName": "Conference Room A"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "items") || !strings.Contains(out, "r1") { + t.Fatalf("expected JSON items output, got: %s", out) + } +} + +func TestResourcesCalendarsListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsListCmd{} + + // Should not error on empty list + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestResourcesCalendarsListCmd_FilterByBuilding(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"resourceId": "r1", "resourceName": "Room A", "buildingId": "b1"}, + {"resourceId": "r2", "resourceName": "Room B", "buildingId": "b2"}, + {"resourceId": "r3", "resourceName": "Room C", "buildingId": "b1"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsListCmd{Building: "b1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should show only rooms in b1 + if !strings.Contains(out, "Room A") || !strings.Contains(out, "Room C") { + t.Fatalf("expected rooms from b1, got: %s", out) + } + if strings.Contains(out, "Room B") { + t.Fatalf("should not contain Room B (b2), got: %s", out) + } +} + +func TestResourcesCalendarsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars/r1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r1", + "resourceName": "Conference Room A", + "resourceEmail": "room-a@example.com", + "resourceCategory": "CONFERENCE_ROOM", + "resourceDescription": "Main conference room", + "userVisibleDescription": "Large room with projector", + "buildingId": "b1", + "floorName": "1", + "capacity": 15, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsGetCmd{ResourceID: "r1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ID:") || !strings.Contains(out, "r1") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Name:") || !strings.Contains(out, "Conference Room A") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "Email:") || !strings.Contains(out, "room-a@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesCalendarsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/calendars/r1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r1", + "resourceName": "Conference Room A", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsGetCmd{ResourceID: "r1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "resourceId") || !strings.Contains(out, "r1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesCalendarsGetCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsGetCmd{ResourceID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty resource ID") + } + if !strings.Contains(err.Error(), "resource ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesCalendarsCreateCmd(t *testing.T) { + var gotName string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + var payload struct { + ResourceName string `json:"resourceName"` + ResourceCategory string `json:"resourceCategory"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotName = payload.ResourceName + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r1", + "resourceName": payload.ResourceName, + "resourceEmail": "room@example.com", + "resourceCategory": payload.ResourceCategory, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsCreateCmd{Name: "Training Room", Type: "CONFERENCE_ROOM"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotName != "Training Room" { + t.Fatalf("unexpected name: %q", gotName) + } + if !strings.Contains(out, "Created calendar resource") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesCalendarsCreateCmd_WithAllFields(t *testing.T) { + var gotPayload struct { + ResourceName string `json:"resourceName"` + ResourceCategory string `json:"resourceCategory"` + BuildingId string `json:"buildingId"` + FloorName string `json:"floorName"` + Capacity int64 `json:"capacity"` + } + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r-new", + "resourceName": gotPayload.ResourceName, + "resourceEmail": "new-room@example.com", + "resourceCategory": gotPayload.ResourceCategory, + "buildingId": gotPayload.BuildingId, + "floorName": gotPayload.FloorName, + "capacity": gotPayload.Capacity, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsCreateCmd{ + Name: "Large Meeting Room", + Type: "CONFERENCE_ROOM", + Building: "b1", + Floor: "2", + Capacity: 20, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPayload.ResourceName != "Large Meeting Room" { + t.Errorf("expected name 'Large Meeting Room', got %q", gotPayload.ResourceName) + } + if gotPayload.ResourceCategory != "CONFERENCE_ROOM" { + t.Errorf("expected category 'CONFERENCE_ROOM', got %q", gotPayload.ResourceCategory) + } + if gotPayload.BuildingId != "b1" { + t.Errorf("expected buildingId 'b1', got %q", gotPayload.BuildingId) + } + if gotPayload.FloorName != "2" { + t.Errorf("expected floor '2', got %q", gotPayload.FloorName) + } + if gotPayload.Capacity != 20 { + t.Errorf("expected capacity 20, got %d", gotPayload.Capacity) + } + if !strings.Contains(out, "Created calendar resource") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesCalendarsCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/calendars") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r-new", + "resourceName": "New Room", + "resourceEmail": "new-room@example.com", + "resourceCategory": "OTHER", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsCreateCmd{Name: "New Room", Type: "OTHER"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "resourceId") || !strings.Contains(out, "r-new") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesCalendarsCreateCmd_EmptyName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsCreateCmd{Name: " ", Type: "CONFERENCE_ROOM"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesCalendarsUpdateCmd(t *testing.T) { + var gotPayload struct { + ResourceName string `json:"resourceName"` + Capacity int64 `json:"capacity"` + } + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/calendars/r1") { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r1", + "resourceName": gotPayload.ResourceName, + "capacity": gotPayload.Capacity, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated Room" + newCapacity := int64(25) + cmd := &ResourcesCalendarsUpdateCmd{ + ResourceID: "r1", + Name: &newName, + Capacity: &newCapacity, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPayload.ResourceName != "Updated Room" { + t.Errorf("expected name 'Updated Room', got %q", gotPayload.ResourceName) + } + if gotPayload.Capacity != 25 { + t.Errorf("expected capacity 25, got %d", gotPayload.Capacity) + } + if !strings.Contains(out, "Updated calendar resource") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesCalendarsUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !strings.Contains(r.URL.Path, "/resources/calendars/r1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceId": "r1", + "resourceName": "Updated Room", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated Room" + cmd := &ResourcesCalendarsUpdateCmd{ResourceID: "r1", Name: &newName} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "resourceId") || !strings.Contains(out, "r1") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesCalendarsUpdateCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated" + cmd := &ResourcesCalendarsUpdateCmd{ResourceID: " ", Name: &newName} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty resource ID") + } + if !strings.Contains(err.Error(), "resource ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesCalendarsUpdateCmd_NoUpdates(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesCalendarsUpdateCmd{ResourceID: "r1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates") + } + if !strings.Contains(err.Error(), "no updates specified") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesCalendarsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/calendars/r1") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesCalendarsDeleteCmd{ResourceID: "r1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted calendar resource") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesCalendarsDeleteCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/calendars/r1") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesCalendarsDeleteCmd{ResourceID: "r1"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") || !strings.Contains(out, "true") { + t.Fatalf("expected JSON output with deleted:true, got: %s", out) + } +} + +func TestResourcesCalendarsDeleteCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesCalendarsDeleteCmd{ResourceID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty resource ID") + } + if !strings.Contains(err.Error(), "resource ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ============================================================================= +// Features Tests +// ============================================================================= + +func TestResourcesFeaturesListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "features": []map[string]any{ + {"name": "Projector"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Projector") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesFeaturesListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "features": []map[string]any{ + {"name": "Projector"}, + {"name": "Whiteboard"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "features") || !strings.Contains(out, "Projector") { + t.Fatalf("expected JSON features output, got: %s", out) + } +} + +func TestResourcesFeaturesListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "features": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesListCmd{} + + // Should not error on empty list + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestResourcesFeaturesListCmd_Pagination(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/features") { + http.NotFound(w, r) + return + } + // Check pagination parameters + if r.URL.Query().Get("maxResults") != "50" { + t.Errorf("expected maxResults=50, got %s", r.URL.Query().Get("maxResults")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "features": []map[string]any{ + {"name": "Projector"}, + }, + "nextPageToken": "token456", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesListCmd{Max: 50} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestResourcesFeaturesCreateCmd(t *testing.T) { + var gotName string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/features") { + http.NotFound(w, r) + return + } + var payload struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotName = payload.Name + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": payload.Name, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesCreateCmd{Name: "Video Conferencing"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotName != "Video Conferencing" { + t.Fatalf("unexpected name: %q", gotName) + } + if !strings.Contains(out, "Created feature") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesFeaturesCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/resources/features") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "New Feature", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesCreateCmd{Name: "New Feature"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "name") || !strings.Contains(out, "New Feature") { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestResourcesFeaturesCreateCmd_EmptyName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ResourcesFeaturesCreateCmd{Name: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResourcesFeaturesDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/features/Projector") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesFeaturesDeleteCmd{Name: "Projector"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted feature") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestResourcesFeaturesDeleteCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/resources/features/Projector") { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesFeaturesDeleteCmd{Name: "Projector"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "deleted") || !strings.Contains(out, "true") { + t.Fatalf("expected JSON output with deleted:true, got: %s", out) + } +} + +func TestResourcesFeaturesDeleteCmd_EmptyName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ResourcesFeaturesDeleteCmd{Name: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty feature name") + } + if !strings.Contains(err.Error(), "feature name is required") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cmd/roles.go b/internal/cmd/roles.go new file mode 100644 index 00000000..e370c2e8 --- /dev/null +++ b/internal/cmd/roles.go @@ -0,0 +1,472 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strconv" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type RolesCmd struct { + List RolesListCmd `cmd:"" name:"list" aliases:"ls" help:"List admin roles"` + Get RolesGetCmd `cmd:"" name:"get" help:"Get role details"` + Create RolesCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create admin role"` + Update RolesUpdateCmd `cmd:"" name:"update" help:"Update admin role"` + Delete RolesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete admin role"` + Privileges RolesPrivilegesCmd `cmd:"" name:"privileges" help:"List available privileges"` +} + +type RolesListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *RolesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + call := svc.Roles.List(adminCustomerID()).MaxResults(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list roles: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Items) == 0 { + u.Err().Println("No roles found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ROLE ID\tNAME\tSYSTEM\tSUPERADMIN\tPRIVILEGES") + for _, role := range resp.Items { + if role == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%t\t%t\t%d\n", + sanitizeTab(strconv.FormatInt(role.RoleId, 10)), + sanitizeTab(role.RoleName), + role.IsSystemRole, + role.IsSuperAdminRole, + len(role.RolePrivileges), + ) + } + + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type RolesGetCmd struct { + Role string `arg:"" name:"role" help:"Role ID or name"` +} + +func (c *RolesGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + roleID, role, err := resolveRole(ctx, svc, c.Role) + if err != nil { + return err + } + if role == nil { + role, err = svc.Roles.Get(adminCustomerID(), roleID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get role %s: %w", c.Role, err) + } + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, role) + } + + u.Out().Printf("Role ID: %s\n", strconv.FormatInt(role.RoleId, 10)) + u.Out().Printf("Name: %s\n", role.RoleName) + u.Out().Printf("System Role: %v\n", role.IsSystemRole) + u.Out().Printf("Super Admin: %v\n", role.IsSuperAdminRole) + if role.RoleDescription != "" { + u.Out().Printf("Description: %s\n", role.RoleDescription) + } + if len(role.RolePrivileges) > 0 { + privs := make([]string, 0, len(role.RolePrivileges)) + for _, p := range role.RolePrivileges { + if p == nil { + continue + } + privs = append(privs, p.PrivilegeName) + } + sort.Strings(privs) + u.Out().Printf("Privileges: %s\n", strings.Join(privs, ", ")) + } + return nil +} + +type RolesCreateCmd struct { + Name string `arg:"" name:"name" help:"Role name"` + Privileges string `name:"privileges" required:"" help:"Comma-separated privilege names"` + Description string `name:"description" help:"Role description"` +} + +func (c *RolesCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + privNames := splitCSV(c.Privileges) + if len(privNames) == 0 { + return usage("--privileges is required") + } + + privs, err := buildRolePrivileges(ctx, svc, privNames) + if err != nil { + return err + } + + role := &admin.Role{ + RoleName: c.Name, + RoleDescription: c.Description, + RolePrivileges: privs, + } + + created, err := svc.Roles.Insert(adminCustomerID(), role).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create role %s: %w", c.Name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Created role: %s (%d)\n", created.RoleName, created.RoleId) + return nil +} + +type RolesUpdateCmd struct { + Role string `arg:"" name:"role" help:"Role ID or name"` + Name *string `name:"name" help:"New role name"` + Description *string `name:"description" help:"Role description"` + AddPrivileges string `name:"add-privileges" help:"Comma-separated privileges to add"` + RemovePrivileges string `name:"remove-privileges" help:"Comma-separated privileges to remove"` +} + +func (c *RolesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + roleID, role, err := resolveRole(ctx, svc, c.Role) + if err != nil { + return err + } + if role == nil { + role, err = svc.Roles.Get(adminCustomerID(), roleID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get role %s: %w", c.Role, err) + } + } + + hasUpdates := false + if c.Name != nil { + role.RoleName = *c.Name + hasUpdates = true + } + if c.Description != nil { + role.RoleDescription = *c.Description + if *c.Description == "" { + role.ForceSendFields = append(role.ForceSendFields, "RoleDescription") + } + hasUpdates = true + } + + addNames := splitCSV(c.AddPrivileges) + removeNames := splitCSV(c.RemovePrivileges) + if len(addNames) > 0 || len(removeNames) > 0 { + var updatedPrivs []*admin.RoleRolePrivileges + updatedPrivs, err = updateRolePrivileges(ctx, svc, role.RolePrivileges, addNames, removeNames) + if err != nil { + return err + } + role.RolePrivileges = updatedPrivs + hasUpdates = true + } + + if !hasUpdates { + return usage("no updates specified") + } + + updated, err := svc.Roles.Update(adminCustomerID(), roleID, role).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update role %s: %w", c.Role, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Updated role: %s (%d)\n", updated.RoleName, updated.RoleId) + return nil +} + +type RolesDeleteCmd struct { + Role string `arg:"" name:"role" help:"Role ID or name"` +} + +func (c *RolesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete role %s", c.Role)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + roleID, _, err := resolveRole(ctx, svc, c.Role) + if err != nil { + return err + } + + if err := svc.Roles.Delete(adminCustomerID(), roleID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete role %s: %w", c.Role, err) + } + + u.Out().Printf("Deleted role: %s\n", c.Role) + return nil +} + +type RolesPrivilegesCmd struct{} + +func (c *RolesPrivilegesCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Privileges.List(adminCustomerID()).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list privileges: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Items) == 0 { + u.Err().Println("No privileges found") + return nil + } + + flat := flattenPrivileges(resp.Items) + sort.Slice(flat, func(i, j int) bool { return flat[i].PrivilegeName < flat[j].PrivilegeName }) + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PRIVILEGE\tSERVICE ID\tOU SCOPABLE") + for _, priv := range flat { + fmt.Fprintf(w, "%s\t%s\t%t\n", + sanitizeTab(priv.PrivilegeName), + sanitizeTab(priv.ServiceId), + priv.IsOuScopable, + ) + } + return nil +} + +func resolveRole(ctx context.Context, svc *admin.Service, role string) (string, *admin.Role, error) { + role = strings.TrimSpace(role) + if role == "" { + return "", nil, usage("role required") + } + if _, err := strconv.ParseInt(role, 10, 64); err == nil { + return role, nil, nil + } + + roles, err := listAllRoles(ctx, svc) + if err != nil { + return "", nil, err + } + for _, r := range roles { + if r == nil { + continue + } + if strings.EqualFold(r.RoleName, role) { + return strconv.FormatInt(r.RoleId, 10), r, nil + } + } + return "", nil, fmt.Errorf("role %q not found", role) +} + +func listAllRoles(ctx context.Context, svc *admin.Service) ([]*admin.Role, error) { + roles := make([]*admin.Role, 0) + call := svc.Roles.List(adminCustomerID()).MaxResults(200) + for { + resp, err := call.Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("list roles: %w", err) + } + roles = append(roles, resp.Items...) + if resp.NextPageToken == "" { + break + } + call = call.PageToken(resp.NextPageToken) + } + return roles, nil +} + +func buildRolePrivileges(ctx context.Context, svc *admin.Service, names []string) ([]*admin.RoleRolePrivileges, error) { + privs, err := privilegeMap(ctx, svc) + if err != nil { + return nil, err + } + + out := make([]*admin.RoleRolePrivileges, 0, len(names)) + for _, name := range names { + key := strings.TrimSpace(name) + if key == "" { + continue + } + priv, ok := privs[strings.ToLower(key)] + if !ok { + return nil, fmt.Errorf("unknown privilege %q", key) + } + out = append(out, &admin.RoleRolePrivileges{PrivilegeName: priv.PrivilegeName, ServiceId: priv.ServiceId}) + } + if len(out) == 0 { + return nil, usage("no privileges specified") + } + return out, nil +} + +func updateRolePrivileges(ctx context.Context, svc *admin.Service, existing []*admin.RoleRolePrivileges, add, remove []string) ([]*admin.RoleRolePrivileges, error) { + set := make(map[string]*admin.RoleRolePrivileges) + for _, p := range existing { + if p == nil { + continue + } + set[strings.ToLower(p.PrivilegeName)] = p + } + + if len(remove) > 0 { + for _, name := range remove { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + continue + } + delete(set, key) + } + } + + if len(add) > 0 { + privs, err := privilegeMap(ctx, svc) + if err != nil { + return nil, err + } + for _, name := range add { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + continue + } + priv, ok := privs[key] + if !ok { + return nil, fmt.Errorf("unknown privilege %q", name) + } + set[key] = &admin.RoleRolePrivileges{PrivilegeName: priv.PrivilegeName, ServiceId: priv.ServiceId} + } + } + + out := make([]*admin.RoleRolePrivileges, 0, len(set)) + for _, p := range set { + out = append(out, p) + } + return out, nil +} + +func privilegeMap(ctx context.Context, svc *admin.Service) (map[string]*admin.Privilege, error) { + resp, err := svc.Privileges.List(adminCustomerID()).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("list privileges: %w", err) + } + flat := flattenPrivileges(resp.Items) + out := make(map[string]*admin.Privilege, len(flat)) + for _, p := range flat { + if p == nil { + continue + } + out[strings.ToLower(p.PrivilegeName)] = p + } + return out, nil +} + +func flattenPrivileges(items []*admin.Privilege) []*admin.Privilege { + var out []*admin.Privilege + var walk func(p *admin.Privilege) + walk = func(p *admin.Privilege) { + if p == nil { + return + } + out = append(out, p) + for _, child := range p.ChildPrivileges { + walk(child) + } + } + for _, p := range items { + walk(p) + } + return out +} diff --git a/internal/cmd/roles_test.go b/internal/cmd/roles_test.go new file mode 100644 index 00000000..62939fe0 --- /dev/null +++ b/internal/cmd/roles_test.go @@ -0,0 +1,423 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestRolesListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "roleId": "123", + "roleName": "Helpdesk", + "isSystemRole": false, + "isSuperAdminRole": false, + "rolePrivileges": []map[string]any{{"privilegeName": "READ"}}, + }, + }, + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &RolesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Helpdesk") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "privileges"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"privilegeName": "READ", "serviceId": "svc"}, + {"privilegeName": "WRITE", "serviceId": "svc"}, + }, + }) + return + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleId": "456", + "roleName": "Helpdesk", + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &RolesCreateCmd{Name: "Helpdesk", Privileges: "READ,WRITE"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created role") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAdminsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"roleId": "123", "roleName": "Helpdesk"}, + }, + }) + return + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "roleAssignmentId": "1", + "roleId": "123", + "assignedTo": "user-1", + "scopeType": "CUSTOMER", + }, + }, + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Helpdesk") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAdminsCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"roleId": "123", "roleName": "Helpdesk"}, + }, + }) + return + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/users/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "user-1"}) + return + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/roleassignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"roleAssignmentId": "99"}) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &AdminsCreateCmd{User: "sam@example.com", Role: "Helpdesk"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Assigned role") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestAdminsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/roleassignments/"): + w.WriteHeader(http.StatusOK) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &AdminsDeleteCmd{AssignmentID: "99"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted admin assignment") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleId": "123", + "roleName": "Super Admin", + "isSystemRole": true, + "isSuperAdminRole": true, + "roleDescription": "Full access", + "rolePrivileges": []map[string]any{ + {"privilegeName": "ADMIN_READ"}, + {"privilegeName": "ADMIN_WRITE"}, + }, + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &RolesGetCmd{Role: "123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Super Admin") || !strings.Contains(out, "Full access") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesUpdateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleId": "456", + "roleName": "Custom Role", + "rolePrivileges": []map[string]any{{"privilegeName": "READ", "serviceId": "svc"}}, + }) + return + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/roles/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleId": "456", + "roleName": "Updated Role", + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated Role" + cmd := &RolesUpdateCmd{Role: "456", Name: &newName} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated role") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"roleId": "789", "roleName": "ToDelete"}, + }, + }) + return + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/roles/"): + w.WriteHeader(http.StatusOK) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &RolesDeleteCmd{Role: "ToDelete"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted role") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesPrivilegesCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/privileges"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"privilegeName": "ADMIN_READ", "serviceId": "admin.directory", "isOuScopable": true}, + {"privilegeName": "ADMIN_WRITE", "serviceId": "admin.directory", "isOuScopable": false}, + }, + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &RolesPrivilegesCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ADMIN_READ") || !strings.Contains(out, "ADMIN_WRITE") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesUpdateCmd_AddRemovePrivileges(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/privileges"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"privilegeName": "READ", "serviceId": "svc"}, + {"privilegeName": "WRITE", "serviceId": "svc"}, + {"privilegeName": "DELETE", "serviceId": "svc"}, + }, + }) + return + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/roles/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleId": "456", + "roleName": "Custom Role", + "rolePrivileges": []map[string]any{ + {"privilegeName": "READ", "serviceId": "svc"}, + }, + }) + return + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/roles/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "roleId": "456", + "roleName": "Custom Role", + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &RolesUpdateCmd{Role: "456", AddPrivileges: "WRITE", RemovePrivileges: "READ"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated role") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestRolesGetCmd_ByName(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/roles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "roleId": "999", + "roleName": "CustomRole", + "isSystemRole": false, + "isSuperAdminRole": false, + "roleDescription": "A custom role", + "rolePrivileges": []map[string]any{{"privilegeName": "SOME_PRIVILEGE"}}, + }, + }, + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &RolesGetCmd{Role: "CustomRole"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "CustomRole") || !strings.Contains(out, "A custom role") { + t.Fatalf("unexpected output: %s", out) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 28012deb..116c6029 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -21,11 +21,14 @@ import ( const ( colorAuto = "auto" colorNever = "never" + + strTrue = "true" + strFalse = "false" ) type RootFlags struct { Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets)"` + Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/admin)"` Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}"` @@ -40,25 +43,55 @@ type CLI struct { Version kong.VersionFlag `help:"Print version and exit"` - Auth AuthCmd `cmd:"" help:"Auth and credentials"` - Groups GroupsCmd `cmd:"" help:"Google Groups"` - Drive DriveCmd `cmd:"" help:"Google Drive"` - Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"` - Slides SlidesCmd `cmd:"" help:"Google Slides"` - Calendar CalendarCmd `cmd:"" help:"Google Calendar"` - Classroom ClassroomCmd `cmd:"" help:"Google Classroom"` - Time TimeCmd `cmd:"" help:"Local time utilities"` - Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` - Chat ChatCmd `cmd:"" help:"Google Chat"` - Contacts ContactsCmd `cmd:"" help:"Google Contacts"` - Tasks TasksCmd `cmd:"" help:"Google Tasks"` - People PeopleCmd `cmd:"" help:"Google People"` - Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` - Sheets SheetsCmd `cmd:"" help:"Google Sheets"` - Config ConfigCmd `cmd:"" help:"Manage configuration"` - VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` - Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` - Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` + Auth AuthCmd `cmd:"" help:"Auth and credentials"` + Groups GroupsCmd `cmd:"" help:"Google Groups"` + Users UsersCmd `cmd:"" help:"Workspace users"` + Orgunits OrgunitsCmd `cmd:"" help:"Organizational units"` + Domains DomainsCmd `cmd:"" help:"Workspace domains"` + Aliases AliasesCmd `cmd:"" help:"Workspace aliases"` + Roles RolesCmd `cmd:"" help:"Admin roles"` + Admins AdminsCmd `cmd:"" help:"Admin assignments"` + Reports ReportsCmd `cmd:"" help:"Admin reports"` + Vault VaultCmd `cmd:"" help:"Google Vault"` + Alerts AlertsCmd `cmd:"" help:"Security alerts"` + SSO SSOCmd `cmd:"" name:"sso" help:"Inbound SSO"` + CAA CAACmd `cmd:"" name:"caa" help:"Context-aware access"` + Licenses LicensesCmd `cmd:"" help:"Workspace licenses"` + Resources ResourcesCmd `cmd:"" help:"Calendar resources"` + Schemas SchemasCmd `cmd:"" help:"Custom user schemas"` + Transfer TransferCmd `cmd:"" name:"transfer" help:"Data transfer"` + Printers PrintersCmd `cmd:"" help:"Chrome printers"` + Forms FormsCmd `cmd:"" help:"Google Forms"` + Sites SitesCmd `cmd:"" help:"Google Sites"` + YouTube YouTubeCmd `cmd:"" help:"YouTube"` + Looker LookerStudioCmd `cmd:"" name:"lookerstudio" help:"Looker Studio"` + Meet MeetCmd `cmd:"" help:"Google Meet"` + Analytics AnalyticsCmd `cmd:"" help:"Analytics Admin"` + Labels LabelsCmd `cmd:"" help:"Drive Labels"` + CI CloudIdentityCmd `cmd:"" name:"ci" help:"Cloud Identity"` + Reseller ResellerCmd `cmd:"" help:"Reseller API"` + Channel ChannelCmd `cmd:"" help:"Cloud Channel"` + Projects ProjectsCmd `cmd:"" help:"GCP projects"` + ServiceAccounts ServiceAccountsCmd `cmd:"" name:"serviceaccounts" help:"Service accounts"` + Drive DriveCmd `cmd:"" help:"Google Drive"` + Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"` + Slides SlidesCmd `cmd:"" help:"Google Slides"` + Calendar CalendarCmd `cmd:"" help:"Google Calendar"` + Classroom ClassroomCmd `cmd:"" help:"Google Classroom"` + Time TimeCmd `cmd:"" help:"Local time utilities"` + Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` + Chat ChatCmd `cmd:"" help:"Google Chat"` + Contacts ContactsCmd `cmd:"" help:"Google Contacts"` + Tasks TasksCmd `cmd:"" help:"Google Tasks"` + People PeopleCmd `cmd:"" help:"Google People"` + Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` + Sheets SheetsCmd `cmd:"" help:"Google Sheets"` + Config ConfigCmd `cmd:"" help:"Manage configuration"` + CSV CSVCmd `cmd:"" name:"csv" help:"Process commands from CSV files"` + Batch BatchCmd `cmd:"" help:"Run commands from a file"` + VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` + Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` + Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` } type exitPanic struct{ code int } @@ -163,9 +196,9 @@ func envOr(key, fallback string) string { func boolString(v bool) string { if v { - return "true" + return strTrue } - return "false" + return strFalse } func newParser(description string) (*kong.Kong, *CLI, error) { diff --git a/internal/cmd/schemas.go b/internal/cmd/schemas.go new file mode 100644 index 00000000..8526dd22 --- /dev/null +++ b/internal/cmd/schemas.go @@ -0,0 +1,348 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SchemasCmd struct { + List SchemasListCmd `cmd:"" name:"list" aliases:"ls" help:"List schemas"` + Get SchemasGetCmd `cmd:"" name:"get" help:"Get schema"` + Create SchemasCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create schema"` + Update SchemasUpdateCmd `cmd:"" name:"update" help:"Update schema"` + Delete SchemasDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete schema"` +} + +type SchemasListCmd struct{} + +func (c *SchemasListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Schemas.List(adminCustomerID()).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list schemas: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Schemas) == 0 { + u.Err().Println("No schemas found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tFIELDS\tID") + for _, schema := range resp.Schemas { + if schema == nil { + continue + } + fmt.Fprintf(w, "%s\t%d\t%s\n", + sanitizeTab(schema.SchemaName), + len(schema.Fields), + sanitizeTab(schema.SchemaId), + ) + } + return nil +} + +type SchemasGetCmd struct { + Name string `arg:"" name:"name" help:"Schema name or ID"` +} + +func (c *SchemasGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("schema name is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + schema, err := svc.Schemas.Get(adminCustomerID(), name).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get schema %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, schema) + } + + u.Out().Printf("Name: %s\n", schema.SchemaName) + u.Out().Printf("ID: %s\n", schema.SchemaId) + if schema.DisplayName != "" { + u.Out().Printf("Display: %s\n", schema.DisplayName) + } + if len(schema.Fields) > 0 { + u.Out().Printf("Fields: %d\n", len(schema.Fields)) + for _, field := range schema.Fields { + if field == nil { + continue + } + u.Out().Printf("- %s (%s)\n", field.FieldName, field.FieldType) + } + } + return nil +} + +type SchemasCreateCmd struct { + Name string `arg:"" name:"name" help:"Schema name"` + Fields []string `name:"field" help:"Field spec NAME:TYPE (repeatable)"` +} + +func (c *SchemasCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("schema name is required") + } + + fields, err := parseSchemaFields(c.Fields) + if err != nil { + return err + } + if len(fields) == 0 { + return usage("--field is required") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + schema := &admin.Schema{ + SchemaName: name, + DisplayName: name, + Fields: fields, + } + + created, err := svc.Schemas.Insert(adminCustomerID(), schema).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create schema %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created schema: %s (%s)\n", created.SchemaName, created.SchemaId) + return nil +} + +type SchemasUpdateCmd struct { + Name string `arg:"" name:"name" help:"Schema name or ID"` + AddFields []string `name:"add-field" help:"Field spec NAME:TYPE (repeatable)"` + RemoveField []string `name:"remove-field" help:"Field name to remove (repeatable)"` +} + +func (c *SchemasUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("schema name is required") + } + + if len(c.AddFields) == 0 && len(c.RemoveField) == 0 { + return usage("no updates specified") + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + schema, err := svc.Schemas.Get(adminCustomerID(), name).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get schema %s: %w", name, err) + } + + fieldMap := make(map[string]*admin.SchemaFieldSpec, len(schema.Fields)) + order := make([]string, 0, len(schema.Fields)) + for _, field := range schema.Fields { + if field == nil { + continue + } + fieldMap[field.FieldName] = field + order = append(order, field.FieldName) + } + + newFields, err := parseSchemaFields(c.AddFields) + if err != nil { + return err + } + for _, field := range newFields { + if fieldMap[field.FieldName] != nil { + return fmt.Errorf("field %s already exists", field.FieldName) + } + fieldMap[field.FieldName] = field + order = append(order, field.FieldName) + } + + for _, remove := range c.RemoveField { + remove = strings.TrimSpace(remove) + if remove == "" { + continue + } + if _, ok := fieldMap[remove]; !ok { + return fmt.Errorf("field %s not found", remove) + } + delete(fieldMap, remove) + } + + updatedFields := make([]*admin.SchemaFieldSpec, 0, len(fieldMap)) + seen := make(map[string]struct{}, len(fieldMap)) + for _, name := range order { + if field, ok := fieldMap[name]; ok { + updatedFields = append(updatedFields, field) + seen[name] = struct{}{} + } + } + if len(seen) != len(fieldMap) { + remaining := make([]string, 0, len(fieldMap)-len(seen)) + for key := range fieldMap { + if _, ok := seen[key]; !ok { + remaining = append(remaining, key) + } + } + sort.Strings(remaining) + for _, key := range remaining { + updatedFields = append(updatedFields, fieldMap[key]) + } + } + + schema.Fields = updatedFields + updated, err := svc.Schemas.Update(adminCustomerID(), name, schema).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update schema %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated schema: %s\n", updated.SchemaName) + return nil +} + +type SchemasDeleteCmd struct { + Name string `arg:"" name:"name" help:"Schema name or ID"` +} + +func (c *SchemasDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + name := strings.TrimSpace(c.Name) + if name == "" { + return usage("schema name is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete schema %s", name)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Schemas.Delete(adminCustomerID(), name).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete schema %s: %w", name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"schema": name, "deleted": true}) + } + + u.Out().Printf("Deleted schema: %s\n", name) + return nil +} + +func parseSchemaFields(fields []string) ([]*admin.SchemaFieldSpec, error) { + out := make([]*admin.SchemaFieldSpec, 0, len(fields)) + for _, spec := range fields { + spec = strings.TrimSpace(spec) + if spec == "" { + continue + } + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field spec %q (expected NAME:TYPE)", spec) + } + name := strings.TrimSpace(parts[0]) + if name == "" { + return nil, fmt.Errorf("invalid field spec %q (empty name)", spec) + } + fieldType, ok := normalizeSchemaFieldType(parts[1]) + if !ok { + return nil, fmt.Errorf("invalid field type %q", parts[1]) + } + out = append(out, &admin.SchemaFieldSpec{ + FieldName: name, + FieldType: fieldType, + DisplayName: name, + }) + } + return out, nil +} + +func normalizeSchemaFieldType(raw string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "bool", "boolean": + return "BOOL", true + case "date": + return "DATE", true + case "double": + return "DOUBLE", true + case "email": + return "EMAIL", true + case "int", "int64": + return "INT64", true + case "phone": + return "PHONE", true + case "string", "str": + return "STRING", true + default: + return "", false + } +} diff --git a/internal/cmd/schemas_test.go b/internal/cmd/schemas_test.go new file mode 100644 index 00000000..82ecbbd4 --- /dev/null +++ b/internal/cmd/schemas_test.go @@ -0,0 +1,863 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestSchemasListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/schemas") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemas": []map[string]any{ + {"schemaName": "Custom", "schemaId": "s1", "fields": []map[string]any{{"fieldName": "Department"}}}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Custom") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSchemasListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemas": []map[string]any{ + {"schemaName": "Custom", "schemaId": "s1"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"schemas"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestSchemasListCmd_MultipleSchemas(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemas": []map[string]any{ + {"schemaName": "Schema1", "schemaId": "s1", "fields": []map[string]any{{"fieldName": "f1"}, {"fieldName": "f2"}}}, + {"schemaName": "Schema2", "schemaId": "s2", "fields": []map[string]any{{"fieldName": "f3"}}}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Schema1") || !strings.Contains(out, "Schema2") { + t.Fatalf("unexpected output: %s", out) + } + // Schema1 has 2 fields + if !strings.Contains(out, "2") { + t.Fatalf("expected field count, got: %s", out) + } +} + +func TestSchemasGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/schemas/Custom") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "displayName": "Custom Schema", + "fields": []map[string]any{ + {"fieldName": "Department", "fieldType": "STRING"}, + {"fieldName": "EmployeeID", "fieldType": "INT64"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasGetCmd{Name: "Custom"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Name:") || !strings.Contains(out, "Custom") { + t.Fatalf("unexpected output: %s", out) + } + if !strings.Contains(out, "ID:") || !strings.Contains(out, "s1") { + t.Fatalf("expected ID, got: %s", out) + } + if !strings.Contains(out, "Display:") || !strings.Contains(out, "Custom Schema") { + t.Fatalf("expected display name, got: %s", out) + } + if !strings.Contains(out, "Department") || !strings.Contains(out, "STRING") { + t.Fatalf("expected fields, got: %s", out) + } +} + +func TestSchemasGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasGetCmd{Name: "Custom"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"schemaName"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestSchemasGetCmd_MissingName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasGetCmd{Name: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "schema name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasGetCmd_NoDisplayName(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "fields": []map[string]any{}, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasGetCmd{Name: "Custom"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should not contain "Display:" when displayName is empty + if strings.Contains(out, "Display:") { + t.Fatalf("expected no display line when empty, got: %s", out) + } +} + +func TestSchemasCreateCmd(t *testing.T) { + var gotName string + var gotType string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/schemas"): + var payload struct { + SchemaName string `json:"schemaName"` + Fields []struct { + FieldName string `json:"fieldName"` + FieldType string `json:"fieldType"` + } `json:"fields"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotName = payload.SchemaName + if len(payload.Fields) > 0 { + gotType = payload.Fields[0].FieldType + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": payload.SchemaName, + "schemaId": "s1", + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{"Department:string"}} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotName != "Custom" { + t.Fatalf("unexpected name: %q", gotName) + } + if gotType != "STRING" { + t.Fatalf("unexpected type: %q", gotType) + } + if !strings.Contains(out, "Created schema") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSchemasCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{"Department:string"}} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"schemaName"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestSchemasCreateCmd_MissingName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasCreateCmd{Name: "", Fields: []string{"Department:string"}} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "schema name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasCreateCmd_MissingFields(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{}} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing fields") + } + if !strings.Contains(err.Error(), "--field is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasCreateCmd_MultipleFields(t *testing.T) { + var gotFields []string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var payload struct { + Fields []struct { + FieldName string `json:"fieldName"` + FieldType string `json:"fieldType"` + } `json:"fields"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + for _, f := range payload.Fields { + gotFields = append(gotFields, f.FieldName+":"+f.FieldType) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasCreateCmd{Name: "Custom", Fields: []string{"Department:string", "EmployeeID:int", "Active:bool"}} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(gotFields) != 3 { + t.Fatalf("expected 3 fields, got: %v", gotFields) + } + if gotFields[0] != "Department:STRING" { + t.Errorf("unexpected field: %s", gotFields[0]) + } + if gotFields[1] != "EmployeeID:INT64" { + t.Errorf("unexpected field: %s", gotFields[1]) + } + if gotFields[2] != "Active:BOOL" { + t.Errorf("unexpected field: %s", gotFields[2]) + } +} + +func TestSchemasUpdateCmd(t *testing.T) { + calls := 0 + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/schemas/Custom"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "fields": []map[string]any{ + {"fieldName": "Department", "fieldType": "STRING"}, + }, + }) + return + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/schemas/Custom"): + calls++ + var payload struct { + Fields []struct { + FieldName string `json:"fieldName"` + FieldType string `json:"fieldType"` + } `json:"fields"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + }) + return + default: + http.NotFound(w, r) + } + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: "Custom", AddFields: []string{"Title:string"}} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if calls != 1 { + t.Fatalf("expected 1 update call, got %d", calls) + } + if !strings.Contains(out, "Updated schema") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSchemasUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "fields": []map[string]any{{"fieldName": "Department", "fieldType": "STRING"}}, + }) + return + } + if r.Method == http.MethodPut { + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: "Custom", AddFields: []string{"Title:string"}} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"schemaName"`) { + t.Fatalf("expected JSON output, got: %s", out) + } +} + +func TestSchemasUpdateCmd_MissingName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: " ", AddFields: []string{"Title:string"}} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "schema name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasUpdateCmd_NoUpdates(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: "Custom"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates") + } + if !strings.Contains(err.Error(), "no updates specified") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasUpdateCmd_RemoveField(t *testing.T) { + var updatedFields []string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "fields": []map[string]any{ + {"fieldName": "Department", "fieldType": "STRING"}, + {"fieldName": "Title", "fieldType": "STRING"}, + }, + }) + return + } + if r.Method == http.MethodPut { + var payload struct { + Fields []struct { + FieldName string `json:"fieldName"` + } `json:"fields"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + for _, f := range payload.Fields { + updatedFields = append(updatedFields, f.FieldName) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: "Custom", RemoveField: []string{"Title"}} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(updatedFields) != 1 || updatedFields[0] != "Department" { + t.Fatalf("expected only Department field, got: %v", updatedFields) + } +} + +func TestSchemasUpdateCmd_FieldAlreadyExists(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "fields": []map[string]any{ + {"fieldName": "Department", "fieldType": "STRING"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: "Custom", AddFields: []string{"Department:int"}} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for duplicate field") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasUpdateCmd_FieldNotFound(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaName": "Custom", + "schemaId": "s1", + "fields": []map[string]any{ + {"fieldName": "Department", "fieldType": "STRING"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SchemasUpdateCmd{Name: "Custom", RemoveField: []string{"NonExistent"}} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for non-existent field") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasDeleteCmd(t *testing.T) { + var deleteCalled bool + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/schemas/Custom") { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &SchemasDeleteCmd{Name: "Custom"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("delete was not called") + } + if !strings.Contains(out, "Deleted schema") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSchemasDeleteCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &SchemasDeleteCmd{Name: "Custom"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"deleted"`) || !strings.Contains(out, `true`) { + t.Fatalf("expected JSON output with deleted: true, got: %s", out) + } +} + +func TestSchemasDeleteCmd_MissingName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &SchemasDeleteCmd{Name: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "schema name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSchemasDeleteCmd_RequiresConfirmation(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: false, NoInput: true} + cmd := &SchemasDeleteCmd{Name: "Custom"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error when confirmation is required but not provided") + } + if !strings.Contains(err.Error(), "refusing") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseSchemaFields(t *testing.T) { + tests := []struct { + name string + input []string + wantLen int + wantName string + wantType string + wantErr bool + }{ + { + name: "string type", + input: []string{"Department:string"}, + wantLen: 1, + wantName: "Department", + wantType: "STRING", + }, + { + name: "str alias", + input: []string{"Name:str"}, + wantLen: 1, + wantName: "Name", + wantType: "STRING", + }, + { + name: "int type", + input: []string{"Count:int"}, + wantLen: 1, + wantName: "Count", + wantType: "INT64", + }, + { + name: "int64 type", + input: []string{"Count:int64"}, + wantLen: 1, + wantName: "Count", + wantType: "INT64", + }, + { + name: "bool type", + input: []string{"Active:bool"}, + wantLen: 1, + wantName: "Active", + wantType: "BOOL", + }, + { + name: "boolean type", + input: []string{"Active:boolean"}, + wantLen: 1, + wantName: "Active", + wantType: "BOOL", + }, + { + name: "date type", + input: []string{"StartDate:date"}, + wantLen: 1, + wantName: "StartDate", + wantType: "DATE", + }, + { + name: "double type", + input: []string{"Salary:double"}, + wantLen: 1, + wantName: "Salary", + wantType: "DOUBLE", + }, + { + name: "email type", + input: []string{"Contact:email"}, + wantLen: 1, + wantName: "Contact", + wantType: "EMAIL", + }, + { + name: "phone type", + input: []string{"Mobile:phone"}, + wantLen: 1, + wantName: "Mobile", + wantType: "PHONE", + }, + { + name: "multiple fields", + input: []string{"Name:string", "Age:int", "Active:bool"}, + wantLen: 3, + }, + { + name: "empty input", + input: []string{}, + wantLen: 0, + }, + { + name: "whitespace only", + input: []string{" ", "\t"}, + wantLen: 0, + }, + { + name: "invalid format - no colon", + input: []string{"Department"}, + wantErr: true, + }, + { + name: "invalid type", + input: []string{"Department:invalid"}, + wantErr: true, + }, + { + name: "empty name", + input: []string{":string"}, + wantErr: true, + }, + { + name: "case insensitive type", + input: []string{"Field:STRING"}, + wantLen: 1, + wantName: "Field", + wantType: "STRING", + }, + { + name: "type with spaces", + input: []string{"Field: string "}, + wantLen: 1, + wantName: "Field", + wantType: "STRING", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSchemaFields(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != tt.wantLen { + t.Fatalf("expected %d fields, got %d", tt.wantLen, len(got)) + } + if tt.wantLen > 0 && tt.wantName != "" { + if got[0].FieldName != tt.wantName { + t.Errorf("expected name %q, got %q", tt.wantName, got[0].FieldName) + } + if got[0].FieldType != tt.wantType { + t.Errorf("expected type %q, got %q", tt.wantType, got[0].FieldType) + } + } + }) + } +} + +func TestNormalizeSchemaFieldType(t *testing.T) { + tests := []struct { + input string + wantType string + wantOK bool + }{ + {"bool", "BOOL", true}, + {"boolean", "BOOL", true}, + {"BOOL", "BOOL", true}, + {"date", "DATE", true}, + {"DATE", "DATE", true}, + {"double", "DOUBLE", true}, + {"DOUBLE", "DOUBLE", true}, + {"email", "EMAIL", true}, + {"EMAIL", "EMAIL", true}, + {"int", "INT64", true}, + {"int64", "INT64", true}, + {"INT64", "INT64", true}, + {"phone", "PHONE", true}, + {"PHONE", "PHONE", true}, + {"string", "STRING", true}, + {"str", "STRING", true}, + {"STRING", "STRING", true}, + {" string ", "STRING", true}, + {"invalid", "", false}, + {"", "", false}, + {"text", "", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + gotType, gotOK := normalizeSchemaFieldType(tt.input) + if gotOK != tt.wantOK { + t.Errorf("normalizeSchemaFieldType(%q) ok = %v, want %v", tt.input, gotOK, tt.wantOK) + } + if gotType != tt.wantType { + t.Errorf("normalizeSchemaFieldType(%q) = %q, want %q", tt.input, gotType, tt.wantType) + } + }) + } +} diff --git a/internal/cmd/serviceaccounts.go b/internal/cmd/serviceaccounts.go new file mode 100644 index 00000000..70465a56 --- /dev/null +++ b/internal/cmd/serviceaccounts.go @@ -0,0 +1,364 @@ +package cmd + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "google.golang.org/api/iam/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newIAMService = googleapi.NewIAM + +type ServiceAccountsCmd struct { + List ServiceAccountsListCmd `cmd:"" name:"list" aliases:"ls" help:"List service accounts"` + Create ServiceAccountsCreateCmd `cmd:"" name:"create" help:"Create a service account"` + Delete ServiceAccountsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a service account"` + Keys ServiceAccountsKeysCmd `cmd:"" name:"keys" help:"Manage service account keys"` +} + +type ServiceAccountsListCmd struct { + Project string `name:"project" help:"GCP project ID" required:""` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *ServiceAccountsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + project := strings.TrimSpace(c.Project) + if project == "" { + return usage("--project is required") + } + + svc, err := newIAMService(ctx, account) + if err != nil { + return err + } + + parent := "projects/" + project + call := svc.Projects.ServiceAccounts.List(parent).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list service accounts: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Accounts) == 0 { + u.Err().Println("No service accounts found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tNAME\tRESOURCE") + for _, sa := range resp.Accounts { + if sa == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(sa.Email), + sanitizeTab(sa.DisplayName), + sanitizeTab(sa.Name), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type ServiceAccountsCreateCmd struct { + Project string `name:"project" help:"GCP project ID" required:""` + Name string `name:"name" help:"Service account ID (short name)" required:""` + DisplayName string `name:"display-name" help:"Display name"` +} + +func (c *ServiceAccountsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + project := strings.TrimSpace(c.Project) + accountID := strings.TrimSpace(c.Name) + if project == "" || accountID == "" { + return usage("--project and --name are required") + } + if !isValidServiceAccountID(accountID) { + return usage("--name must be a valid service account ID (lowercase letters, digits, hyphens)") + } + + display := strings.TrimSpace(c.DisplayName) + if display == "" { + display = accountID + } + + svc, err := newIAMService(ctx, account) + if err != nil { + return err + } + + parent := "projects/" + project + req := &iam.CreateServiceAccountRequest{ + AccountId: accountID, + ServiceAccount: &iam.ServiceAccount{ + DisplayName: display, + }, + } + + resp, err := svc.Projects.ServiceAccounts.Create(parent, req).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create service account: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + u.Out().Printf("Created service account %s\n", resp.Email) + return nil +} + +type ServiceAccountsDeleteCmd struct { + ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"` +} + +func (c *ServiceAccountsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + sa := strings.TrimSpace(c.ServiceAccount) + if sa == "" { + return usage("service account is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete service account %s", sa)); err != nil { + return err + } + + svc, err := newIAMService(ctx, account) + if err != nil { + return err + } + + name := normalizeServiceAccountName(sa) + if _, err := svc.Projects.ServiceAccounts.Delete(name).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete service account %s: %w", sa, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "serviceAccount": sa}) + } + + u.Out().Printf("Deleted service account %s\n", sa) + return nil +} + +type ServiceAccountsKeysCmd struct { + List ServiceAccountsKeysListCmd `cmd:"" name:"list" aliases:"ls" help:"List service account keys"` + Create ServiceAccountsKeysCreateCmd `cmd:"" name:"create" help:"Create a service account key"` + Delete ServiceAccountsKeysDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a service account key"` +} + +type ServiceAccountsKeysListCmd struct { + ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"` +} + +func (c *ServiceAccountsKeysListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + sa := strings.TrimSpace(c.ServiceAccount) + if sa == "" { + return usage("service account is required") + } + + svc, err := newIAMService(ctx, account) + if err != nil { + return err + } + + name := normalizeServiceAccountName(sa) + resp, err := svc.Projects.ServiceAccounts.Keys.List(name).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list keys: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Keys) == 0 { + u.Err().Println("No keys found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "KEY\tTYPE\tCREATED\tEXPIRES") + for _, key := range resp.Keys { + if key == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(key.Name), + sanitizeTab(key.KeyType), + sanitizeTab(key.ValidAfterTime), + sanitizeTab(key.ValidBeforeTime), + ) + } + return nil +} + +type ServiceAccountsKeysCreateCmd struct { + ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"` + Output string `name:"output" help:"Output file path" required:""` +} + +func (c *ServiceAccountsKeysCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + sa := strings.TrimSpace(c.ServiceAccount) + if sa == "" { + return usage("service account is required") + } + output := strings.TrimSpace(c.Output) + if output == "" { + return usage("--output is required") + } + + svc, err := newIAMService(ctx, account) + if err != nil { + return err + } + + name := normalizeServiceAccountName(sa) + resp, err := svc.Projects.ServiceAccounts.Keys.Create(name, &iam.CreateServiceAccountKeyRequest{ + PrivateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE", + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create key: %w", err) + } + + payload, err := base64.StdEncoding.DecodeString(resp.PrivateKeyData) + if err != nil { + return fmt.Errorf("decode key data: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(output), 0o750); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + if err := os.WriteFile(output, payload, 0o600); err != nil { + return fmt.Errorf("write key file: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"key": resp.Name, "output": output}) + } + + u.Out().Printf("Created key %s\n", resp.Name) + u.Out().Printf("Wrote credentials to %s\n", output) + return nil +} + +type ServiceAccountsKeysDeleteCmd struct { + ServiceAccount string `arg:"" name:"service-account" help:"Service account email or resource name"` + KeyID string `arg:"" name:"key" help:"Key ID or resource name"` +} + +func (c *ServiceAccountsKeysDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + sa := strings.TrimSpace(c.ServiceAccount) + key := strings.TrimSpace(c.KeyID) + if sa == "" || key == "" { + return usage("service account and key are required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete key %s", key)); err != nil { + return err + } + + svc, err := newIAMService(ctx, account) + if err != nil { + return err + } + + keyName := normalizeServiceAccountKeyName(sa, key) + if _, err := svc.Projects.ServiceAccounts.Keys.Delete(keyName).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete key %s: %w", key, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "key": key}) + } + + u.Out().Printf("Deleted key %s\n", key) + return nil +} + +func normalizeServiceAccountName(sa string) string { + trimmed := strings.TrimSpace(sa) + if strings.HasPrefix(trimmed, "projects/") { + return trimmed + } + return "projects/-/serviceAccounts/" + trimmed +} + +func normalizeServiceAccountKeyName(sa, key string) string { + trimmed := strings.TrimSpace(key) + if strings.HasPrefix(trimmed, "projects/") { + return trimmed + } + return fmt.Sprintf("%s/keys/%s", normalizeServiceAccountName(sa), trimmed) +} + +func isValidServiceAccountID(id string) bool { + if id == "" { + return false + } + for i, r := range id { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '-': + default: + return false + } + if i == 0 && (r < 'a' || r > 'z') { + return false + } + } + return true +} diff --git a/internal/cmd/serviceaccounts_test.go b/internal/cmd/serviceaccounts_test.go new file mode 100644 index 00000000..4ef56c50 --- /dev/null +++ b/internal/cmd/serviceaccounts_test.go @@ -0,0 +1,1176 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/iam/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func newIAMServiceStub(t *testing.T, handler http.HandlerFunc) (*iam.Service, func()) { + t.Helper() + + srv := httptest.NewServer(handler) + svc, err := iam.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + srv.Close() + t.Fatalf("NewService: %v", err) + } + return svc, srv.Close +} + +func stubIAMService(t *testing.T, svc *iam.Service) { + t.Helper() + orig := newIAMService + t.Cleanup(func() { newIAMService = orig }) + newIAMService = func(context.Context, string) (*iam.Service, error) { return svc, nil } +} + +// ============================================================================ +// ServiceAccountsListCmd Tests +// ============================================================================ + +func TestServiceAccountsListCmd_Success(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/serviceAccounts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": []map[string]any{ + { + "email": "sa1@test-project.iam.gserviceaccount.com", + "displayName": "Service Account One", + "name": "projects/test-project/serviceAccounts/sa1@test-project.iam.gserviceaccount.com", + }, + { + "email": "sa2@test-project.iam.gserviceaccount.com", + "displayName": "Service Account Two", + "name": "projects/test-project/serviceAccounts/sa2@test-project.iam.gserviceaccount.com", + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsListCmd{Project: "test-project", Max: 100} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "sa1@test-project.iam.gserviceaccount.com") { + t.Fatalf("expected email in output, got: %s", out) + } + if !strings.Contains(out, "Service Account One") { + t.Fatalf("expected display name in output, got: %s", out) + } +} + +func TestServiceAccountsListCmd_JSON(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/serviceAccounts") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": []map[string]any{ + { + "email": "sa1@test-project.iam.gserviceaccount.com", + "displayName": "Service Account One", + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsListCmd{Project: "test-project"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "accounts") { + t.Fatalf("expected JSON accounts output, got: %s", out) + } +} + +func TestServiceAccountsListCmd_EmptyResults(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsListCmd{Project: "test-project"} + + // Should not error even with no accounts + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestServiceAccountsListCmd_EmptyProject(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsListCmd{Project: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty project") + } + if !strings.Contains(err.Error(), "--project is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsListCmd_NoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &ServiceAccountsListCmd{Project: "test-project"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestServiceAccountsListCmd_APIError(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsListCmd{Project: "test-project"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "list service accounts") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsListCmd_Pagination(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pageToken := r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + if pageToken == "page2" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": []map[string]any{ + {"email": "sa2@test-project.iam.gserviceaccount.com"}, + }, + }) + } else { + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": []map[string]any{ + {"email": "sa1@test-project.iam.gserviceaccount.com"}, + }, + "nextPageToken": "page2", + }) + } + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsListCmd{Project: "test-project", Page: "page2"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "sa2@test-project.iam.gserviceaccount.com") { + t.Fatalf("expected page 2 results, got: %s", out) + } +} + +// ============================================================================ +// ServiceAccountsCreateCmd Tests +// ============================================================================ + +func TestServiceAccountsCreateCmd_Success(t *testing.T) { + var gotAccountID, gotDisplayName string + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/serviceAccounts") { + http.NotFound(w, r) + return + } + var payload iam.CreateServiceAccountRequest + _ = json.NewDecoder(r.Body).Decode(&payload) + gotAccountID = payload.AccountId + if payload.ServiceAccount != nil { + gotDisplayName = payload.ServiceAccount.DisplayName + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": gotAccountID + "@test-project.iam.gserviceaccount.com", + "displayName": gotDisplayName, + "name": "projects/test-project/serviceAccounts/" + gotAccountID + "@test-project.iam.gserviceaccount.com", + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{ + Project: "test-project", + Name: "my-service-account", + DisplayName: "My Service Account", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotAccountID != "my-service-account" { + t.Fatalf("expected accountId 'my-service-account', got: %s", gotAccountID) + } + if gotDisplayName != "My Service Account" { + t.Fatalf("expected displayName 'My Service Account', got: %s", gotDisplayName) + } + if !strings.Contains(out, "Created service account") { + t.Fatalf("expected success message, got: %s", out) + } +} + +func TestServiceAccountsCreateCmd_DefaultDisplayName(t *testing.T) { + var gotDisplayName string + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload iam.CreateServiceAccountRequest + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload.ServiceAccount != nil { + gotDisplayName = payload.ServiceAccount.DisplayName + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "my-sa@test-project.iam.gserviceaccount.com", + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{ + Project: "test-project", + Name: "my-sa", + // No DisplayName - should default to Name + } + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotDisplayName != "my-sa" { + t.Fatalf("expected displayName to default to accountId 'my-sa', got: %s", gotDisplayName) + } +} + +func TestServiceAccountsCreateCmd_JSON(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "my-sa@test-project.iam.gserviceaccount.com", + "displayName": "My SA", + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{ + Project: "test-project", + Name: "my-sa", + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "email") { + t.Fatalf("expected JSON output with email, got: %s", out) + } +} + +func TestServiceAccountsCreateCmd_EmptyProject(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{Project: "", Name: "my-sa"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty project") + } + if !strings.Contains(err.Error(), "--project and --name are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsCreateCmd_EmptyName(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "--project and --name are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsCreateCmd_InvalidName(t *testing.T) { + tests := []struct { + name string + accountName string + }{ + {"uppercase", "MyServiceAccount"}, + {"starts with digit", "1my-service-account"}, + {"starts with hyphen", "-my-service-account"}, + {"contains underscore", "my_service_account"}, + {"contains special chars", "my-sa@test"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: tc.accountName} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatalf("expected error for invalid name: %s", tc.accountName) + } + if !strings.Contains(err.Error(), "valid service account ID") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestServiceAccountsCreateCmd_ValidNames(t *testing.T) { + tests := []struct { + name string + accountName string + }{ + {"simple", "my-sa"}, + {"with digits", "my-sa-123"}, + {"all lowercase", "myserviceaccount"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": tc.accountName + "@test-project.iam.gserviceaccount.com", + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: tc.accountName} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("expected no error for valid name %s, got: %v", tc.accountName, err) + } + }) + } +} + +func TestServiceAccountsCreateCmd_APIError(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "already exists", http.StatusConflict) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsCreateCmd{ + Project: "test-project", + Name: "my-sa", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "create service account") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsCreateCmd_NoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &ServiceAccountsCreateCmd{Project: "test-project", Name: "my-sa"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +// ============================================================================ +// ServiceAccountsDeleteCmd Tests +// ============================================================================ + +func TestServiceAccountsDeleteCmd_Success(t *testing.T) { + var deletedName string + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/serviceAccounts/") { + http.NotFound(w, r) + return + } + deletedName = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(deletedName, "my-sa@test-project.iam.gserviceaccount.com") { + t.Fatalf("expected delete to include email, got: %s", deletedName) + } + if !strings.Contains(out, "Deleted service account") { + t.Fatalf("expected success message, got: %s", out) + } +} + +func TestServiceAccountsDeleteCmd_FullResourceName(t *testing.T) { + var deletedPath string + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.NotFound(w, r) + return + } + deletedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsDeleteCmd{ + ServiceAccount: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com", + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + // When already a full resource name, it should use it as-is + if !strings.Contains(deletedPath, "projects/test-project/serviceAccounts/my-sa") { + t.Fatalf("expected full resource name in path, got: %s", deletedPath) + } +} + +func TestServiceAccountsDeleteCmd_JSON(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"deleted":true`) && !strings.Contains(out, `"deleted": true`) { + t.Fatalf("expected JSON output with deleted:true, got: %s", out) + } +} + +func TestServiceAccountsDeleteCmd_EmptyServiceAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsDeleteCmd{ServiceAccount: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty service account") + } + if !strings.Contains(err.Error(), "service account is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsDeleteCmd_NoForce(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &ServiceAccountsDeleteCmd{ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error without force") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got: %v", err) + } +} + +func TestServiceAccountsDeleteCmd_APIError(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsDeleteCmd{ + ServiceAccount: "nonexistent@test-project.iam.gserviceaccount.com", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "delete service account") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsDeleteCmd_NoAccount(t *testing.T) { + flags := &RootFlags{Force: true} + cmd := &ServiceAccountsDeleteCmd{ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +// ============================================================================ +// ServiceAccountsKeysListCmd Tests +// ============================================================================ + +func TestServiceAccountsKeysListCmd_Success(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/keys") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + { + "name": "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/key1", + "keyType": "USER_MANAGED", + "validAfterTime": "2024-01-01T00:00:00Z", + "validBeforeTime": "2025-01-01T00:00:00Z", + }, + }, + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysListCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "USER_MANAGED") { + t.Fatalf("expected key type in output, got: %s", out) + } +} + +func TestServiceAccountsKeysListCmd_JSON(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + {"name": "key1", "keyType": "USER_MANAGED"}, + }, + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysListCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "keys") { + t.Fatalf("expected JSON keys output, got: %s", out) + } +} + +func TestServiceAccountsKeysListCmd_EmptyResults(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{}, + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysListCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + } + + // Should not error even with no keys + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestServiceAccountsKeysListCmd_EmptyServiceAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysListCmd{ServiceAccount: ""} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty service account") + } + if !strings.Contains(err.Error(), "service account is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysListCmd_APIError(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "permission denied", http.StatusForbidden) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysListCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "list keys") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysListCmd_NoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &ServiceAccountsKeysListCmd{ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +// ============================================================================ +// ServiceAccountsKeysCreateCmd Tests +// ============================================================================ + +func TestServiceAccountsKeysCreateCmd_Success(t *testing.T) { + keyData := `{"type":"service_account","project_id":"test-project"}` + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/keys") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/newkey", + "privateKeyData": base64.StdEncoding.EncodeToString([]byte(keyData)), + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "subdir", "key.json") + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysCreateCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + Output: outputPath, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created key") { + t.Fatalf("expected success message, got: %s", out) + } + + // Verify key file was written + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read key file: %v", err) + } + if string(data) != keyData { + t.Fatalf("expected key data %q, got %q", keyData, string(data)) + } + + // Verify file permissions (0600) + info, err := os.Stat(outputPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("expected 0600 permissions, got %o", info.Mode().Perm()) + } +} + +func TestServiceAccountsKeysCreateCmd_JSON(t *testing.T) { + keyData := `{"type":"service_account"}` + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/newkey", + "privateKeyData": base64.StdEncoding.EncodeToString([]byte(keyData)), + }) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "key.json") + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysCreateCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + Output: outputPath, + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "key") || !strings.Contains(out, "output") { + t.Fatalf("expected JSON output with key and output, got: %s", out) + } +} + +func TestServiceAccountsKeysCreateCmd_EmptyServiceAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysCreateCmd{ + ServiceAccount: "", + Output: "/tmp/key.json", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty service account") + } + if !strings.Contains(err.Error(), "service account is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysCreateCmd_EmptyOutput(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysCreateCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + Output: " ", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty output") + } + if !strings.Contains(err.Error(), "--output is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysCreateCmd_APIError(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "quota exceeded", http.StatusTooManyRequests) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "key.json") + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &ServiceAccountsKeysCreateCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + Output: outputPath, + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "create key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysCreateCmd_NoAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &ServiceAccountsKeysCreateCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + Output: "/tmp/key.json", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +// ============================================================================ +// ServiceAccountsKeysDeleteCmd Tests +// ============================================================================ + +func TestServiceAccountsKeysDeleteCmd_Success(t *testing.T) { + var deletedPath string + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/keys/") { + http.NotFound(w, r) + return + } + deletedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "key123", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(deletedPath, "key123") { + t.Fatalf("expected key id in deleted path, got: %s", deletedPath) + } + if !strings.Contains(out, "Deleted key") { + t.Fatalf("expected success message, got: %s", out) + } +} + +func TestServiceAccountsKeysDeleteCmd_FullResourceName(t *testing.T) { + var deletedPath string + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.NotFound(w, r) + return + } + deletedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com/keys/key456", + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if !strings.Contains(deletedPath, "key456") { + t.Fatalf("expected full key path, got: %s", deletedPath) + } +} + +func TestServiceAccountsKeysDeleteCmd_JSON(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "key123", + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, `"deleted":true`) && !strings.Contains(out, `"deleted": true`) { + t.Fatalf("expected JSON output with deleted:true, got: %s", out) + } +} + +func TestServiceAccountsKeysDeleteCmd_EmptyServiceAccount(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "", + KeyID: "key123", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty service account") + } + if !strings.Contains(err.Error(), "service account and key are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysDeleteCmd_EmptyKey(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty key") + } + if !strings.Contains(err.Error(), "service account and key are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysDeleteCmd_NoForce(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "key123", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error without force") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got: %v", err) + } +} + +func TestServiceAccountsKeysDeleteCmd_APIError(t *testing.T) { + svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + t.Cleanup(closeSrv) + stubIAMService(t, svc) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "nonexistent", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "delete key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServiceAccountsKeysDeleteCmd_NoAccount(t *testing.T) { + flags := &RootFlags{Force: true} + cmd := &ServiceAccountsKeysDeleteCmd{ + ServiceAccount: "my-sa@test-project.iam.gserviceaccount.com", + KeyID: "key123", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +// ============================================================================ +// Helper function tests +// ============================================================================ + +func TestNormalizeServiceAccountName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + { + input: "my-sa@project.iam.gserviceaccount.com", + expected: "projects/-/serviceAccounts/my-sa@project.iam.gserviceaccount.com", + }, + { + input: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com", + expected: "projects/test-project/serviceAccounts/my-sa@test-project.iam.gserviceaccount.com", + }, + { + input: " my-sa@project.iam.gserviceaccount.com ", + expected: "projects/-/serviceAccounts/my-sa@project.iam.gserviceaccount.com", + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := normalizeServiceAccountName(tc.input) + if result != tc.expected { + t.Fatalf("normalizeServiceAccountName(%q) = %q, want %q", tc.input, result, tc.expected) + } + }) + } +} + +func TestNormalizeServiceAccountKeyName(t *testing.T) { + tests := []struct { + sa string + key string + expected string + }{ + { + sa: "my-sa@project.iam.gserviceaccount.com", + key: "key123", + expected: "projects/-/serviceAccounts/my-sa@project.iam.gserviceaccount.com/keys/key123", + }, + { + sa: "my-sa@project.iam.gserviceaccount.com", + key: "projects/test/serviceAccounts/sa/keys/key456", + expected: "projects/test/serviceAccounts/sa/keys/key456", + }, + } + + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + result := normalizeServiceAccountKeyName(tc.sa, tc.key) + if result != tc.expected { + t.Fatalf("normalizeServiceAccountKeyName(%q, %q) = %q, want %q", tc.sa, tc.key, result, tc.expected) + } + }) + } +} + +func TestIsValidServiceAccountID(t *testing.T) { + tests := []struct { + id string + valid bool + }{ + {"my-service-account", true}, + {"mysa", true}, + {"my-sa-123", true}, + {"a", true}, + {"a1", true}, + {"MyServiceAccount", false}, // uppercase + {"1my-sa", false}, // starts with digit + {"-my-sa", false}, // starts with hyphen + {"my_sa", false}, // underscore + {"my.sa", false}, // dot + {"my@sa", false}, // @ symbol + {"", false}, // empty + } + + for _, tc := range tests { + t.Run(tc.id, func(t *testing.T) { + result := isValidServiceAccountID(tc.id) + if result != tc.valid { + t.Fatalf("isValidServiceAccountID(%q) = %v, want %v", tc.id, result, tc.valid) + } + }) + } +} diff --git a/internal/cmd/sites.go b/internal/cmd/sites.go new file mode 100644 index 00000000..b9e3dda4 --- /dev/null +++ b/internal/cmd/sites.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +const driveMimeGoogleSite = "application/vnd.google-apps.site" + +// SitesCmd manages Google Sites (via Drive API). +type SitesCmd struct { + List SitesListCmd `cmd:"" name:"list" aliases:"ls" help:"List sites"` + Delete SitesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a site"` +} + +type SitesListCmd struct { + User string `name:"user" help:"User email to list sites for"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *SitesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + query := fmt.Sprintf("mimeType='%s' and trashed=false", driveMimeGoogleSite) + call := svc.Files.List(). + Q(query). + Fields("files(id,name,owners(emailAddress),webViewLink,createdTime),nextPageToken"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true) + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list sites: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Files) == 0 { + u.Err().Println("No sites found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tOWNER\tURL\tCREATED") + for _, file := range resp.Files { + if file == nil { + continue + } + owner := "" + if len(file.Owners) > 0 { + owner = file.Owners[0].EmailAddress + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(file.Id), + sanitizeTab(file.Name), + sanitizeTab(owner), + sanitizeTab(file.WebViewLink), + sanitizeTab(file.CreatedTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type SitesDeleteCmd struct { + Site string `arg:"" name:"site" help:"Site ID or URL"` +} + +func (c *SitesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + site := strings.TrimSpace(c.Site) + if site == "" { + return usage("site is required") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + siteID, err := resolveSiteID(ctx, svc, site) + if err != nil { + return err + } + + if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete site %s", siteID)); err != nil { + return err + } + + if err := svc.Files.Delete(siteID).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete site %s: %w", siteID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"siteId": siteID, "deleted": true}) + } + + u.Out().Printf("Deleted site: %s\n", siteID) + return nil +} + +func resolveSiteID(ctx context.Context, svc *drive.Service, input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", usage("site is required") + } + if !strings.Contains(trimmed, "/") && !strings.Contains(trimmed, "http") { + return trimmed, nil + } + + pageToken := "" + query := fmt.Sprintf("mimeType='%s' and trashed=false", driveMimeGoogleSite) + for { + call := svc.Files.List(). + Q(query). + Fields("files(id,name,webViewLink),nextPageToken"). + SupportsAllDrives(true). + IncludeItemsFromAllDrives(true) + if pageToken != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("resolve site %s: %w", trimmed, err) + } + + for _, file := range resp.Files { + if file == nil { + continue + } + if file.Id == trimmed { + return file.Id, nil + } + if file.WebViewLink == trimmed || strings.Contains(file.WebViewLink, trimmed) { + return file.Id, nil + } + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return "", fmt.Errorf("could not resolve site %q (use gog sites list to find the site ID)", trimmed) +} diff --git a/internal/cmd/sites_test.go b/internal/cmd/sites_test.go new file mode 100644 index 00000000..7200b4f5 --- /dev/null +++ b/internal/cmd/sites_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestSitesListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "site1", "name": "Marketing", "webViewLink": "https://sites.google.com/example.com/marketing", "createdTime": "2026-01-01T00:00:00Z", "owners": []map[string]any{{"emailAddress": "owner@example.com"}}}, + }, + }) + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &SitesListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Marketing") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSitesDeleteCmd_ResolvesURL(t *testing.T) { + var deleted string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "files": []map[string]any{ + {"id": "site123", "name": "Marketing", "webViewLink": "https://sites.google.com/example.com/marketing"}, + }, + }) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/files/site123"): + deleted = "site123" + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + stubDrive(t, h) + + flags := &RootFlags{Account: "user@example.com", Force: true} + cmd := &SitesDeleteCmd{Site: "https://sites.google.com/example.com/marketing"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if deleted != "site123" { + t.Fatalf("expected delete site123, got %q", deleted) + } + if !strings.Contains(out, "Deleted site") { + t.Fatalf("unexpected output: %s", out) + } +} diff --git a/internal/cmd/split_helpers.go b/internal/cmd/split_helpers.go new file mode 100644 index 00000000..27899ad5 --- /dev/null +++ b/internal/cmd/split_helpers.go @@ -0,0 +1,20 @@ +package cmd + +import "strings" + +func splitCSV(input string) []string { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil + } + parts := strings.Split(trimmed, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + out = append(out, value) + } + return out +} diff --git a/internal/cmd/sso.go b/internal/cmd/sso.go new file mode 100644 index 00000000..b9ab465f --- /dev/null +++ b/internal/cmd/sso.go @@ -0,0 +1,468 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/cloudidentity/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +const ssoModeOff = "SSO_OFF" + +var newInboundSSOService = googleapi.NewCloudIdentityInboundSSO + +type SSOCmd struct { + Settings SSOSettingsCmd `cmd:"" name:"settings" help:"Inbound SSO settings"` + Assignments SSOAssignmentsCmd `cmd:"" name:"assignments" help:"Inbound SSO assignments"` +} + +type SSOSettingsCmd struct { + Get SSOSettingsGetCmd `cmd:"" name:"get" help:"Get inbound SSO settings"` + Update SSOSettingsUpdateCmd `cmd:"" name:"update" help:"Update inbound SSO settings"` +} + +type SSOSettingsGetCmd struct{} + +func (c *SSOSettingsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newInboundSSOService(ctx, account) + if err != nil { + return err + } + + profile, err := firstInboundSamlProfile(ctx, svc) + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, profile) + } + + u.Out().Printf("Profile: %s\n", profile.Name) + if profile.DisplayName != "" { + u.Out().Printf("Display Name: %s\n", profile.DisplayName) + } + if profile.IdpConfig != nil { + if profile.IdpConfig.EntityId != "" { + u.Out().Printf("Entity ID: %s\n", profile.IdpConfig.EntityId) + } + if profile.IdpConfig.SingleSignOnServiceUri != "" { + u.Out().Printf("SSO URL: %s\n", profile.IdpConfig.SingleSignOnServiceUri) + } + if profile.IdpConfig.LogoutRedirectUri != "" { + u.Out().Printf("Logout URL: %s\n", profile.IdpConfig.LogoutRedirectUri) + } + if profile.IdpConfig.ChangePasswordUri != "" { + u.Out().Printf("Change Password: %s\n", profile.IdpConfig.ChangePasswordUri) + } + } + if profile.SpConfig != nil { + if profile.SpConfig.EntityId != "" { + u.Out().Printf("SP Entity ID: %s\n", profile.SpConfig.EntityId) + } + if profile.SpConfig.AssertionConsumerServiceUri != "" { + u.Out().Printf("SP ACS URL: %s\n", profile.SpConfig.AssertionConsumerServiceUri) + } + } + return nil +} + +type SSOSettingsUpdateCmd struct { + SSOURL string `name:"sso-url" help:"SSO URL (SingleSignOnService URI)"` + LogoutURL string `name:"logout-url" help:"Logout redirect URL"` + ChangePasswordURL string `name:"change-password-url" help:"Change password URL"` + Certificate string `name:"certificate" help:"IdP signing certificate (PEM string or file path)"` +} + +func (c *SSOSettingsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newInboundSSOService(ctx, account) + if err != nil { + return err + } + + if strings.TrimSpace(c.SSOURL) == "" && strings.TrimSpace(c.LogoutURL) == "" && strings.TrimSpace(c.ChangePasswordURL) == "" && strings.TrimSpace(c.Certificate) == "" { + return usage("no updates specified") + } + + profile, err := firstInboundSamlProfile(ctx, svc) + if err != nil { + return err + } + + updateMask := make([]string, 0, 3) + patch := &cloudidentity.InboundSamlSsoProfile{} + + idpConfig := &cloudidentity.SamlIdpConfig{} + if strings.TrimSpace(c.SSOURL) != "" { + idpConfig.SingleSignOnServiceUri = strings.TrimSpace(c.SSOURL) + updateMask = append(updateMask, "idpConfig.singleSignOnServiceUri") + } + if strings.TrimSpace(c.LogoutURL) != "" { + idpConfig.LogoutRedirectUri = strings.TrimSpace(c.LogoutURL) + updateMask = append(updateMask, "idpConfig.logoutRedirectUri") + } + if strings.TrimSpace(c.ChangePasswordURL) != "" { + idpConfig.ChangePasswordUri = strings.TrimSpace(c.ChangePasswordURL) + updateMask = append(updateMask, "idpConfig.changePasswordUri") + } + if len(updateMask) > 0 { + patch.IdpConfig = idpConfig + } + + var patchOp *cloudidentity.Operation + if len(updateMask) > 0 { + patchOp, err = svc.InboundSamlSsoProfiles.Patch(profile.Name, patch). + UpdateMask(strings.Join(updateMask, ",")). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("update inbound sso profile: %w", err) + } + } + + var certOp *cloudidentity.Operation + if strings.TrimSpace(c.Certificate) != "" { + pemData, err := readValueOrFile(c.Certificate) + if err != nil { + return fmt.Errorf("read certificate: %w", err) + } + if strings.TrimSpace(pemData) == "" { + return usage("certificate is empty") + } + certOp, err = svc.InboundSamlSsoProfiles.IdpCredentials.Add(profile.Name, &cloudidentity.AddIdpCredentialRequest{ + PemData: pemData, + }).Context(ctx).Do() + if err != nil { + return fmt.Errorf("add idp credential: %w", err) + } + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{"profile": profile.Name} + if patchOp != nil { + payload["update"] = patchOp + } + if certOp != nil { + payload["certificate"] = certOp + } + return outfmt.WriteJSON(os.Stdout, payload) + } + + if patchOp != nil { + u.Out().Printf("Updated inbound SSO profile: %s\n", profile.Name) + if patchOp.Name != "" { + u.Out().Printf("Update operation: %s\n", patchOp.Name) + } + } + if certOp != nil { + if patchOp == nil { + u.Out().Printf("Updated inbound SSO profile: %s\n", profile.Name) + } + if certOp.Name != "" { + u.Out().Printf("Certificate operation: %s\n", certOp.Name) + } + } + return nil +} + +type SSOAssignmentsCmd struct { + List SSOAssignmentsListCmd `cmd:"" name:"list" help:"List inbound SSO assignments"` + Create SSOAssignmentsCreateCmd `cmd:"" name:"create" help:"Create inbound SSO assignment"` + Delete SSOAssignmentsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete inbound SSO assignment"` +} + +type SSOAssignmentsListCmd struct { + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *SSOAssignmentsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newInboundSSOService(ctx, account) + if err != nil { + return err + } + + call := svc.InboundSsoAssignments.List() + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list inbound sso assignments: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.InboundSsoAssignments) == 0 { + u.Err().Println("No inbound SSO assignments found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ASSIGNMENT ID\tMODE\tTARGET\tPROFILE") + for _, assignment := range resp.InboundSsoAssignments { + if assignment == nil { + continue + } + target := assignment.TargetOrgUnit + if target == "" { + target = assignment.TargetGroup + } + profile := "" + if assignment.SamlSsoInfo != nil { + profile = assignment.SamlSsoInfo.InboundSamlSsoProfile + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(assignment.Name), + sanitizeTab(assignment.SsoMode), + sanitizeTab(target), + sanitizeTab(profile), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type SSOAssignmentsCreateCmd struct { + OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path or ID" required:""` + Mode string `name:"mode" help:"SSO mode: SSO_OFF|SSO_ON|NONE" enum:"SSO_OFF,SSO_ON,NONE" required:""` +} + +func (c *SSOAssignmentsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + orgUnit := strings.TrimSpace(c.OrgUnit) + if orgUnit == "" { + return usage("--org-unit is required") + } + + svc, err := newInboundSSOService(ctx, account) + if err != nil { + return err + } + + targetOrgUnit, err := resolveOrgUnitResource(ctx, flags, orgUnit) + if err != nil { + return err + } + + mode := strings.ToUpper(strings.TrimSpace(c.Mode)) + if mode == "NONE" { + return clearInboundSSOAssignments(ctx, svc, targetOrgUnit) + } + + ssoMode, err := mapInboundSSOMode(mode) + if err != nil { + return err + } + + assignment := &cloudidentity.InboundSsoAssignment{ + TargetOrgUnit: targetOrgUnit, + SsoMode: ssoMode, + } + + op, err := svc.InboundSsoAssignments.Create(assignment).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create inbound sso assignment: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Created inbound SSO assignment for %s\n", targetOrgUnit) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +type SSOAssignmentsDeleteCmd struct { + AssignmentID string `arg:"" name:"assignment-id" help:"Assignment resource name"` +} + +func (c *SSOAssignmentsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if strings.TrimSpace(c.AssignmentID) == "" { + return usage("assignment ID is required") + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete inbound SSO assignment %s", c.AssignmentID)); err != nil { + return err + } + + svc, err := newInboundSSOService(ctx, account) + if err != nil { + return err + } + + op, err := svc.InboundSsoAssignments.Delete(c.AssignmentID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("delete inbound sso assignment %s: %w", c.AssignmentID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, op) + } + + u.Out().Printf("Deleted inbound SSO assignment: %s\n", c.AssignmentID) + if op.Name != "" { + u.Out().Printf("Operation: %s\n", op.Name) + } + return nil +} + +func firstInboundSamlProfile(ctx context.Context, svc *cloudidentity.Service) (*cloudidentity.InboundSamlSsoProfile, error) { + resp, err := svc.InboundSamlSsoProfiles.List().Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("list inbound sso profiles: %w", err) + } + if len(resp.InboundSamlSsoProfiles) == 0 { + return nil, fmt.Errorf("no inbound SAML SSO profiles found") + } + return resp.InboundSamlSsoProfiles[0], nil +} + +func mapInboundSSOMode(mode string) (string, error) { + switch strings.ToUpper(strings.TrimSpace(mode)) { + case ssoModeOff: + return ssoModeOff, nil + case "SSO_ON": + return "DOMAIN_WIDE_SAML_IF_ENABLED", nil + default: + return "", usage("mode must be SSO_OFF, SSO_ON, or NONE") + } +} + +func resolveOrgUnitResource(ctx context.Context, flags *RootFlags, orgUnit string) (string, error) { + if strings.HasPrefix(orgUnit, "orgUnits/") { + return orgUnit, nil + } + + account, err := requireAccount(flags) + if err != nil { + return "", err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return "", err + } + + ou, err := svc.Orgunits.Get(adminCustomerID(), orgUnit).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("resolve org unit %s: %w", orgUnit, err) + } + if strings.TrimSpace(ou.OrgUnitId) == "" { + return "", fmt.Errorf("org unit %s has no ID", orgUnit) + } + return "orgUnits/" + ou.OrgUnitId, nil +} + +func clearInboundSSOAssignments(ctx context.Context, svc *cloudidentity.Service, targetOrgUnit string) error { + u := ui.FromContext(ctx) + resp, err := svc.InboundSsoAssignments.List().Context(ctx).Do() + if err != nil { + return fmt.Errorf("list inbound sso assignments: %w", err) + } + + deleted := make([]string, 0) + for _, assignment := range resp.InboundSsoAssignments { + if assignment == nil || assignment.TargetOrgUnit != targetOrgUnit { + continue + } + if _, err := svc.InboundSsoAssignments.Delete(assignment.Name).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete inbound sso assignment %s: %w", assignment.Name, err) + } + deleted = append(deleted, assignment.Name) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "targetOrgUnit": targetOrgUnit, + "deleted": deleted, + }) + } + + if len(deleted) == 0 { + u.Err().Printf("No inbound SSO assignments found for %s\n", targetOrgUnit) + return nil + } + + u.Out().Printf("Deleted %d inbound SSO assignments for %s\n", len(deleted), targetOrgUnit) + return nil +} + +// readValueOrFile interprets value as either a literal string or a file reference. +// Prefix with "@" to explicitly read from a file path (e.g. "@/path/to/cert.pem"). +// JSON values (starting with "{" or "[") are returned as-is. As a fallback, +// if the value happens to match an existing file path on disk it will be read; +// prefer the "@" prefix to avoid ambiguity. +func readValueOrFile(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", nil + } + if strings.HasPrefix(trimmed, "@") { + path := strings.TrimSpace(strings.TrimPrefix(trimmed, "@")) + if path == "" { + return "", fmt.Errorf("empty @file path") + } + data, err := os.ReadFile(path) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return "", err + } + return string(data), nil + } + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + return trimmed, nil + } + if info, err := os.Stat(trimmed); err == nil && !info.IsDir() { + data, err := os.ReadFile(trimmed) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return "", err + } + return string(data), nil + } + return trimmed, nil +} diff --git a/internal/cmd/sso_test.go b/internal/cmd/sso_test.go new file mode 100644 index 00000000..e3526a91 --- /dev/null +++ b/internal/cmd/sso_test.go @@ -0,0 +1,1342 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/cloudidentity/v1" + "google.golang.org/api/option" +) + +// ----------------------------------------------------------------------------- +// SSOSettingsGetCmd Tests +// ----------------------------------------------------------------------------- + +func TestSSOSettingsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{ + { + "name": "inboundSamlSsoProfiles/profile-1", + "displayName": "Workspace", + "idpConfig": map[string]any{ + "entityId": "https://idp.example.com", + "singleSignOnServiceUri": "https://sso.example.com", + "logoutRedirectUri": "https://logout.example.com", + }, + }, + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsGetCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "https://sso.example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSSOSettingsGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{ + { + "name": "inboundSamlSsoProfiles/profile-1", + "displayName": "Workspace", + "idpConfig": map[string]any{ + "entityId": "https://idp.example.com", + "singleSignOnServiceUri": "https://sso.example.com", + }, + }, + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsGetCmd{} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["name"] != "inboundSamlSsoProfiles/profile-1" { + t.Fatalf("unexpected name in JSON: %v", result["name"]) + } +} + +func TestSSOSettingsGetCmd_AllFields(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{ + { + "name": "inboundSamlSsoProfiles/profile-1", + "displayName": "My SSO", + "idpConfig": map[string]any{ + "entityId": "https://idp.example.com", + "singleSignOnServiceUri": "https://sso.example.com", + "logoutRedirectUri": "https://logout.example.com", + "changePasswordUri": "https://password.example.com", + }, + "spConfig": map[string]any{ + "entityId": "https://sp.example.com", + "assertionConsumerServiceUri": "https://acs.example.com", + }, + }, + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsGetCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + expected := []string{ + "Profile:", + "Display Name:", + "Entity ID:", + "SSO URL:", + "Logout URL:", + "Change Password:", + "SP Entity ID:", + "SP ACS URL:", + } + for _, exp := range expected { + if !strings.Contains(out, exp) { + t.Fatalf("expected %q in output: %s", exp, out) + } + } +} + +func TestSSOSettingsGetCmd_NoProfiles(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{}, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsGetCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no profiles") + } + if !strings.Contains(err.Error(), "no inbound SAML SSO profiles found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSOSettingsGetCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &SSOSettingsGetCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestSSOSettingsGetCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 403, + "message": "access denied", + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsGetCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } +} + +// ----------------------------------------------------------------------------- +// SSOSettingsUpdateCmd Tests +// ----------------------------------------------------------------------------- + +func TestSSOSettingsUpdateCmd_SSOURL(t *testing.T) { + var gotURL string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{{ + "name": "inboundSamlSsoProfiles/profile-1", + }}, + }) + case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/inboundSamlSsoProfiles/profile-1"): + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + if cfg, ok := payload["idpConfig"].(map[string]any); ok { + gotURL, _ = cfg["singleSignOnServiceUri"].(string) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-update-1", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{SSOURL: "https://new-sso.example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotURL != "https://new-sso.example.com" { + t.Fatalf("expected sso url to be set, got: %s", gotURL) + } + if !strings.Contains(out, "Updated inbound SSO profile") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSSOSettingsUpdateCmd_MultipleURLs(t *testing.T) { + var gotSSOURL, gotLogoutURL, gotPwdURL string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{{ + "name": "inboundSamlSsoProfiles/profile-1", + }}, + }) + case r.Method == http.MethodPatch: + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + if cfg, ok := payload["idpConfig"].(map[string]any); ok { + gotSSOURL, _ = cfg["singleSignOnServiceUri"].(string) + gotLogoutURL, _ = cfg["logoutRedirectUri"].(string) + gotPwdURL, _ = cfg["changePasswordUri"].(string) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-update-1", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{ + SSOURL: "https://sso.example.com", + LogoutURL: "https://logout.example.com", + ChangePasswordURL: "https://password.example.com", + } + + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotSSOURL != "https://sso.example.com" { + t.Fatalf("expected sso url, got: %s", gotSSOURL) + } + if gotLogoutURL != "https://logout.example.com" { + t.Fatalf("expected logout url, got: %s", gotLogoutURL) + } + if gotPwdURL != "https://password.example.com" { + t.Fatalf("expected password url, got: %s", gotPwdURL) + } +} + +func TestSSOSettingsUpdateCmd_Certificate(t *testing.T) { + var gotPemData string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{{ + "name": "inboundSamlSsoProfiles/profile-1", + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/idpCredentials:add"): + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + gotPemData, _ = payload["pemData"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-cert-1", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPemData == "" { + t.Fatal("expected certificate to be set") + } + if !strings.Contains(out, "Updated inbound SSO profile") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSSOSettingsUpdateCmd_CertificateFromFile(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "cert.pem") + certContent := "-----BEGIN CERTIFICATE-----\nfilecontent\n-----END CERTIFICATE-----" + if err := os.WriteFile(tmpFile, []byte(certContent), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var gotPemData string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{{ + "name": "inboundSamlSsoProfiles/profile-1", + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/idpCredentials:add"): + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + gotPemData, _ = payload["pemData"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-cert-1", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{Certificate: "@" + tmpFile} + + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotPemData != certContent { + t.Fatalf("expected certificate from file, got: %s", gotPemData) + } +} + +func TestSSOSettingsUpdateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{{ + "name": "inboundSamlSsoProfiles/profile-1", + }}, + }) + case r.Method == http.MethodPatch: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-update-1", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{SSOURL: "https://sso.example.com"} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["profile"] != "inboundSamlSsoProfiles/profile-1" { + t.Fatalf("unexpected profile in JSON: %v", result["profile"]) + } +} + +func TestSSOSettingsUpdateCmd_NoUpdates(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for no updates") + } + if !strings.Contains(err.Error(), "no updates specified") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSOSettingsUpdateCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &SSOSettingsUpdateCmd{SSOURL: "https://sso.example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestSSOSettingsUpdateCmd_EmptyCertificate(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "empty.pem") + if err := os.WriteFile(tmpFile, []byte(" "), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSamlSsoProfiles") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSamlSsoProfiles": []map[string]any{{ + "name": "inboundSamlSsoProfiles/profile-1", + }}, + }) + return + } + http.NotFound(w, r) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOSettingsUpdateCmd{Certificate: "@" + tmpFile} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty certificate") + } + if !strings.Contains(err.Error(), "certificate is empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// SSOAssignmentsListCmd Tests +// ----------------------------------------------------------------------------- + +func TestSSOAssignmentsListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{ + { + "name": "inboundSsoAssignments/assignment-1", + "ssoMode": "SSO_OFF", + "targetOrgUnit": "orgUnits/ou-123", + }, + }, + "nextPageToken": "token-123", + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsListCmd{} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result struct { + InboundSsoAssignments []struct { + Name string `json:"name"` + SsoMode string `json:"ssoMode"` + TargetOrgUnit string `json:"targetOrgUnit"` + } `json:"inboundSsoAssignments"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result.NextPageToken != "token-123" { + t.Fatalf("unexpected nextPageToken: %v", result.NextPageToken) + } + if len(result.InboundSsoAssignments) != 1 { + t.Fatalf("expected 1 assignment, got %d", len(result.InboundSsoAssignments)) + } + if result.InboundSsoAssignments[0].Name != "inboundSsoAssignments/assignment-1" { + t.Fatalf("unexpected assignment name: %s", result.InboundSsoAssignments[0].Name) + } + if result.InboundSsoAssignments[0].SsoMode != "SSO_OFF" { + t.Fatalf("unexpected assignment mode: %s", result.InboundSsoAssignments[0].SsoMode) + } +} + +func TestSSOAssignmentsListCmd_Empty(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{}, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsListCmd{} + + // Should not error on empty list + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestSSOAssignmentsListCmd_WithTargetGroup(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{ + { + "name": "inboundSsoAssignments/assignment-2", + "ssoMode": "DOMAIN_WIDE_SAML_IF_ENABLED", + "targetGroup": "groups/group-123", + }, + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "groups/group-123") { + t.Fatalf("expected group target in output: %s", out) + } +} + +func TestSSOAssignmentsListCmd_WithPagination(t *testing.T) { + var gotPageSize, gotPageToken string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPageSize = r.URL.Query().Get("pageSize") + gotPageToken = r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{}, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsListCmd{Max: 50, Page: "next-page-token"} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotPageSize != "50" { + t.Fatalf("expected pageSize 50, got: %s", gotPageSize) + } + if gotPageToken != "next-page-token" { + t.Fatalf("expected pageToken, got: %s", gotPageToken) + } +} + +func TestSSOAssignmentsListCmd_NilAssignment(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // API might return null entries + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []any{ + nil, + map[string]any{ + "name": "inboundSsoAssignments/assignment-1", + "ssoMode": "SSO_OFF", + "targetOrgUnit": "orgUnits/ou-123", + }, + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "assignment-1") { + t.Fatalf("expected assignment in output: %s", out) + } +} + +func TestSSOAssignmentsListCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &SSOAssignmentsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestSSOAssignmentsListCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 500, + "message": "internal error", + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsListCmd{} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } +} + +// ----------------------------------------------------------------------------- +// SSOAssignmentsCreateCmd Tests +// ----------------------------------------------------------------------------- + +func TestSSOAssignmentsCreateCmd(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/orgunits/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "ou-123", + "orgUnitPath": "/Sales", + }) + }) + stubAdminDirectory(t, adminHandler) + + var gotTarget, gotMode string + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments"): + var payload struct { + TargetOrgUnit string `json:"targetOrgUnit"` + SsoMode string `json:"ssoMode"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotTarget = payload.TargetOrgUnit + gotMode = payload.SsoMode + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-1", + }) + return + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Sales", Mode: "SSO_ON"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotTarget != "orgUnits/ou-123" { + t.Fatalf("expected target orgUnits/ou-123, got %q", gotTarget) + } + if gotMode != "DOMAIN_WIDE_SAML_IF_ENABLED" { + t.Fatalf("expected mode DOMAIN_WIDE_SAML_IF_ENABLED, got %q", gotMode) + } + if !strings.Contains(out, "Created inbound SSO assignment") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSSOAssignmentsCreateCmd_SSOOff(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "ou-456", + "orgUnitPath": "/Engineering", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + var gotMode string + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") { + var payload struct { + SsoMode string `json:"ssoMode"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + gotMode = payload.SsoMode + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-2", + }) + return + } + http.NotFound(w, r) + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Engineering", Mode: "SSO_OFF"} + + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotMode != "SSO_OFF" { + t.Fatalf("expected mode SSO_OFF, got %q", gotMode) + } +} + +func TestSSOAssignmentsCreateCmd_DirectOrgUnitID(t *testing.T) { + // When orgUnit already starts with "orgUnits/", no lookup needed + var gotTarget string + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") { + var payload struct { + TargetOrgUnit string `json:"targetOrgUnit"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + gotTarget = payload.TargetOrgUnit + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-3", + }) + return + } + http.NotFound(w, r) + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "orgUnits/direct-ou", Mode: "SSO_OFF"} + + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if gotTarget != "orgUnits/direct-ou" { + t.Fatalf("expected direct org unit, got %q", gotTarget) + } +} + +func TestSSOAssignmentsCreateCmd_ModeNone_ClearAssignments(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "ou-789", + "orgUnitPath": "/Marketing", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + var deleteCalled bool + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{ + { + "name": "inboundSsoAssignments/assign-to-delete", + "targetOrgUnit": "orgUnits/ou-789", + }, + }, + }) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "assign-to-delete"): + deleteCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-delete", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Marketing", Mode: "NONE"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !deleteCalled { + t.Fatal("expected delete to be called for NONE mode") + } + if !strings.Contains(out, "Deleted 1 inbound SSO assignments") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestSSOAssignmentsCreateCmd_ModeNone_NoAssignments(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "ou-empty", + "orgUnitPath": "/Empty", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Empty", Mode: "NONE"} + + // Should not error when no assignments to delete + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestSSOAssignmentsCreateCmd_JSON(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "ou-json", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-json", + }) + return + } + http.NotFound(w, r) + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "SSO_OFF"} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["name"] != "operations/op-json" { + t.Fatalf("unexpected operation name: %v", result["name"]) + } +} + +func TestSSOAssignmentsCreateCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "SSO_ON"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestSSOAssignmentsCreateCmd_EmptyOrgUnit(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: " ", Mode: "SSO_ON"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty org unit") + } + if !strings.Contains(err.Error(), "--org-unit is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// SSOAssignmentsDeleteCmd Tests +// ----------------------------------------------------------------------------- + +func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) { + var deleteCalled bool + var deletedID string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments/") { + deleteCalled = true + deletedID = strings.TrimPrefix(r.URL.Path, "/v1/inboundSsoAssignments/") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-delete-json", + }) + return + } + http.NotFound(w, r) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-json"} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if !deleteCalled { + t.Fatal("delete was not called") + } + if deletedID != "assignment-json" { + t.Fatalf("unexpected deleted ID: %s", deletedID) + } + if result["name"] != "operations/op-delete-json" { + t.Fatalf("unexpected operation name: %v", result["name"]) + } +} + +func TestSSOAssignmentsDeleteCmd_RequiresConfirmation(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com", NoInput: true} + cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing confirmation") + } +} + +func TestSSOAssignmentsDeleteCmd_EmptyID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &SSOAssignmentsDeleteCmd{AssignmentID: " "} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for empty assignment ID") + } + if !strings.Contains(err.Error(), "assignment ID is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSOAssignmentsDeleteCmd_MissingAccount(t *testing.T) { + flags := &RootFlags{} + cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-1"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestSSOAssignmentsDeleteCmd_APIError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 404, + "message": "assignment not found", + }, + }) + }) + stubInboundSSO(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/nonexistent"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Fatal("expected error for API failure") + } +} + +// ----------------------------------------------------------------------------- +// Helper Function Tests +// ----------------------------------------------------------------------------- + +func TestMapInboundSSOMode(t *testing.T) { + tests := []struct { + input string + expected string + wantErr bool + }{ + {"SSO_OFF", "SSO_OFF", false}, + {"sso_off", "SSO_OFF", false}, + {" SSO_OFF ", "SSO_OFF", false}, + {"SSO_ON", "DOMAIN_WIDE_SAML_IF_ENABLED", false}, + {"sso_on", "DOMAIN_WIDE_SAML_IF_ENABLED", false}, + {"INVALID", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := mapInboundSSOMode(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("mapInboundSSOMode(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.expected { + t.Fatalf("mapInboundSSOMode(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestReadValueOrFile(t *testing.T) { + // Test empty value + result, err := readValueOrFile("") + if err != nil || result != "" { + t.Fatalf("expected empty result for empty input, got: %q, err: %v", result, err) + } + + // Test whitespace-only value + result, err = readValueOrFile(" ") + if err != nil || result != "" { + t.Fatalf("expected empty result for whitespace input, got: %q, err: %v", result, err) + } + + // Test direct value + result, err = readValueOrFile("direct-value") + if err != nil || result != "direct-value" { + t.Fatalf("expected direct value, got: %q, err: %v", result, err) + } + + // Test JSON object + result, err = readValueOrFile(`{"key": "value"}`) + if err != nil || result != `{"key": "value"}` { + t.Fatalf("expected JSON object, got: %q, err: %v", result, err) + } + + // Test JSON array + result, err = readValueOrFile(`["a", "b"]`) + if err != nil || result != `["a", "b"]` { + t.Fatalf("expected JSON array, got: %q, err: %v", result, err) + } + + // Test @file syntax + tmpFile := filepath.Join(t.TempDir(), "testfile.txt") + if err = os.WriteFile(tmpFile, []byte("file-content"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + result, err = readValueOrFile("@" + tmpFile) + if err != nil || result != "file-content" { + t.Fatalf("expected file content, got: %q, err: %v", result, err) + } + + // Test @file with empty path + _, err = readValueOrFile("@") + if err == nil { + t.Fatal("expected error for empty @file path") + } + if !strings.Contains(err.Error(), "empty @file path") { + t.Fatalf("unexpected error: %v", err) + } + + // Test @file with spaces around path + result, err = readValueOrFile("@ " + tmpFile + " ") + if err != nil || result != "file-content" { + t.Fatalf("expected file content with trimmed path, got: %q, err: %v", result, err) + } + + // Test file path detection (file exists) + tmpFile2 := filepath.Join(t.TempDir(), "detectfile.txt") + if err = os.WriteFile(tmpFile2, []byte("detected-content"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + result, err = readValueOrFile(tmpFile2) + if err != nil || result != "detected-content" { + t.Fatalf("expected detected file content, got: %q, err: %v", result, err) + } + + // Test nonexistent file in @syntax + _, err = readValueOrFile("@/nonexistent/file.txt") + if err == nil { + t.Fatal("expected error for nonexistent @file") + } +} + +func TestResolveOrgUnitResource_DirectID(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + + result, err := resolveOrgUnitResource(context.Background(), flags, "orgUnits/direct-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "orgUnits/direct-123" { + t.Fatalf("expected direct ID passthrough, got: %s", result) + } +} + +func TestResolveOrgUnitResource_Lookup(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "resolved-ou-id", + "orgUnitPath": "/Resolved", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + flags := &RootFlags{Account: "admin@example.com"} + + result, err := resolveOrgUnitResource(testContext(t), flags, "/Resolved") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "orgUnits/resolved-ou-id" { + t.Fatalf("expected resolved org unit ID, got: %s", result) + } +} + +func TestResolveOrgUnitResource_EmptyOrgUnitID(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "", + "orgUnitPath": "/NoID", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + flags := &RootFlags{Account: "admin@example.com"} + + _, err := resolveOrgUnitResource(testContext(t), flags, "/NoID") + if err == nil { + t.Fatal("expected error for empty org unit ID") + } + if !strings.Contains(err.Error(), "has no ID") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClearInboundSSOAssignments_JSON(t *testing.T) { + adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/orgunits/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "orgUnitId": "ou-json-clear", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, adminHandler) + + cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/inboundSsoAssignments"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inboundSsoAssignments": []map[string]any{ + { + "name": "inboundSsoAssignments/assign-1", + "targetOrgUnit": "orgUnits/ou-json-clear", + }, + }, + }) + case r.Method == http.MethodDelete: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "operations/op-del", + }) + default: + http.NotFound(w, r) + } + }) + stubInboundSSO(t, cloudHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "NONE"} + + ctx := testContextJSON(t) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if result["targetOrgUnit"] != "orgUnits/ou-json-clear" { + t.Fatalf("unexpected targetOrgUnit: %v", result["targetOrgUnit"]) + } + deleted, ok := result["deleted"].([]any) + if !ok || len(deleted) != 1 { + t.Fatalf("expected 1 deleted assignment, got: %v", result["deleted"]) + } +} + +// ----------------------------------------------------------------------------- +// Test Helper Functions +// ----------------------------------------------------------------------------- + +func stubInboundSSO(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newInboundSSOService + svc, err := cloudidentity.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new cloudidentity service: %v", err) + } + newInboundSSOService = func(context.Context, string) (*cloudidentity.Service, error) { return svc, nil } + t.Cleanup(func() { + newInboundSSOService = orig + srv.Close() + }) +} diff --git a/internal/cmd/tasks_helpers_test.go b/internal/cmd/tasks_helpers_test.go new file mode 100644 index 00000000..bf639ff1 --- /dev/null +++ b/internal/cmd/tasks_helpers_test.go @@ -0,0 +1,987 @@ +package cmd + +import ( + "context" + "strings" + "testing" + "time" +) + +// ============================================================================= +// Tasks Repeat Tests +// ============================================================================= + +func TestParseRepeatUnit(t *testing.T) { + tests := []struct { + input string + want repeatUnit + wantErr bool + }{ + {"", repeatNone, false}, + {" ", repeatNone, false}, + {"daily", repeatDaily, false}, + {"day", repeatDaily, false}, + {"DAILY", repeatDaily, false}, + {" Daily ", repeatDaily, false}, + {"weekly", repeatWeekly, false}, + {"week", repeatWeekly, false}, + {"WEEKLY", repeatWeekly, false}, + {"monthly", repeatMonthly, false}, + {"month", repeatMonthly, false}, + {"MONTHLY", repeatMonthly, false}, + {"yearly", repeatYearly, false}, + {"year", repeatYearly, false}, + {"annually", repeatYearly, false}, + {"YEARLY", repeatYearly, false}, + {"invalid", repeatNone, true}, + {"bi-weekly", repeatNone, true}, + {"hourly", repeatNone, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseRepeatUnit(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseRepeatUnit(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("parseRepeatUnit(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseTaskDate(t *testing.T) { + tests := []struct { + input string + wantYear int + wantMonth time.Month + wantDay int + hasTime bool + wantErr bool + }{ + {"", 0, 0, 0, false, true}, + {" ", 0, 0, 0, false, true}, + {"2025-01-15", 2025, time.January, 15, false, false}, + {"2025-12-31", 2025, time.December, 31, false, false}, + {"2025-01-15T10:30:00Z", 2025, time.January, 15, true, false}, + {"2025-01-15T10:30:00.123456789Z", 2025, time.January, 15, true, false}, + {"2025-01-15T10:30:00", 2025, time.January, 15, true, false}, + {"2025-01-15 10:30", 2025, time.January, 15, true, false}, + {"not-a-date", 0, 0, 0, false, true}, + {"2025/01/15", 0, 0, 0, false, true}, + {"01-15-2025", 0, 0, 0, false, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, hasTime, err := parseTaskDate(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseTaskDate(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if hasTime != tt.hasTime { + t.Errorf("parseTaskDate(%q) hasTime = %v, want %v", tt.input, hasTime, tt.hasTime) + } + if got.Year() != tt.wantYear || got.Month() != tt.wantMonth || got.Day() != tt.wantDay { + t.Errorf("parseTaskDate(%q) = %v, want year=%d month=%d day=%d", tt.input, got, tt.wantYear, tt.wantMonth, tt.wantDay) + } + }) + } +} + +func TestAddRepeat(t *testing.T) { + base := time.Date(2025, time.January, 15, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + unit repeatUnit + n int + want time.Time + }{ + {"none 0", repeatNone, 0, base}, + {"none 5", repeatNone, 5, base}, + {"daily 0", repeatDaily, 0, base}, + {"daily 1", repeatDaily, 1, time.Date(2025, time.January, 16, 10, 0, 0, 0, time.UTC)}, + {"daily 7", repeatDaily, 7, time.Date(2025, time.January, 22, 10, 0, 0, 0, time.UTC)}, + {"weekly 0", repeatWeekly, 0, base}, + {"weekly 1", repeatWeekly, 1, time.Date(2025, time.January, 22, 10, 0, 0, 0, time.UTC)}, + {"weekly 4", repeatWeekly, 4, time.Date(2025, time.February, 12, 10, 0, 0, 0, time.UTC)}, + {"monthly 0", repeatMonthly, 0, base}, + {"monthly 1", repeatMonthly, 1, time.Date(2025, time.February, 15, 10, 0, 0, 0, time.UTC)}, + {"monthly 12", repeatMonthly, 12, time.Date(2026, time.January, 15, 10, 0, 0, 0, time.UTC)}, + {"yearly 0", repeatYearly, 0, base}, + {"yearly 1", repeatYearly, 1, time.Date(2026, time.January, 15, 10, 0, 0, 0, time.UTC)}, + {"yearly 5", repeatYearly, 5, time.Date(2030, time.January, 15, 10, 0, 0, 0, time.UTC)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := addRepeat(base, tt.unit, tt.n) + if !got.Equal(tt.want) { + t.Errorf("addRepeat(%v, %v, %d) = %v, want %v", base, tt.unit, tt.n, got, tt.want) + } + }) + } +} + +func TestExpandRepeatSchedule(t *testing.T) { + start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + unit repeatUnit + count int + until *time.Time + want int + }{ + {"none", repeatNone, 0, nil, 1}, + {"no count no until", repeatDaily, 0, nil, 1}, + {"count 3 daily", repeatDaily, 3, nil, 3}, + {"count 5 weekly", repeatWeekly, 5, nil, 5}, + {"count 2 monthly", repeatMonthly, 2, nil, 2}, + {"count 4 yearly", repeatYearly, 4, nil, 4}, + {"negative count", repeatDaily, -1, nil, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := expandRepeatSchedule(start, tt.unit, tt.count, tt.until) + if len(got) != tt.want { + t.Errorf("expandRepeatSchedule() returned %d items, want %d", len(got), tt.want) + } + }) + } + + // Test with until date + t.Run("until date", func(t *testing.T) { + until := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC) + got := expandRepeatSchedule(start, repeatDaily, 0, &until) + if len(got) != 5 { + t.Errorf("expandRepeatSchedule with until returned %d items, want 5", len(got)) + } + }) + + // Test count takes precedence over until + t.Run("count limits before until", func(t *testing.T) { + until := time.Date(2025, time.January, 10, 0, 0, 0, 0, time.UTC) + got := expandRepeatSchedule(start, repeatDaily, 3, &until) + if len(got) != 3 { + t.Errorf("expandRepeatSchedule with count and until returned %d items, want 3", len(got)) + } + }) +} + +func TestFormatTaskDue(t *testing.T) { + ts := time.Date(2025, time.January, 15, 10, 30, 0, 0, time.UTC) + + tests := []struct { + name string + hasTime bool + want string + }{ + {"with time", true, "2025-01-15T10:30:00Z"}, + {"without time", false, "2025-01-15T10:30:00Z"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTaskDue(ts, tt.hasTime) + if got != tt.want { + t.Errorf("formatTaskDue() = %v, want %v", got, tt.want) + } + }) + } +} + +// ============================================================================= +// CSV Helper Tests +// ============================================================================= + +func TestSplitCSVFields(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"", nil}, + {" ", nil}, + {"field1", []string{"field1"}}, + {"field1,field2", []string{"field1", "field2"}}, + {"field1, field2, field3", []string{"field1", "field2", "field3"}}, + {" field1 , field2 ", []string{"field1", "field2"}}, + {"field1,,field2", []string{"field1", "field2"}}, + {",,", []string{}}, + {"a,b,c,d,e", []string{"a", "b", "c", "d", "e"}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := splitCSV(tt.input) + if tt.want == nil && got != nil { + t.Errorf("splitCSV(%q) = %v, want nil", tt.input, got) + return + } + if tt.want != nil && got == nil { + t.Errorf("splitCSV(%q) = nil, want %v", tt.input, tt.want) + return + } + if len(got) != len(tt.want) { + t.Errorf("splitCSV(%q) = %v, want %v", tt.input, got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitCSV(%q)[%d] = %v, want %v", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +// ============================================================================= +// Completion Tests +// ============================================================================= + +func TestCompletionScript(t *testing.T) { + tests := []struct { + shell string + marker string + wantErr bool + }{ + {"bash", "complete -F _gog_complete gog", false}, + {"zsh", "bashcompinit", false}, + {"fish", "complete -c gog", false}, + {"powershell", "Register-ArgumentCompleter", false}, + {"invalid", "", true}, + {"sh", "", true}, + } + + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + got, err := completionScript(tt.shell) + if (err != nil) != tt.wantErr { + t.Errorf("completionScript(%q) error = %v, wantErr %v", tt.shell, err, tt.wantErr) + return + } + if !tt.wantErr && !strings.Contains(got, tt.marker) { + t.Errorf("completionScript(%q) = %q, expected to contain %q", tt.shell, got, tt.marker) + } + }) + } +} + +func TestNormalizeCword(t *testing.T) { + tests := []struct { + name string + cword int + wordCount int + want int + }{ + {"negative cword", -1, 3, 2}, + {"negative cword empty", -1, 0, -1}, + {"zero cword", 0, 3, 0}, + {"valid cword", 2, 5, 2}, + {"cword exceeds count", 10, 3, 3}, + {"cword equals count", 3, 3, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeCword(tt.cword, tt.wordCount) + if got != tt.want { + t.Errorf("normalizeCword(%d, %d) = %d, want %d", tt.cword, tt.wordCount, got, tt.want) + } + }) + } +} + +func TestCompletionStartIndex(t *testing.T) { + tests := []struct { + name string + words []string + want int + }{ + {"empty", []string{}, 0}, + {"gog command", []string{"gog"}, 1}, + {"GOG command", []string{"GOG"}, 1}, + {"gog.exe command", []string{"gog.exe"}, 1}, + {"path to gog", []string{"/usr/bin/gog"}, 1}, + {"non-gog command", []string{"other"}, 0}, + {"subcommand", []string{"auth"}, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := completionStartIndex(tt.words) + if got != tt.want { + t.Errorf("completionStartIndex(%v) = %d, want %d", tt.words, got, tt.want) + } + }) + } +} + +func TestIsProgramName(t *testing.T) { + tests := []struct { + word string + want bool + }{ + {"gog", true}, + {"GOG", true}, + {"gog.exe", true}, + {"GOG.EXE", true}, + {"/usr/bin/gog", true}, + {"/usr/local/bin/GOG", true}, + // Windows paths are handled by filepath.Base which works differently on Unix + // {"C:\\Program Files\\gog.exe", true}, // Skipped: filepath.Base behavior varies by OS + {"other", false}, + {"gogg", false}, + {"notgog", false}, + } + + for _, tt := range tests { + t.Run(tt.word, func(t *testing.T) { + got := isProgramName(tt.word) + if got != tt.want { + t.Errorf("isProgramName(%q) = %v, want %v", tt.word, got, tt.want) + } + }) + } +} + +func TestSplitFlagToken(t *testing.T) { + tests := []struct { + word string + wantFlag string + hasValue bool + }{ + {"--flag", "--flag", false}, + {"--flag=value", "--flag", true}, + {"-f", "-f", false}, + {"-f=v", "-f", true}, + {"--long-flag=some-value", "--long-flag", true}, + {"--flag=", "--flag", true}, + } + + for _, tt := range tests { + t.Run(tt.word, func(t *testing.T) { + gotFlag, gotHasValue := splitFlagToken(tt.word) + if gotFlag != tt.wantFlag { + t.Errorf("splitFlagToken(%q) flag = %q, want %q", tt.word, gotFlag, tt.wantFlag) + } + if gotHasValue != tt.hasValue { + t.Errorf("splitFlagToken(%q) hasValue = %v, want %v", tt.word, gotHasValue, tt.hasValue) + } + }) + } +} + +func TestMatchingCommands(t *testing.T) { + node := &completionNode{ + children: map[string]*completionNode{ + "auth": {}, + "calendar": {}, + "contacts": {}, + "drive": {}, + }, + } + + tests := []struct { + prefix string + want int + }{ + {"", 4}, + {"a", 1}, + {"c", 2}, + {"d", 1}, + {"x", 0}, + {"auth", 1}, + {"cal", 1}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + got := matchingCommands(node, tt.prefix) + if len(got) != tt.want { + t.Errorf("matchingCommands(node, %q) returned %d items, want %d", tt.prefix, len(got), tt.want) + } + }) + } +} + +func TestMatchingFlags(t *testing.T) { + node := &completionNode{ + flags: map[string]completionFlag{ + "--help": {takesValue: false}, + "--verbose": {takesValue: false}, + "--account": {takesValue: true}, + "-h": {takesValue: false}, + }, + } + + tests := []struct { + prefix string + want int + }{ + {"", 4}, + {"-", 4}, + {"--", 3}, + {"--h", 1}, + {"--v", 1}, + {"--a", 1}, + {"-h", 1}, + {"--x", 0}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + got := matchingFlags(node, tt.prefix) + if len(got) != tt.want { + t.Errorf("matchingFlags(node, %q) returned %d items, want %d", tt.prefix, len(got), tt.want) + } + }) + } +} + +func TestShouldStopAfterTerminator(t *testing.T) { + tests := []struct { + name string + terminatorIndex int + cword int + words []string + want bool + }{ + {"no terminator", -1, 2, []string{"gog", "auth", "list"}, false}, + {"terminator before cword", 2, 3, []string{"gog", "auth", "--", "extra"}, true}, + {"terminator at cword", 2, 2, []string{"gog", "auth", "--"}, true}, + {"cword is terminator", -1, 2, []string{"gog", "auth", "--"}, true}, + {"terminator after cword", 3, 2, []string{"gog", "auth", "list", "--"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldStopAfterTerminator(tt.terminatorIndex, tt.cword, tt.words) + if got != tt.want { + t.Errorf("shouldStopAfterTerminator(%d, %d, %v) = %v, want %v", + tt.terminatorIndex, tt.cword, tt.words, got, tt.want) + } + }) + } +} + +func TestCompleteWordsEmpty(t *testing.T) { + got, err := completeWords(0, nil) + if err != nil { + t.Fatalf("completeWords: %v", err) + } + if got != nil { + t.Errorf("completeWords(0, nil) = %v, want nil", got) + } + + got, err = completeWords(0, []string{}) + if err != nil { + t.Fatalf("completeWords: %v", err) + } + if got != nil { + t.Errorf("completeWords(0, []) = %v, want nil", got) + } +} + +func TestCompletionInternalCmd(t *testing.T) { + cmd := &CompletionInternalCmd{ + Cword: 1, + Words: []string{"gog", "a"}, + } + + out := captureStdout(t, func() { + err := cmd.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + }) + + // Should return completions starting with 'a' like 'auth' + if !strings.Contains(out, "auth") { + t.Errorf("expected 'auth' in completion output, got %q", out) + } +} + +// ============================================================================= +// ToDrive Helper Tests +// ============================================================================= + +func TestToDriveNumber(t *testing.T) { + tests := []struct { + value int64 + want string + }{ + {0, "0"}, + {1, "1"}, + {-1, "-1"}, + {12345, "12345"}, + {9223372036854775807, "9223372036854775807"}, + {-9223372036854775808, "-9223372036854775808"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := toDriveNumber(tt.value) + if got != tt.want { + t.Errorf("toDriveNumber(%d) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestToDriveBool(t *testing.T) { + if got := toDriveBool(true); got != "true" { + t.Errorf("toDriveBool(true) = %q, want %q", got, "true") + } + if got := toDriveBool(false); got != "false" { + t.Errorf("toDriveBool(false) = %q, want %q", got, "false") + } +} + +func TestToDriveRow(t *testing.T) { + tests := []struct { + name string + values []string + }{ + {"empty", []string{}}, + {"single", []string{"a"}}, + {"multiple", []string{"a", "b", "c"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toDriveRow(tt.values...) + if len(got) != len(tt.values) { + t.Errorf("toDriveRow returned %d items, want %d", len(got), len(tt.values)) + } + for i, v := range tt.values { + if got[i] != v { + t.Errorf("toDriveRow[%d] = %q, want %q", i, got[i], v) + } + } + }) + } +} + +func TestToDriveTitle(t *testing.T) { + tests := []struct { + name string + base string + sheetName string + want string + }{ + {"use base", "Base Title", "", "Base Title"}, + {"use sheet name", "Base Title", "Custom Sheet", "Custom Sheet"}, + {"empty base", "", "Custom Sheet", "Custom Sheet"}, + {"empty both", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := ToDriveFlags{SheetName: tt.sheetName} + got := toDriveTitle(tt.base, opts) + if got != tt.want { + t.Errorf("toDriveTitle(%q, {SheetName: %q}) = %q, want %q", + tt.base, tt.sheetName, got, tt.want) + } + }) + } +} + +func TestToDriveFlagsEnabled(t *testing.T) { + tests := []struct { + name string + flags ToDriveFlags + enabled bool + }{ + {"disabled", ToDriveFlags{ToDrive: false}, false}, + {"enabled", ToDriveFlags{ToDrive: true}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.flags.enabled() + if got != tt.enabled { + t.Errorf("ToDriveFlags{ToDrive: %v}.enabled() = %v, want %v", + tt.flags.ToDrive, got, tt.enabled) + } + }) + } +} + +func TestWriteToDriveDisabled(t *testing.T) { + ctx := context.Background() + flags := &RootFlags{Account: "test@example.com"} + opts := ToDriveFlags{ToDrive: false} + + handled, err := writeToDrive(ctx, flags, "title", []string{"h1"}, [][]string{{"r1"}}, opts) + if err != nil { + t.Fatalf("writeToDrive: %v", err) + } + if handled { + t.Error("writeToDrive should return handled=false when ToDrive is disabled") + } +} + +func TestWriteToDriveMissingAccount(t *testing.T) { + ctx := context.Background() + flags := &RootFlags{Account: ""} + opts := ToDriveFlags{ToDrive: true} + + handled, err := writeToDrive(ctx, flags, "title", []string{"h1"}, [][]string{{"r1"}}, opts) + if !handled { + t.Error("writeToDrive should return handled=true when enabled") + } + if err == nil { + t.Error("writeToDrive should return error when account is missing") + } +} + +// ============================================================================= +// Timezone Tests +// ============================================================================= + +func TestWarnInvalidConfigTimezone(t *testing.T) { + // Test fallback mode warning + t.Run("fallback mode", func(t *testing.T) { + out := captureStderr(t, func() { + warnInvalidConfigTimezone("InvalidTZ", timezoneWithFallback) + }) + if !strings.Contains(out, "warning") || !strings.Contains(out, "InvalidTZ") { + t.Errorf("expected warning about InvalidTZ, got %q", out) + } + if !strings.Contains(out, "using local timezone") { + t.Errorf("expected 'using local timezone' in fallback warning, got %q", out) + } + }) + + // Test explicit-only mode warning + t.Run("explicit only mode", func(t *testing.T) { + out := captureStderr(t, func() { + warnInvalidConfigTimezone("BadTZ", timezoneExplicitOnly) + }) + if !strings.Contains(out, "warning") || !strings.Contains(out, "BadTZ") { + t.Errorf("expected warning about BadTZ, got %q", out) + } + if !strings.Contains(out, "ignoring") { + t.Errorf("expected 'ignoring' in explicit-only warning, got %q", out) + } + }) +} + +func TestParseTimezoneValue(t *testing.T) { + tests := []struct { + name string + label string + value string + allowLocal bool + wantNil bool + wantOK bool + wantErr bool + }{ + {"empty value", "test", "", false, true, false, false}, + {"whitespace only", "test", " ", false, true, false, false}, + {"local allowed", "test", "local", true, false, true, false}, + {"local not allowed", "test", "local", false, true, true, true}, + {"valid timezone", "test", "UTC", false, false, true, false}, + {"valid timezone America", "test", "America/New_York", false, false, true, false}, + {"invalid timezone", "test", "Invalid/Zone", false, true, true, true}, + {"local uppercase", "test", "LOCAL", true, false, true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, ok, err := parseTimezoneValue(tt.label, tt.value, tt.allowLocal) + if (err != nil) != tt.wantErr { + t.Errorf("parseTimezoneValue error = %v, wantErr %v", err, tt.wantErr) + return + } + if ok != tt.wantOK { + t.Errorf("parseTimezoneValue ok = %v, wantOK %v", ok, tt.wantOK) + } + if (loc == nil) != tt.wantNil { + t.Errorf("parseTimezoneValue loc nil = %v, wantNil %v", loc == nil, tt.wantNil) + } + }) + } +} + +func TestResolveOutputLocationHelpers(t *testing.T) { + tests := []struct { + name string + timezone string + local bool + wantErr bool + }{ + {"local flag", "", true, false}, + {"valid timezone", "UTC", false, false}, + {"empty returns local", "", false, false}, + {"invalid timezone", "Invalid/Zone", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, err := resolveOutputLocation(tt.timezone, tt.local) + if (err != nil) != tt.wantErr { + t.Errorf("resolveOutputLocation error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && loc == nil { + t.Error("resolveOutputLocation returned nil location") + } + }) + } +} + +func TestGetConfiguredTimezoneHelpers(t *testing.T) { + tests := []struct { + name string + timezone string + wantNil bool + wantErr bool + }{ + {"empty returns nil", "", true, false}, + {"valid timezone", "UTC", false, false}, + {"local timezone", "local", false, false}, + {"invalid timezone", "Invalid/Zone", true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, err := getConfiguredTimezone(tt.timezone) + if (err != nil) != tt.wantErr { + t.Errorf("getConfiguredTimezone error = %v, wantErr %v", err, tt.wantErr) + return + } + if (loc == nil) != tt.wantNil { + t.Errorf("getConfiguredTimezone loc nil = %v, wantNil %v", loc == nil, tt.wantNil) + } + }) + } +} + +// ============================================================================= +// Additional Completion Tests for Coverage +// ============================================================================= + +func TestExpectsFlagValue(t *testing.T) { + node := &completionNode{ + flags: map[string]completionFlag{ + "--account": {takesValue: true}, + "--verbose": {takesValue: false}, + }, + } + + tests := []struct { + name string + cword int + words []string + start int + expect bool + }{ + {"cword at start", 0, []string{"gog"}, 0, false}, + {"cword below start", 0, []string{"gog", "auth"}, 1, false}, + {"previous is flag with value", 2, []string{"gog", "--account", ""}, 1, true}, + {"previous is bool flag", 2, []string{"gog", "--verbose", ""}, 1, false}, + {"previous is flag=value", 2, []string{"gog", "--account=test", ""}, 1, true}, + {"previous is not flag", 2, []string{"gog", "auth", ""}, 1, false}, + {"cword exceeds words", 5, []string{"gog", "auth"}, 1, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := expectsFlagValue(node, tt.cword, tt.words, tt.start) + if got != tt.expect { + t.Errorf("expectsFlagValue(node, %d, %v, %d) = %v, want %v", + tt.cword, tt.words, tt.start, got, tt.expect) + } + }) + } +} + +func TestAdvanceCompletionNode(t *testing.T) { + // Build a simple test tree + childNode := &completionNode{ + children: make(map[string]*completionNode), + flags: map[string]completionFlag{ + "--list": {takesValue: false}, + }, + } + root := &completionNode{ + children: map[string]*completionNode{ + "auth": childNode, + }, + flags: map[string]completionFlag{ + "--account": {takesValue: true}, + "--verbose": {takesValue: false}, + }, + } + + tests := []struct { + name string + words []string + start int + cword int + wantSameAsRoot bool + wantTerminator int + wantNeedsValue bool + }{ + { + name: "subcommand traversal", + words: []string{"gog", "auth", ""}, + start: 1, + cword: 2, + wantSameAsRoot: false, + wantTerminator: -1, + wantNeedsValue: false, + }, + { + name: "terminator stops traversal", + words: []string{"gog", "auth", "--", "extra"}, + start: 1, + cword: 3, + wantSameAsRoot: false, + wantTerminator: 2, + wantNeedsValue: false, + }, + { + name: "flag with value inline", + words: []string{"gog", "--account=test", "auth"}, + start: 1, + cword: 2, + wantSameAsRoot: true, // After processing --account=test, we're back at root and "auth" isn't processed yet + wantTerminator: -1, + wantNeedsValue: false, + }, + { + name: "flag expecting value", + words: []string{"gog", "--account", ""}, + start: 1, + cword: 2, + wantSameAsRoot: true, + wantTerminator: -1, + wantNeedsValue: true, + }, + { + name: "bool flag continues", + words: []string{"gog", "--verbose", "auth"}, + start: 1, + cword: 2, + wantSameAsRoot: true, // Bool flag is skipped and "auth" isn't processed before cword + wantTerminator: -1, + wantNeedsValue: false, + }, + { + name: "unknown command skipped", + words: []string{"gog", "unknown", ""}, + start: 1, + cword: 2, + wantSameAsRoot: true, + wantTerminator: -1, + wantNeedsValue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node, terminatorIdx, needsValue := advanceCompletionNode(root, tt.words, tt.start, tt.cword) + if terminatorIdx != tt.wantTerminator { + t.Errorf("terminatorIndex = %d, want %d", terminatorIdx, tt.wantTerminator) + } + if needsValue != tt.wantNeedsValue { + t.Errorf("needsValue = %v, want %v", needsValue, tt.wantNeedsValue) + } + if tt.wantSameAsRoot && node != root { + t.Error("expected to stay at root node") + } + if !tt.wantSameAsRoot && node == root { + t.Error("expected to advance from root node") + } + }) + } +} + +func TestNegatedFlagName(t *testing.T) { + tests := []struct { + name string + negatable string + flagName string + want string + }{ + // These test the negatedFlagName logic through the actual kong Flag + // but we can test the logic pattern with simplified inputs + } + + // The function uses *kong.Flag which is complex to construct + // We'll test the pattern through the addFlagTokens function indirectly + // by observing behavior in completion tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = tt // placeholder + }) + } +} + +func TestAddFlag(t *testing.T) { + flags := make(map[string]completionFlag) + + // Test empty token is ignored + addFlag(flags, "", false) + if len(flags) != 0 { + t.Error("empty token should not be added") + } + + // Test normal add + addFlag(flags, "--test", true) + if _, ok := flags["--test"]; !ok { + t.Error("flag --test should be added") + } + if !flags["--test"].takesValue { + t.Error("flag --test should take value") + } + + // Test duplicate is not overwritten + addFlag(flags, "--test", false) + if !flags["--test"].takesValue { + t.Error("duplicate flag should not overwrite existing") + } +} + +func TestCompleteWordsWithFlags(t *testing.T) { + // Test completing flags + completions, err := completeWords(1, []string{"gog", "--"}) + if err != nil { + t.Fatalf("completeWords error: %v", err) + } + + // Should return some flag completions starting with -- + foundFlag := false + for _, c := range completions { + if strings.HasPrefix(c, "--") { + foundFlag = true + break + } + } + if !foundFlag && len(completions) > 0 { + t.Error("expected at least one flag completion starting with --") + } +} + +func TestCompleteWordsWithSubcommand(t *testing.T) { + // Test completing after navigating to a subcommand + completions, err := completeWords(2, []string{"gog", "auth", ""}) + if err != nil { + t.Fatalf("completeWords error: %v", err) + } + + // Should return subcommand completions for auth (like list, add, etc) + // At minimum it should not error + _ = completions +} diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go index ac27a783..0258af67 100644 --- a/internal/cmd/testutil_test.go +++ b/internal/cmd/testutil_test.go @@ -1,5 +1,10 @@ package cmd +// NOTE: Command tests in this package MUST NOT use t.Parallel() because they +// rely on replacing package-level service factory variables (e.g. +// newAlertCenterService, newAdminDirectoryService) with test stubs. Running +// tests concurrently would cause data races on these shared variables. + import ( "context" "encoding/json" @@ -14,6 +19,8 @@ import ( "github.com/alecthomas/kong" "github.com/steipete/gogcli/internal/googleauth" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" ) // withPrimaryCalendar wraps an http.Handler to also respond to primary calendar requests @@ -70,6 +77,15 @@ func pickNonLocalTimezone(t *testing.T) string { return pickTimezoneExcluding(t, time.Local.String(), "local") } +func testContextJSON(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) +} + func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/internal/cmd/todrive_helpers.go b/internal/cmd/todrive_helpers.go new file mode 100644 index 00000000..2931ea86 --- /dev/null +++ b/internal/cmd/todrive_helpers.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/todrive" + "github.com/steipete/gogcli/internal/ui" +) + +type ToDriveFlags struct { + ToDrive bool `name:"todrive" help:"Output to Google Sheets"` + SheetName string `name:"todrive-sheet" help:"Sheet name"` + Folder string `name:"todrive-folder" help:"Drive folder ID"` + Timestamp bool `name:"todrive-timestamp" help:"Append timestamp to sheet name"` + Notify string `name:"todrive-notify" help:"Email to notify"` + Update bool `name:"todrive-update" help:"Update existing sheet"` +} + +func (t ToDriveFlags) enabled() bool { return t.ToDrive } + +func writeToDrive(ctx context.Context, flags *RootFlags, title string, headers []string, rows [][]string, opts ToDriveFlags) (bool, error) { + if !opts.enabled() { + return false, nil + } + account, err := requireAccount(flags) + if err != nil { + return true, err + } + + writer, err := todrive.New(ctx, account) + if err != nil { + return true, err + } + + result, err := writer.Write(ctx, headers, rows, todrive.Options{ + SheetName: title, + FolderID: opts.Folder, + Timestamp: opts.Timestamp, + Notify: opts.Notify, + Update: opts.Update, + }) + if err != nil { + return true, err + } + + if outfmt.IsJSON(ctx) { + return true, outfmt.WriteJSON(os.Stdout, map[string]any{ + "sheetId": result.SpreadsheetID, + "sheetName": result.SheetName, + "url": result.URL, + }) + } + + u := ui.FromContext(ctx) + if u != nil { + u.Out().Printf("Saved to Google Sheets: %s\n", result.URL) + } + return true, nil +} + +func toDriveTitle(base string, opts ToDriveFlags) string { + if opts.SheetName != "" { + return opts.SheetName + } + return base +} + +func toDriveRow(values ...string) []string { + row := make([]string, len(values)) + copy(row, values) + return row +} + +func toDriveBool(value bool) string { + if value { + return strTrue + } + return strFalse +} + +func toDriveNumber(value int64) string { + return fmt.Sprintf("%d", value) +} diff --git a/internal/cmd/transfer.go b/internal/cmd/transfer.go new file mode 100644 index 00000000..c455db2a --- /dev/null +++ b/internal/cmd/transfer.go @@ -0,0 +1,311 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + datatransfer "google.golang.org/api/admin/datatransfer/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newDataTransferService = googleapi.NewDataTransfer + +type TransferCmd struct { + List TransferListCmd `cmd:"" name:"list" aliases:"ls" help:"List data transfers"` + Get TransferGetCmd `cmd:"" name:"get" help:"Get data transfer"` + Create TransferCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create data transfer"` + Applications TransferApplicationsCmd `cmd:"" name:"applications" help:"List transferable applications"` +} + +type TransferListCmd struct { + OldOwner string `name:"old-owner" help:"Filter by old owner email"` + NewOwner string `name:"new-owner" help:"Filter by new owner email"` + Status string `name:"status" help:"Filter by status"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *TransferListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newDataTransferService(ctx, account) + if err != nil { + return err + } + + call := svc.Transfers.List().CustomerId(adminCustomerID()) + if c.OldOwner != "" { + call = call.OldOwnerUserId(c.OldOwner) + } + if c.NewOwner != "" { + call = call.NewOwnerUserId(c.NewOwner) + } + if c.Status != "" { + call = call.Status(c.Status) + } + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list transfers: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.DataTransfers) == 0 { + u.Err().Println("No transfers found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "TRANSFER ID\tOLD OWNER\tNEW OWNER\tSTATUS\tAPPLICATIONS") + for _, transfer := range resp.DataTransfers { + if transfer == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", + sanitizeTab(transfer.Id), + sanitizeTab(transfer.OldOwnerUserId), + sanitizeTab(transfer.NewOwnerUserId), + sanitizeTab(transfer.OverallTransferStatusCode), + len(transfer.ApplicationDataTransfers), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type TransferGetCmd struct { + TransferID string `arg:"" name:"transfer-id" help:"Transfer ID"` +} + +func (c *TransferGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + transferID := strings.TrimSpace(c.TransferID) + if transferID == "" { + return usage("transfer ID is required") + } + + svc, err := newDataTransferService(ctx, account) + if err != nil { + return err + } + + transfer, err := svc.Transfers.Get(transferID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get transfer %s: %w", transferID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, transfer) + } + + u.Out().Printf("ID: %s\n", transfer.Id) + u.Out().Printf("Old Owner: %s\n", transfer.OldOwnerUserId) + u.Out().Printf("New Owner: %s\n", transfer.NewOwnerUserId) + u.Out().Printf("Status: %s\n", transfer.OverallTransferStatusCode) + if len(transfer.ApplicationDataTransfers) > 0 { + u.Out().Printf("Apps: %d\n", len(transfer.ApplicationDataTransfers)) + } + return nil +} + +type TransferCreateCmd struct { + OldOwner string `name:"old-owner" help:"Old owner email" required:""` + NewOwner string `name:"new-owner" help:"New owner email" required:""` + Application string `name:"application" help:"Application ID" required:""` + Parameters string `name:"parameters" help:"Transfer parameters (JSON map or key=value list)"` +} + +func (c *TransferCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + oldOwner := strings.TrimSpace(c.OldOwner) + newOwner := strings.TrimSpace(c.NewOwner) + if oldOwner == "" || newOwner == "" { + return usage("--old-owner and --new-owner are required") + } + + appID, err := strconv.ParseInt(strings.TrimSpace(c.Application), 10, 64) + if err != nil { + return usage("--application must be a numeric application ID") + } + + params, err := parseTransferParams(c.Parameters) + if err != nil { + return err + } + + svc, err := newDataTransferService(ctx, account) + if err != nil { + return err + } + + transfer := &datatransfer.DataTransfer{ + OldOwnerUserId: oldOwner, + NewOwnerUserId: newOwner, + ApplicationDataTransfers: []*datatransfer.ApplicationDataTransfer{ + { + ApplicationId: appID, + ApplicationTransferParams: params, + }, + }, + } + + created, err := svc.Transfers.Insert(transfer).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create transfer: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u.Out().Printf("Created transfer: %s\n", created.Id) + return nil +} + +type TransferApplicationsCmd struct{} + +func (c *TransferApplicationsCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newDataTransferService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Applications.List().Context(ctx).Do() + if err != nil { + return fmt.Errorf("list applications: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Applications) == 0 { + u.Err().Println("No applications found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ID\tNAME\tPARAMS") + for _, app := range resp.Applications { + if app == nil { + continue + } + fmt.Fprintf(w, "%d\t%s\t%d\n", + app.Id, + sanitizeTab(app.Name), + len(app.TransferParams), + ) + } + return nil +} + +func parseTransferParams(input string) ([]*datatransfer.ApplicationTransferParam, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, nil + } + + payload, err := readValueOrFile(trimmed) + if err != nil { + return nil, err + } + payload = strings.TrimSpace(payload) + if payload == "" { + return nil, nil + } + + if strings.HasPrefix(payload, "[") { + var params []*datatransfer.ApplicationTransferParam + if err := json.Unmarshal([]byte(payload), ¶ms); err == nil { + return params, nil + } + } + + if strings.HasPrefix(payload, "{") { + var paramsMap map[string][]string + if err := json.Unmarshal([]byte(payload), ¶msMap); err == nil { + return transferParamsFromMap(paramsMap), nil + } + var paramsSimple map[string]string + if err := json.Unmarshal([]byte(payload), ¶msSimple); err == nil { + paramsMap = make(map[string][]string, len(paramsSimple)) + for key, value := range paramsSimple { + paramsMap[key] = []string{value} + } + return transferParamsFromMap(paramsMap), nil + } + } + + pairs := splitCSV(payload) + paramsMap := make(map[string][]string, len(pairs)) + for _, pair := range pairs { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid parameter %q (expected key=value)", pair) + } + key := strings.TrimSpace(parts[0]) + if key == "" { + return nil, fmt.Errorf("invalid parameter %q (empty key)", pair) + } + valuePart := strings.TrimSpace(parts[1]) + if valuePart == "" { + return nil, fmt.Errorf("invalid parameter %q (empty value)", pair) + } + values := strings.Split(valuePart, "|") + for i := range values { + values[i] = strings.TrimSpace(values[i]) + } + paramsMap[key] = values + } + + return transferParamsFromMap(paramsMap), nil +} + +func transferParamsFromMap(paramsMap map[string][]string) []*datatransfer.ApplicationTransferParam { + params := make([]*datatransfer.ApplicationTransferParam, 0, len(paramsMap)) + for key, values := range paramsMap { + params = append(params, &datatransfer.ApplicationTransferParam{ + Key: key, + Value: values, + }) + } + return params +} diff --git a/internal/cmd/transfer_test.go b/internal/cmd/transfer_test.go new file mode 100644 index 00000000..314ac5e3 --- /dev/null +++ b/internal/cmd/transfer_test.go @@ -0,0 +1,760 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + datatransfer "google.golang.org/api/admin/datatransfer/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestTransferListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dataTransfers": []map[string]any{ + {"id": "t1", "oldOwnerUserId": "old@example.com", "newOwnerUserId": "new@example.com", "overallTransferStatusCode": "completed"}, + }, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "t1") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestTransferCreateCmd(t *testing.T) { + var gotOld string + var gotApp string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + var payload struct { + OldOwnerUserId string `json:"oldOwnerUserId"` + ApplicationDataTransfers []struct { + ApplicationId string `json:"applicationId"` + } `json:"applicationDataTransfers"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + gotOld = payload.OldOwnerUserId + if len(payload.ApplicationDataTransfers) > 0 { + gotApp = payload.ApplicationDataTransfers[0].ApplicationId + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t2", + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferCreateCmd{OldOwner: "old@example.com", NewOwner: "new@example.com", Application: "4350700"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotOld != "old@example.com" { + t.Fatalf("unexpected old owner: %q", gotOld) + } + if gotApp != "4350700" { + t.Fatalf("unexpected app id: %s", gotApp) + } + if !strings.Contains(out, "Created transfer") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubDataTransfer(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newDataTransferService + svc, err := datatransfer.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new datatransfer service: %v", err) + } + newDataTransferService = func(context.Context, string) (*datatransfer.Service, error) { return svc, nil } + t.Cleanup(func() { + newDataTransferService = orig + srv.Close() + }) +} + +func TestTransferListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dataTransfers": []map[string]any{ + { + "id": "t1", + "oldOwnerUserId": "old@example.com", + "newOwnerUserId": "new@example.com", + "overallTransferStatusCode": "completed", + "applicationDataTransfers": []map[string]any{{"applicationId": "123"}}, + }, + }, + "nextPageToken": "npt", + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferListCmd{} + + ctx := testContextTransfer(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result struct { + DataTransfers []struct { + ID string `json:"id"` + OldOwnerUserId string `json:"oldOwnerUserId"` + NewOwnerUserId string `json:"newOwnerUserId"` + OverallTransferStatusCode string `json:"overallTransferStatusCode"` + } `json:"dataTransfers"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if len(result.DataTransfers) != 1 { + t.Fatalf("expected 1 transfer, got %d", len(result.DataTransfers)) + } + if result.DataTransfers[0].ID != "t1" { + t.Fatalf("expected transfer ID t1, got %q", result.DataTransfers[0].ID) + } + if result.NextPageToken != "npt" { + t.Fatalf("expected next page token npt, got %q", result.NextPageToken) + } +} + +func TestTransferListCmd_WithFilters(t *testing.T) { + var gotOldOwner, gotNewOwner, gotStatus string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + gotOldOwner = r.URL.Query().Get("oldOwnerUserId") + gotNewOwner = r.URL.Query().Get("newOwnerUserId") + gotStatus = r.URL.Query().Get("status") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dataTransfers": []map[string]any{}, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferListCmd{ + OldOwner: "old@example.com", + NewOwner: "new@example.com", + Status: "completed", + Max: 50, + } + + _ = captureStdout(t, func() { + _ = captureStderr(t, func() { + _ = cmd.Run(testContextTransfer(t), flags) + }) + }) + + if gotOldOwner != "old@example.com" { + t.Fatalf("expected old owner filter, got %q", gotOldOwner) + } + if gotNewOwner != "new@example.com" { + t.Fatalf("expected new owner filter, got %q", gotNewOwner) + } + if gotStatus != "completed" { + t.Fatalf("expected status filter, got %q", gotStatus) + } +} + +func TestTransferListCmd_EmptyList(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dataTransfers": []map[string]any{}, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferListCmd{} + + stderr := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + }) + + if !strings.Contains(stderr, "No transfers found") { + t.Fatalf("expected 'No transfers found' in stderr, got %q", stderr) + } +} + +func TestTransferListCmd_WithPagination(t *testing.T) { + var gotPageToken string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + gotPageToken = r.URL.Query().Get("pageToken") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dataTransfers": []map[string]any{ + {"id": "t1"}, + }, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferListCmd{Page: "page123"} + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotPageToken != "page123" { + t.Fatalf("expected page token 'page123', got %q", gotPageToken) + } +} + +func TestTransferGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers/t123") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t123", + "oldOwnerUserId": "old@example.com", + "newOwnerUserId": "new@example.com", + "overallTransferStatusCode": "completed", + "applicationDataTransfers": []map[string]any{{"applicationId": "456"}}, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferGetCmd{TransferID: "t123"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "t123") { + t.Fatalf("expected transfer ID in output, got %q", out) + } + if !strings.Contains(out, "old@example.com") { + t.Fatalf("expected old owner in output, got %q", out) + } + if !strings.Contains(out, "new@example.com") { + t.Fatalf("expected new owner in output, got %q", out) + } + if !strings.Contains(out, "completed") { + t.Fatalf("expected status in output, got %q", out) + } +} + +func TestTransferGetCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers/t456") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t456", + "oldOwnerUserId": "old@example.com", + "newOwnerUserId": "new@example.com", + "overallTransferStatusCode": "inProgress", + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferGetCmd{TransferID: "t456"} + + ctx := testContextTransfer(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result struct { + ID string `json:"id"` + OldOwnerUserId string `json:"oldOwnerUserId"` + NewOwnerUserId string `json:"newOwnerUserId"` + OverallTransferStatusCode string `json:"overallTransferStatusCode"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if result.ID != "t456" { + t.Fatalf("expected ID t456, got %q", result.ID) + } + if result.OverallTransferStatusCode != "inProgress" { + t.Fatalf("expected status inProgress, got %q", result.OverallTransferStatusCode) + } +} + +func TestTransferGetCmd_EmptyID(t *testing.T) { + stubDataTransfer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferGetCmd{TransferID: " "} + + err := cmd.Run(testContextTransfer(t), flags) + if err == nil { + t.Fatal("expected error for empty transfer ID") + } + if !strings.Contains(err.Error(), "required") { + t.Fatalf("expected 'required' in error, got %v", err) + } +} + +func TestTransferCreateCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t789", + "oldOwnerUserId": "old@example.com", + "newOwnerUserId": "new@example.com", + "overallTransferStatusCode": "pending", + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferCreateCmd{ + OldOwner: "old@example.com", + NewOwner: "new@example.com", + Application: "12345", + } + + ctx := testContextTransfer(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if result.ID != "t789" { + t.Fatalf("expected ID t789, got %q", result.ID) + } +} + +func TestTransferCreateCmd_MissingOwners(t *testing.T) { + stubDataTransfer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferCreateCmd{ + OldOwner: "", + NewOwner: "new@example.com", + Application: "12345", + } + + err := cmd.Run(testContextTransfer(t), flags) + if err == nil { + t.Fatal("expected error for missing old owner") + } + if !strings.Contains(err.Error(), "required") { + t.Fatalf("expected 'required' in error, got %v", err) + } +} + +func TestTransferCreateCmd_InvalidApplication(t *testing.T) { + stubDataTransfer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferCreateCmd{ + OldOwner: "old@example.com", + NewOwner: "new@example.com", + Application: "not-a-number", + } + + err := cmd.Run(testContextTransfer(t), flags) + if err == nil { + t.Fatal("expected error for invalid application ID") + } + if !strings.Contains(err.Error(), "numeric") { + t.Fatalf("expected 'numeric' in error, got %v", err) + } +} + +func TestTransferCreateCmd_WithJSONParams(t *testing.T) { + var gotParams []map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + var payload struct { + ApplicationDataTransfers []struct { + ApplicationTransferParams []map[string]any `json:"applicationTransferParams"` + } `json:"applicationDataTransfers"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if len(payload.ApplicationDataTransfers) > 0 { + gotParams = payload.ApplicationDataTransfers[0].ApplicationTransferParams + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "t1"}) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferCreateCmd{ + OldOwner: "old@example.com", + NewOwner: "new@example.com", + Application: "12345", + Parameters: `{"key1": ["value1", "value2"]}`, + } + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if len(gotParams) != 1 { + t.Fatalf("expected 1 param, got %d", len(gotParams)) + } +} + +func TestTransferCreateCmd_WithKeyValueParams(t *testing.T) { + var gotParams []map[string]any + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/transfers") { + http.NotFound(w, r) + return + } + var payload struct { + ApplicationDataTransfers []struct { + ApplicationTransferParams []map[string]any `json:"applicationTransferParams"` + } `json:"applicationDataTransfers"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if len(payload.ApplicationDataTransfers) > 0 { + gotParams = payload.ApplicationDataTransfers[0].ApplicationTransferParams + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "t1"}) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferCreateCmd{ + OldOwner: "old@example.com", + NewOwner: "new@example.com", + Application: "12345", + Parameters: "key1=value1,key2=value2", + } + + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if len(gotParams) != 2 { + t.Fatalf("expected 2 params, got %d", len(gotParams)) + } +} + +func TestTransferApplicationsCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/applications") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "applications": []map[string]any{ + {"id": "100", "name": "Google Drive", "transferParams": []map[string]any{{"key": "param1"}}}, + {"id": "200", "name": "Google Calendar", "transferParams": []map[string]any{}}, + }, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferApplicationsCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Google Drive") { + t.Fatalf("expected Google Drive in output, got %q", out) + } + if !strings.Contains(out, "Google Calendar") { + t.Fatalf("expected Google Calendar in output, got %q", out) + } +} + +func TestTransferApplicationsCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/applications") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "applications": []map[string]any{ + {"id": "100", "name": "Google Drive"}, + }, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferApplicationsCmd{} + + ctx := testContextTransfer(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var result struct { + Applications []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"applications"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if len(result.Applications) != 1 { + t.Fatalf("expected 1 application, got %d", len(result.Applications)) + } + if result.Applications[0].Name != "Google Drive" { + t.Fatalf("expected Google Drive, got %q", result.Applications[0].Name) + } +} + +func TestTransferApplicationsCmd_EmptyList(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/admin/datatransfer/v1/applications") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "applications": []map[string]any{}, + }) + }) + stubDataTransfer(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &TransferApplicationsCmd{} + + stderr := captureStderr(t, func() { + _ = captureStdout(t, func() { + if err := cmd.Run(testContextWithStdoutTransfer(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + }) + + if !strings.Contains(stderr, "No applications found") { + t.Fatalf("expected 'No applications found' in stderr, got %q", stderr) + } +} + +func TestParseTransferParams_EmptyInput(t *testing.T) { + params, err := parseTransferParams("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params != nil { + t.Fatalf("expected nil params, got %v", params) + } +} + +func TestParseTransferParams_SimpleJSON(t *testing.T) { + params, err := parseTransferParams(`{"key1": "value1"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(params) != 1 { + t.Fatalf("expected 1 param, got %d", len(params)) + } + if params[0].Key != "key1" { + t.Fatalf("expected key1, got %q", params[0].Key) + } +} + +func TestParseTransferParams_ArrayJSON(t *testing.T) { + params, err := parseTransferParams(`{"key1": ["v1", "v2"]}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(params) != 1 { + t.Fatalf("expected 1 param, got %d", len(params)) + } + if len(params[0].Value) != 2 { + t.Fatalf("expected 2 values, got %d", len(params[0].Value)) + } +} + +func TestParseTransferParams_KeyValuePairs(t *testing.T) { + params, err := parseTransferParams("key1=value1,key2=value2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(params) != 2 { + t.Fatalf("expected 2 params, got %d", len(params)) + } +} + +func TestParseTransferParams_PipeDelimitedValues(t *testing.T) { + params, err := parseTransferParams("key1=v1|v2|v3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(params) != 1 { + t.Fatalf("expected 1 param, got %d", len(params)) + } + if len(params[0].Value) != 3 { + t.Fatalf("expected 3 values, got %d", len(params[0].Value)) + } +} + +func TestParseTransferParams_InvalidKeyValue(t *testing.T) { + _, err := parseTransferParams("invalid-no-equals") + if err == nil { + t.Fatal("expected error for invalid key=value") + } + if !strings.Contains(err.Error(), "expected key=value") { + t.Fatalf("expected 'expected key=value' in error, got %v", err) + } +} + +func TestParseTransferParams_EmptyKey(t *testing.T) { + _, err := parseTransferParams("=value") + if err == nil { + t.Fatal("expected error for empty key") + } + if !strings.Contains(err.Error(), "empty key") { + t.Fatalf("expected 'empty key' in error, got %v", err) + } +} + +func TestParseTransferParams_EmptyValue(t *testing.T) { + _, err := parseTransferParams("key=") + if err == nil { + t.Fatal("expected error for empty value") + } + if !strings.Contains(err.Error(), "empty value") { + t.Fatalf("expected 'empty value' in error, got %v", err) + } +} + +func TestTransferParamsFromMap(t *testing.T) { + paramsMap := map[string][]string{ + "key1": {"value1"}, + "key2": {"v2a", "v2b"}, + } + params := transferParamsFromMap(paramsMap) + if len(params) != 2 { + t.Fatalf("expected 2 params, got %d", len(params)) + } +} + +func testContextTransfer(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func testContextWithStdoutTransfer(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} diff --git a/internal/cmd/users.go b/internal/cmd/users.go new file mode 100644 index 00000000..481cd5c8 --- /dev/null +++ b/internal/cmd/users.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + + "github.com/steipete/gogcli/internal/googleapi" +) + +var newAdminDirectory = googleapi.NewAdminDirectory + +// UsersCmd is the top-level users command. +type UsersCmd struct { + List UsersListCmd `cmd:"" name:"list" aliases:"ls" help:"List users in domain"` + Get UsersGetCmd `cmd:"" name:"get" aliases:"info" help:"Get user details"` + Create UsersCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create a new user"` + Update UsersUpdateCmd `cmd:"" name:"update" help:"Update user attributes"` + Delete UsersDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a user"` + Suspend UsersSuspendCmd `cmd:"" name:"suspend" help:"Suspend a user"` + Unsuspend UsersUnsuspendCmd `cmd:"" name:"unsuspend" aliases:"activate" help:"Unsuspend a user"` + Password UsersPasswordCmd `cmd:"" name:"password" aliases:"passwd" help:"Reset user password"` + Signout UsersSignoutCmd `cmd:"" name:"signout" help:"Sign out user from all sessions"` + TurnOff2SV UsersTurnOff2SVCmd `cmd:"" name:"turnoff2sv" aliases:"disable2fa" help:"Turn off 2-step verification"` + BackupCodes UsersBackupCodesCmd `cmd:"" name:"backupcodes" aliases:"verificationcodes" help:"Manage backup verification codes"` + ASPs UsersASPsCmd `cmd:"" name:"asps" aliases:"apppasswords" help:"Manage app-specific passwords"` + Tokens UsersTokensCmd `cmd:"" name:"tokens" help:"Manage user tokens"` + Count UsersCountCmd `cmd:"" name:"count" help:"Count users by org unit"` +} + +func extractDomain(email string) string { + if idx := strings.LastIndex(email, "@"); idx >= 0 { + return email[idx+1:] + } + return email +} + +func generatePassword(length int) (string, error) { + if length < 8 { + length = 8 + } + const lower = "abcdefghijklmnopqrstuvwxyz" + const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const digits = "0123456789" + const special = "!@#$%^&*()_+-=[]{}|;:,.<>?" + const all = lower + upper + digits + special + + sets := []string{lower, upper, digits, special} + b := make([]byte, length) + for i, set := range sets { + ch, err := randChar(set) + if err != nil { + return "", err + } + b[i] = ch + } + for i := len(sets); i < length; i++ { + ch, err := randChar(all) + if err != nil { + return "", err + } + b[i] = ch + } + + for i := len(b) - 1; i > 0; i-- { + j, err := randInt(i + 1) + if err != nil { + return "", err + } + b[i], b[j] = b[j], b[i] + } + + return string(b), nil +} + +func randChar(set string) (byte, error) { + if len(set) == 0 { + return 0, fmt.Errorf("empty character set") + } + idx, err := randInt(len(set)) + if err != nil { + return 0, err + } + return set[idx], nil +} + +func normalizeUserHashFunction(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "md5": + return "MD5", nil + case "sha-1", "sha1": + return "SHA-1", nil + case "crypt": + return "crypt", nil + case "": + return "", nil + default: + return "", usage("invalid --hash-function (expected MD5, SHA-1, crypt)") + } +} + +func randInt(maxVal int) (int, error) { + if maxVal <= 0 { + return 0, fmt.Errorf("invalid max %d", maxVal) + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(maxVal))) + if err != nil { + return 0, err + } + return int(n.Int64()), nil +} diff --git a/internal/cmd/users_2fa.go b/internal/cmd/users_2fa.go new file mode 100644 index 00000000..1fa8bf9b --- /dev/null +++ b/internal/cmd/users_2fa.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersTurnOff2SVCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` +} + +func (c *UsersTurnOff2SVCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("turn off 2-step verification for %s", c.User)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.TwoStepVerification.TurnOff(c.User).Context(ctx).Do(); err != nil { + return fmt.Errorf("turn off 2SV for %s: %w", c.User, err) + } + + u.Out().Printf("Turned off 2-step verification for: %s\n", c.User) + return nil +} + +type UsersBackupCodesCmd struct { + List UsersBackupCodesListCmd `cmd:"" name:"list" aliases:"show" help:"List backup codes"` + Generate UsersBackupCodesGenerateCmd `cmd:"" name:"generate" aliases:"create" help:"Generate new backup codes"` + Delete UsersBackupCodesDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete all backup codes"` +} + +type UsersBackupCodesListCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` +} + +func (c *UsersBackupCodesListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + codes, err := svc.VerificationCodes.List(c.User).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list backup codes for %s: %w", c.User, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, codes) + } + + if len(codes.Items) == 0 { + u.Err().Println("No backup codes found") + return nil + } + + u.Out().Printf("Backup codes for %s:\n", c.User) + for _, code := range codes.Items { + u.Out().Printf(" %s\n", code.VerificationCode) + } + + return nil +} + +type UsersBackupCodesGenerateCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` +} + +func (c *UsersBackupCodesGenerateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.VerificationCodes.Generate(c.User).Context(ctx).Do(); err != nil { + return fmt.Errorf("generate backup codes for %s: %w", c.User, err) + } + + u.Out().Printf("Generated new backup codes for: %s\n", c.User) + u.Out().Println("Use 'gog users backupcodes list' to view the new codes") + + return nil +} + +type UsersBackupCodesDeleteCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` +} + +func (c *UsersBackupCodesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete all backup codes for %s", c.User)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.VerificationCodes.Invalidate(c.User).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete backup codes for %s: %w", c.User, err) + } + + u.Out().Printf("Deleted all backup codes for: %s\n", c.User) + return nil +} diff --git a/internal/cmd/users_advanced_test.go b/internal/cmd/users_advanced_test.go new file mode 100644 index 00000000..ccbd53f7 --- /dev/null +++ b/internal/cmd/users_advanced_test.go @@ -0,0 +1,1323 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" +) + +// ==================== +// users.go helper functions +// ==================== + +func TestGeneratePassword(t *testing.T) { + t.Run("generates password with minimum length", func(t *testing.T) { + pwd, err := generatePassword(5) + if err != nil { + t.Fatalf("generatePassword(5): %v", err) + } + if len(pwd) < 8 { + t.Errorf("password should be at least 8 chars, got %d", len(pwd)) + } + }) + + t.Run("generates password with specified length", func(t *testing.T) { + pwd, err := generatePassword(16) + if err != nil { + t.Fatalf("generatePassword(16): %v", err) + } + if len(pwd) != 16 { + t.Errorf("expected 16 chars, got %d", len(pwd)) + } + }) + + t.Run("contains required character types", func(t *testing.T) { + pwd, err := generatePassword(20) + if err != nil { + t.Fatalf("generatePassword(20): %v", err) + } + + hasLower := strings.ContainsAny(pwd, "abcdefghijklmnopqrstuvwxyz") + hasUpper := strings.ContainsAny(pwd, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") + hasDigit := strings.ContainsAny(pwd, "0123456789") + hasSpecial := strings.ContainsAny(pwd, "!@#$%^&*()_+-=[]{}|;:,.<>?") + + if !hasLower { + t.Error("password missing lowercase") + } + if !hasUpper { + t.Error("password missing uppercase") + } + if !hasDigit { + t.Error("password missing digit") + } + if !hasSpecial { + t.Error("password missing special character") + } + }) +} + +func TestRandChar(t *testing.T) { + t.Run("returns char from set", func(t *testing.T) { + set := "abc" + ch, err := randChar(set) + if err != nil { + t.Fatalf("randChar: %v", err) + } + if !strings.ContainsRune(set, rune(ch)) { + t.Errorf("char %c not in set %s", ch, set) + } + }) + + t.Run("empty set returns error", func(t *testing.T) { + _, err := randChar("") + if err == nil { + t.Error("expected error for empty set") + } + }) +} + +func TestRandInt(t *testing.T) { + t.Run("returns value in range", func(t *testing.T) { + for i := 0; i < 100; i++ { + n, err := randInt(10) + if err != nil { + t.Fatalf("randInt: %v", err) + } + if n < 0 || n >= 10 { + t.Errorf("value %d out of range [0, 10)", n) + } + } + }) + + t.Run("zero max returns error", func(t *testing.T) { + _, err := randInt(0) + if err == nil { + t.Error("expected error for max=0") + } + }) + + t.Run("negative max returns error", func(t *testing.T) { + _, err := randInt(-1) + if err == nil { + t.Error("expected error for max=-1") + } + }) +} + +func TestNormalizeUserHashFunction(t *testing.T) { + tests := []struct { + input string + want string + wantErr bool + }{ + {"md5", "MD5", false}, + {"MD5", "MD5", false}, + {"sha-1", "SHA-1", false}, + {"SHA-1", "SHA-1", false}, + {"sha1", "SHA-1", false}, + {"SHA1", "SHA-1", false}, + {"crypt", "crypt", false}, + {"CRYPT", "crypt", false}, + {"", "", false}, + {" md5 ", "MD5", false}, + {"invalid", "", true}, + {"bcrypt", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := normalizeUserHashFunction(tt.input) + if tt.wantErr && err == nil { + t.Error("expected error") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// ==================== +// users_2fa.go +// ==================== + +func TestUsersTurnOff2SVCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/twoStepVerification/turnOff") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &UsersTurnOff2SVCmd{User: "user@example.com"} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestUsersBackupCodesListCmd(t *testing.T) { + t.Run("list codes plain", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/verificationCodes") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"verificationCode": "12345678"}, + {"verificationCode": "87654321"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersBackupCodesListCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "12345678") || !strings.Contains(out, "87654321") { + t.Errorf("expected codes in output: %s", out) + } + }) + + t.Run("list codes JSON", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/verificationCodes") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"verificationCode": "12345678"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersBackupCodesListCmd{User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "items") { + t.Errorf("expected JSON output: %s", out) + } + }) + + t.Run("empty codes", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/verificationCodes") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersBackupCodesListCmd{User: "user@example.com"} + + // Should not error even with empty list + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) +} + +func TestUsersBackupCodesGenerateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/verificationCodes/generate") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersBackupCodesGenerateCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Generated new backup codes") { + t.Errorf("expected success message: %s", out) + } +} + +func TestUsersBackupCodesDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/verificationCodes/invalidate") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &UsersBackupCodesDeleteCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted all backup codes") { + t.Errorf("expected success message: %s", out) + } +} + +// ==================== +// users_asps.go +// ==================== + +func TestUsersASPsListCmd(t *testing.T) { + t.Run("list ASPs plain", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/asps") { + w.Header().Set("Content-Type", "application/json") + // Note: creationTime and lastTimeUsed are string-encoded per API spec + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"codeId": 123, "name": "iPhone Mail", "creationTime": "1704067200", "lastTimeUsed": "1704153600"}, + {"codeId": 456, "name": "Outlook", "creationTime": "1704067200", "lastTimeUsed": "0"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersASPsListCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "iPhone Mail") || !strings.Contains(out, "Outlook") { + t.Errorf("expected ASP names in output: %s", out) + } + }) + + t.Run("list ASPs JSON", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/asps") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"codeId": 123, "name": "iPhone Mail"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersASPsListCmd{User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "items") { + t.Errorf("expected JSON output: %s", out) + } + }) + + t.Run("empty ASPs", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/asps") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersASPsListCmd{User: "user@example.com"} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) +} + +func TestUsersASPsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/asps/123") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersASPsDeleteCmd{User: "user@example.com", CodeID: 123} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted app-specific password") { + t.Errorf("expected success message: %s", out) + } +} + +func TestFormatUnixSeconds(t *testing.T) { + tests := []struct { + name string + ts int64 + want string + }{ + {"zero returns never", 0, "never"}, + {"negative returns never", -1, "never"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatUnixSeconds(tt.ts) + if got != tt.want { + t.Errorf("formatUnixSeconds(%d) = %q, want %q", tt.ts, got, tt.want) + } + }) + } + + // Test positive value - just verify it returns a valid RFC3339 time string + t.Run("positive value returns RFC3339", func(t *testing.T) { + got := formatUnixSeconds(1704067200) + // Should contain date components (time.RFC3339 format) + if !strings.Contains(got, "2024") && !strings.Contains(got, "2023") { + t.Errorf("formatUnixSeconds(1704067200) should contain year, got %q", got) + } + if !strings.Contains(got, "T") { + t.Errorf("formatUnixSeconds(1704067200) should be RFC3339 format with T separator, got %q", got) + } + }) +} + +// ==================== +// users_create.go +// ==================== + +func TestUsersCreateCmd(t *testing.T) { + t.Run("create with password", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") { + var req admin.User + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "user-123", + "primaryEmail": req.PrimaryEmail, + "name": req.Name, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersCreateCmd{ + Email: "new@example.com", + FirstName: "New", + LastName: "User", + Password: "SecurePass123!", + OrgUnit: "/Sales", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created user:") || !strings.Contains(out, "new@example.com") { + t.Errorf("unexpected output: %s", out) + } + }) + + t.Run("create with generated password", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "user-456", + "primaryEmail": "gen@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersCreateCmd{ + Email: "gen@example.com", + FirstName: "Generated", + LastName: "Password", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Generated password:") { + t.Errorf("expected generated password in output: %s", out) + } + }) + + t.Run("create with hash function", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") { + var req admin.User + _ = json.NewDecoder(r.Body).Decode(&req) + if req.HashFunction != "MD5" { + http.Error(w, "expected MD5 hash function", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "user-789", + "primaryEmail": "hash@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersCreateCmd{ + Email: "hash@example.com", + FirstName: "Hash", + LastName: "User", + Password: "prehashed", + HashFunction: "md5", + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("create JSON output", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/users") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "user-json", + "primaryEmail": "json@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersCreateCmd{ + Email: "json@example.com", + FirstName: "JSON", + LastName: "User", + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "generatedPassword") { + t.Errorf("expected generatedPassword in JSON: %s", out) + } + }) + + t.Run("invalid hash function", func(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersCreateCmd{ + Email: "bad@example.com", + FirstName: "Bad", + LastName: "Hash", + Password: "pass", + HashFunction: "bcrypt", + } + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Error("expected error for invalid hash function") + } + }) +} + +// ==================== +// users_delete.go +// ==================== + +func TestUsersDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/users/user@example.com") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &UsersDeleteCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted user:") { + t.Errorf("expected success message: %s", out) + } +} + +// ==================== +// users_password.go +// ==================== + +func TestUsersPasswordCmd(t *testing.T) { + t.Run("reset with specified password", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersPasswordCmd{ + User: "user@example.com", + Password: "NewPass123!", + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Password reset for:") { + t.Errorf("expected success message: %s", out) + } + if strings.Contains(out, "New password:") { + t.Errorf("should not show password when specified: %s", out) + } + }) + + t.Run("reset with generated password", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersPasswordCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "New password:") { + t.Errorf("expected generated password in output: %s", out) + } + }) + + t.Run("reset JSON output", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersPasswordCmd{User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "generatedPassword") { + t.Errorf("expected generatedPassword in JSON: %s", out) + } + }) + + t.Run("reset with hash function", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + var req admin.User + _ = json.NewDecoder(r.Body).Decode(&req) + if req.HashFunction != "SHA-1" { + http.Error(w, "expected SHA-1 hash function", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersPasswordCmd{ + User: "user@example.com", + Password: "prehashed", + HashFunction: "sha-1", + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) +} + +// ==================== +// users_signout.go +// ==================== + +func TestUsersSignoutCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/signOut") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersSignoutCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Signed out user from all sessions:") { + t.Errorf("expected success message: %s", out) + } +} + +// ==================== +// users_suspend.go +// ==================== + +func TestUsersSuspendCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/user@example.com") { + var req admin.User + _ = json.NewDecoder(r.Body).Decode(&req) + // Verify Suspended is set to true - we check ForceSendFields was used + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + "suspended": true, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersSuspendCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Suspended user:") { + t.Errorf("expected success message: %s", out) + } +} + +func TestUsersUnsuspendCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/user@example.com") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + "suspended": false, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersUnsuspendCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Unsuspended user:") { + t.Errorf("expected success message: %s", out) + } +} + +// ==================== +// users_tokens.go +// ==================== + +func TestUsersTokensListCmd(t *testing.T) { + t.Run("list tokens plain", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tokens") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"clientId": "client1.apps.googleusercontent.com", "displayText": "My App", "scopes": []string{"email", "profile"}, "anonymous": false}, + {"clientId": "client2.apps.googleusercontent.com", "displayText": "Other App", "scopes": []string{"drive"}, "anonymous": true}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersTokensListCmd{User: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "My App") || !strings.Contains(out, "Other App") { + t.Errorf("expected token names in output: %s", out) + } + }) + + t.Run("list tokens JSON", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tokens") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"clientId": "client1.apps.googleusercontent.com", "displayText": "My App"}, + }, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersTokensListCmd{User: "user@example.com"} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "items") { + t.Errorf("expected JSON output: %s", out) + } + }) + + t.Run("empty tokens", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/tokens") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{}, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersTokensListCmd{User: "user@example.com"} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) +} + +func TestUsersTokensDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/tokens/client1.apps.googleusercontent.com") { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersTokensDeleteCmd{User: "user@example.com", ClientID: "client1.apps.googleusercontent.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Revoked token") { + t.Errorf("expected success message: %s", out) + } +} + +// ==================== +// users_update.go (additional coverage) +// ==================== + +func TestUsersUpdateCmd_FieldUpdates(t *testing.T) { + t.Run("update multiple fields", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + firstName := "Updated" + lastName := "User" + orgUnit := "/Engineering" + cmd := &UsersUpdateCmd{ + User: "user@example.com", + FirstName: &firstName, + LastName: &lastName, + OrgUnit: &orgUnit, + } + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated user:") { + t.Errorf("expected success message: %s", out) + } + }) + + t.Run("update suspended state", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + "suspended": true, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + suspended := true + cmd := &UsersUpdateCmd{ + User: "user@example.com", + Suspended: &suspended, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("update archived state", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + "archived": true, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + archived := true + cmd := &UsersUpdateCmd{ + User: "user@example.com", + Archived: &archived, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("update recovery info", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + recoveryEmail := "recovery@example.com" + recoveryPhone := "+15555555555" + cmd := &UsersUpdateCmd{ + User: "user@example.com", + RecoveryEmail: &recoveryEmail, + RecoveryPhone: &recoveryPhone, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("clear recovery info", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + empty := "" + cmd := &UsersUpdateCmd{ + User: "user@example.com", + RecoveryEmail: &empty, + RecoveryPhone: &empty, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("update change password flag", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + changePassword := true + cmd := &UsersUpdateCmd{ + User: "user@example.com", + ChangePassword: &changePassword, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("update primary email", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "newemail@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newEmail := "newemail@example.com" + cmd := &UsersUpdateCmd{ + User: "user@example.com", + PrimaryEmail: &newEmail, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("no updates specified", func(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersUpdateCmd{User: "user@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Error("expected error for no updates") + } + }) + + t.Run("admin and field updates", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/makeAdmin") { + w.WriteHeader(http.StatusOK) + return + } + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + admin := true + firstName := "Admin" + cmd := &UsersUpdateCmd{ + User: "user@example.com", + Admin: &admin, + FirstName: &firstName, + } + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + t.Run("JSON output", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/users/") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "user@example.com", + "name": map[string]any{"givenName": "Test"}, + }) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + firstName := "Test" + cmd := &UsersUpdateCmd{ + User: "user@example.com", + FirstName: &firstName, + } + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "primaryEmail") { + t.Errorf("expected JSON output: %s", out) + } + }) +} + +// ==================== +// Validation tests (missing account) +// ==================== + +func TestUsers_MissingAccount(t *testing.T) { + tests := []struct { + name string + cmd interface { + Run(context.Context, *RootFlags) error + } + }{ + {"TurnOff2SV", &UsersTurnOff2SVCmd{User: "user@example.com"}}, + {"BackupCodesList", &UsersBackupCodesListCmd{User: "user@example.com"}}, + {"BackupCodesGenerate", &UsersBackupCodesGenerateCmd{User: "user@example.com"}}, + {"BackupCodesDelete", &UsersBackupCodesDeleteCmd{User: "user@example.com"}}, + {"ASPsList", &UsersASPsListCmd{User: "user@example.com"}}, + {"ASPsDelete", &UsersASPsDeleteCmd{User: "user@example.com", CodeID: 123}}, + {"Create", &UsersCreateCmd{Email: "new@example.com", FirstName: "New", LastName: "User"}}, + {"Delete", &UsersDeleteCmd{User: "user@example.com"}}, + {"Password", &UsersPasswordCmd{User: "user@example.com"}}, + {"Signout", &UsersSignoutCmd{User: "user@example.com"}}, + {"Suspend", &UsersSuspendCmd{User: "user@example.com"}}, + {"Unsuspend", &UsersUnsuspendCmd{User: "user@example.com"}}, + {"TokensList", &UsersTokensListCmd{User: "user@example.com"}}, + {"TokensDelete", &UsersTokensDeleteCmd{User: "user@example.com", ClientID: "client"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flags := &RootFlags{Account: ""} + err := tt.cmd.Run(testContext(t), flags) + if err == nil { + t.Error("expected error for missing account") + } + }) + } +} + +// ==================== +// API error handling +// ==================== + +func TestUsers_APIErrors(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error": {"message": "Not Found"}}`, http.StatusNotFound) + }) + stubAdminDirectory(t, h) + + t.Run("delete API error", func(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &UsersDeleteCmd{User: "nonexistent@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Error("expected error for API failure") + } + }) + + t.Run("suspend API error", func(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersSuspendCmd{User: "nonexistent@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Error("expected error for API failure") + } + }) + + t.Run("signout API error", func(t *testing.T) { + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersSignoutCmd{User: "nonexistent@example.com"} + + err := cmd.Run(testContext(t), flags) + if err == nil { + t.Error("expected error for API failure") + } + }) +} diff --git a/internal/cmd/users_asps.go b/internal/cmd/users_asps.go new file mode 100644 index 00000000..79c99839 --- /dev/null +++ b/internal/cmd/users_asps.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersASPsCmd struct { + List UsersASPsListCmd `cmd:"" name:"list" aliases:"ls" help:"List app-specific passwords"` + Delete UsersASPsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete an app-specific password"` +} + +type UsersASPsListCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` +} + +func (c *UsersASPsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + asps, err := svc.Asps.List(c.User).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list ASPs for %s: %w", c.User, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, asps) + } + + if len(asps.Items) == 0 { + u.Err().Println("No app-specific passwords found") + return nil + } + + tw, flush := tableWriter(ctx) + defer flush() + + fmt.Fprintln(tw, "CODE ID\tNAME\tCREATED\tLAST USED") + for _, asp := range asps.Items { + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", + asp.CodeId, + sanitizeTab(asp.Name), + formatUnixSeconds(asp.CreationTime), + formatUnixSeconds(asp.LastTimeUsed), + ) + } + + return nil +} + +type UsersASPsDeleteCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + CodeID int64 `arg:"" name:"code-id" help:"ASP code ID to delete"` +} + +func (c *UsersASPsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Asps.Delete(c.User, c.CodeID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete ASP %d for %s: %w", c.CodeID, c.User, err) + } + + u.Out().Printf("Deleted app-specific password %d for: %s\n", c.CodeID, c.User) + return nil +} + +func formatUnixSeconds(ts int64) string { + if ts <= 0 { + return "never" + } + return time.Unix(ts, 0).Format(time.RFC3339) +} diff --git a/internal/cmd/users_count.go b/internal/cmd/users_count.go new file mode 100644 index 00000000..c4944520 --- /dev/null +++ b/internal/cmd/users_count.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersCountCmd struct { + Domain string `name:"domain" short:"d" help:"Domain to count users from"` + OrgUnit string `name:"org-unit" aliases:"ou" help:"Organizational unit path to filter"` +} + +func (c *UsersCountCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + domain := strings.TrimSpace(c.Domain) + if domain == "" { + domain = extractDomain(account) + } + + call := svc.Users.List().Domain(domain).MaxResults(500).Projection("basic").Fields("users(orgUnitPath),nextPageToken") + if c.OrgUnit != "" { + call = call.Query(fmt.Sprintf("orgUnitPath='%s'", c.OrgUnit)) + } + + counts := make(map[string]int) + for { + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list users: %w", err) + } + for _, user := range resp.Users { + if user == nil { + continue + } + path := user.OrgUnitPath + if path == "" { + path = "/" + } + counts[path]++ + } + if resp.NextPageToken == "" { + break + } + call = call.PageToken(resp.NextPageToken) + } + + if outfmt.IsJSON(ctx) { + type item struct { + OrgUnitPath string `json:"orgUnitPath"` + Count int `json:"count"` + } + items := make([]item, 0, len(counts)) + for path, count := range counts { + items = append(items, item{OrgUnitPath: path, Count: count}) + } + sort.Slice(items, func(i, j int) bool { return items[i].OrgUnitPath < items[j].OrgUnitPath }) + return outfmt.WriteJSON(os.Stdout, map[string]any{"counts": items}) + } + + if len(counts) == 0 { + u.Err().Println("No users found") + return nil + } + + paths := make([]string, 0, len(counts)) + for path := range counts { + paths = append(paths, path) + } + sort.Strings(paths) + + tw, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(tw, "ORG UNIT\tCOUNT") + for _, path := range paths { + fmt.Fprintf(tw, "%s\t%d\n", sanitizeTab(path), counts[path]) + } + + return nil +} diff --git a/internal/cmd/users_create.go b/internal/cmd/users_create.go new file mode 100644 index 00000000..32c3401d --- /dev/null +++ b/internal/cmd/users_create.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersCreateCmd struct { + Email string `arg:"" name:"email" help:"Primary email address"` + FirstName string `name:"first-name" aliases:"given-name,fn" required:"" help:"First/given name"` + LastName string `name:"last-name" aliases:"family-name,ln" required:"" help:"Last/family name"` + Password string `name:"password" aliases:"pass" help:"Password (generated if not specified)"` + OrgUnit string `name:"org-unit" aliases:"ou" default:"/" help:"Organizational unit path"` + ChangePassword bool `name:"change-password" default:"true" help:"Require password change on first login"` + Suspended bool `name:"suspended" help:"Create user in suspended state"` + Archived bool `name:"archived" help:"Create user in archived state"` + RecoveryEmail string `name:"recovery-email" help:"Recovery email address"` + RecoveryPhone string `name:"recovery-phone" help:"Recovery phone number (E.164 format)"` + HashFunction string `name:"hash-function" help:"Password hash function if pre-hashed (MD5, SHA-1, crypt)"` +} + +func (c *UsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + password := c.Password + generated := false + if password == "" { + password, err = generatePassword(16) + if err != nil { + return fmt.Errorf("generate password: %w", err) + } + generated = true + } + + user := &admin.User{ + PrimaryEmail: c.Email, + Name: &admin.UserName{ + GivenName: c.FirstName, + FamilyName: c.LastName, + }, + Password: password, + ChangePasswordAtNextLogin: c.ChangePassword, + OrgUnitPath: c.OrgUnit, + Suspended: c.Suspended, + Archived: c.Archived, + } + + if c.HashFunction != "" { + var hash string + hash, err = normalizeUserHashFunction(c.HashFunction) + if err != nil { + return err + } + user.HashFunction = hash + } + if c.RecoveryEmail != "" { + user.RecoveryEmail = c.RecoveryEmail + } + if c.RecoveryPhone != "" { + user.RecoveryPhone = c.RecoveryPhone + } + + created, err := svc.Users.Insert(user).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create user: %w", err) + } + + if outfmt.IsJSON(ctx) { + result := map[string]any{ + "user": created, + } + if generated { + result["generatedPassword"] = password + } + return outfmt.WriteJSON(os.Stdout, result) + } + + u.Out().Printf("Created user: %s\n", created.PrimaryEmail) + u.Out().Printf("User ID: %s\n", created.Id) + if generated { + u.Out().Printf("Generated password: %s\n", password) + } + if c.ChangePassword { + u.Out().Println("User must change password on first login") + } + + return nil +} diff --git a/internal/cmd/users_delete.go b/internal/cmd/users_delete.go new file mode 100644 index 00000000..45d749f9 --- /dev/null +++ b/internal/cmd/users_delete.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/steipete/gogcli/internal/ui" +) + +type UsersDeleteCmd struct { + User string `arg:"" name:"user" help:"User email or ID to delete"` +} + +func (c *UsersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete user %s", c.User)); err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Users.Delete(c.User).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete user %s: %w", c.User, err) + } + + u.Out().Printf("Deleted user: %s\n", c.User) + return nil +} diff --git a/internal/cmd/users_get.go b/internal/cmd/users_get.go new file mode 100644 index 00000000..510c2478 --- /dev/null +++ b/internal/cmd/users_get.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersGetCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + Projection string `name:"projection" default:"full" enum:"basic,full,custom" help:"Data projection"` +} + +func (c *UsersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + user, err := svc.Users.Get(c.User).Projection(c.Projection).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get user %s: %w", c.User, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, user) + } + + name := "" + if user.Name != nil { + name = strings.TrimSpace(strings.Join([]string{user.Name.GivenName, user.Name.FamilyName}, " ")) + } + + u.Out().Printf("Email: %s\n", user.PrimaryEmail) + u.Out().Printf("Name: %s\n", name) + u.Out().Printf("ID: %s\n", user.Id) + u.Out().Printf("Is Admin: %v\n", user.IsAdmin) + u.Out().Printf("Is Delegated Admin: %v\n", user.IsDelegatedAdmin) + u.Out().Printf("Suspended: %v\n", user.Suspended) + if user.SuspensionReason != "" { + u.Out().Printf("Suspension Reason: %s\n", user.SuspensionReason) + } + u.Out().Printf("Archived: %v\n", user.Archived) + u.Out().Printf("Org Unit: %s\n", user.OrgUnitPath) + u.Out().Printf("Creation Time: %s\n", user.CreationTime) + u.Out().Printf("Last Login: %s\n", user.LastLoginTime) + u.Out().Printf("Agreed to Terms: %v\n", user.AgreedToTerms) + u.Out().Printf("Change Password: %v\n", user.ChangePasswordAtNextLogin) + u.Out().Printf("2SV Enrolled: %v\n", user.IsEnrolledIn2Sv) + u.Out().Printf("2SV Enforced: %v\n", user.IsEnforcedIn2Sv) + + if user.RecoveryEmail != "" { + u.Out().Printf("Recovery Email: %s\n", user.RecoveryEmail) + } + if user.RecoveryPhone != "" { + u.Out().Printf("Recovery Phone: %s\n", user.RecoveryPhone) + } + + if len(user.Aliases) > 0 { + u.Out().Printf("Aliases: %v\n", user.Aliases) + } + if len(user.NonEditableAliases) > 0 { + u.Out().Printf("Non-Editable: %v\n", user.NonEditableAliases) + } + + return nil +} diff --git a/internal/cmd/users_list.go b/internal/cmd/users_list.go new file mode 100644 index 00000000..e745613f --- /dev/null +++ b/internal/cmd/users_list.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersListCmd struct { + Domain string `name:"domain" short:"d" help:"Domain to list users from"` + Query string `name:"query" short:"q" help:"Search query (e.g., 'email:admin*', 'name:John*', 'orgUnitPath=/Sales')"` + OrgUnit string `name:"org-unit" aliases:"ou" help:"Organizational unit path"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Maximum users to return"` + Page string `name:"page" help:"Page token for pagination"` + Suspended *bool `name:"suspended" help:"Filter by suspended state"` + Admin *bool `name:"admin" help:"Filter by admin status"` + OrderBy string `name:"order-by" default:"email" enum:"email,familyName,givenName" help:"Sort field"` + SortOrder string `name:"sort-order" default:"ASCENDING" enum:"ASCENDING,DESCENDING" help:"Sort direction"` + Projection string `name:"projection" default:"basic" enum:"basic,full,custom" help:"Amount of user data to return"` + Fields string `name:"fields" help:"Custom fields to return (comma-separated)"` + ToDrive ToDriveFlags `embed:""` +} + +func (c *UsersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + call := svc.Users.List() + + domain := strings.TrimSpace(c.Domain) + if domain == "" { + domain = extractDomain(account) + } + call = call.Domain(domain) + + var queryParts []string + if c.Query != "" { + queryParts = append(queryParts, c.Query) + } + if c.OrgUnit != "" { + queryParts = append(queryParts, fmt.Sprintf("orgUnitPath='%s'", c.OrgUnit)) + } + if c.Suspended != nil { + queryParts = append(queryParts, fmt.Sprintf("isSuspended=%v", *c.Suspended)) + } + if c.Admin != nil { + queryParts = append(queryParts, fmt.Sprintf("isAdmin=%v", *c.Admin)) + } + if len(queryParts) > 0 { + call = call.Query(strings.Join(queryParts, " ")) + } + + call = call.MaxResults(c.Max) + call = call.OrderBy(c.OrderBy) + call = call.SortOrder(c.SortOrder) + call = call.Projection(c.Projection) + if c.Page != "" { + call = call.PageToken(c.Page) + } + if c.Fields != "" { + call = call.CustomFieldMask(c.Fields) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list users: %w", err) + } + + if len(resp.Users) == 0 { + u.Err().Println("No users found") + return nil + } + + rows := make([][]string, 0, len(resp.Users)) + for _, user := range resp.Users { + if user == nil { + continue + } + suspended := "" + if user.Suspended { + suspended = "yes" + } + admin := "" + if user.IsAdmin { + admin = "yes" + } + name := "" + if user.Name != nil { + name = strings.TrimSpace(strings.Join([]string{user.Name.GivenName, user.Name.FamilyName}, " ")) + } + rows = append(rows, toDriveRow( + user.PrimaryEmail, + name, + suspended, + admin, + user.OrgUnitPath, + formatDateTime(user.LastLoginTime), + )) + } + + if ok, err := writeToDrive(ctx, flags, toDriveTitle("Users", c.ToDrive), []string{"EMAIL", "NAME", "SUSPENDED", "ADMIN", "ORG UNIT", "LAST LOGIN"}, rows, c.ToDrive); ok { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + tw, flush := tableWriter(ctx) + defer flush() + + fmt.Fprintln(tw, "EMAIL\tNAME\tSUSPENDED\tADMIN\tORG UNIT\tLAST LOGIN") + for _, row := range rows { + if len(row) < 6 { + continue + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + sanitizeTab(row[0]), + sanitizeTab(row[1]), + sanitizeTab(row[2]), + sanitizeTab(row[3]), + sanitizeTab(row[4]), + sanitizeTab(row[5]), + ) + } + + printNextPageHint(u, resp.NextPageToken) + return nil +} diff --git a/internal/cmd/users_password.go b/internal/cmd/users_password.go new file mode 100644 index 00000000..3fecc0bd --- /dev/null +++ b/internal/cmd/users_password.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersPasswordCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + Password string `name:"password" aliases:"pass" help:"New password (generated if not specified)"` + ChangePassword bool `name:"change-password" default:"true" help:"Require password change on next login"` + HashFunction string `name:"hash-function" help:"Password hash function if pre-hashed (MD5, SHA-1, crypt)"` +} + +func (c *UsersPasswordCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + password := c.Password + generated := false + if password == "" { + password, err = generatePassword(16) + if err != nil { + return fmt.Errorf("generate password: %w", err) + } + generated = true + } + + user := &admin.User{ + Password: password, + ChangePasswordAtNextLogin: c.ChangePassword, + } + user.ForceSendFields = append(user.ForceSendFields, "ChangePasswordAtNextLogin") + if c.HashFunction != "" { + var hash string + hash, err = normalizeUserHashFunction(c.HashFunction) + if err != nil { + return err + } + user.HashFunction = hash + } + + updated, err := svc.Users.Update(c.User, user).Context(ctx).Do() + if err != nil { + return fmt.Errorf("reset password for %s: %w", c.User, err) + } + + if outfmt.IsJSON(ctx) { + result := map[string]any{ + "user": updated.PrimaryEmail, + } + if generated { + result["generatedPassword"] = password + } + return outfmt.WriteJSON(os.Stdout, result) + } + + u.Out().Printf("Password reset for: %s\n", updated.PrimaryEmail) + if generated { + u.Out().Printf("New password: %s\n", password) + } + if c.ChangePassword { + u.Out().Println("User must change password on next login") + } + + return nil +} diff --git a/internal/cmd/users_signout.go b/internal/cmd/users_signout.go new file mode 100644 index 00000000..67568817 --- /dev/null +++ b/internal/cmd/users_signout.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/steipete/gogcli/internal/ui" +) + +type UsersSignoutCmd struct { + User string `arg:"" name:"user" help:"User email or ID to sign out"` +} + +func (c *UsersSignoutCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Users.SignOut(c.User).Context(ctx).Do(); err != nil { + return fmt.Errorf("sign out user %s: %w", c.User, err) + } + + u.Out().Printf("Signed out user from all sessions: %s\n", c.User) + return nil +} diff --git a/internal/cmd/users_suspend.go b/internal/cmd/users_suspend.go new file mode 100644 index 00000000..eb41202b --- /dev/null +++ b/internal/cmd/users_suspend.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "context" + "fmt" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/ui" +) + +type UsersSuspendCmd struct { + User string `arg:"" name:"user" help:"User email or ID to suspend"` +} + +func (c *UsersSuspendCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + user := &admin.User{Suspended: true} + user.ForceSendFields = append(user.ForceSendFields, "Suspended") + + if _, err := svc.Users.Update(c.User, user).Context(ctx).Do(); err != nil { + return fmt.Errorf("suspend user %s: %w", c.User, err) + } + + u.Out().Printf("Suspended user: %s\n", c.User) + return nil +} + +type UsersUnsuspendCmd struct { + User string `arg:"" name:"user" help:"User email or ID to unsuspend"` +} + +func (c *UsersUnsuspendCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + user := &admin.User{Suspended: false} + user.ForceSendFields = append(user.ForceSendFields, "Suspended") + + if _, err := svc.Users.Update(c.User, user).Context(ctx).Do(); err != nil { + return fmt.Errorf("unsuspend user %s: %w", c.User, err) + } + + u.Out().Printf("Unsuspended user: %s\n", c.User) + return nil +} diff --git a/internal/cmd/users_test.go b/internal/cmd/users_test.go new file mode 100644 index 00000000..f52e34bb --- /dev/null +++ b/internal/cmd/users_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestUsersListCmd_JSON(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/users") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "users": []map[string]any{ + { + "primaryEmail": "alex@example.com", + "name": map[string]any{"givenName": "Alex", "familyName": "Admin"}, + "orgUnitPath": "/", + "suspended": false, + "isAdmin": true, + "lastLoginTime": "2026-01-01T00:00:00Z", + }, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersListCmd{} + + ctx := testContext(t) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "users") { + t.Fatalf("expected JSON users output, got: %s", out) + } +} + +func TestUsersGetCmd_Plain(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/users/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "primaryEmail": "sam@example.com", + "name": map[string]any{"givenName": "Sam", "familyName": "User"}, + "id": "user-1", + "isAdmin": false, + "suspended": false, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersGetCmd{User: "sam@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Email:") || !strings.Contains(out, "sam@example.com") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestUsersUpdateCmd_AdminOnly(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/makeAdmin") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": true}) + return + } + http.NotFound(w, r) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersUpdateCmd{User: "sam@example.com", Admin: boolPtr(true)} + + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestUsersCountCmd(t *testing.T) { + calls := 0 + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/users") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + calls++ + if calls == 1 { + _ = json.NewEncoder(w).Encode(map[string]any{ + "users": []map[string]any{ + {"orgUnitPath": "/"}, + {"orgUnitPath": "/Sales"}, + }, + "nextPageToken": "next", + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "users": []map[string]any{ + {"orgUnitPath": "/Sales"}, + }, + }) + }) + stubAdminDirectory(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &UsersCountCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "/Sales") || !strings.Contains(out, "2") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubAdminDirectory(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newAdminDirectory + svc, err := newAdminDirectoryForServer(srv) + if err != nil { + t.Fatalf("new admin service: %v", err) + } + newAdminDirectory = func(context.Context, string) (*admin.Service, error) { return svc, nil } + t.Cleanup(func() { + newAdminDirectory = orig + srv.Close() + }) +} + +func newAdminDirectoryForServer(srv *httptest.Server) (*admin.Service, error) { + return admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) +} + +func testContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func testContextWithStdout(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func boolPtr(v bool) *bool { return &v } diff --git a/internal/cmd/users_tokens.go b/internal/cmd/users_tokens.go new file mode 100644 index 00000000..834f420a --- /dev/null +++ b/internal/cmd/users_tokens.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersTokensCmd struct { + List UsersTokensListCmd `cmd:"" name:"list" aliases:"ls" help:"List user tokens"` + Delete UsersTokensDeleteCmd `cmd:"" name:"delete" aliases:"rm,revoke" help:"Revoke a token"` +} + +type UsersTokensListCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` +} + +func (c *UsersTokensListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + tokens, err := svc.Tokens.List(c.User).Context(ctx).Do() + if err != nil { + return fmt.Errorf("list tokens for %s: %w", c.User, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, tokens) + } + + if len(tokens.Items) == 0 { + u.Err().Println("No tokens found") + return nil + } + + tw, flush := tableWriter(ctx) + defer flush() + + fmt.Fprintln(tw, "CLIENT ID\tDISPLAY TEXT\tSCOPES\tANONYMOUS") + for _, token := range tokens.Items { + fmt.Fprintf(tw, "%s\t%s\t%d\t%v\n", + sanitizeTab(token.ClientId), + sanitizeTab(token.DisplayText), + len(token.Scopes), + token.Anonymous, + ) + } + + return nil +} + +type UsersTokensDeleteCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + ClientID string `arg:"" name:"client-id" help:"OAuth client ID to revoke"` +} + +func (c *UsersTokensDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + if err := svc.Tokens.Delete(c.User, c.ClientID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete token %s for %s: %w", c.ClientID, c.User, err) + } + + u.Out().Printf("Revoked token %s for: %s\n", c.ClientID, c.User) + return nil +} diff --git a/internal/cmd/users_update.go b/internal/cmd/users_update.go new file mode 100644 index 00000000..031ae147 --- /dev/null +++ b/internal/cmd/users_update.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type UsersUpdateCmd struct { + User string `arg:"" name:"user" help:"User email or ID"` + FirstName *string `name:"first-name" aliases:"given-name,fn" help:"First/given name"` + LastName *string `name:"last-name" aliases:"family-name,ln" help:"Last/family name"` + PrimaryEmail *string `name:"primary-email" help:"Change primary email address"` + OrgUnit *string `name:"org-unit" aliases:"ou" help:"Organizational unit path"` + Suspended *bool `name:"suspended" help:"Suspended state"` + Archived *bool `name:"archived" help:"Archived state"` + RecoveryEmail *string `name:"recovery-email" help:"Recovery email"` + RecoveryPhone *string `name:"recovery-phone" help:"Recovery phone"` + ChangePassword *bool `name:"change-password" help:"Require password change on next login"` + Admin *bool `name:"admin" help:"Super admin status (use with caution)"` +} + +func (c *UsersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newAdminDirectory(ctx, account) + if err != nil { + return err + } + + user := &admin.User{} + hasFieldUpdates := false + + if c.FirstName != nil || c.LastName != nil { + user.Name = &admin.UserName{} + if c.FirstName != nil { + user.Name.GivenName = *c.FirstName + hasFieldUpdates = true + } + if c.LastName != nil { + user.Name.FamilyName = *c.LastName + hasFieldUpdates = true + } + } + if c.PrimaryEmail != nil { + user.PrimaryEmail = *c.PrimaryEmail + hasFieldUpdates = true + } + if c.OrgUnit != nil { + user.OrgUnitPath = *c.OrgUnit + hasFieldUpdates = true + } + if c.Suspended != nil { + user.Suspended = *c.Suspended + user.ForceSendFields = append(user.ForceSendFields, "Suspended") + hasFieldUpdates = true + } + if c.Archived != nil { + user.Archived = *c.Archived + user.ForceSendFields = append(user.ForceSendFields, "Archived") + hasFieldUpdates = true + } + if c.RecoveryEmail != nil { + user.RecoveryEmail = *c.RecoveryEmail + if *c.RecoveryEmail == "" { + user.ForceSendFields = append(user.ForceSendFields, "RecoveryEmail") + } + hasFieldUpdates = true + } + if c.RecoveryPhone != nil { + user.RecoveryPhone = *c.RecoveryPhone + if *c.RecoveryPhone == "" { + user.ForceSendFields = append(user.ForceSendFields, "RecoveryPhone") + } + hasFieldUpdates = true + } + if c.ChangePassword != nil { + user.ChangePasswordAtNextLogin = *c.ChangePassword + user.ForceSendFields = append(user.ForceSendFields, "ChangePasswordAtNextLogin") + hasFieldUpdates = true + } + + if c.Admin == nil && !hasFieldUpdates { + return usage("no updates specified") + } + + if c.Admin != nil { + if err = svc.Users.MakeAdmin(c.User, &admin.UserMakeAdmin{Status: *c.Admin}).Context(ctx).Do(); err != nil { + return fmt.Errorf("update admin status for %s: %w", c.User, err) + } + if !hasFieldUpdates { + u.Out().Printf("Updated admin status for: %s\n", c.User) + return nil + } + } + + updated, err := svc.Users.Update(c.User, user).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update user %s: %w", c.User, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u.Out().Printf("Updated user: %s\n", updated.PrimaryEmail) + return nil +} diff --git a/internal/cmd/vault.go b/internal/cmd/vault.go new file mode 100644 index 00000000..77350a15 --- /dev/null +++ b/internal/cmd/vault.go @@ -0,0 +1,14 @@ +package cmd + +import "github.com/steipete/gogcli/internal/googleapi" + +var ( + newVaultService = googleapi.NewVault + newStorageService = googleapi.NewStorage +) + +type VaultCmd struct { + Matters VaultMattersCmd `cmd:"" name:"matters" help:"Manage Vault matters"` + Holds VaultHoldsCmd `cmd:"" name:"holds" help:"Manage Vault holds"` + Exports VaultExportsCmd `cmd:"" name:"exports" help:"Manage Vault exports"` +} diff --git a/internal/cmd/vault_exports.go b/internal/cmd/vault_exports.go new file mode 100644 index 00000000..5010e2ae --- /dev/null +++ b/internal/cmd/vault_exports.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "google.golang.org/api/storage/v1" + "google.golang.org/api/vault/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type VaultExportsCmd struct { + List VaultExportsListCmd `cmd:"" name:"list" aliases:"ls" help:"List exports"` + Get VaultExportsGetCmd `cmd:"" name:"get" help:"Get export"` + Create VaultExportsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create export"` + Download VaultExportsDownloadCmd `cmd:"" name:"download" help:"Download export files"` +} + +type VaultExportsListCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *VaultExportsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + call := svc.Matters.Exports.List(c.MatterID).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list exports: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Exports) == 0 { + u.Err().Println("No exports found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EXPORT ID\tNAME\tSTATUS\tCREATED") + for _, exp := range resp.Exports { + if exp == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(exp.Id), + sanitizeTab(exp.Name), + sanitizeTab(exp.Status), + sanitizeTab(exp.CreateTime), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type VaultExportsGetCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + ExportID string `arg:"" name:"export-id" help:"Export ID"` +} + +func (c *VaultExportsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + exp, err := svc.Matters.Exports.Get(c.MatterID, c.ExportID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get export %s: %w", c.ExportID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, exp) + } + + u.Out().Printf("Export ID: %s\n", exp.Id) + u.Out().Printf("Name: %s\n", exp.Name) + u.Out().Printf("Status: %s\n", exp.Status) + u.Out().Printf("Created: %s\n", exp.CreateTime) + if exp.Query != nil { + u.Out().Printf("Corpus: %s\n", exp.Query.Corpus) + } + return nil +} + +type VaultExportsCreateCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + Name string `name:"name" required:"" help:"Export name"` + Query string `name:"query" help:"Search query terms"` +} + +func (c *VaultExportsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + query := &vault.Query{ + Corpus: "MAIL", + SearchMethod: "ENTIRE_ORG", + DataScope: "ALL_DATA", + } + if strings.TrimSpace(c.Query) != "" { + query.Terms = c.Query + } + + export := &vault.Export{ + Name: c.Name, + Query: query, + } + + created, err := svc.Matters.Exports.Create(c.MatterID, export).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create export: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Created export: %s (%s)\n", created.Name, created.Id) + return nil +} + +type VaultExportsDownloadCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + ExportID string `arg:"" name:"export-id" help:"Export ID"` + Output string `name:"output" required:"" help:"Output directory"` +} + +func (c *VaultExportsDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + exp, err := svc.Matters.Exports.Get(c.MatterID, c.ExportID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get export %s: %w", c.ExportID, err) + } + if exp.CloudStorageSink == nil || len(exp.CloudStorageSink.Files) == 0 { + return fmt.Errorf("export %s has no Cloud Storage files", c.ExportID) + } + + if err = os.MkdirAll(c.Output, 0o750); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + storageSvc, err := newStorageService(ctx, account) + if err != nil { + return err + } + + results := make([]map[string]string, 0, len(exp.CloudStorageSink.Files)) + for _, file := range exp.CloudStorageSink.Files { + if file == nil { + continue + } + path, err := downloadExportFile(ctx, storageSvc, file, c.Output) + if err != nil { + return err + } + results = append(results, map[string]string{ + "bucket": file.BucketName, + "object": file.ObjectName, + "path": path, + }) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"files": results}) + } + + for _, item := range results { + u.Out().Printf("Downloaded %s/%s -> %s\n", item["bucket"], item["object"], item["path"]) + } + return nil +} + +func downloadExportFile(ctx context.Context, svc *storage.Service, file *vault.CloudStorageFile, outputDir string) (string, error) { + if file.BucketName == "" || file.ObjectName == "" { + return "", fmt.Errorf("invalid storage file metadata") + } + + resp, err := svc.Objects.Get(file.BucketName, file.ObjectName).Context(ctx).Download() + if err != nil { + return "", fmt.Errorf("download %s/%s: %w", file.BucketName, file.ObjectName, err) + } + defer resp.Body.Close() + + name := filepath.Base(file.ObjectName) + if name == "." || name == "/" || name == "" { + name = "export" + } + path := filepath.Join(outputDir, name) + + out, err := os.Create(path) //nolint:gosec // path is constructed from validated components + if err != nil { + return "", fmt.Errorf("create file: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return "", fmt.Errorf("write file: %w", err) + } + + return path, nil +} diff --git a/internal/cmd/vault_holds.go b/internal/cmd/vault_holds.go new file mode 100644 index 00000000..09c8d577 --- /dev/null +++ b/internal/cmd/vault_holds.go @@ -0,0 +1,233 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/vault/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type VaultHoldsCmd struct { + List VaultHoldsListCmd `cmd:"" name:"list" aliases:"ls" help:"List holds"` + Get VaultHoldsGetCmd `cmd:"" name:"get" help:"Get hold"` + Create VaultHoldsCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create hold"` + Delete VaultHoldsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete hold"` +} + +type VaultHoldsListCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *VaultHoldsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + call := svc.Matters.Holds.List(c.MatterID).PageSize(c.Max) + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list holds: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Holds) == 0 { + u.Err().Println("No holds found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "HOLD ID\tNAME\tCORPUS\tSCOPE") + for _, hold := range resp.Holds { + if hold == nil { + continue + } + scope := holdScope(hold) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(hold.HoldId), + sanitizeTab(hold.Name), + sanitizeTab(hold.Corpus), + sanitizeTab(scope), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type VaultHoldsGetCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + HoldID string `arg:"" name:"hold-id" help:"Hold ID"` +} + +func (c *VaultHoldsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + hold, err := svc.Matters.Holds.Get(c.MatterID, c.HoldID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get hold %s: %w", c.HoldID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, hold) + } + + u.Out().Printf("Hold ID: %s\n", hold.HoldId) + u.Out().Printf("Name: %s\n", hold.Name) + u.Out().Printf("Corpus: %s\n", hold.Corpus) + u.Out().Printf("Scope: %s\n", holdScope(hold)) + return nil +} + +type VaultHoldsCreateCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + Name string `name:"name" required:"" help:"Hold name"` + Corpus string `name:"corpus" required:"" enum:"MAIL,DRIVE,GROUPS" help:"Corpus to hold"` + Accounts string `name:"accounts" help:"Comma-separated account emails"` + OrgUnit string `name:"org-unit" aliases:"ou" help:"Org unit path"` + Query string `name:"query" help:"Search query"` +} + +func (c *VaultHoldsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + hold := &vault.Hold{ + Name: c.Name, + Corpus: strings.ToUpper(c.Corpus), + } + + accounts := splitCSV(c.Accounts) + orgUnit := strings.TrimSpace(c.OrgUnit) + if len(accounts) == 0 && orgUnit == "" { + return usage("--accounts or --org-unit required") + } + if len(accounts) > 0 && orgUnit != "" { + return usage("use only one of --accounts or --org-unit") + } + + if len(accounts) > 0 { + hold.Accounts = make([]*vault.HeldAccount, 0, len(accounts)) + for _, email := range accounts { + email = strings.TrimSpace(email) + if email == "" { + continue + } + hold.Accounts = append(hold.Accounts, &vault.HeldAccount{Email: email}) + } + } + + if orgUnit != "" { + adminSvc, adminErr := newAdminDirectory(ctx, account) + if adminErr != nil { + return adminErr + } + orgID, orgErr := resolveOrgUnitID(ctx, adminSvc, orgUnit) + if orgErr != nil { + return orgErr + } + hold.OrgUnit = &vault.HeldOrgUnit{OrgUnitId: orgID} + } + + if strings.TrimSpace(c.Query) != "" { + switch hold.Corpus { + case "DRIVE": + return usage("drive holds do not support --query") + case "MAIL": + hold.Query = &vault.CorpusQuery{MailQuery: &vault.HeldMailQuery{Terms: c.Query}} + case "GROUPS": + hold.Query = &vault.CorpusQuery{GroupsQuery: &vault.HeldGroupsQuery{Terms: c.Query}} + } + } + + created, err := svc.Matters.Holds.Create(c.MatterID, hold).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create hold: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Created hold: %s (%s)\n", created.Name, created.HoldId) + return nil +} + +type VaultHoldsDeleteCmd struct { + MatterID string `name:"matter" required:"" help:"Matter ID"` + HoldID string `arg:"" name:"hold-id" help:"Hold ID"` +} + +func (c *VaultHoldsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete hold %s", c.HoldID)); err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Matters.Holds.Delete(c.MatterID, c.HoldID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete hold %s: %w", c.HoldID, err) + } + + u.Out().Printf("Deleted hold: %s\n", c.HoldID) + return nil +} + +func holdScope(hold *vault.Hold) string { + if hold == nil { + return "" + } + if hold.OrgUnit != nil && hold.OrgUnit.OrgUnitId != "" { + return "org-unit" + } + if len(hold.Accounts) > 0 { + return fmt.Sprintf("accounts:%d", len(hold.Accounts)) + } + return "" +} diff --git a/internal/cmd/vault_matters.go b/internal/cmd/vault_matters.go new file mode 100644 index 00000000..ca34cd1c --- /dev/null +++ b/internal/cmd/vault_matters.go @@ -0,0 +1,299 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/vault/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type VaultMattersCmd struct { + List VaultMattersListCmd `cmd:"" name:"list" aliases:"ls" help:"List Vault matters"` + Get VaultMattersGetCmd `cmd:"" name:"get" help:"Get Vault matter"` + Create VaultMattersCreateCmd `cmd:"" name:"create" aliases:"add" help:"Create Vault matter"` + Update VaultMattersUpdateCmd `cmd:"" name:"update" help:"Update Vault matter"` + Close VaultMattersCloseCmd `cmd:"" name:"close" help:"Close Vault matter"` + Reopen VaultMattersReopenCmd `cmd:"" name:"reopen" help:"Reopen Vault matter"` + Delete VaultMattersDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete Vault matter"` +} + +type VaultMattersListCmd struct { + State string `name:"state" help:"Filter by state (OPEN, CLOSED, DELETED)"` + Max int64 `name:"max" aliases:"limit" default:"100" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *VaultMattersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + call := svc.Matters.List().View("BASIC") + if state := strings.ToUpper(strings.TrimSpace(c.State)); state != "" { + switch state { + case "OPEN", "CLOSED", "DELETED": + call = call.State(state) + default: + return usage("invalid --state (expected OPEN, CLOSED, DELETED)") + } + } + if c.Max > 0 { + call = call.PageSize(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list matters: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Matters) == 0 { + u.Err().Println("No matters found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "MATTER ID\tNAME\tSTATE") + for _, matter := range resp.Matters { + if matter == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(matter.MatterId), + sanitizeTab(matter.Name), + sanitizeTab(matter.State), + ) + } + printNextPageHint(u, resp.NextPageToken) + return nil +} + +type VaultMattersGetCmd struct { + MatterID string `arg:"" name:"matter-id" help:"Matter ID"` +} + +func (c *VaultMattersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + matter, err := svc.Matters.Get(c.MatterID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("get matter %s: %w", c.MatterID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, matter) + } + + u.Out().Printf("Matter ID: %s\n", matter.MatterId) + u.Out().Printf("Name: %s\n", matter.Name) + u.Out().Printf("State: %s\n", matter.State) + if matter.Description != "" { + u.Out().Printf("Description: %s\n", matter.Description) + } + return nil +} + +type VaultMattersCreateCmd struct { + Name string `arg:"" name:"name" help:"Matter name"` + Description string `name:"description" help:"Description"` +} + +func (c *VaultMattersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + matter := &vault.Matter{Name: c.Name, Description: c.Description} + created, err := svc.Matters.Create(matter).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create matter %s: %w", c.Name, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, created) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Created matter: %s (%s)\n", created.Name, created.MatterId) + return nil +} + +type VaultMattersUpdateCmd struct { + MatterID string `arg:"" name:"matter-id" help:"Matter ID"` + Name *string `name:"name" help:"Matter name"` + Description *string `name:"description" help:"Description"` +} + +func (c *VaultMattersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + matter := &vault.Matter{} + hasUpdates := false + if c.Name != nil { + matter.Name = *c.Name + hasUpdates = true + } + if c.Description != nil { + matter.Description = *c.Description + if *c.Description == "" { + matter.ForceSendFields = append(matter.ForceSendFields, "Description") + } + hasUpdates = true + } + if !hasUpdates { + return usage("no updates specified") + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + updated, err := svc.Matters.Update(c.MatterID, matter).Context(ctx).Do() + if err != nil { + return fmt.Errorf("update matter %s: %w", c.MatterID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, updated) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Updated matter: %s (%s)\n", updated.Name, updated.MatterId) + return nil +} + +type VaultMattersCloseCmd struct { + MatterID string `arg:"" name:"matter-id" help:"Matter ID"` +} + +func (c *VaultMattersCloseCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("close matter %s", c.MatterID)); err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Matters.Close(c.MatterID, &vault.CloseMatterRequest{}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("close matter %s: %w", c.MatterID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + matterID := c.MatterID + if resp != nil && resp.Matter != nil && resp.Matter.MatterId != "" { + matterID = resp.Matter.MatterId + } + u.Out().Printf("Closed matter: %s\n", matterID) + return nil +} + +type VaultMattersReopenCmd struct { + MatterID string `arg:"" name:"matter-id" help:"Matter ID"` +} + +func (c *VaultMattersReopenCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Matters.Reopen(c.MatterID, &vault.ReopenMatterRequest{}).Context(ctx).Do() + if err != nil { + return fmt.Errorf("reopen matter %s: %w", c.MatterID, err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + matterID := c.MatterID + if resp != nil && resp.Matter != nil && resp.Matter.MatterId != "" { + matterID = resp.Matter.MatterId + } + u := ui.FromContext(ctx) + u.Out().Printf("Reopened matter: %s\n", matterID) + return nil +} + +type VaultMattersDeleteCmd struct { + MatterID string `arg:"" name:"matter-id" help:"Matter ID"` +} + +func (c *VaultMattersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete matter %s", c.MatterID)); err != nil { + return err + } + + svc, err := newVaultService(ctx, account) + if err != nil { + return err + } + + if _, err := svc.Matters.Delete(c.MatterID).Context(ctx).Do(); err != nil { + return fmt.Errorf("delete matter %s: %w", c.MatterID, err) + } + + u.Out().Printf("Deleted matter: %s\n", c.MatterID) + return nil +} diff --git a/internal/cmd/vault_test.go b/internal/cmd/vault_test.go new file mode 100644 index 00000000..2ec1d2d2 --- /dev/null +++ b/internal/cmd/vault_test.go @@ -0,0 +1,556 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/storage/v1" + "google.golang.org/api/vault/v1" +) + +func TestVaultMattersListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/matters") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matters": []map[string]any{ + {"matterId": "matter-1", "name": "Case One", "state": "OPEN"}, + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultMattersListCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Case One") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultExportsDownloadCmd(t *testing.T) { + vaultHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/exports/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "export-1", + "name": "Export One", + "cloudStorageSink": map[string]any{ + "files": []map[string]any{ + {"bucketName": "vault-bucket", "objectName": "exports/export1.zip"}, + }, + }, + }) + }) + stubVault(t, vaultHandler) + + storageHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/b/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write([]byte("vault-export")) + }) + stubStorage(t, storageHandler) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultExportsDownloadCmd{MatterID: "matter-1", ExportID: "export-1", Output: t.TempDir()} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + path := filepath.Join(cmd.Output, "export1.zip") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read export: %v", err) + } + if string(data) != "vault-export" { + t.Fatalf("unexpected file contents: %s", string(data)) + } + if !strings.Contains(out, "Downloaded") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubVault(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newVaultService + svc, err := vault.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new vault service: %v", err) + } + newVaultService = func(context.Context, string) (*vault.Service, error) { return svc, nil } + t.Cleanup(func() { + newVaultService = orig + srv.Close() + }) +} + +func stubStorage(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newStorageService + svc, err := storage.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new storage service: %v", err) + } + newStorageService = func(context.Context, string) (*storage.Service, error) { return svc, nil } + t.Cleanup(func() { + newStorageService = orig + srv.Close() + }) +} + +func TestVaultMattersGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/matters/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matterId": "matter-1", + "name": "Test Matter", + "state": "OPEN", + "description": "Test description", + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultMattersGetCmd{MatterID: "matter-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Test Matter") || !strings.Contains(out, "OPEN") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultMattersCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/matters") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matterId": "new-matter", + "name": "New Matter", + "state": "OPEN", + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultMattersCreateCmd{Name: "New Matter", Description: "Test"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created matter") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultMattersUpdateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !strings.Contains(r.URL.Path, "/matters/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matterId": "matter-1", + "name": "Updated Matter", + "state": "OPEN", + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + newName := "Updated Matter" + cmd := &VaultMattersUpdateCmd{MatterID: "matter-1", Name: &newName} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Updated matter") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultMattersCloseCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":close") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matter": map[string]any{ + "matterId": "matter-1", + "name": "Closed Matter", + "state": "CLOSED", + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &VaultMattersCloseCmd{MatterID: "matter-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Closed matter") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultMattersReopenCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, ":reopen") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matter": map[string]any{ + "matterId": "matter-1", + "name": "Reopened Matter", + "state": "OPEN", + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultMattersReopenCmd{MatterID: "matter-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Reopened matter") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultMattersDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/matters/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "matterId": "matter-1", + "state": "DELETED", + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &VaultMattersDeleteCmd{MatterID: "matter-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted matter") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultExportsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/exports") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "exports": []map[string]any{ + { + "id": "export-1", + "name": "Test Export", + "status": "COMPLETED", + "createTime": "2024-01-01T00:00:00Z", + }, + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultExportsListCmd{MatterID: "matter-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Test Export") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultExportsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/exports/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "export-1", + "name": "Export Details", + "status": "COMPLETED", + "createTime": "2024-01-01T00:00:00Z", + "query": map[string]any{ + "corpus": "MAIL", + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultExportsGetCmd{MatterID: "matter-1", ExportID: "export-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Export Details") || !strings.Contains(out, "COMPLETED") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultExportsCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/exports") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "new-export", + "name": "New Export", + "status": "IN_PROGRESS", + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultExportsCreateCmd{MatterID: "matter-1", Name: "New Export", Query: "test"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created export") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultHoldsListCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/holds") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "holds": []map[string]any{ + { + "holdId": "hold-1", + "name": "Legal Hold", + "corpus": "MAIL", + "accounts": []map[string]any{ + {"email": "user@example.com"}, + }, + }, + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultHoldsListCmd{MatterID: "matter-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Legal Hold") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultHoldsGetCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/holds/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "holdId": "hold-1", + "name": "Legal Hold Details", + "corpus": "MAIL", + "orgUnit": map[string]any{ + "orgUnitId": "ou-123", + }, + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultHoldsGetCmd{MatterID: "matter-1", HoldID: "hold-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Legal Hold Details") || !strings.Contains(out, "org-unit") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultHoldsCreateCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/holds") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "holdId": "new-hold", + "name": "New Legal Hold", + "corpus": "MAIL", + }) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com"} + cmd := &VaultHoldsCreateCmd{MatterID: "matter-1", Name: "New Legal Hold", Corpus: "MAIL", Accounts: "user@example.com"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Created hold") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestVaultHoldsDeleteCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || !strings.Contains(r.URL.Path, "/holds/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + stubVault(t, h) + + flags := &RootFlags{Account: "admin@example.com", Force: true} + cmd := &VaultHoldsDeleteCmd{MatterID: "matter-1", HoldID: "hold-1"} + + out := captureStdout(t, func() { + if err := cmd.Run(testContextWithStdout(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Deleted hold") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestHoldScope(t *testing.T) { + tests := []struct { + name string + hold *vault.Hold + expected string + }{ + {"nil hold", nil, ""}, + {"empty hold", &vault.Hold{}, ""}, + {"with org unit", &vault.Hold{OrgUnit: &vault.HeldOrgUnit{OrgUnitId: "ou-1"}}, "org-unit"}, + {"with accounts", &vault.Hold{Accounts: []*vault.HeldAccount{{Email: "a@b.c"}, {Email: "d@e.f"}}}, "accounts:2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := holdScope(tt.hold) + if result != tt.expected { + t.Errorf("holdScope() = %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/internal/cmd/youtube.go b/internal/cmd/youtube.go new file mode 100644 index 00000000..2e38e18e --- /dev/null +++ b/internal/cmd/youtube.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newYouTubeService = googleapi.NewYouTube + +type YouTubeCmd struct { + Accounts YouTubeAccountsCmd `cmd:"" name:"accounts" help:"List YouTube accounts (managed channels)"` + Channels YouTubeChannelsCmd `cmd:"" name:"channels" help:"List YouTube channels (owned by user)"` +} + +type YouTubeAccountsCmd struct { + User string `name:"user" help:"User email to list accounts for"` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeAccountsCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newYouTubeService(ctx, account) + if err != nil { + return err + } + + call := svc.Channels.List([]string{"snippet"}).ManagedByMe(true) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list youtube accounts: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Items) == 0 { + ui.FromContext(ctx).Err().Println("No YouTube accounts found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "CHANNEL ID\tTITLE\tCUSTOM URL") + for _, ch := range resp.Items { + if ch == nil { + continue + } + title := "" + customURL := "" + if ch.Snippet != nil { + title = ch.Snippet.Title + customURL = ch.Snippet.CustomUrl + } + fmt.Fprintf(w, "%s\t%s\t%s\n", sanitizeTab(ch.Id), sanitizeTab(title), sanitizeTab(customURL)) + } + printNextPageHint(ui.FromContext(ctx), resp.NextPageToken) + return nil +} + +type YouTubeChannelsCmd struct { + User string `name:"user" help:"User email to list channels for"` + Max int64 `name:"max" aliases:"limit" default:"50" help:"Max results"` + Page string `name:"page" help:"Page token"` +} + +func (c *YouTubeChannelsCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + if strings.TrimSpace(c.User) != "" { + account = strings.TrimSpace(c.User) + } + + svc, err := newYouTubeService(ctx, account) + if err != nil { + return err + } + + call := svc.Channels.List([]string{"snippet"}).Mine(true) + if c.Max > 0 { + call = call.MaxResults(c.Max) + } + if c.Page != "" { + call = call.PageToken(c.Page) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return fmt.Errorf("list youtube channels: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, resp) + } + + if len(resp.Items) == 0 { + ui.FromContext(ctx).Err().Println("No YouTube channels found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "CHANNEL ID\tTITLE\tCUSTOM URL") + for _, ch := range resp.Items { + if ch == nil { + continue + } + title := "" + customURL := "" + if ch.Snippet != nil { + title = ch.Snippet.Title + customURL = ch.Snippet.CustomUrl + } + fmt.Fprintf(w, "%s\t%s\t%s\n", sanitizeTab(ch.Id), sanitizeTab(title), sanitizeTab(customURL)) + } + printNextPageHint(ui.FromContext(ctx), resp.NextPageToken) + return nil +} diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go new file mode 100644 index 00000000..9c2fbd81 --- /dev/null +++ b/internal/cmd/youtube_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/youtube/v3" +) + +func TestYouTubeAccountsCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/youtube/v3/channels") || r.URL.Query().Get("managedByMe") != "true" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "ch1", "snippet": map[string]any{"title": "Brand", "customUrl": "brand"}}, + }, + }) + }) + stubYouTube(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &YouTubeAccountsCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Brand") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestYouTubeChannelsCmd(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/youtube/v3/channels") || r.URL.Query().Get("mine") != "true" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "ch2", "snippet": map[string]any{"title": "Owner", "customUrl": "owner"}}, + }, + }) + }) + stubYouTube(t, h) + + flags := &RootFlags{Account: "user@example.com"} + cmd := &YouTubeChannelsCmd{} + + out := captureStdout(t, func() { + if err := cmd.Run(testContext(t), flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "Owner") { + t.Fatalf("unexpected output: %s", out) + } +} + +func stubYouTube(t *testing.T, handler http.Handler) { + t.Helper() + + srv := httptest.NewServer(handler) + orig := newYouTubeService + svc, err := youtube.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("new youtube service: %v", err) + } + newYouTubeService = func(context.Context, string) (*youtube.Service, error) { return svc, nil } + t.Cleanup(func() { + newYouTubeService = orig + srv.Close() + }) +} diff --git a/internal/csv/processor.go b/internal/csv/processor.go new file mode 100644 index 00000000..040c5301 --- /dev/null +++ b/internal/csv/processor.go @@ -0,0 +1,307 @@ +package csv + +import ( + "encoding/csv" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" +) + +var ( + errEmptyCSV = errors.New("empty csv") + errFileRequired = errors.New("file is required") + errInvalidToken = errors.New("invalid replacement token") + errInvalidFilterFormat = errors.New("invalid filter format") + errInvalidFilterField = errors.New("invalid filter field") +) + +type FieldFilter struct { + Field string + Regex *regexp.Regexp +} + +type Options struct { + Fields []string + Match []FieldFilter + Skip []FieldFilter + SkipRows int + MaxRows int +} + +type Row struct { + Index int + Values map[string]string +} + +func Process(path string, opts Options, fn func(Row) error) error { + reader, closer, err := openCSV(path) + if err != nil { + return err + } + + if closer != nil { + defer closer.Close() + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("read csv: %w", err) + } + + if len(records) == 0 { + return errEmptyCSV + } + + headers := normalizeHeader(records[0]) + selected := normalizeFields(opts.Fields) + + processed := 0 + + for i, row := range records[1:] { + rowIndex := i + 1 + if opts.SkipRows > 0 && rowIndex <= opts.SkipRows { + continue + } + + values := mapRow(headers, row, selected) + if !matchesAllFilters(values, opts.Match) { + continue + } + + if matchesAnyFilter(values, opts.Skip) { + continue + } + + processed++ + if opts.MaxRows > 0 && processed > opts.MaxRows { + break + } + + if err := fn(Row{Index: rowIndex, Values: values}); err != nil { + return err + } + } + + return nil +} + +func SubstituteArgs(args []string, row Row) ([]string, error) { + out := make([]string, len(args)) + for i, arg := range args { + sub, err := substituteArg(arg, row) + if err != nil { + return nil, err + } + out[i] = sub + } + + return out, nil +} + +func openCSV(path string) (*csv.Reader, io.Closer, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return nil, nil, errFileRequired + } + + if trimmed == "-" { + return csv.NewReader(os.Stdin), nil, nil + } + + f, err := os.Open(trimmed) //nolint:gosec // G304: user-provided file path is intentional + if err != nil { + return nil, nil, fmt.Errorf("open csv: %w", err) + } + + return csv.NewReader(f), f, nil +} + +func normalizeHeader(header []string) []string { + out := make([]string, len(header)) + for i, h := range header { + out[i] = normalizeField(h) + } + + return out +} + +func normalizeFields(fields []string) map[string]struct{} { + if len(fields) == 0 { + return nil + } + + set := make(map[string]struct{}, len(fields)) + for _, f := range fields { + if f = normalizeField(f); f != "" { + set[f] = struct{}{} + } + } + + return set +} + +func normalizeField(field string) string { + return strings.ToLower(strings.TrimSpace(field)) +} + +func mapRow(headers, row []string, allowed map[string]struct{}) map[string]string { + values := make(map[string]string, len(headers)) + for i, key := range headers { + if key == "" { + continue + } + + if allowed != nil { + if _, ok := allowed[key]; !ok { + continue + } + } + + if i >= len(row) { + values[key] = "" + continue + } + values[key] = strings.TrimSpace(row[i]) + } + + return values +} + +func matchesAllFilters(values map[string]string, filters []FieldFilter) bool { + if len(filters) == 0 { + return true + } + + for _, filter := range filters { + value := values[filter.Field] + if filter.Regex == nil { + continue + } + + if !filter.Regex.MatchString(value) { + return false + } + } + + return true +} + +func matchesAnyFilter(values map[string]string, filters []FieldFilter) bool { + if len(filters) == 0 { + return false + } + + for _, filter := range filters { + value := values[filter.Field] + if filter.Regex == nil { + continue + } + + if filter.Regex.MatchString(value) { + return true + } + } + + return false +} + +func substituteArg(arg string, row Row) (string, error) { + if strings.Contains(arg, "~~") { + replaced, err := replaceDoubleTilde(arg, row) + if err != nil { + return "", err + } + + return replaced, nil + } + + if strings.HasPrefix(arg, "~") { + field := normalizeField(strings.TrimPrefix(arg, "~")) + if field == "" { + return "", nil + } + + return row.Values[field], nil + } + + return arg, nil +} + +func replaceDoubleTilde(input string, row Row) (string, error) { + out := input + for { + start := strings.Index(out, "~~") + if start == -1 { + return out, nil + } + rest := out[start+2:] + + end := strings.Index(rest, "~~") + if end == -1 { + return out, nil + } + token := rest[:end] + + replacement, err := resolveToken(token, row) + if err != nil { + return "", err + } + out = out[:start] + replacement + rest[end+2:] + } +} + +func resolveToken(token string, row Row) (string, error) { + if strings.Contains(token, "~!~") { + parts := strings.Split(token, "~!~") + if len(parts) != 3 { + return "", fmt.Errorf("%w: %s", errInvalidToken, token) + } + field := normalizeField(parts[0]) + pattern := parts[1] + repl := parts[2] + value := row.Values[field] + + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("invalid regex %q: %w", pattern, err) + } + + return re.ReplaceAllString(value, repl), nil + } + + field := normalizeField(token) + + return row.Values[field], nil +} + +func ParseFieldFilters(inputs []string) ([]FieldFilter, error) { + filters := make([]FieldFilter, 0, len(inputs)) + for _, item := range inputs { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("%w: %q (expected FIELD:REGEX)", errInvalidFilterFormat, item) + } + + field := normalizeField(parts[0]) + if field == "" { + return nil, fmt.Errorf("%w: %q (missing field)", errInvalidFilterField, item) + } + + re, err := regexp.Compile(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid filter regex %q: %w", parts[1], err) + } + + filters = append(filters, FieldFilter{Field: field, Regex: re}) + } + + return filters, nil +} diff --git a/internal/csv/processor_test.go b/internal/csv/processor_test.go new file mode 100644 index 00000000..37f34cf1 --- /dev/null +++ b/internal/csv/processor_test.go @@ -0,0 +1,1245 @@ +package csv + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +var errCallbackFailed = errors.New("callback failed") + +// Helper to create temp CSV files for testing +func createTempCSV(t *testing.T, content string) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "test-*.csv") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer f.Close() + + if _, err := f.WriteString(content); err != nil { + t.Fatalf("write temp file: %v", err) + } + + return f.Name() +} + +// ───────────────────────────────────────────────────────────────────────────── +// SubstituteArgs Tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestSubstituteArgs_SimpleSubstitution(t *testing.T) { + row := Row{ + Index: 1, + Values: map[string]string{ + "email": "user@example.com", + "firstname": "John", + "lastname": "Doe", + }, + } + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "single field", + args: []string{"~email"}, + want: []string{"user@example.com"}, + }, + { + name: "multiple fields", + args: []string{"~firstname", "~lastname"}, + want: []string{"John", "Doe"}, + }, + { + name: "mixed literal and field", + args: []string{"--name", "~firstname", "--email", "~email"}, + want: []string{"--name", "John", "--email", "user@example.com"}, + }, + { + name: "field not in row returns empty", + args: []string{"~missing"}, + want: []string{""}, + }, + { + name: "case insensitive field name", + args: []string{"~EMAIL", "~FirstName"}, + want: []string{"user@example.com", "John"}, + }, + { + name: "empty tilde returns empty", + args: []string{"~"}, + want: []string{""}, + }, + { + name: "tilde with spaces normalizes", + args: []string{"~ email "}, + want: []string{"user@example.com"}, + }, + { + name: "no substitution for literal", + args: []string{"literal", "text"}, + want: []string{"literal", "text"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SubstituteArgs(tt.args, row) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != len(tt.want) { + t.Fatalf("got %d args, want %d", len(got), len(tt.want)) + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("arg[%d]: got %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestSubstituteArgs_AdvancedSubstitution(t *testing.T) { + row := Row{ + Index: 1, + Values: map[string]string{ + "email": "user@example.com", + "firstname": "John", + "lastname": "Doe", + }, + } + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "double tilde substitution", + args: []string{"Hello ~~firstname~~!"}, + want: []string{"Hello John!"}, + }, + { + name: "multiple double tilde in single arg", + args: []string{"~~firstname~~ ~~lastname~~"}, + want: []string{"John Doe"}, + }, + { + name: "double tilde in quoted string", + args: []string{`"Name: ~~firstname~~ ~~lastname~~"`}, + want: []string{`"Name: John Doe"`}, + }, + { + name: "double tilde missing field returns empty", + args: []string{"Hello ~~missing~~!"}, + want: []string{"Hello !"}, + }, + { + name: "unclosed double tilde left as is", + args: []string{"Hello ~~firstname"}, + want: []string{"Hello ~~firstname"}, + }, + { + name: "single tilde inside double tilde context", + args: []string{"prefix ~~email~~ suffix"}, + want: []string{"prefix user@example.com suffix"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SubstituteArgs(tt.args, row) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != len(tt.want) { + t.Fatalf("got %d args, want %d", len(got), len(tt.want)) + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("arg[%d]: got %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestSubstituteArgs_RegexReplacement(t *testing.T) { + row := Row{ + Index: 1, + Values: map[string]string{ + "email": "user@example.com", + "phone": "+1-555-123-4567", + "name": "John Doe", + "country": "USA", + }, + } + + tests := []struct { + name string + args []string + want []string + wantErr bool + }{ + { + name: "regex with replacement string", + args: []string{"~~email~!~@example.com~!~@company.org~~"}, + want: []string{"user@company.org"}, + }, + { + name: "capture group replacement", + args: []string{"~~name~!~(\\w+) (\\w+)~!~$2, $1~~"}, + want: []string{"Doe, John"}, + }, + { + name: "regex no match leaves unchanged", + args: []string{"~~country~!~XXX~!~YYY~~"}, + want: []string{"USA"}, + }, + { + name: "regex replace with single char", + args: []string{"~~phone~!~[^0-9]~!~_~~"}, + want: []string{"_1_555_123_4567"}, + }, + { + name: "extract username with capture group", + args: []string{"~~email~!~(.*)@.*~!~$1~~"}, + want: []string{"user"}, + }, + { + name: "multiple regex replacements in one arg", + args: []string{"~~name~!~ ~!~_~~ at ~~email~!~@~!~ AT ~~"}, + want: []string{"John_Doe at user AT example.com"}, + }, + { + name: "invalid regex pattern", + args: []string{"~~email~!~[invalid~!~x~~"}, + wantErr: true, + }, + { + name: "malformed replacement token (too few parts)", + args: []string{"~~email~!~pattern~~"}, + wantErr: true, + }, + { + name: "malformed replacement token (too many parts)", + args: []string{"~~email~!~a~!~b~!~c~~"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SubstituteArgs(tt.args, row) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != len(tt.want) { + t.Fatalf("got %d args, want %d", len(got), len(tt.want)) + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("arg[%d]: got %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestSubstituteArgs_EmptyArgs(t *testing.T) { + row := Row{Index: 1, Values: map[string]string{"a": "b"}} + + got, err := SubstituteArgs([]string{}, row) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// ParseFieldFilters Tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestParseFieldFilters_Valid(t *testing.T) { + tests := []struct { + name string + inputs []string + want []struct { + field string + pattern string + } + }{ + { + name: "single filter", + inputs: []string{"status:active"}, + want: []struct{ field, pattern string }{{field: "status", pattern: "active"}}, + }, + { + name: "multiple filters", + inputs: []string{"status:active", "role:admin"}, + want: []struct{ field, pattern string }{ + {field: "status", pattern: "active"}, + {field: "role", pattern: "admin"}, + }, + }, + { + name: "regex pattern", + inputs: []string{"email:.*@example\\.com$"}, + want: []struct{ field, pattern string }{{field: "email", pattern: ".*@example\\.com$"}}, + }, + { + name: "field name normalized to lowercase", + inputs: []string{"Status:active", "EMAIL:test"}, + want: []struct{ field, pattern string }{ + {field: "status", pattern: "active"}, + {field: "email", pattern: "test"}, + }, + }, + { + name: "empty input list", + inputs: []string{}, + want: []struct{ field, pattern string }{}, + }, + { + name: "outer whitespace trimmed field normalized", + inputs: []string{" status :active"}, + want: []struct{ field, pattern string }{{field: "status", pattern: "active"}}, + }, + { + name: "empty string skipped", + inputs: []string{"", " ", "status:ok"}, + want: []struct{ field, pattern string }{{field: "status", pattern: "ok"}}, + }, + { + name: "colon in regex pattern", + inputs: []string{"time:^\\d{2}:\\d{2}$"}, + want: []struct{ field, pattern string }{{field: "time", pattern: "^\\d{2}:\\d{2}$"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFieldFilters(tt.inputs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != len(tt.want) { + t.Fatalf("got %d filters, want %d", len(got), len(tt.want)) + } + + for i, wf := range tt.want { + if got[i].Field != wf.field { + t.Errorf("filter[%d].Field: got %q, want %q", i, got[i].Field, wf.field) + } + + if got[i].Regex.String() != wf.pattern { + t.Errorf("filter[%d].Regex: got %q, want %q", i, got[i].Regex.String(), wf.pattern) + } + } + }) + } +} + +func TestParseFieldFilters_Errors(t *testing.T) { + tests := []struct { + name string + inputs []string + wantErr string + }{ + { + name: "missing colon", + inputs: []string{"statusactive"}, + wantErr: "invalid filter", + }, + { + name: "missing field name", + inputs: []string{":active"}, + wantErr: "missing field", + }, + { + name: "invalid regex", + inputs: []string{"status:[invalid"}, + wantErr: "invalid filter regex", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseFieldFilters(tt.inputs) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantErr) + } + }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Process Tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestProcess_BasicCSV(t *testing.T) { + csv := `email,firstName,lastName +alice@example.com,Alice,Smith +bob@example.com,Bob,Jones +charlie@example.com,Charlie,Brown +` + path := createTempCSV(t, csv) + + var rows []Row + + err := Process(path, Options{}, func(row Row) error { + rows = append(rows, row) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // Verify first row + if rows[0].Index != 1 { + t.Errorf("row[0].Index: got %d, want 1", rows[0].Index) + } + + if rows[0].Values["email"] != "alice@example.com" { + t.Errorf("row[0].email: got %q", rows[0].Values["email"]) + } + + if rows[0].Values["firstname"] != "Alice" { + t.Errorf("row[0].firstname: got %q", rows[0].Values["firstname"]) + } +} + +func TestProcess_HeaderNormalization(t *testing.T) { + csv := ` Email , First Name , LAST_NAME +test@test.com,Test,User +` + path := createTempCSV(t, csv) + + var row Row + + err := Process(path, Options{}, func(r Row) error { + row = r + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // Headers should be normalized to lowercase and trimmed + if row.Values["email"] != "test@test.com" { + t.Errorf("email field not found or wrong: %v", row.Values) + } + + if row.Values["first name"] != "Test" { + t.Errorf("first name field not found or wrong: %v", row.Values) + } + + if row.Values["last_name"] != "User" { + t.Errorf("last_name field not found or wrong: %v", row.Values) + } +} + +func TestProcess_FieldSelection(t *testing.T) { + csv := `email,name,role,status +user@test.com,User,admin,active +` + path := createTempCSV(t, csv) + + var row Row + + err := Process(path, Options{Fields: []string{"email", "role"}}, func(r Row) error { + row = r + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // Only selected fields should be present + if _, ok := row.Values["email"]; !ok { + t.Error("email field should be present") + } + + if _, ok := row.Values["role"]; !ok { + t.Error("role field should be present") + } + + if _, ok := row.Values["name"]; ok { + t.Error("name field should NOT be present") + } + + if _, ok := row.Values["status"]; ok { + t.Error("status field should NOT be present") + } +} + +func TestProcess_MatchFilter(t *testing.T) { + csv := `email,status,role +alice@test.com,active,admin +bob@test.com,inactive,user +charlie@test.com,active,user +` + path := createTempCSV(t, csv) + + // Use anchored regex ^active$ to match exactly "active", not "inactive" + matchFilters, _ := ParseFieldFilters([]string{"status:^active$"}) + + var emails []string + + err := Process(path, Options{Match: matchFilters}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(emails) != 2 { + t.Fatalf("expected 2 matching rows, got %d", len(emails)) + } + + if emails[0] != "alice@test.com" || emails[1] != "charlie@test.com" { + t.Errorf("wrong emails matched: %v", emails) + } +} + +func TestProcess_SkipFilter(t *testing.T) { + csv := `email,status,role +alice@test.com,active,admin +bob@test.com,inactive,user +charlie@test.com,active,user +` + path := createTempCSV(t, csv) + + skipFilters, _ := ParseFieldFilters([]string{"status:inactive"}) + + var emails []string + + err := Process(path, Options{Skip: skipFilters}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(emails) != 2 { + t.Fatalf("expected 2 rows (skipping inactive), got %d", len(emails)) + } + + for _, e := range emails { + if e == "bob@test.com" { + t.Error("bob@test.com should have been skipped") + } + } +} + +func TestProcess_MatchAndSkipCombined(t *testing.T) { + csv := `email,status,role +alice@test.com,active,admin +bob@test.com,active,user +charlie@test.com,active,guest +dave@test.com,inactive,admin +` + path := createTempCSV(t, csv) + + // Use anchored regex ^active$ to avoid matching "inactive" + matchFilters, _ := ParseFieldFilters([]string{"status:^active$"}) + skipFilters, _ := ParseFieldFilters([]string{"role:^guest$"}) + + var emails []string + + err := Process(path, Options{Match: matchFilters, Skip: skipFilters}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // Should match active users, but skip guests + if len(emails) != 2 { + t.Fatalf("expected 2 rows, got %d: %v", len(emails), emails) + } + + expected := map[string]bool{"alice@test.com": true, "bob@test.com": true} + for _, e := range emails { + if !expected[e] { + t.Errorf("unexpected email: %s", e) + } + } +} + +func TestProcess_MatchWithRegex(t *testing.T) { + csv := `email,domain +alice@example.com,example.com +bob@test.org,test.org +charlie@example.net,example.net +` + path := createTempCSV(t, csv) + + matchFilters, _ := ParseFieldFilters([]string{"email:@example\\."}) + + var emails []string + + err := Process(path, Options{Match: matchFilters}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(emails) != 2 { + t.Fatalf("expected 2 rows matching @example., got %d", len(emails)) + } +} + +func TestProcess_SkipRows(t *testing.T) { + csv := `email,name +row1@test.com,Row1 +row2@test.com,Row2 +row3@test.com,Row3 +row4@test.com,Row4 +` + path := createTempCSV(t, csv) + + var emails []string + + err := Process(path, Options{SkipRows: 2}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(emails) != 2 { + t.Fatalf("expected 2 rows after skipping 2, got %d", len(emails)) + } + + if emails[0] != "row3@test.com" { + t.Errorf("first email should be row3@test.com, got %s", emails[0]) + } +} + +func TestProcess_MaxRows(t *testing.T) { + csv := `email,name +row1@test.com,Row1 +row2@test.com,Row2 +row3@test.com,Row3 +row4@test.com,Row4 +` + path := createTempCSV(t, csv) + + var count int + + err := Process(path, Options{MaxRows: 2}, func(row Row) error { + count++ + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if count != 2 { + t.Errorf("expected 2 rows with MaxRows=2, got %d", count) + } +} + +func TestProcess_SkipAndMaxRows(t *testing.T) { + csv := `email,name +row1@test.com,Row1 +row2@test.com,Row2 +row3@test.com,Row3 +row4@test.com,Row4 +row5@test.com,Row5 +` + path := createTempCSV(t, csv) + + var emails []string + + err := Process(path, Options{SkipRows: 2, MaxRows: 2}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(emails) != 2 { + t.Fatalf("expected 2 rows, got %d", len(emails)) + } + + if emails[0] != "row3@test.com" || emails[1] != "row4@test.com" { + t.Errorf("wrong emails: %v", emails) + } +} + +func TestProcess_CallbackError(t *testing.T) { + csv := `email,name +alice@test.com,Alice +bob@test.com,Bob +` + path := createTempCSV(t, csv) + + var count int + err := Process(path, Options{}, func(row Row) error { + count++ + if count == 1 { + return errCallbackFailed + } + + return nil + }) + + if !errors.Is(err, errCallbackFailed) { + t.Errorf("expected callback error, got: %v", err) + } + + if count != 1 { + t.Errorf("callback should have been called once, got %d", count) + } +} + +func TestProcess_EmptyCSV(t *testing.T) { + path := createTempCSV(t, "") + + err := Process(path, Options{}, func(row Row) error { + return nil + }) + if err == nil || !strings.Contains(err.Error(), "empty csv") { + t.Errorf("expected empty csv error, got: %v", err) + } +} + +func TestProcess_HeaderOnly(t *testing.T) { + csv := `email,name,status +` + path := createTempCSV(t, csv) + + var count int + + err := Process(path, Options{}, func(row Row) error { + count++ + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if count != 0 { + t.Errorf("expected 0 rows (header only), got %d", count) + } +} + +func TestProcess_FileNotFound(t *testing.T) { + err := Process("/nonexistent/path.csv", Options{}, func(row Row) error { + return nil + }) + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestProcess_EmptyPath(t *testing.T) { + err := Process("", Options{}, func(row Row) error { + return nil + }) + if err == nil || !strings.Contains(err.Error(), "file is required") { + t.Errorf("expected 'file is required' error, got: %v", err) + } +} + +func TestProcess_WhitespacePath(t *testing.T) { + err := Process(" ", Options{}, func(row Row) error { + return nil + }) + if err == nil || !strings.Contains(err.Error(), "file is required") { + t.Errorf("expected 'file is required' error, got: %v", err) + } +} + +func TestProcess_ShortRowFails(t *testing.T) { + // Go's CSV reader by default requires consistent field counts + // Row with fewer columns than header should fail + csv := `email,name,role +alice@test.com,Alice +` + path := createTempCSV(t, csv) + + err := Process(path, Options{}, func(r Row) error { + return nil + }) + + // Should fail because CSV reader enforces consistent field counts + if err == nil { + t.Error("expected error for short row") + } + + if !strings.Contains(err.Error(), "wrong number of fields") { + t.Errorf("expected 'wrong number of fields' error, got: %v", err) + } +} + +func TestProcess_ValueTrimming(t *testing.T) { + csv := `email,name + alice@test.com , Alice +` + path := createTempCSV(t, csv) + + var row Row + + err := Process(path, Options{}, func(r Row) error { + row = r + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // Values should be trimmed + if row.Values["email"] != "alice@test.com" { + t.Errorf("email not trimmed: %q", row.Values["email"]) + } + + if row.Values["name"] != "Alice" { + t.Errorf("name not trimmed: %q", row.Values["name"]) + } +} + +func TestProcess_EmptyHeaderColumn(t *testing.T) { + // Empty column header should be skipped + csv := `email,,name +alice@test.com,ignored,Alice +` + path := createTempCSV(t, csv) + + var row Row + + err := Process(path, Options{}, func(r Row) error { + row = r + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if _, ok := row.Values[""]; ok { + t.Error("empty header column should not create a value") + } + + if row.Values["email"] != "alice@test.com" { + t.Errorf("wrong email: %q", row.Values["email"]) + } + + if row.Values["name"] != "Alice" { + t.Errorf("wrong name: %q", row.Values["name"]) + } +} + +func TestProcess_RowIndexing(t *testing.T) { + csv := `email +row1@test.com +row2@test.com +row3@test.com +` + path := createTempCSV(t, csv) + + var indices []int + + err := Process(path, Options{}, func(row Row) error { + indices = append(indices, row.Index) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // Row indices should be 1, 2, 3 (1-based, after header) + expected := []int{1, 2, 3} + for i, want := range expected { + if indices[i] != want { + t.Errorf("indices[%d]: got %d, want %d", i, indices[i], want) + } + } +} + +func TestProcess_MalformedCSV(t *testing.T) { + // Create a file with malformed CSV (unmatched quotes) + dir := t.TempDir() + path := filepath.Join(dir, "malformed.csv") + // Unbalanced quotes cause csv.ReadAll to fail + err := os.WriteFile(path, []byte(`email,name +"alice@test.com,"Alice +`), 0o644) + if err != nil { + t.Fatalf("write file: %v", err) + } + + err = Process(path, Options{}, func(row Row) error { + return nil + }) + if err == nil { + t.Error("expected error for malformed CSV") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Integration: SubstituteArgs with Process +// ───────────────────────────────────────────────────────────────────────────── + +func TestIntegration_SubstituteWithProcess(t *testing.T) { + csv := `email,firstName,lastName,dept +alice@example.com,Alice,Smith,Engineering +bob@example.com,Bob,Jones,Sales +` + path := createTempCSV(t, csv) + + template := []string{ + "--email", "~email", + "--name", "~~firstName~~ ~~lastName~~", + "--dept", "~~dept~!~Engineering~!~Eng~~", + } + + var results [][]string + + err := Process(path, Options{}, func(row Row) error { + args, err := SubstituteArgs(template, row) + if err != nil { + return err + } + + results = append(results, args) + + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + // First row + expected1 := []string{"--email", "alice@example.com", "--name", "Alice Smith", "--dept", "Eng"} + for i, want := range expected1 { + if results[0][i] != want { + t.Errorf("results[0][%d]: got %q, want %q", i, results[0][i], want) + } + } + + // Second row (Sales stays unchanged) + if results[1][5] != "Sales" { + t.Errorf("results[1][5]: got %q, want %q", results[1][5], "Sales") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Additional Edge Case Tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestProcess_MultipleMatchFilters(t *testing.T) { + csv := `email,status,role +alice@test.com,active,admin +bob@test.com,active,user +charlie@test.com,inactive,admin +dave@test.com,active,guest +` + path := createTempCSV(t, csv) + + // Both filters must match (AND logic) + matchFilters, _ := ParseFieldFilters([]string{"status:^active$", "role:^admin$"}) + + var emails []string + + err := Process(path, Options{Match: matchFilters}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // Only alice matches both active AND admin + if len(emails) != 1 || emails[0] != "alice@test.com" { + t.Errorf("expected only alice, got: %v", emails) + } +} + +func TestProcess_MultipleSkipFilters(t *testing.T) { + csv := `email,status,role +alice@test.com,active,admin +bob@test.com,inactive,user +charlie@test.com,suspended,admin +dave@test.com,active,guest +` + path := createTempCSV(t, csv) + + // Skip if any filter matches (OR logic) + skipFilters, _ := ParseFieldFilters([]string{"status:inactive", "status:suspended"}) + + var emails []string + + err := Process(path, Options{Skip: skipFilters}, func(row Row) error { + emails = append(emails, row.Values["email"]) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // alice and dave should remain (bob=inactive, charlie=suspended skipped) + if len(emails) != 2 { + t.Fatalf("expected 2 rows, got %d: %v", len(emails), emails) + } +} + +func TestProcess_NilRegexInFilter(t *testing.T) { + // Test that nil Regex in filter is handled gracefully + csv := `email,status +alice@test.com,active +` + path := createTempCSV(t, csv) + + // Create filter with nil regex manually + nilFilter := FieldFilter{Field: "status", Regex: nil} + + var count int + + err := Process(path, Options{Match: []FieldFilter{nilFilter}}, func(row Row) error { + count++ + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + // With nil regex, filter should be skipped (passes by default) + if count != 1 { + t.Errorf("expected 1 row (nil filter ignored), got %d", count) + } +} + +func TestSubstituteArgs_NestedTildes(t *testing.T) { + row := Row{ + Index: 1, + Values: map[string]string{ + "path": "/home/~user/files", + }, + } + + // Value contains literal tilde - should be preserved + args, err := SubstituteArgs([]string{"~~path~~"}, row) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if args[0] != "/home/~user/files" { + t.Errorf("tilde in value should be preserved, got: %s", args[0]) + } +} + +func TestSubstituteArgs_EmptyRow(t *testing.T) { + row := Row{Index: 1, Values: map[string]string{}} + + args, err := SubstituteArgs([]string{"~missing", "~~also_missing~~"}, row) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if args[0] != "" || args[1] != "" { + t.Errorf("missing fields should return empty strings, got: %v", args) + } +} + +func TestParseFieldFilters_ComplexRegex(t *testing.T) { + tests := []struct { + name string + input string + pattern string + }{ + { + name: "email regex with escaped dots", + input: "email:^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", + pattern: "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", + }, + { + name: "phone regex", + input: "phone:^\\+?[0-9]{10,14}$", + pattern: "^\\+?[0-9]{10,14}$", + }, + { + name: "date regex", + input: "date:^\\d{4}-\\d{2}-\\d{2}$", + pattern: "^\\d{4}-\\d{2}-\\d{2}$", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filters, err := ParseFieldFilters([]string{tt.input}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(filters) != 1 { + t.Fatalf("expected 1 filter, got %d", len(filters)) + } + + if filters[0].Regex.String() != tt.pattern { + t.Errorf("pattern mismatch: got %q, want %q", filters[0].Regex.String(), tt.pattern) + } + }) + } +} + +func TestProcess_SpecialCharactersInCSV(t *testing.T) { + // CSV with special characters that need proper handling + csv := `email,note,tags +alice@test.com,"Contains, commas",tag1|tag2 +bob@test.com,"Has ""quotes""",tag3 +charlie@test.com,Normal text,tag4|tag5|tag6 +` + path := createTempCSV(t, csv) + + var rows []Row + + err := Process(path, Options{}, func(row Row) error { + rows = append(rows, row) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // Check that commas inside quotes are preserved + if rows[0].Values["note"] != "Contains, commas" { + t.Errorf("row[0].note: got %q, want %q", rows[0].Values["note"], "Contains, commas") + } + + // Check that escaped quotes are unescaped + if rows[1].Values["note"] != `Has "quotes"` { + t.Errorf("row[1].note: got %q, want %q", rows[1].Values["note"], `Has "quotes"`) + } +} + +func TestProcess_UnicodeInCSV(t *testing.T) { + csv := `name,city,emoji +日本語,東京,🎌 +Español,México,🇲🇽 +Français,Paris,🇫🇷 +` + path := createTempCSV(t, csv) + + var rows []Row + + err := Process(path, Options{}, func(row Row) error { + rows = append(rows, row) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + if rows[0].Values["name"] != "日本語" || rows[0].Values["city"] != "東京" { + t.Errorf("Unicode not preserved in row[0]: %v", rows[0].Values) + } + + if rows[0].Values["emoji"] != "🎌" { + t.Errorf("Emoji not preserved: got %q", rows[0].Values["emoji"]) + } +} + +func TestProcess_ReadFromStdin(t *testing.T) { + csv := `email,name,status +alice@test.com,Alice,active +bob@test.com,Bob,inactive +` + + // Create a pipe to simulate stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create pipe: %v", err) + } + defer r.Close() + + // Write CSV data to the pipe + go func() { + defer w.Close() + + if _, writeErr := w.WriteString(csv); writeErr != nil { + t.Errorf("write to pipe: %v", writeErr) + } + }() + + // Temporarily replace stdin + oldStdin := os.Stdin + os.Stdin = r + + defer func() { os.Stdin = oldStdin }() + + var rows []Row + + err = Process("-", Options{}, func(row Row) error { + rows = append(rows, row) + return nil + }) + if err != nil { + t.Fatalf("Process error: %v", err) + } + + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + + if rows[0].Values["email"] != "alice@test.com" { + t.Errorf("row[0].email: got %q, want %q", rows[0].Values["email"], "alice@test.com") + } + + if rows[1].Values["name"] != "Bob" { + t.Errorf("row[1].name: got %q, want %q", rows[1].Values["name"], "Bob") + } +} diff --git a/internal/googleapi/accesscontext.go b/internal/googleapi/accesscontext.go new file mode 100644 index 00000000..b312d815 --- /dev/null +++ b/internal/googleapi/accesscontext.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/accesscontextmanager/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewAccessContextManager(ctx context.Context, email string) (*accesscontextmanager.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceAccessContext, email) + if err != nil { + return nil, fmt.Errorf("access context options: %w", err) + } + + svc, err := accesscontextmanager.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create access context service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/admin_directory.go b/internal/googleapi/admin_directory.go new file mode 100644 index 00000000..21078e26 --- /dev/null +++ b/internal/googleapi/admin_directory.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewAdminDirectory(ctx context.Context, email string) (*admin.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceAdminDirectory, email) + if err != nil { + return nil, fmt.Errorf("admin directory options: %w", err) + } + + svc, err := admin.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create admin directory service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/alertcenter.go b/internal/googleapi/alertcenter.go new file mode 100644 index 00000000..4fa5ea26 --- /dev/null +++ b/internal/googleapi/alertcenter.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + alertcenter "google.golang.org/api/alertcenter/v1beta1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewAlertCenter(ctx context.Context, email string) (*alertcenter.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceAlertCenter, email) + if err != nil { + return nil, fmt.Errorf("alertcenter options: %w", err) + } + + svc, err := alertcenter.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create alertcenter service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/analytics.go b/internal/googleapi/analytics.go new file mode 100644 index 00000000..779b90a8 --- /dev/null +++ b/internal/googleapi/analytics.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + analyticsadmin "google.golang.org/api/analyticsadmin/v1beta" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewAnalyticsAdmin(ctx context.Context, email string) (*analyticsadmin.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceAnalytics, email) + if err != nil { + return nil, fmt.Errorf("analytics admin options: %w", err) + } + + svc, err := analyticsadmin.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create analytics admin service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/cloudchannel.go b/internal/googleapi/cloudchannel.go new file mode 100644 index 00000000..6ce5cd72 --- /dev/null +++ b/internal/googleapi/cloudchannel.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/cloudchannel/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewCloudChannel(ctx context.Context, email string) (*cloudchannel.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceCloudChannel, email) + if err != nil { + return nil, fmt.Errorf("cloud channel options: %w", err) + } + + svc, err := cloudchannel.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create cloud channel service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/cloudidentity.go b/internal/googleapi/cloudidentity.go index d982f01d..0f0e9369 100644 --- a/internal/googleapi/cloudidentity.go +++ b/internal/googleapi/cloudidentity.go @@ -5,6 +5,8 @@ import ( "fmt" "google.golang.org/api/cloudidentity/v1" + + "github.com/steipete/gogcli/internal/googleauth" ) const ( @@ -22,3 +24,33 @@ func NewCloudIdentityGroups(ctx context.Context, email string) (*cloudidentity.S return svc, nil } } + +// NewCloudIdentityInboundSSO creates a Cloud Identity service for inbound SSO administration. +func NewCloudIdentityInboundSSO(ctx context.Context, email string) (*cloudidentity.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceInboundSSO, email) + if err != nil { + return nil, fmt.Errorf("cloudidentity inbound sso options: %w", err) + } + + svc, err := cloudidentity.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create cloudidentity inbound sso service: %w", err) + } + + return svc, nil +} + +// NewCloudIdentity creates a Cloud Identity service for admin-level group and policy management. +func NewCloudIdentity(ctx context.Context, email string) (*cloudidentity.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceCloudIdentity, email) + if err != nil { + return nil, fmt.Errorf("cloud identity options: %w", err) + } + + svc, err := cloudidentity.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create cloud identity service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/cloudresourcemanager.go b/internal/googleapi/cloudresourcemanager.go new file mode 100644 index 00000000..9954b57b --- /dev/null +++ b/internal/googleapi/cloudresourcemanager.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/cloudresourcemanager/v3" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewCloudResourceManager(ctx context.Context, email string) (*cloudresourcemanager.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceCloudResource, email) + if err != nil { + return nil, fmt.Errorf("cloud resource manager options: %w", err) + } + + svc, err := cloudresourcemanager.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create cloud resource manager service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/datatransfer.go b/internal/googleapi/datatransfer.go new file mode 100644 index 00000000..84dd2ed5 --- /dev/null +++ b/internal/googleapi/datatransfer.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + datatransfer "google.golang.org/api/admin/datatransfer/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewDataTransfer(ctx context.Context, email string) (*datatransfer.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceDataTransfer, email) + if err != nil { + return nil, fmt.Errorf("datatransfer options: %w", err) + } + + svc, err := datatransfer.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create datatransfer service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/drive.go b/internal/googleapi/drive.go index 5e1bde75..c834b518 100644 --- a/internal/googleapi/drive.go +++ b/internal/googleapi/drive.go @@ -3,12 +3,25 @@ package googleapi import ( "context" "fmt" + "strings" "google.golang.org/api/drive/v3" "github.com/steipete/gogcli/internal/googleauth" ) +// EscapeDriveQueryValue escapes a value for safe interpolation into a +// Google Drive API query string. At minimum it handles backslashes and +// single quotes which are the two characters with special meaning inside +// single-quoted literals in the Drive query grammar. +func EscapeDriveQueryValue(s string) string { + // Escape backslashes first, then single quotes. + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "'", "\\'") + + return s +} + func NewDrive(ctx context.Context, email string) (*drive.Service, error) { if opts, err := optionsForAccount(ctx, googleauth.ServiceDrive, email); err != nil { return nil, fmt.Errorf("drive options: %w", err) diff --git a/internal/googleapi/drive_test.go b/internal/googleapi/drive_test.go new file mode 100644 index 00000000..86b7f637 --- /dev/null +++ b/internal/googleapi/drive_test.go @@ -0,0 +1,30 @@ +package googleapi + +import "testing" + +func TestEscapeDriveQueryValue(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"no special chars", "hello", "hello"}, + {"single quote", "'", "\\'"}, + {"backslash", "\\", "\\\\"}, + {"backslash then quote", "\\'", "\\\\\\'"}, + {"multiple quotes", "a'b'c", "a\\'b\\'c"}, + {"already escaped", "a\\\\'b", "a\\\\\\\\\\'b"}, + {"email address", "user@example.com", "user@example.com"}, + {"folder ID", "1A2B3C_xyz", "1A2B3C_xyz"}, + {"mixed", "it's a \\path", "it\\'s a \\\\path"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EscapeDriveQueryValue(tt.in) + if got != tt.want { + t.Errorf("EscapeDriveQueryValue(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/googleapi/driveactivity.go b/internal/googleapi/driveactivity.go new file mode 100644 index 00000000..feab641c --- /dev/null +++ b/internal/googleapi/driveactivity.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/driveactivity/v2" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewDriveActivity(ctx context.Context, email string) (*driveactivity.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceDriveActivity, email) + if err != nil { + return nil, fmt.Errorf("drive activity options: %w", err) + } + + svc, err := driveactivity.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create drive activity service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/drivelabels.go b/internal/googleapi/drivelabels.go new file mode 100644 index 00000000..e1acd453 --- /dev/null +++ b/internal/googleapi/drivelabels.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/drivelabels/v2" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewDriveLabels(ctx context.Context, email string) (*drivelabels.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceDriveLabels, email) + if err != nil { + return nil, fmt.Errorf("drive labels options: %w", err) + } + + svc, err := drivelabels.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create drive labels service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/forms.go b/internal/googleapi/forms.go new file mode 100644 index 00000000..407b2c38 --- /dev/null +++ b/internal/googleapi/forms.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/forms/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewForms(ctx context.Context, email string) (*forms.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceForms, email) + if err != nil { + return nil, fmt.Errorf("forms options: %w", err) + } + + svc, err := forms.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create forms service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/groupssettings.go b/internal/googleapi/groupssettings.go new file mode 100644 index 00000000..0c0cfde1 --- /dev/null +++ b/internal/googleapi/groupssettings.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/groupssettings/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewGroupsSettings(ctx context.Context, email string) (*groupssettings.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceAdminDirectory, email) + if err != nil { + return nil, fmt.Errorf("groups settings options: %w", err) + } + + svc, err := groupssettings.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create groups settings service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/iam.go b/internal/googleapi/iam.go new file mode 100644 index 00000000..3083c9b7 --- /dev/null +++ b/internal/googleapi/iam.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/iam/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewIAM(ctx context.Context, email string) (*iam.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceIAM, email) + if err != nil { + return nil, fmt.Errorf("iam options: %w", err) + } + + svc, err := iam.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create iam service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/licensing.go b/internal/googleapi/licensing.go new file mode 100644 index 00000000..df49d444 --- /dev/null +++ b/internal/googleapi/licensing.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/licensing/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewLicensing(ctx context.Context, email string) (*licensing.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceLicensing, email) + if err != nil { + return nil, fmt.Errorf("licensing options: %w", err) + } + + svc, err := licensing.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create licensing service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/meet.go b/internal/googleapi/meet.go new file mode 100644 index 00000000..be522478 --- /dev/null +++ b/internal/googleapi/meet.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewMeet(ctx context.Context, email string) (*meet.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceMeet, email) + if err != nil { + return nil, fmt.Errorf("meet options: %w", err) + } + + svc, err := meet.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create meet service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/reports.go b/internal/googleapi/reports.go new file mode 100644 index 00000000..f371104f --- /dev/null +++ b/internal/googleapi/reports.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + reports "google.golang.org/api/admin/reports/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewReports(ctx context.Context, email string) (*reports.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceReports, email) + if err != nil { + return nil, fmt.Errorf("reports options: %w", err) + } + + svc, err := reports.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create reports service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/reseller.go b/internal/googleapi/reseller.go new file mode 100644 index 00000000..a99be885 --- /dev/null +++ b/internal/googleapi/reseller.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/reseller/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewReseller(ctx context.Context, email string) (*reseller.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceReseller, email) + if err != nil { + return nil, fmt.Errorf("reseller options: %w", err) + } + + svc, err := reseller.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create reseller service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/storage.go b/internal/googleapi/storage.go new file mode 100644 index 00000000..e764cde2 --- /dev/null +++ b/internal/googleapi/storage.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/storage/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewStorage(ctx context.Context, email string) (*storage.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceVault, email) + if err != nil { + return nil, fmt.Errorf("storage options: %w", err) + } + + svc, err := storage.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create storage service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/vault.go b/internal/googleapi/vault.go new file mode 100644 index 00000000..41a519b0 --- /dev/null +++ b/internal/googleapi/vault.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/vault/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewVault(ctx context.Context, email string) (*vault.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceVault, email) + if err != nil { + return nil, fmt.Errorf("vault options: %w", err) + } + + svc, err := vault.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create vault service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleapi/youtube.go b/internal/googleapi/youtube.go new file mode 100644 index 00000000..b3e68d63 --- /dev/null +++ b/internal/googleapi/youtube.go @@ -0,0 +1,24 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/youtube/v3" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewYouTube(ctx context.Context, email string) (*youtube.Service, error) { + opts, err := optionsForAccount(ctx, googleauth.ServiceYouTube, email) + if err != nil { + return nil, fmt.Errorf("youtube options: %w", err) + } + + svc, err := youtube.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create youtube service: %w", err) + } + + return svc, nil +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index e59cdb4e..4c32efcb 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -10,18 +10,37 @@ import ( type Service string const ( - ServiceGmail Service = "gmail" - ServiceCalendar Service = "calendar" - ServiceChat Service = "chat" - ServiceClassroom Service = "classroom" - ServiceDrive Service = "drive" - ServiceDocs Service = "docs" - ServiceContacts Service = "contacts" - ServiceTasks Service = "tasks" - ServicePeople Service = "people" - ServiceSheets Service = "sheets" - ServiceGroups Service = "groups" - ServiceKeep Service = "keep" + ServiceGmail Service = "gmail" + ServiceCalendar Service = "calendar" + ServiceChat Service = "chat" + ServiceClassroom Service = "classroom" + ServiceDrive Service = "drive" + ServiceDocs Service = "docs" + ServiceContacts Service = "contacts" + ServiceTasks Service = "tasks" + ServicePeople Service = "people" + ServiceSheets Service = "sheets" + ServiceGroups Service = "groups" + ServiceKeep Service = "keep" + ServiceAdminDirectory Service = "admin" + ServiceReports Service = "reports" + ServiceVault Service = "vault" + ServiceAlertCenter Service = "alertcenter" + ServiceInboundSSO Service = "inboundsso" + ServiceAccessContext Service = "accesscontext" + ServiceLicensing Service = "licensing" + ServiceDataTransfer Service = "datatransfer" + ServiceForms Service = "forms" + ServiceYouTube Service = "youtube" + ServiceMeet Service = "meet" + ServiceAnalytics Service = "analytics" + ServiceDriveLabels Service = "drivelabels" + ServiceDriveActivity Service = "driveactivity" + ServiceCloudIdentity Service = "cloudidentity" + ServiceReseller Service = "reseller" + ServiceCloudChannel Service = "cloudchannel" + ServiceCloudResource Service = "cloudresourcemanager" + ServiceIAM Service = "iam" ) const ( @@ -68,6 +87,25 @@ var serviceOrder = []Service{ ServicePeople, ServiceGroups, ServiceKeep, + ServiceAdminDirectory, + ServiceReports, + ServiceVault, + ServiceAlertCenter, + ServiceInboundSSO, + ServiceAccessContext, + ServiceLicensing, + ServiceDataTransfer, + ServiceForms, + ServiceYouTube, + ServiceMeet, + ServiceAnalytics, + ServiceDriveLabels, + ServiceDriveActivity, + ServiceCloudIdentity, + ServiceReseller, + ServiceCloudChannel, + ServiceCloudResource, + ServiceIAM, } var serviceInfoByService = map[Service]serviceInfo{ @@ -170,6 +208,198 @@ var serviceInfoByService = map[Service]serviceInfo{ apis: []string{"Keep API"}, note: "Workspace only; service account (domain-wide delegation)", }, + ServiceAdminDirectory: { + scopes: []string{ + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.orgunit", + "https://www.googleapis.com/auth/admin.directory.orgunit.readonly", + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.group.readonly", + "https://www.googleapis.com/auth/admin.directory.group.member", + "https://www.googleapis.com/auth/admin.directory.group.member.readonly", + "https://www.googleapis.com/auth/admin.directory.domain", + "https://www.googleapis.com/auth/admin.directory.domain.readonly", + "https://www.googleapis.com/auth/admin.directory.rolemanagement", + "https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly", + "https://www.googleapis.com/auth/admin.directory.userschema", + "https://www.googleapis.com/auth/admin.directory.userschema.readonly", + "https://www.googleapis.com/auth/admin.directory.resource.calendar", + "https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly", + "https://www.googleapis.com/auth/admin.directory.customer", + "https://www.googleapis.com/auth/admin.directory.customer.readonly", + "https://www.googleapis.com/auth/apps.groups.settings", + "https://www.googleapis.com/auth/admin.chrome.printers", + "https://www.googleapis.com/auth/admin.chrome.printers.readonly", + }, + user: false, + apis: []string{"Admin SDK Directory API", "Groups Settings API"}, + note: "Workspace admin (domain-wide delegation)", + }, + ServiceReports: { + scopes: []string{ + "https://www.googleapis.com/auth/admin.reports.audit.readonly", + "https://www.googleapis.com/auth/admin.reports.usage.readonly", + }, + user: false, + apis: []string{"Admin SDK Reports API"}, + note: "Workspace audit + usage reports", + }, + ServiceVault: { + scopes: []string{ + "https://www.googleapis.com/auth/ediscovery", + "https://www.googleapis.com/auth/ediscovery.readonly", + "https://www.googleapis.com/auth/devstorage.read_only", + }, + user: false, + apis: []string{"Google Vault API", "Cloud Storage API"}, + note: "Vault exports (Cloud Storage download)", + }, + ServiceAlertCenter: { + scopes: []string{ + "https://www.googleapis.com/auth/apps.alerts", + }, + user: false, + apis: []string{"Google Workspace Alert Center API"}, + note: "Workspace alerts", + }, + ServiceInboundSSO: { + scopes: []string{ + "https://www.googleapis.com/auth/cloud-identity.inboundsso", + "https://www.googleapis.com/auth/cloud-identity.inboundsso.readonly", + }, + user: false, + apis: []string{"Cloud Identity API"}, + note: "Inbound SSO profiles + assignments", + }, + ServiceAccessContext: { + scopes: []string{ + "https://www.googleapis.com/auth/cloud-platform", + }, + user: false, + apis: []string{"Access Context Manager API"}, + note: "Context-aware access levels", + }, + ServiceLicensing: { + scopes: []string{ + "https://www.googleapis.com/auth/apps.licensing", + }, + user: false, + apis: []string{"Enterprise License Manager API"}, + note: "Workspace licenses", + }, + ServiceDataTransfer: { + scopes: []string{ + "https://www.googleapis.com/auth/admin.datatransfer", + "https://www.googleapis.com/auth/admin.datatransfer.readonly", + }, + user: false, + apis: []string{"Admin SDK Data Transfer API"}, + note: "Workspace data transfers", + }, + ServiceForms: { + scopes: []string{ + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + }, + user: true, + apis: []string{"Forms API"}, + note: "Forms and responses", + }, + ServiceYouTube: { + scopes: []string{ + "https://www.googleapis.com/auth/youtube.readonly", + }, + user: false, + apis: []string{"YouTube Data API"}, + note: "YouTube channels", + }, + ServiceMeet: { + scopes: []string{ + "https://www.googleapis.com/auth/meetings.space.created", + "https://www.googleapis.com/auth/meetings.space.readonly", + "https://www.googleapis.com/auth/meetings.space.settings", + }, + user: false, + apis: []string{"Google Meet API"}, + note: "Meet spaces", + }, + ServiceAnalytics: { + scopes: []string{ + "https://www.googleapis.com/auth/analytics.edit", + "https://www.googleapis.com/auth/analytics.readonly", + }, + user: false, + apis: []string{"Google Analytics Admin API"}, + note: "Analytics admin", + }, + ServiceDriveLabels: { + scopes: []string{ + "https://www.googleapis.com/auth/drive.admin.labels", + "https://www.googleapis.com/auth/drive.admin.labels.readonly", + "https://www.googleapis.com/auth/drive.labels", + "https://www.googleapis.com/auth/drive.labels.readonly", + }, + user: false, + apis: []string{"Drive Labels API"}, + note: "Drive classification labels", + }, + ServiceDriveActivity: { + scopes: []string{ + "https://www.googleapis.com/auth/drive.activity", + "https://www.googleapis.com/auth/drive.activity.readonly", + }, + user: false, + apis: []string{"Drive Activity API"}, + note: "Drive activity history", + }, + ServiceCloudIdentity: { + scopes: []string{ + "https://www.googleapis.com/auth/cloud-identity.groups", + "https://www.googleapis.com/auth/cloud-identity.groups.readonly", + "https://www.googleapis.com/auth/cloud-identity.policies", + "https://www.googleapis.com/auth/cloud-identity.policies.readonly", + }, + user: false, + apis: []string{"Cloud Identity API"}, + note: "Cloud Identity groups + policies", + }, + ServiceReseller: { + scopes: []string{ + "https://www.googleapis.com/auth/apps.order", + "https://www.googleapis.com/auth/apps.order.readonly", + }, + user: false, + apis: []string{"Reseller API"}, + note: "Workspace reseller", + }, + ServiceCloudChannel: { + scopes: []string{ + "https://www.googleapis.com/auth/apps.order", + "https://www.googleapis.com/auth/apps.reports.usage.readonly", + }, + user: false, + apis: []string{"Cloud Channel API"}, + note: "Cloud Channel customers + offers", + }, + ServiceCloudResource: { + scopes: []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/cloud-platform.read-only", + }, + user: false, + apis: []string{"Cloud Resource Manager API"}, + note: "GCP projects", + }, + ServiceIAM: { + scopes: []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/cloud-platform.read-only", + }, + user: false, + apis: []string{"IAM API"}, + note: "Service accounts", + }, } func ParseService(s string) (Service, error) { @@ -463,6 +693,8 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, return Scopes(service) case ServiceKeep: return Scopes(service) + case ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms, ServiceYouTube, ServiceMeet, ServiceAnalytics, ServiceDriveLabels, ServiceDriveActivity, ServiceCloudIdentity, ServiceReseller, ServiceCloudChannel, ServiceCloudResource, ServiceIAM: + return Scopes(service) default: return nil, errUnknownService } diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 1bb65ad0..5fc7288f 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -20,6 +20,25 @@ func TestParseService(t *testing.T) { {"sheets", ServiceSheets}, {"groups", ServiceGroups}, {"keep", ServiceKeep}, + {"admin", ServiceAdminDirectory}, + {"reports", ServiceReports}, + {"vault", ServiceVault}, + {"alertcenter", ServiceAlertCenter}, + {"inboundsso", ServiceInboundSSO}, + {"accesscontext", ServiceAccessContext}, + {"licensing", ServiceLicensing}, + {"datatransfer", ServiceDataTransfer}, + {"forms", ServiceForms}, + {"youtube", ServiceYouTube}, + {"meet", ServiceMeet}, + {"analytics", ServiceAnalytics}, + {"drivelabels", ServiceDriveLabels}, + {"driveactivity", ServiceDriveActivity}, + {"cloudidentity", ServiceCloudIdentity}, + {"reseller", ServiceReseller}, + {"cloudchannel", ServiceCloudChannel}, + {"cloudresourcemanager", ServiceCloudResource}, + {"iam", ServiceIAM}, } for _, tt := range tests { got, err := ParseService(tt.in) @@ -62,7 +81,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 12 { + if len(svcs) != 31 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -71,7 +90,7 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms, ServiceYouTube, ServiceMeet, ServiceAnalytics, ServiceDriveLabels, ServiceDriveActivity, ServiceCloudIdentity, ServiceReseller, ServiceCloudChannel, ServiceCloudResource, ServiceIAM} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -80,16 +99,19 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 10 { + if len(svcs) != 11 { t.Fatalf("unexpected: %v", svcs) } seenDocs := false + seenForms := false for _, s := range svcs { switch s { case ServiceDocs: seenDocs = true + case ServiceForms: + seenForms = true case ServiceKeep: t.Fatalf("unexpected keep in user services") } @@ -98,10 +120,14 @@ func TestUserServices(t *testing.T) { if !seenDocs { t.Fatalf("missing docs in user services") } + + if !seenForms { + t.Fatalf("missing forms in user services") + } } func TestUserServiceCSV(t *testing.T) { - want := "gmail,calendar,chat,classroom,drive,docs,contacts,tasks,sheets,people" + want := "gmail,calendar,chat,classroom,drive,docs,contacts,tasks,sheets,people,forms" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) } diff --git a/internal/todrive/writer.go b/internal/todrive/writer.go new file mode 100644 index 00000000..b83adf32 --- /dev/null +++ b/internal/todrive/writer.go @@ -0,0 +1,214 @@ +package todrive + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "google.golang.org/api/drive/v3" + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/googleapi" +) + +var ( + newDriveService = googleapi.NewDrive + newSheetsService = googleapi.NewSheets +) + +var errMissingSpreadsheetID = errors.New("missing spreadsheet id") + +const defaultSheetName = "Report" + +// Options control Google Sheets output. +type Options struct { + SheetName string + FolderID string + Timestamp bool + Notify string + Update bool +} + +// Result describes the created or updated spreadsheet. +type Result struct { + SpreadsheetID string + SheetName string + URL string +} + +type Writer struct { + drive *drive.Service + sheets *sheets.Service +} + +func New(ctx context.Context, account string) (*Writer, error) { + driveSvc, err := newDriveService(ctx, account) + if err != nil { + return nil, err + } + + sheetsSvc, err := newSheetsService(ctx, account) + if err != nil { + return nil, err + } + + return &Writer{drive: driveSvc, sheets: sheetsSvc}, nil +} + +func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, opts Options) (*Result, error) { + title := strings.TrimSpace(opts.SheetName) + if title == "" { + title = defaultSheetName + } + + if opts.Timestamp { + title = fmt.Sprintf("%s-%s", title, time.Now().Format("2006-01-02-150405")) + } + + spreadsheetID := "" + spreadsheetURL := "" + + if opts.Update { + id, url, err := w.findSpreadsheet(ctx, title, opts.FolderID) + if err != nil { + return nil, err + } + spreadsheetID = id + spreadsheetURL = url + } + + sheetName := "Data" + if spreadsheetID == "" { + created, err := w.sheets.Spreadsheets.Create(&sheets.Spreadsheet{ + Properties: &sheets.SpreadsheetProperties{Title: title}, + Sheets: []*sheets.Sheet{{ + Properties: &sheets.SheetProperties{Title: sheetName}, + }}, + }).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("create sheet: %w", err) + } + spreadsheetID = created.SpreadsheetId + spreadsheetURL = created.SpreadsheetUrl + + if strings.TrimSpace(opts.FolderID) != "" { + if err := w.moveToFolder(ctx, spreadsheetID, opts.FolderID); err != nil { + return nil, err + } + } + } else { + // Get the first sheet name from existing spreadsheet metadata + ss, err := w.sheets.Spreadsheets.Get(spreadsheetID).Fields("sheets.properties.title").Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("fetch spreadsheet metadata: %w", err) + } + + if len(ss.Sheets) > 0 && ss.Sheets[0].Properties != nil && ss.Sheets[0].Properties.Title != "" { + sheetName = ss.Sheets[0].Properties.Title + } + } + + if spreadsheetID == "" { + return nil, errMissingSpreadsheetID + } + + if opts.Update { + _, _ = w.sheets.Spreadsheets.Values.Clear(spreadsheetID, sheetName, &sheets.ClearValuesRequest{}).Context(ctx).Do() + } + + values := make([][]interface{}, 0, len(rows)+1) + if len(headers) > 0 { + values = append(values, toInterfaceRow(headers)) + } + + for _, row := range rows { + values = append(values, toInterfaceRow(row)) + } + + _, err := w.sheets.Spreadsheets.Values.Update(spreadsheetID, sheetName+"!A1", &sheets.ValueRange{ + Values: values, + }).ValueInputOption("RAW").Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("update sheet: %w", err) + } + + if strings.TrimSpace(opts.Notify) != "" { + if err := w.shareWith(ctx, spreadsheetID, strings.TrimSpace(opts.Notify)); err != nil { + return nil, err + } + } + + if spreadsheetURL == "" { + spreadsheetURL = fmt.Sprintf("https://docs.google.com/spreadsheets/d/%s", spreadsheetID) + } + + return &Result{ + SpreadsheetID: spreadsheetID, + SheetName: title, + URL: spreadsheetURL, + }, nil +} + +func (w *Writer) findSpreadsheet(ctx context.Context, name, folderID string) (string, string, error) { + query := fmt.Sprintf("mimeType='application/vnd.google-apps.spreadsheet' and name='%s' and trashed=false", googleapi.EscapeDriveQueryValue(name)) + if strings.TrimSpace(folderID) != "" { + query = fmt.Sprintf("%s and '%s' in parents", query, googleapi.EscapeDriveQueryValue(strings.TrimSpace(folderID))) + } + + resp, err := w.drive.Files.List().Q(query).Fields("files(id,name,webViewLink)").Context(ctx).Do() + if err != nil { + return "", "", fmt.Errorf("find sheet: %w", err) + } + + if len(resp.Files) == 0 { + return "", "", nil + } + + file := resp.Files[0] + + return file.Id, file.WebViewLink, nil +} + +func (w *Writer) moveToFolder(ctx context.Context, fileID, folderID string) error { + file, err := w.drive.Files.Get(fileID).Fields("parents").Context(ctx).Do() + if err != nil { + return fmt.Errorf("fetch parents: %w", err) + } + + remove := strings.Join(file.Parents, ",") + + call := w.drive.Files.Update(fileID, nil).AddParents(folderID) + if remove != "" { + call = call.RemoveParents(remove) + } + + if _, err := call.Context(ctx).Do(); err != nil { + return fmt.Errorf("move sheet: %w", err) + } + + return nil +} + +func (w *Writer) shareWith(ctx context.Context, fileID, email string) error { + _, err := w.drive.Permissions.Create(fileID, &drive.Permission{ + Type: "user", + Role: "reader", + EmailAddress: email, + }).SendNotificationEmail(true).Context(ctx).Do() + if err != nil { + return fmt.Errorf("share sheet: %w", err) + } + + return nil +} + +func toInterfaceRow(values []string) []interface{} { + row := make([]interface{}, len(values)) + for i, value := range values { + row[i] = value + } + + return row +} diff --git a/internal/todrive/writer_test.go b/internal/todrive/writer_test.go new file mode 100644 index 00000000..80e29a09 --- /dev/null +++ b/internal/todrive/writer_test.go @@ -0,0 +1,90 @@ +package todrive + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/drive/v3" + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" +) + +func TestWriterCreateAndWrite(t *testing.T) { + var gotValues [][]interface{} + + sheetsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v4/spreadsheets": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "sheet1", + "spreadsheetUrl": "https://sheet/1", + }) + + return + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/v4/spreadsheets/sheet1/values/Data!A1"): + var vr sheets.ValueRange + _ = json.NewDecoder(r.Body).Decode(&vr) + gotValues = vr.Values + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"updatedRange": "Data!A1"}) + + return + default: + http.NotFound(w, r) + return + } + })) + defer sheetsSrv.Close() + + driveSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer driveSrv.Close() + + origSheets := newSheetsService + origDrive := newDriveService + + t.Cleanup(func() { + newSheetsService = origSheets + newDriveService = origDrive + }) + + newSheetsService = func(ctx context.Context, _ string) (*sheets.Service, error) { + return sheets.NewService(ctx, + option.WithoutAuthentication(), + option.WithHTTPClient(sheetsSrv.Client()), + option.WithEndpoint(sheetsSrv.URL+"/"), + ) + } + newDriveService = func(ctx context.Context, _ string) (*drive.Service, error) { + return drive.NewService(ctx, + option.WithoutAuthentication(), + option.WithHTTPClient(driveSrv.Client()), + option.WithEndpoint(driveSrv.URL+"/"), + ) + } + + writer, err := New(context.Background(), "user@example.com") + if err != nil { + t.Fatalf("New: %v", err) + } + + res, err := writer.Write(context.Background(), []string{"A", "B"}, [][]string{{"1", "2"}}, Options{SheetName: "Report"}) + if err != nil { + t.Fatalf("Write: %v", err) + } + + if res.SpreadsheetID != "sheet1" { + t.Fatalf("unexpected id: %s", res.SpreadsheetID) + } + + if len(gotValues) != 2 { + t.Fatalf("expected 2 rows, got %d", len(gotValues)) + } +}