From 5137fcb782e995e698110c341ab3d3fb97f83dea Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 2 Feb 2026 20:09:20 -0800
Subject: [PATCH 01/48] feat(gmail): add history types filter to watch serve
---
docs/watch.md | 4 +-
internal/cmd/gmail_watch_cmds.go | 35 +++++---
internal/cmd/gmail_watch_server.go | 69 ++++++++++++---
.../cmd/gmail_watch_server_helpers_test.go | 31 ++++++-
internal/cmd/gmail_watch_server_more_test.go | 87 +++++++++++++++++++
internal/cmd/gmail_watch_types.go | 38 ++++++++
6 files changed, 234 insertions(+), 30 deletions(-)
diff --git a/docs/watch.md b/docs/watch.md
index bb373836..d6256357 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: all.
## State
diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go
index fdeafbc1..bfbdc1f2 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: all"`
+ 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..4136d029 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,16 +189,35 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
return nil, err
}
+ nextHistoryID := payload.HistoryID
+ if historyResp != nil && historyResp.HistoryId != 0 {
+ nextHistoryID = formatHistoryID(historyResp.HistoryId)
+ }
+ if len(s.cfg.HistoryTypes) > 0 && (historyResp == nil || len(historyResp.History) == 0) {
+ 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
+ }
+ state.UpdatedAtMs = time.Now().UnixMilli()
+ return nil
+ }); err != nil {
+ s.warnf("watch: failed to update state: %v", err)
+ }
+ return nil, errNoNewMessages
+ }
+
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 {
@@ -479,6 +500,16 @@ func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string {
}
seen := make(map[string]struct{})
out := make([]string, 0)
+ addMessage := func(id string) {
+ if strings.TrimSpace(id) == "" {
+ return
+ }
+ if _, ok := seen[id]; ok {
+ return
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
for _, h := range resp.History {
if h == nil {
continue
@@ -487,21 +518,31 @@ func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string {
if added == nil || added.Message == nil || added.Message.Id == "" {
continue
}
- if _, ok := seen[added.Message.Id]; ok {
+ addMessage(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)
+ addMessage(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
+ }
+ addMessage(added.Message.Id)
+ }
+ for _, removed := range h.LabelsRemoved {
+ if removed == nil || removed.Message == nil || removed.Message.Id == "" {
continue
}
- if _, ok := seen[msg.Id]; ok {
+ addMessage(removed.Message.Id)
+ }
+ for _, msg := range h.Messages {
+ if msg == nil || msg.Id == "" {
continue
}
- seen[msg.Id] = struct{}{}
- out = append(out, msg.Id)
+ addMessage(msg.Id)
}
}
return out
diff --git a/internal/cmd/gmail_watch_server_helpers_test.go b/internal/cmd/gmail_watch_server_helpers_test.go
index 6bbdc7bd..26ea088a 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: ""},
@@ -62,11 +72,30 @@ 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") {
+ if !strings.Contains(joined, "m1") ||
+ !strings.Contains(joined, "m2") ||
+ !strings.Contains(joined, "m3") ||
+ !strings.Contains(joined, "m4") ||
+ !strings.Contains(joined, "m5") ||
+ !strings.Contains(joined, "m6") {
t.Fatalf("unexpected ids: %v", ids)
}
}
+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 TestDecodeGmailPushPayload(t *testing.T) {
payload := `{"emailAddress":"a@b.com","historyId":"123"}`
env := &pubsubPushEnvelope{}
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_types.go b/internal/cmd/gmail_watch_types.go
index 67e7e613..54a570cd 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,43 @@ type gmailWatchServeConfig struct {
VerboseOutput bool
}
+var gmailHistoryTypeAliases = map[string]string{
+ "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 {
+ return nil, 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"`
From 323c3e6dd23037da0b473ff922d6102553dd6950 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 2 Feb 2026 20:15:51 -0800
Subject: [PATCH 02/48] fix(gmail): default history types to messageAdded for
backward compat
The commit 5137fcb added --history-types flag but changed the default
behavior: when not specified, it now fetched ALL history types instead
of just messageAdded (the previous hardcoded behavior).
This fix ensures backward compatibility by defaulting to messageAdded
when --history-types is not provided.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_watch_cmds.go | 2 +-
.../cmd/gmail_watch_server_helpers_test.go | 21 +++++++++++++++++++
internal/cmd/gmail_watch_types.go | 4 +++-
3 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go
index bfbdc1f2..4121ba0b 100644
--- a/internal/cmd/gmail_watch_cmds.go
+++ b/internal/cmd/gmail_watch_cmds.go
@@ -204,7 +204,7 @@ type GmailWatchServeCmd struct {
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: all"`
+ 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"`
}
diff --git a/internal/cmd/gmail_watch_server_helpers_test.go b/internal/cmd/gmail_watch_server_helpers_test.go
index 26ea088a..8db3758d 100644
--- a/internal/cmd/gmail_watch_server_helpers_test.go
+++ b/internal/cmd/gmail_watch_server_helpers_test.go
@@ -96,6 +96,27 @@ func TestParseHistoryTypes(t *testing.T) {
}
}
+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 TestDecodeGmailPushPayload(t *testing.T) {
payload := `{"emailAddress":"a@b.com","historyId":"123"}`
env := &pubsubPushEnvelope{}
diff --git a/internal/cmd/gmail_watch_types.go b/internal/cmd/gmail_watch_types.go
index 54a570cd..cd36877b 100644
--- a/internal/cmd/gmail_watch_types.go
+++ b/internal/cmd/gmail_watch_types.go
@@ -75,7 +75,9 @@ var gmailHistoryTypeAliases = map[string]string{
func parseHistoryTypes(values []string) ([]string, error) {
if len(values) == 0 {
- return nil, nil
+ // 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{})
From 79067b38ce2c20e9765308832eb9cf647dd1f00e Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 2 Feb 2026 20:20:35 -0800
Subject: [PATCH 03/48] fix(gmail): separate deleted message IDs from fetchable
messages
When messageDeleted history type is requested, deleted messages cannot
be fetched from Gmail API (returns 404). Now collectHistoryMessageIDs
returns a struct with separate FetchIDs and DeletedIDs lists, and the
hook payload includes deletedMessageIds field for consumers.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_history.go | 4 +-
internal/cmd/gmail_watch_server.go | 77 +++++++++++++-----
.../cmd/gmail_watch_server_helpers_test.go | 79 ++++++++++++++++---
internal/cmd/gmail_watch_types.go | 9 ++-
4 files changed, 135 insertions(+), 34 deletions(-)
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_watch_server.go b/internal/cmd/gmail_watch_server.go
index 4136d029..0e4cb3ab 100644
--- a/internal/cmd/gmail_watch_server.go
+++ b/internal/cmd/gmail_watch_server.go
@@ -213,8 +213,8 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
return nil, errNoNewMessages
}
- messageIDs := collectHistoryMessageIDs(historyResp)
- msgs, err := s.fetchMessages(ctx, svc, messageIDs)
+ historyIDs := collectHistoryMessageIDs(historyResp)
+ msgs, err := s.fetchMessages(ctx, svc, historyIDs.FetchIDs)
if err != nil {
return nil, err
}
@@ -236,10 +236,11 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
}
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
}
@@ -494,22 +495,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)
}
- seen := make(map[string]struct{})
- out := make([]string, 0)
- addMessage := func(id string) {
+
+ addDeleted := func(id string) {
if strings.TrimSpace(id) == "" {
return
}
- if _, ok := seen[id]; ok {
+ if _, ok := seenDeleted[id]; ok {
return
}
- seen[id] = struct{}{}
- out = append(out, id)
+ // 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)
}
+
for _, h := range resp.History {
if h == nil {
continue
@@ -518,32 +555,32 @@ func collectHistoryMessageIDs(resp *gmail.ListHistoryResponse) []string {
if added == nil || added.Message == nil || added.Message.Id == "" {
continue
}
- addMessage(added.Message.Id)
+ addFetch(added.Message.Id)
}
for _, deleted := range h.MessagesDeleted {
if deleted == nil || deleted.Message == nil || deleted.Message.Id == "" {
continue
}
- addMessage(deleted.Message.Id)
+ addDeleted(deleted.Message.Id)
}
for _, added := range h.LabelsAdded {
if added == nil || added.Message == nil || added.Message.Id == "" {
continue
}
- addMessage(added.Message.Id)
+ addFetch(added.Message.Id)
}
for _, removed := range h.LabelsRemoved {
if removed == nil || removed.Message == nil || removed.Message.Id == "" {
continue
}
- addMessage(removed.Message.Id)
+ addFetch(removed.Message.Id)
}
for _, msg := range h.Messages {
if msg == nil || msg.Id == "" {
continue
}
- addMessage(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 8db3758d..45ae490b 100644
--- a/internal/cmd/gmail_watch_server_helpers_test.go
+++ b/internal/cmd/gmail_watch_server_helpers_test.go
@@ -70,15 +70,76 @@ 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") ||
- !strings.Contains(joined, "m4") ||
- !strings.Contains(joined, "m5") ||
- !strings.Contains(joined, "m6") {
- 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)
+ }
+
+ // 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")
}
}
diff --git a/internal/cmd/gmail_watch_types.go b/internal/cmd/gmail_watch_types.go
index cd36877b..5f081290 100644
--- a/internal/cmd/gmail_watch_types.go
+++ b/internal/cmd/gmail_watch_types.go
@@ -159,8 +159,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"`
}
From 6553de8d4339576df5c2bff9eaddad854c8cb33b Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 2 Feb 2026 20:24:48 -0800
Subject: [PATCH 04/48] fix(gmail): address code review minor issues
- Add canonical forms to history type alias map for robustness
- Add length assertion for FetchIDs in TestCollectHistoryMessageIDs
- Clarify documentation that default is messageAdded
- Add test for empty input edge case in parseHistoryTypes
- Extract duplicate state update logic into updateStateAfterHistory helper
Co-Authored-By: Claude Opus 4.5
---
docs/watch.md | 2 +-
internal/cmd/gmail_watch_server.go | 56 +++++++------------
.../cmd/gmail_watch_server_helpers_test.go | 37 ++++++++++++
internal/cmd/gmail_watch_types.go | 6 ++
4 files changed, 64 insertions(+), 37 deletions(-)
diff --git a/docs/watch.md b/docs/watch.md
index d6256357..feadc002 100644
--- a/docs/watch.md
+++ b/docs/watch.md
@@ -59,7 +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: all.
+- `watch serve --history-types` accepts `messageAdded`, `messageDeleted`, `labelAdded`, `labelRemoved` (repeatable or comma-separated). Default: `messageAdded` (for backward compatibility).
## State
diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go
index 0e4cb3ab..4a75819d 100644
--- a/internal/cmd/gmail_watch_server.go
+++ b/internal/cmd/gmail_watch_server.go
@@ -195,18 +195,7 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
}
if len(s.cfg.HistoryTypes) > 0 && (historyResp == nil || len(historyResp.History) == 0) {
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
- }
- state.UpdatedAtMs = time.Now().UnixMilli()
- return nil
+ return updateStateAfterHistory(state, nextHistoryID, payload.MessageID)
}); err != nil {
s.warnf("watch: failed to update state: %v", err)
}
@@ -219,18 +208,7 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
return nil, err
}
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
- }
- state.UpdatedAtMs = time.Now().UnixMilli()
- return nil
+ return updateStateAfterHistory(state, nextHistoryID, payload.MessageID)
}); err != nil {
s.warnf("watch: failed to update state: %v", err)
}
@@ -261,18 +239,7 @@ func (s *gmailWatchServer) resyncHistory(ctx context.Context, svc *gmail.Service
}
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
+ return updateStateAfterHistory(state, historyID, messageID)
}); err != nil {
s.warnf("watch: failed to update state after resync: %v", err)
}
@@ -463,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) {
diff --git a/internal/cmd/gmail_watch_server_helpers_test.go b/internal/cmd/gmail_watch_server_helpers_test.go
index 45ae490b..4e6cdcee 100644
--- a/internal/cmd/gmail_watch_server_helpers_test.go
+++ b/internal/cmd/gmail_watch_server_helpers_test.go
@@ -87,6 +87,11 @@ func TestCollectHistoryMessageIDs(t *testing.T) {
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)
@@ -178,6 +183,38 @@ func TestParseHistoryTypes_DefaultsToMessageAdded(t *testing.T) {
}
}
+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)
+ }
+}
+
func TestDecodeGmailPushPayload(t *testing.T) {
payload := `{"emailAddress":"a@b.com","historyId":"123"}`
env := &pubsubPushEnvelope{}
diff --git a/internal/cmd/gmail_watch_types.go b/internal/cmd/gmail_watch_types.go
index 5f081290..4ca3abad 100644
--- a/internal/cmd/gmail_watch_types.go
+++ b/internal/cmd/gmail_watch_types.go
@@ -63,6 +63,12 @@ type gmailWatchServeConfig struct {
}
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",
From 71714aeca55c905f486a8f218dd398f75335ca85 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 2 Feb 2026 20:27:55 -0800
Subject: [PATCH 05/48] fix(gmail): resolve variable shadowing lint warning
Rename inner `err` to `updateErr` to avoid shadowing the outer
variable, satisfying golangci-lint's govet shadow checker.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_watch_server.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go
index 4a75819d..f939c617 100644
--- a/internal/cmd/gmail_watch_server.go
+++ b/internal/cmd/gmail_watch_server.go
@@ -194,10 +194,10 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
nextHistoryID = formatHistoryID(historyResp.HistoryId)
}
if len(s.cfg.HistoryTypes) > 0 && (historyResp == nil || len(historyResp.History) == 0) {
- if err := store.Update(func(state *gmailWatchState) error {
+ if updateErr := store.Update(func(state *gmailWatchState) error {
return updateStateAfterHistory(state, nextHistoryID, payload.MessageID)
- }); err != nil {
- s.warnf("watch: failed to update state: %v", err)
+ }); updateErr != nil {
+ s.warnf("watch: failed to update state: %v", updateErr)
}
return nil, errNoNewMessages
}
From 71b54468cc92a945821b4a6c35b68b0953da2e3c Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 01:27:42 -0800
Subject: [PATCH 06/48] fix(gmail): resolve remaining variable shadowing lint
warnings
Rename inner `err` to `updateErr` in two additional store.Update()
calls to avoid shadowing outer variables, completing the fix from
commit 71714ae. Both locations now match the established pattern.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_watch_server.go | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go
index f939c617..bfcc61aa 100644
--- a/internal/cmd/gmail_watch_server.go
+++ b/internal/cmd/gmail_watch_server.go
@@ -207,10 +207,10 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
if err != nil {
return nil, err
}
- if err := store.Update(func(state *gmailWatchState) error {
+ if updateErr := store.Update(func(state *gmailWatchState) error {
return updateStateAfterHistory(state, nextHistoryID, payload.MessageID)
- }); err != nil {
- s.warnf("watch: failed to update state: %v", err)
+ }); updateErr != nil {
+ s.warnf("watch: failed to update state: %v", updateErr)
}
return &gmailHookPayload{
@@ -238,10 +238,10 @@ func (s *gmailWatchServer) resyncHistory(ctx context.Context, svc *gmail.Service
return nil, err
}
- if err := s.store.Update(func(state *gmailWatchState) error {
+ if updateErr := s.store.Update(func(state *gmailWatchState) error {
return updateStateAfterHistory(state, historyID, messageID)
- }); err != nil {
- s.warnf("watch: failed to update state after resync: %v", err)
+ }); updateErr != nil {
+ s.warnf("watch: failed to update state after resync: %v", updateErr)
}
return &gmailHookPayload{
From 704d368ef57a8ce988444ff46dc7ad6bebd05749 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 02:12:38 -0800
Subject: [PATCH 07/48] feat(admin): add workspace admin foundation
---
internal/cmd/admin_directory_helpers.go | 3 +
internal/cmd/admingroups_test.go | 122 +++++++
internal/cmd/aliases.go | 180 ++++++++++
internal/cmd/aliases_test.go | 35 ++
internal/cmd/domains.go | 15 +
internal/cmd/domains_aliases.go | 119 +++++++
internal/cmd/domains_create.go | 42 +++
internal/cmd/domains_delete.go | 36 ++
internal/cmd/domains_get.go | 51 +++
internal/cmd/domains_list.go | 56 +++
internal/cmd/domains_test.go | 37 ++
internal/cmd/groups.go | 17 +-
internal/cmd/groups_admin.go | 435 ++++++++++++++++++++++++
internal/cmd/orgunits.go | 9 +
internal/cmd/orgunits_create.go | 55 +++
internal/cmd/orgunits_delete.go | 36 ++
internal/cmd/orgunits_get.go | 50 +++
internal/cmd/orgunits_list.go | 68 ++++
internal/cmd/orgunits_test.go | 37 ++
internal/cmd/orgunits_update.go | 67 ++++
internal/cmd/root.go | 6 +-
internal/cmd/users.go | 96 ++++++
internal/cmd/users_2fa.go | 136 ++++++++
internal/cmd/users_asps.go | 94 +++++
internal/cmd/users_count.go | 95 ++++++
internal/cmd/users_create.go | 98 ++++++
internal/cmd/users_delete.go | 36 ++
internal/cmd/users_get.go | 77 +++++
internal/cmd/users_list.go | 121 +++++++
internal/cmd/users_password.go | 76 +++++
internal/cmd/users_signout.go | 32 ++
internal/cmd/users_suspend.go | 64 ++++
internal/cmd/users_test.go | 193 +++++++++++
internal/cmd/users_tokens.go | 86 +++++
internal/cmd/users_update.go | 117 +++++++
internal/googleapi/admin_directory.go | 22 ++
internal/googleapi/groupssettings.go | 22 ++
internal/googleauth/service.go | 52 ++-
internal/googleauth/service_test.go | 5 +-
39 files changed, 2878 insertions(+), 20 deletions(-)
create mode 100644 internal/cmd/admin_directory_helpers.go
create mode 100644 internal/cmd/admingroups_test.go
create mode 100644 internal/cmd/aliases.go
create mode 100644 internal/cmd/aliases_test.go
create mode 100644 internal/cmd/domains.go
create mode 100644 internal/cmd/domains_aliases.go
create mode 100644 internal/cmd/domains_create.go
create mode 100644 internal/cmd/domains_delete.go
create mode 100644 internal/cmd/domains_get.go
create mode 100644 internal/cmd/domains_list.go
create mode 100644 internal/cmd/domains_test.go
create mode 100644 internal/cmd/groups_admin.go
create mode 100644 internal/cmd/orgunits.go
create mode 100644 internal/cmd/orgunits_create.go
create mode 100644 internal/cmd/orgunits_delete.go
create mode 100644 internal/cmd/orgunits_get.go
create mode 100644 internal/cmd/orgunits_list.go
create mode 100644 internal/cmd/orgunits_test.go
create mode 100644 internal/cmd/orgunits_update.go
create mode 100644 internal/cmd/users.go
create mode 100644 internal/cmd/users_2fa.go
create mode 100644 internal/cmd/users_asps.go
create mode 100644 internal/cmd/users_count.go
create mode 100644 internal/cmd/users_create.go
create mode 100644 internal/cmd/users_delete.go
create mode 100644 internal/cmd/users_get.go
create mode 100644 internal/cmd/users_list.go
create mode 100644 internal/cmd/users_password.go
create mode 100644 internal/cmd/users_signout.go
create mode 100644 internal/cmd/users_suspend.go
create mode 100644 internal/cmd/users_test.go
create mode 100644 internal/cmd/users_tokens.go
create mode 100644 internal/cmd/users_update.go
create mode 100644 internal/googleapi/admin_directory.go
create mode 100644 internal/googleapi/groupssettings.go
diff --git a/internal/cmd/admin_directory_helpers.go b/internal/cmd/admin_directory_helpers.go
new file mode 100644
index 00000000..fb4b9d55
--- /dev/null
+++ b/internal/cmd/admin_directory_helpers.go
@@ -0,0 +1,3 @@
+package cmd
+
+const adminCustomerID = "my_customer"
diff --git a/internal/cmd/admingroups_test.go b/internal/cmd/admingroups_test.go
new file mode 100644
index 00000000..5293f764
--- /dev/null
+++ b/internal/cmd/admingroups_test.go
@@ -0,0 +1,122 @@
+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) *httptest.Server {
+ t.Helper()
+
+ srv := httptest.NewServer(handler)
+ orig := newGroupsSettings
+ svc, err := groupssettings.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ 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()
+ })
+ return srv
+}
+
+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()
+}
diff --git a/internal/cmd/aliases.go b/internal/cmd/aliases.go
new file mode 100644
index 00000000..03fc419b
--- /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..8b41274c
--- /dev/null
+++ b/internal/cmd/aliases_test.go
@@ -0,0 +1,35 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+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)
+ }
+}
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..2591ca4f
--- /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..f55c4c07
--- /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..a6aa4a8a
--- /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..b42d4377
--- /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..d985bdfc
--- /dev/null
+++ b/internal/cmd/domains_list.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type DomainsListCmd struct{}
+
+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 outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Domains) == 0 {
+ u.Err().Println("No domains found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "DOMAIN\tPRIMARY\tVERIFIED\tCREATED")
+ for _, domain := range resp.Domains {
+ if domain == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%t\t%t\t%s\n",
+ sanitizeTab(domain.DomainName),
+ domain.IsPrimary,
+ domain.Verified,
+ formatUnixSeconds(domain.CreationTime),
+ )
+ }
+
+ return nil
+}
diff --git a/internal/cmd/domains_test.go b/internal/cmd/domains_test.go
new file mode 100644
index 00000000..fe5b541c
--- /dev/null
+++ b/internal/cmd/domains_test.go
@@ -0,0 +1,37 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+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)
+ }
+}
diff --git a/internal/cmd/groups.go b/internal/cmd/groups.go
index a398a057..4c196361 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,12 @@ 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"`
+ 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"`
+ Add GroupsMembersAddCmd `cmd:"" name:"add" help:"Add member to group (admin)"`
+ Remove GroupsMembersRemoveCmd `cmd:"" name:"remove" help:"Remove member from group (admin)"`
+ Sync GroupsMembersSyncCmd `cmd:"" name:"sync" help:"Sync group members from CSV (admin)"`
}
func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
diff --git a/internal/cmd/groups_admin.go b/internal/cmd/groups_admin.go
new file mode 100644
index 00000000..81801b38
--- /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, err := svc.Groups.Get(c.Group).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("get group settings %s: %w", c.Group, err)
+ }
+ 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)
+ 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/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..169be1a1
--- /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..0714d2f2
--- /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..98f45673
--- /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..d71e07fe
--- /dev/null
+++ b/internal/cmd/orgunits_list.go
@@ -0,0 +1,68 @@
+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"`
+}
+
+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 outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.OrganizationUnits) == 0 {
+ u.Err().Println("No organizational units found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "PATH\tNAME\tID\tDESCRIPTION")
+ for _, ou := range resp.OrganizationUnits {
+ if ou == nil {
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(ou.OrgUnitPath),
+ sanitizeTab(ou.Name),
+ sanitizeTab(ou.OrgUnitId),
+ sanitizeTab(ou.Description),
+ )
+ }
+ return nil
+}
diff --git a/internal/cmd/orgunits_test.go b/internal/cmd/orgunits_test.go
new file mode 100644
index 00000000..c561c99f
--- /dev/null
+++ b/internal/cmd/orgunits_test.go
@@ -0,0 +1,37 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+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)
+ }
+}
diff --git a/internal/cmd/orgunits_update.go b/internal/cmd/orgunits_update.go
new file mode 100644
index 00000000..055e82ce
--- /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/root.go b/internal/cmd/root.go
index 28012deb..e3e7163f 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -25,7 +25,7 @@ const (
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}"`
@@ -42,6 +42,10 @@ type CLI struct {
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"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
diff --git a/internal/cmd/users.go b/internal/cmd/users.go
new file mode 100644
index 00000000..f5ade6d9
--- /dev/null
+++ b/internal/cmd/users.go
@@ -0,0 +1,96 @@
+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 all = lower + upper + digits
+
+ sets := []string{lower, upper, digits}
+ 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 randInt(max int) (int, error) {
+ if max <= 0 {
+ return 0, fmt.Errorf("invalid max %d", max)
+ }
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
+ 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..52a43bff
--- /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_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..9fc3a7e3
--- /dev/null
+++ b/internal/cmd/users_create.go
@@ -0,0 +1,98 @@
+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" enum:"MD5,SHA-1,crypt" help:"Password hash function if pre-hashed"`
+}
+
+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 != "" {
+ user.HashFunction = c.HashFunction
+ }
+ 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..d1a093b4
--- /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..47294f7a
--- /dev/null
+++ b/internal/cmd/users_list.go
@@ -0,0 +1,121 @@
+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)"`
+}
+
+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 outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Users) == 0 {
+ u.Err().Println("No users found")
+ return nil
+ }
+
+ tw, flush := tableWriter(ctx)
+ defer flush()
+
+ fmt.Fprintln(tw, "EMAIL\tNAME\tSUSPENDED\tADMIN\tORG UNIT\tLAST LOGIN")
+ 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}, " "))
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
+ sanitizeTab(user.PrimaryEmail),
+ sanitizeTab(name),
+ sanitizeTab(suspended),
+ sanitizeTab(admin),
+ sanitizeTab(user.OrgUnitPath),
+ sanitizeTab(formatDateTime(user.LastLoginTime)),
+ )
+ }
+
+ 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..2b14e7d4
--- /dev/null
+++ b/internal/cmd/users_password.go
@@ -0,0 +1,76 @@
+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" enum:"MD5,SHA-1,crypt" help:"Password hash function if pre-hashed"`
+}
+
+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 != "" {
+ user.HashFunction = c.HashFunction
+ }
+
+ 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..5435dd6d
--- /dev/null
+++ b/internal/cmd/users_test.go
@@ -0,0 +1,193 @@
+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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
+
+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..4bc0d8e4
--- /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/googleapi/admin_directory.go b/internal/googleapi/admin_directory.go
new file mode 100644
index 00000000..3316ad09
--- /dev/null
+++ b/internal/googleapi/admin_directory.go
@@ -0,0 +1,22 @@
+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/groupssettings.go b/internal/googleapi/groupssettings.go
new file mode 100644
index 00000000..0c5db7df
--- /dev/null
+++ b/internal/googleapi/groupssettings.go
@@ -0,0 +1,22 @@
+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/googleauth/service.go b/internal/googleauth/service.go
index e59cdb4e..67246893 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -10,18 +10,19 @@ 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"
)
const (
@@ -68,6 +69,7 @@ var serviceOrder = []Service{
ServicePeople,
ServiceGroups,
ServiceKeep,
+ ServiceAdminDirectory,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -170,6 +172,32 @@ 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",
+ },
+ user: false,
+ apis: []string{"Admin SDK Directory API", "Groups Settings API"},
+ note: "Workspace admin (domain-wide delegation)",
+ },
}
func ParseService(s string) (Service, error) {
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 1bb65ad0..8ba6bce9 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -20,6 +20,7 @@ func TestParseService(t *testing.T) {
{"sheets", ServiceSheets},
{"groups", ServiceGroups},
{"keep", ServiceKeep},
+ {"admin", ServiceAdminDirectory},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -62,7 +63,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 12 {
+ if len(svcs) != 13 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -71,7 +72,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} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
From 60983d4cc8ccb456cea9b2c4268af60fdcb0fc10 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 02:51:15 -0800
Subject: [PATCH 08/48] feat(admin): add roles reports vault commands
---
internal/cmd/admins.go | 210 +++++++++++++
internal/cmd/reports.go | 380 ++++++++++++++++++++++
internal/cmd/reports_test.go | 108 +++++++
internal/cmd/roles.go | 469 ++++++++++++++++++++++++++++
internal/cmd/roles_test.go | 167 ++++++++++
internal/cmd/root.go | 4 +
internal/cmd/vault.go | 14 +
internal/cmd/vault_exports.go | 248 +++++++++++++++
internal/cmd/vault_holds.go | 231 ++++++++++++++
internal/cmd/vault_matters.go | 295 +++++++++++++++++
internal/cmd/vault_test.go | 138 ++++++++
internal/googleapi/reports.go | 22 ++
internal/googleapi/storage.go | 22 ++
internal/googleapi/vault.go | 22 ++
internal/googleauth/service.go | 23 ++
internal/googleauth/service_test.go | 6 +-
16 files changed, 2357 insertions(+), 2 deletions(-)
create mode 100644 internal/cmd/admins.go
create mode 100644 internal/cmd/reports.go
create mode 100644 internal/cmd/reports_test.go
create mode 100644 internal/cmd/roles.go
create mode 100644 internal/cmd/roles_test.go
create mode 100644 internal/cmd/vault.go
create mode 100644 internal/cmd/vault_exports.go
create mode 100644 internal/cmd/vault_holds.go
create mode 100644 internal/cmd/vault_matters.go
create mode 100644 internal/cmd/vault_test.go
create mode 100644 internal/googleapi/reports.go
create mode 100644 internal/googleapi/storage.go
create mode 100644 internal/googleapi/vault.go
diff --git a/internal/cmd/admins.go b/internal/cmd/admins.go
new file mode 100644
index 00000000..f6bc2c48
--- /dev/null
+++ b/internal/cmd/admins.go
@@ -0,0 +1,210 @@
+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) != "" {
+ 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/reports.go b/internal/cmd/reports.go
new file mode 100644
index 00000000..c8cee87f
--- /dev/null
+++ b/internal/cmd/reports.go
@@ -0,0 +1,380 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ 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"`
+}
+
+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,
+ })
+}
+
+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"`
+}
+
+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,
+ })
+}
+
+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"`
+}
+
+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,
+ })
+}
+
+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"`
+}
+
+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,
+ })
+}
+
+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
+}
+
+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 outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(os.Stdout, resp)
+ }
+
+ if len(resp.Items) == 0 {
+ u.Err().Println("No events found")
+ return nil
+ }
+
+ w, flush := tableWriter(ctx)
+ defer flush()
+ fmt.Fprintln(w, "TIME\tACTOR\tIP\tEVENTS")
+ 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)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ sanitizeTab(timeStr),
+ sanitizeTab(actor),
+ sanitizeTab(item.IpAddress),
+ sanitizeTab(events),
+ )
+ }
+ 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..9979907a
--- /dev/null
+++ b/internal/cmd/reports_test.go
@@ -0,0 +1,108 @@
+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"
+)
+
+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 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 stubReports(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/roles.go b/internal/cmd/roles.go
new file mode 100644
index 00000000..90cfe634
--- /dev/null
+++ b/internal/cmd/roles.go
@@ -0,0 +1,469 @@
+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)
+ }
+
+ fmt.Fprintf(os.Stdout, "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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "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..757f9ac0
--- /dev/null
+++ b/internal/cmd/roles_test.go
@@ -0,0 +1,167 @@
+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(testContext(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)
+ }
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index e3e7163f..ce42c4fc 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -46,6 +46,10 @@ type CLI struct {
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"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
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..6ac33a0f
--- /dev/null
+++ b/internal/cmd/vault_exports.go
@@ -0,0 +1,248 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Export ID: %s\n", exp.Id)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", exp.Name)
+ fmt.Fprintf(os.Stdout, "Status: %s\n", exp.Status)
+ fmt.Fprintf(os.Stdout, "Created: %s\n", exp.CreateTime)
+ if exp.Query != nil {
+ fmt.Fprintf(os.Stdout, "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)
+ }
+
+ fmt.Fprintf(os.Stdout, "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, 0o755); 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)
+ 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..58009325
--- /dev/null
+++ b/internal/cmd/vault_holds.go
@@ -0,0 +1,231 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Hold ID: %s\n", hold.HoldId)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", hold.Name)
+ fmt.Fprintf(os.Stdout, "Corpus: %s\n", hold.Corpus)
+ fmt.Fprintf(os.Stdout, "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, err := newAdminDirectory(ctx, account)
+ if err != nil {
+ return err
+ }
+ orgID, err := resolveOrgUnitID(ctx, adminSvc, orgUnit)
+ if err != nil {
+ return err
+ }
+ hold.OrgUnit = &vault.HeldOrgUnit{OrgUnitId: orgID}
+ }
+
+ if strings.TrimSpace(c.Query) != "" {
+ if hold.Corpus == "DRIVE" {
+ return usage("drive holds do not support --query")
+ }
+ if hold.Corpus == "MAIL" {
+ hold.Query = &vault.CorpusQuery{MailQuery: &vault.HeldMailQuery{Terms: c.Query}}
+ } else if hold.Corpus == "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)
+ }
+
+ fmt.Fprintf(os.Stdout, "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..cdef6858
--- /dev/null
+++ b/internal/cmd/vault_matters.go
@@ -0,0 +1,295 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Matter ID: %s\n", matter.MatterId)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", matter.Name)
+ fmt.Fprintf(os.Stdout, "State: %s\n", matter.State)
+ if matter.Description != "" {
+ fmt.Fprintf(os.Stdout, "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)
+ }
+
+ fmt.Fprintf(os.Stdout, "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)
+ }
+
+ fmt.Fprintf(os.Stdout, "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
+ }
+ fmt.Fprintf(os.Stdout, "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..5a8f714b
--- /dev/null
+++ b/internal/cmd/vault_test.go
@@ -0,0 +1,138 @@
+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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
+
+func stubStorage(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/googleapi/reports.go b/internal/googleapi/reports.go
new file mode 100644
index 00000000..fc6b82a1
--- /dev/null
+++ b/internal/googleapi/reports.go
@@ -0,0 +1,22 @@
+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/storage.go b/internal/googleapi/storage.go
new file mode 100644
index 00000000..49953a5a
--- /dev/null
+++ b/internal/googleapi/storage.go
@@ -0,0 +1,22 @@
+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..3df23b20
--- /dev/null
+++ b/internal/googleapi/vault.go
@@ -0,0 +1,22 @@
+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/googleauth/service.go b/internal/googleauth/service.go
index 67246893..8f9ffa24 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -23,6 +23,8 @@ const (
ServiceGroups Service = "groups"
ServiceKeep Service = "keep"
ServiceAdminDirectory Service = "admin"
+ ServiceReports Service = "reports"
+ ServiceVault Service = "vault"
)
const (
@@ -70,6 +72,8 @@ var serviceOrder = []Service{
ServiceGroups,
ServiceKeep,
ServiceAdminDirectory,
+ ServiceReports,
+ ServiceVault,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -198,6 +202,25 @@ var serviceInfoByService = map[Service]serviceInfo{
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)",
+ },
}
func ParseService(s string) (Service, error) {
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 8ba6bce9..802e0120 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -21,6 +21,8 @@ func TestParseService(t *testing.T) {
{"groups", ServiceGroups},
{"keep", ServiceKeep},
{"admin", ServiceAdminDirectory},
+ {"reports", ServiceReports},
+ {"vault", ServiceVault},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -63,7 +65,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 13 {
+ if len(svcs) != 15 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -72,7 +74,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, ServiceAdminDirectory} {
+ for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep, ServiceAdminDirectory, ServiceReports, ServiceVault} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
From a3ff325c7f6b7f1ce58b484445f5bcd7cac1bb92 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 02:51:35 -0800
Subject: [PATCH 09/48] fix(cli): tighten admin command parsing
---
internal/cmd/admingroups_test.go | 2 +-
internal/cmd/domains_test.go | 2 +-
internal/cmd/groups.go | 66 ++++++++++++++++++++++++++++----
internal/cmd/users.go | 15 ++++++++
internal/cmd/users_create.go | 8 +++-
internal/cmd/users_password.go | 8 +++-
6 files changed, 87 insertions(+), 14 deletions(-)
diff --git a/internal/cmd/admingroups_test.go b/internal/cmd/admingroups_test.go
index 5293f764..9a8d880a 100644
--- a/internal/cmd/admingroups_test.go
+++ b/internal/cmd/admingroups_test.go
@@ -81,7 +81,7 @@ func stubGroupsSettings(t *testing.T, handler http.Handler) *httptest.Server {
svc, err := groupssettings.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
- option.WithEndpoint(srv.URL+"/"),
+ option.WithEndpoint(srv.URL+"/groups/v1/groups/"),
)
if err != nil {
t.Fatalf("new groupssettings service: %v", err)
diff --git a/internal/cmd/domains_test.go b/internal/cmd/domains_test.go
index fe5b541c..eb87bd74 100644
--- a/internal/cmd/domains_test.go
+++ b/internal/cmd/domains_test.go
@@ -16,7 +16,7 @@ func TestDomainsListCmd(t *testing.T) {
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},
+ {"domainName": "example.com", "isPrimary": true, "verified": true, "creationTime": "1700000000"},
},
})
})
diff --git a/internal/cmd/groups.go b/internal/cmd/groups.go
index 4c196361..d93e69fe 100644
--- a/internal/cmd/groups.go
+++ b/internal/cmd/groups.go
@@ -136,12 +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"`
- Add GroupsMembersAddCmd `cmd:"" name:"add" help:"Add member to group (admin)"`
- Remove GroupsMembersRemoveCmd `cmd:"" name:"remove" help:"Remove member from group (admin)"`
- Sync GroupsMembersSyncCmd `cmd:"" name:"sync" help:"Sync group members from CSV (admin)"`
+ 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 {
@@ -151,9 +152,58 @@ 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")
+ }
+ action = ""
+ 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/users.go b/internal/cmd/users.go
index f5ade6d9..f96ba06d 100644
--- a/internal/cmd/users.go
+++ b/internal/cmd/users.go
@@ -84,6 +84,21 @@ func randChar(set string) (byte, error) {
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(max int) (int, error) {
if max <= 0 {
return 0, fmt.Errorf("invalid max %d", max)
diff --git a/internal/cmd/users_create.go b/internal/cmd/users_create.go
index 9fc3a7e3..42f820c4 100644
--- a/internal/cmd/users_create.go
+++ b/internal/cmd/users_create.go
@@ -22,7 +22,7 @@ type UsersCreateCmd struct {
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" enum:"MD5,SHA-1,crypt" help:"Password hash function if pre-hashed"`
+ 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 {
@@ -61,7 +61,11 @@ func (c *UsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if c.HashFunction != "" {
- user.HashFunction = c.HashFunction
+ hash, err := normalizeUserHashFunction(c.HashFunction)
+ if err != nil {
+ return err
+ }
+ user.HashFunction = hash
}
if c.RecoveryEmail != "" {
user.RecoveryEmail = c.RecoveryEmail
diff --git a/internal/cmd/users_password.go b/internal/cmd/users_password.go
index 2b14e7d4..75010cee 100644
--- a/internal/cmd/users_password.go
+++ b/internal/cmd/users_password.go
@@ -15,7 +15,7 @@ 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" enum:"MD5,SHA-1,crypt" help:"Password hash function if pre-hashed"`
+ 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 {
@@ -46,7 +46,11 @@ func (c *UsersPasswordCmd) Run(ctx context.Context, flags *RootFlags) error {
}
user.ForceSendFields = append(user.ForceSendFields, "ChangePasswordAtNextLogin")
if c.HashFunction != "" {
- user.HashFunction = c.HashFunction
+ hash, err := normalizeUserHashFunction(c.HashFunction)
+ if err != nil {
+ return err
+ }
+ user.HashFunction = hash
}
updated, err := svc.Users.Update(c.User, user).Context(ctx).Do()
From bbcf8812468cddb3c148f0e81463270ff04acee8 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 03:53:38 -0800
Subject: [PATCH 10/48] feat(admin): add alert center, inbound sso, caa
---
internal/cmd/alerts.go | 359 ++++++++++++++++++++++
internal/cmd/alerts_test.go | 112 +++++++
internal/cmd/caa.go | 421 +++++++++++++++++++++++++
internal/cmd/caa_test.go | 126 ++++++++
internal/cmd/root.go | 3 +
internal/cmd/sso.go | 460 ++++++++++++++++++++++++++++
internal/cmd/sso_test.go | 130 ++++++++
internal/googleapi/accesscontext.go | 22 ++
internal/googleapi/alertcenter.go | 22 ++
internal/googleapi/cloudidentity.go | 15 +
internal/googleauth/service.go | 31 ++
internal/googleauth/service_test.go | 7 +-
12 files changed, 1706 insertions(+), 2 deletions(-)
create mode 100644 internal/cmd/alerts.go
create mode 100644 internal/cmd/alerts_test.go
create mode 100644 internal/cmd/caa.go
create mode 100644 internal/cmd/caa_test.go
create mode 100644 internal/cmd/sso.go
create mode 100644 internal/cmd/sso_test.go
create mode 100644 internal/googleapi/accesscontext.go
create mode 100644 internal/googleapi/alertcenter.go
diff --git a/internal/cmd/alerts.go b/internal/cmd/alerts.go
new file mode 100644
index 00000000..cddb2521
--- /dev/null
+++ b/internal/cmd/alerts.go
@@ -0,0 +1,359 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Alert ID: %s\n", alert.AlertId)
+ fmt.Fprintf(os.Stdout, "Type: %s\n", alert.Type)
+ fmt.Fprintf(os.Stdout, "Source: %s\n", alert.Source)
+ fmt.Fprintf(os.Stdout, "Created: %s\n", alert.CreateTime)
+ fmt.Fprintf(os.Stdout, "Updated: %s\n", alert.UpdateTime)
+ fmt.Fprintf(os.Stdout, "Deleted: %t\n", alert.Deleted)
+ if alert.StartTime != "" {
+ fmt.Fprintf(os.Stdout, "Start Time: %s\n", alert.StartTime)
+ }
+ if alert.EndTime != "" {
+ fmt.Fprintf(os.Stdout, "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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Notifications: %d\n", len(settings.Notifications))
+ for _, n := range settings.Notifications {
+ if n == nil || n.CloudPubsubTopic == nil {
+ continue
+ }
+ fmt.Fprintf(os.Stdout, "- %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..250a7598
--- /dev/null
+++ b/internal/cmd/alerts_test.go
@@ -0,0 +1,112 @@
+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"
+)
+
+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 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 stubAlertCenter(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/caa.go b/internal/cmd/caa.go
new file mode 100644
index 00000000..bf1c3dca
--- /dev/null
+++ b/internal/cmd/caa.go
@@ -0,0 +1,421 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Name: %s\n", level.Name)
+ if level.Title != "" {
+ fmt.Fprintf(os.Stdout, "Title: %s\n", level.Title)
+ }
+ if level.Description != "" {
+ fmt.Fprintf(os.Stdout, "Description: %s\n", level.Description)
+ }
+ fmt.Fprintf(os.Stdout, "Type: %s\n", accessLevelType(level))
+ if level.Custom != nil && level.Custom.Expr != nil && level.Custom.Expr.Expression != "" {
+ fmt.Fprintf(os.Stdout, "Expression: %s\n", level.Custom.Expr.Expression)
+ }
+ if level.Basic != nil {
+ fmt.Fprintf(os.Stdout, "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 {
+ 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 {
+ 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 "unknown"
+}
+
+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..62a22fcf
--- /dev/null
+++ b/internal/cmd/caa_test.go
@@ -0,0 +1,126 @@
+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 stubAccessContextManager(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index ce42c4fc..4fb29e9a 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -50,6 +50,9 @@ type CLI struct {
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"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
diff --git a/internal/cmd/sso.go b/internal/cmd/sso.go
new file mode 100644
index 00000000..19365d87
--- /dev/null
+++ b/internal/cmd/sso.go
@@ -0,0 +1,460 @@
+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"
+)
+
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Profile: %s\n", profile.Name)
+ if profile.DisplayName != "" {
+ fmt.Fprintf(os.Stdout, "Display Name: %s\n", profile.DisplayName)
+ }
+ if profile.IdpConfig != nil {
+ if profile.IdpConfig.EntityId != "" {
+ fmt.Fprintf(os.Stdout, "Entity ID: %s\n", profile.IdpConfig.EntityId)
+ }
+ if profile.IdpConfig.SingleSignOnServiceUri != "" {
+ fmt.Fprintf(os.Stdout, "SSO URL: %s\n", profile.IdpConfig.SingleSignOnServiceUri)
+ }
+ if profile.IdpConfig.LogoutRedirectUri != "" {
+ fmt.Fprintf(os.Stdout, "Logout URL: %s\n", profile.IdpConfig.LogoutRedirectUri)
+ }
+ if profile.IdpConfig.ChangePasswordUri != "" {
+ fmt.Fprintf(os.Stdout, "Change Password: %s\n", profile.IdpConfig.ChangePasswordUri)
+ }
+ }
+ if profile.SpConfig != nil {
+ if profile.SpConfig.EntityId != "" {
+ fmt.Fprintf(os.Stdout, "SP Entity ID: %s\n", profile.SpConfig.EntityId)
+ }
+ if profile.SpConfig.AssertionConsumerServiceUri != "" {
+ fmt.Fprintf(os.Stdout, "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 "SSO_OFF":
+ return "SSO_OFF", 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
+}
+
+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)
+ 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)
+ 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..1c67d400
--- /dev/null
+++ b/internal/cmd/sso_test.go
@@ -0,0 +1,130 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/cloudidentity/v1"
+ "google.golang.org/api/option"
+)
+
+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(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "https://sso.example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+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 stubInboundSSO(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/googleapi/accesscontext.go b/internal/googleapi/accesscontext.go
new file mode 100644
index 00000000..469b02de
--- /dev/null
+++ b/internal/googleapi/accesscontext.go
@@ -0,0 +1,22 @@
+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/alertcenter.go b/internal/googleapi/alertcenter.go
new file mode 100644
index 00000000..60f40520
--- /dev/null
+++ b/internal/googleapi/alertcenter.go
@@ -0,0 +1,22 @@
+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/cloudidentity.go b/internal/googleapi/cloudidentity.go
index d982f01d..bbed3242 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,16 @@ 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
+}
diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go
index 8f9ffa24..f5f8f541 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -25,6 +25,9 @@ const (
ServiceAdminDirectory Service = "admin"
ServiceReports Service = "reports"
ServiceVault Service = "vault"
+ ServiceAlertCenter Service = "alertcenter"
+ ServiceInboundSSO Service = "inboundsso"
+ ServiceAccessContext Service = "accesscontext"
)
const (
@@ -74,6 +77,9 @@ var serviceOrder = []Service{
ServiceAdminDirectory,
ServiceReports,
ServiceVault,
+ ServiceAlertCenter,
+ ServiceInboundSSO,
+ ServiceAccessContext,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -221,6 +227,31 @@ var serviceInfoByService = map[Service]serviceInfo{
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",
+ },
}
func ParseService(s string) (Service, error) {
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index 802e0120..ed6481eb 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -23,6 +23,9 @@ func TestParseService(t *testing.T) {
{"admin", ServiceAdminDirectory},
{"reports", ServiceReports},
{"vault", ServiceVault},
+ {"alertcenter", ServiceAlertCenter},
+ {"inboundsso", ServiceInboundSSO},
+ {"accesscontext", ServiceAccessContext},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -65,7 +68,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 15 {
+ if len(svcs) != 18 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -74,7 +77,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, ServiceAdminDirectory, ServiceReports, ServiceVault} {
+ for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
From d9a8e15dc2fab28805a4a2a7105b307c4b78e7b3 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 04:06:53 -0800
Subject: [PATCH 11/48] feat(admin): add licenses, resources, schemas
---
internal/cmd/licenses.go | 369 ++++++++++++++++++++++++++++
internal/cmd/licenses_test.go | 120 +++++++++
internal/cmd/resources.go | 7 +
internal/cmd/resources_buildings.go | 247 +++++++++++++++++++
internal/cmd/resources_calendars.go | 275 +++++++++++++++++++++
internal/cmd/resources_features.go | 143 +++++++++++
internal/cmd/resources_test.go | 109 ++++++++
internal/cmd/root.go | 3 +
internal/cmd/schemas.go | 347 ++++++++++++++++++++++++++
internal/cmd/schemas_test.go | 90 +++++++
internal/googleapi/licensing.go | 22 ++
internal/googleauth/service.go | 10 +
internal/googleauth/service_test.go | 5 +-
13 files changed, 1745 insertions(+), 2 deletions(-)
create mode 100644 internal/cmd/licenses.go
create mode 100644 internal/cmd/licenses_test.go
create mode 100644 internal/cmd/resources.go
create mode 100644 internal/cmd/resources_buildings.go
create mode 100644 internal/cmd/resources_calendars.go
create mode 100644 internal/cmd/resources_features.go
create mode 100644 internal/cmd/resources_test.go
create mode 100644 internal/cmd/schemas.go
create mode 100644 internal/cmd/schemas_test.go
create mode 100644 internal/googleapi/licensing.go
diff --git a/internal/cmd/licenses.go b/internal/cmd/licenses.go
new file mode 100644
index 00000000..c0f0d2c2
--- /dev/null
+++ b/internal/cmd/licenses.go
@@ -0,0 +1,369 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "User: %s\n", assignment.UserId)
+ fmt.Fprintf(os.Stdout, "Product: %s\n", assignment.ProductId)
+ if assignment.ProductName != "" {
+ fmt.Fprintf(os.Stdout, "Product Name: %s\n", assignment.ProductName)
+ }
+ fmt.Fprintf(os.Stdout, "SKU: %s\n", assignment.SkuId)
+ if assignment.SkuName != "" {
+ fmt.Fprintf(os.Stdout, "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..616854a5
--- /dev/null
+++ b/internal/cmd/licenses_test.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/licensing/v1"
+ "google.golang.org/api/option"
+)
+
+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 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 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 stubLicensing(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
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..ef7693b9
--- /dev/null
+++ b/internal/cmd/resources_buildings.go
@@ -0,0 +1,247 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "ID: %s\n", building.BuildingId)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", building.BuildingName)
+ if building.Description != "" {
+ fmt.Fprintf(os.Stdout, "Description: %s\n", building.Description)
+ }
+ if len(building.FloorNames) > 0 {
+ fmt.Fprintf(os.Stdout, "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..b1f9236d
--- /dev/null
+++ b/internal/cmd/resources_calendars.go
@@ -0,0 +1,275 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "ID: %s\n", resource.ResourceId)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", resource.ResourceName)
+ fmt.Fprintf(os.Stdout, "Email: %s\n", resource.ResourceEmail)
+ fmt.Fprintf(os.Stdout, "Category: %s\n", resource.ResourceCategory)
+ if resource.ResourceDescription != "" {
+ fmt.Fprintf(os.Stdout, "Description: %s\n", resource.ResourceDescription)
+ }
+ if resource.UserVisibleDescription != "" {
+ fmt.Fprintf(os.Stdout, "User Desc: %s\n", resource.UserVisibleDescription)
+ }
+ if resource.BuildingId != "" {
+ fmt.Fprintf(os.Stdout, "Building: %s\n", resource.BuildingId)
+ }
+ if resource.FloorName != "" {
+ fmt.Fprintf(os.Stdout, "Floor: %s\n", resource.FloorName)
+ }
+ if resource.Capacity != 0 {
+ fmt.Fprintf(os.Stdout, "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..860d9eb5
--- /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..9c4e95dc
--- /dev/null
+++ b/internal/cmd/resources_test.go
@@ -0,0 +1,109 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+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 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 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)
+ }
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 4fb29e9a..31996f1f 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -53,6 +53,9 @@ type CLI struct {
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"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
diff --git a/internal/cmd/schemas.go b/internal/cmd/schemas.go
new file mode 100644
index 00000000..64783746
--- /dev/null
+++ b/internal/cmd/schemas.go
@@ -0,0 +1,347 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Name: %s\n", schema.SchemaName)
+ fmt.Fprintf(os.Stdout, "ID: %s\n", schema.SchemaId)
+ if schema.DisplayName != "" {
+ fmt.Fprintf(os.Stdout, "Display: %s\n", schema.DisplayName)
+ }
+ if len(schema.Fields) > 0 {
+ fmt.Fprintf(os.Stdout, "Fields: %d\n", len(schema.Fields))
+ for _, field := range schema.Fields {
+ if field == nil {
+ continue
+ }
+ fmt.Fprintf(os.Stdout, "- %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..332df879
--- /dev/null
+++ b/internal/cmd/schemas_test.go
@@ -0,0 +1,90 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+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 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)
+ }
+}
diff --git a/internal/googleapi/licensing.go b/internal/googleapi/licensing.go
new file mode 100644
index 00000000..1fa47017
--- /dev/null
+++ b/internal/googleapi/licensing.go
@@ -0,0 +1,22 @@
+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/googleauth/service.go b/internal/googleauth/service.go
index f5f8f541..3e62027e 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -28,6 +28,7 @@ const (
ServiceAlertCenter Service = "alertcenter"
ServiceInboundSSO Service = "inboundsso"
ServiceAccessContext Service = "accesscontext"
+ ServiceLicensing Service = "licensing"
)
const (
@@ -80,6 +81,7 @@ var serviceOrder = []Service{
ServiceAlertCenter,
ServiceInboundSSO,
ServiceAccessContext,
+ ServiceLicensing,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -252,6 +254,14 @@ var serviceInfoByService = map[Service]serviceInfo{
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",
+ },
}
func ParseService(s string) (Service, error) {
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index ed6481eb..dc2c0c5d 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -26,6 +26,7 @@ func TestParseService(t *testing.T) {
{"alertcenter", ServiceAlertCenter},
{"inboundsso", ServiceInboundSSO},
{"accesscontext", ServiceAccessContext},
+ {"licensing", ServiceLicensing},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -68,7 +69,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 18 {
+ if len(svcs) != 19 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -77,7 +78,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, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext} {
+ for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
From 9c4bb65c9d3d40bcbd793f8d3272b85903eba611 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 04:32:10 -0800
Subject: [PATCH 12/48] feat(admin): add transfer, printers, forms
---
internal/cmd/forms.go | 229 ++++++++++++++++++++
internal/cmd/forms_test.go | 127 ++++++++++++
internal/cmd/printers.go | 284 +++++++++++++++++++++++++
internal/cmd/printers_test.go | 79 +++++++
internal/cmd/root.go | 3 +
internal/cmd/transfer.go | 310 ++++++++++++++++++++++++++++
internal/cmd/transfer_test.go | 112 ++++++++++
internal/googleapi/datatransfer.go | 22 ++
internal/googleapi/forms.go | 22 ++
internal/googleauth/service.go | 26 +++
internal/googleauth/service_test.go | 16 +-
11 files changed, 1226 insertions(+), 4 deletions(-)
create mode 100644 internal/cmd/forms.go
create mode 100644 internal/cmd/forms_test.go
create mode 100644 internal/cmd/printers.go
create mode 100644 internal/cmd/printers_test.go
create mode 100644 internal/cmd/transfer.go
create mode 100644 internal/cmd/transfer_test.go
create mode 100644 internal/googleapi/datatransfer.go
create mode 100644 internal/googleapi/forms.go
diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go
new file mode 100644
index 00000000..a6a13b80
--- /dev/null
+++ b/internal/cmd/forms.go
@@ -0,0 +1,229 @@
+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 {
+ 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
+ }
+ fmt.Fprintf(os.Stdout, "ID: %s\n", form.FormId)
+ fmt.Fprintf(os.Stdout, "Title: %s\n", title)
+ if form.ResponderUri != "" {
+ fmt.Fprintf(os.Stdout, "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..1c6ee9aa
--- /dev/null
+++ b/internal/cmd/forms_test.go
@@ -0,0 +1,127 @@
+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"
+)
+
+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 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 stubForms(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
+
+func stubDrive(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/printers.go b/internal/cmd/printers.go
new file mode 100644
index 00000000..f38d93c8
--- /dev/null
+++ b/internal/cmd/printers.go
@@ -0,0 +1,284 @@
+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/")
+ 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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "ID: %s\n", printer.Id)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", printer.DisplayName)
+ fmt.Fprintf(os.Stdout, "URI: %s\n", printer.Uri)
+ if printer.OrgUnitId != "" {
+ fmt.Fprintf(os.Stdout, "Org Unit: %s\n", printer.OrgUnitId)
+ }
+ if printer.Description != "" {
+ fmt.Fprintf(os.Stdout, "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/")
+ 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..66ed481c
--- /dev/null
+++ b/internal/cmd/printers_test.go
@@ -0,0 +1,79 @@
+package cmd
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+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 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)
+ }
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 31996f1f..5bbfecad 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -56,6 +56,9 @@ type CLI struct {
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"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
diff --git a/internal/cmd/transfer.go b/internal/cmd/transfer.go
new file mode 100644
index 00000000..6a6a89a7
--- /dev/null
+++ b/internal/cmd/transfer.go
@@ -0,0 +1,310 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "ID: %s\n", transfer.Id)
+ fmt.Fprintf(os.Stdout, "Old Owner: %s\n", transfer.OldOwnerUserId)
+ fmt.Fprintf(os.Stdout, "New Owner: %s\n", transfer.NewOwnerUserId)
+ fmt.Fprintf(os.Stdout, "Status: %s\n", transfer.OverallTransferStatusCode)
+ if len(transfer.ApplicationDataTransfers) > 0 {
+ fmt.Fprintf(os.Stdout, "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..03e74d32
--- /dev/null
+++ b/internal/cmd/transfer_test.go
@@ -0,0 +1,112 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ datatransfer "google.golang.org/api/admin/datatransfer/v1"
+ "google.golang.org/api/option"
+)
+
+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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/googleapi/datatransfer.go b/internal/googleapi/datatransfer.go
new file mode 100644
index 00000000..62ece8d7
--- /dev/null
+++ b/internal/googleapi/datatransfer.go
@@ -0,0 +1,22 @@
+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/forms.go b/internal/googleapi/forms.go
new file mode 100644
index 00000000..6a9aef61
--- /dev/null
+++ b/internal/googleapi/forms.go
@@ -0,0 +1,22 @@
+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/googleauth/service.go b/internal/googleauth/service.go
index 3e62027e..b7e40512 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -29,6 +29,8 @@ const (
ServiceInboundSSO Service = "inboundsso"
ServiceAccessContext Service = "accesscontext"
ServiceLicensing Service = "licensing"
+ ServiceDataTransfer Service = "datatransfer"
+ ServiceForms Service = "forms"
)
const (
@@ -82,6 +84,8 @@ var serviceOrder = []Service{
ServiceInboundSSO,
ServiceAccessContext,
ServiceLicensing,
+ ServiceDataTransfer,
+ ServiceForms,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -205,6 +209,8 @@ var serviceInfoByService = map[Service]serviceInfo{
"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"},
@@ -262,6 +268,24 @@ var serviceInfoByService = map[Service]serviceInfo{
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",
+ },
}
func ParseService(s string) (Service, error) {
@@ -555,6 +579,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:
+ return Scopes(service)
default:
return nil, errUnknownService
}
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index dc2c0c5d..b75f13a2 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -27,6 +27,8 @@ func TestParseService(t *testing.T) {
{"inboundsso", ServiceInboundSSO},
{"accesscontext", ServiceAccessContext},
{"licensing", ServiceLicensing},
+ {"datatransfer", ServiceDataTransfer},
+ {"forms", ServiceForms},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -69,7 +71,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 19 {
+ if len(svcs) != 21 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -78,7 +80,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, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing} {
+ 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} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
@@ -87,16 +89,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")
}
@@ -105,10 +110,13 @@ 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)
}
From 14b48e1bfd809e6bea95a0ee2675008f797d52ee Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 04:51:22 -0800
Subject: [PATCH 13/48] feat(admin): add sites, youtube, meet, analytics,
labels
---
internal/cmd/analytics.go | 202 ++++++++++++++++
internal/cmd/analytics_test.go | 120 ++++++++++
internal/cmd/labels.go | 357 ++++++++++++++++++++++++++++
internal/cmd/labels_test.go | 152 ++++++++++++
internal/cmd/lookerstudio.go | 184 ++++++++++++++
internal/cmd/lookerstudio_test.go | 115 +++++++++
internal/cmd/meet.go | 208 ++++++++++++++++
internal/cmd/meet_test.go | 132 ++++++++++
internal/cmd/root.go | 6 +
internal/cmd/sites.go | 179 ++++++++++++++
internal/cmd/sites_test.go | 74 ++++++
internal/cmd/youtube.go | 141 +++++++++++
internal/cmd/youtube_test.go | 92 +++++++
internal/googleapi/analytics.go | 22 ++
internal/googleapi/drivelabels.go | 22 ++
internal/googleapi/meet.go | 22 ++
internal/googleapi/youtube.go | 22 ++
internal/googleauth/service.go | 48 +++-
internal/googleauth/service_test.go | 8 +-
19 files changed, 2103 insertions(+), 3 deletions(-)
create mode 100644 internal/cmd/analytics.go
create mode 100644 internal/cmd/analytics_test.go
create mode 100644 internal/cmd/labels.go
create mode 100644 internal/cmd/labels_test.go
create mode 100644 internal/cmd/lookerstudio.go
create mode 100644 internal/cmd/lookerstudio_test.go
create mode 100644 internal/cmd/meet.go
create mode 100644 internal/cmd/meet_test.go
create mode 100644 internal/cmd/sites.go
create mode 100644 internal/cmd/sites_test.go
create mode 100644 internal/cmd/youtube.go
create mode 100644 internal/cmd/youtube_test.go
create mode 100644 internal/googleapi/analytics.go
create mode 100644 internal/googleapi/drivelabels.go
create mode 100644 internal/googleapi/meet.go
create mode 100644 internal/googleapi/youtube.go
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..2b4b77e2
--- /dev/null
+++ b/internal/cmd/analytics_test.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/labels.go b/internal/cmd/labels.go
new file mode 100644
index 00000000..97af6663
--- /dev/null
+++ b/internal/cmd/labels.go
@@ -0,0 +1,357 @@
+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 {
+ 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
+ }
+ fmt.Fprintf(os.Stdout, "Name: %s\n", resp.Name)
+ fmt.Fprintf(os.Stdout, "Title: %s\n", title)
+ fmt.Fprintf(os.Stdout, "Type: %s\n", resp.LabelType)
+ if resp.Lifecycle != nil {
+ fmt.Fprintf(os.Stdout, "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..c1649ec5
--- /dev/null
+++ b/internal/cmd/labels_test.go
@@ -0,0 +1,152 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/drivelabels/v2"
+ "google.golang.org/api/option"
+)
+
+func TestLabelsListCmd(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"}},
+ },
+ })
+ })
+ 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("unexpected output: %s", out)
+ }
+}
+
+func TestLabelsCreateCmd(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("unexpected output: %s", out)
+ }
+}
+
+func TestLabelsUpdateCmd(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("unexpected output: %s", out)
+ }
+}
+
+func stubDriveLabels(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/lookerstudio.go b/internal/cmd/lookerstudio.go
new file mode 100644
index 00000000..1800d7c4
--- /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..1643c649
--- /dev/null
+++ b/internal/cmd/meet.go
@@ -0,0 +1,208 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Name: %s\n", resp.Name)
+ fmt.Fprintf(os.Stdout, "Meeting Code: %s\n", resp.MeetingCode)
+ fmt.Fprintf(os.Stdout, "Meeting URI: %s\n", resp.MeetingUri)
+ if resp.Config != nil {
+ fmt.Fprintf(os.Stdout, "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..3467267a
--- /dev/null
+++ b/internal/cmd/meet_test.go
@@ -0,0 +1,132 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/meet/v2"
+ "google.golang.org/api/option"
+)
+
+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(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "spaces/space1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+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(testContextWithStdout(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 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(testContextWithStdout(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 stubMeet(t *testing.T, handler http.Handler) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 5bbfecad..7fefb9f3 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -59,6 +59,12 @@ type CLI struct {
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"`
Drive DriveCmd `cmd:"" help:"Google Drive"`
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
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/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..c8a411f8
--- /dev/null
+++ b/internal/cmd/youtube_test.go
@@ -0,0 +1,92 @@
+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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/googleapi/analytics.go b/internal/googleapi/analytics.go
new file mode 100644
index 00000000..566d1caf
--- /dev/null
+++ b/internal/googleapi/analytics.go
@@ -0,0 +1,22 @@
+package googleapi
+
+import (
+ "context"
+ "fmt"
+
+ "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/drivelabels.go b/internal/googleapi/drivelabels.go
new file mode 100644
index 00000000..66a91264
--- /dev/null
+++ b/internal/googleapi/drivelabels.go
@@ -0,0 +1,22 @@
+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/meet.go b/internal/googleapi/meet.go
new file mode 100644
index 00000000..87f43c83
--- /dev/null
+++ b/internal/googleapi/meet.go
@@ -0,0 +1,22 @@
+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/youtube.go b/internal/googleapi/youtube.go
new file mode 100644
index 00000000..c68baaad
--- /dev/null
+++ b/internal/googleapi/youtube.go
@@ -0,0 +1,22 @@
+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 b7e40512..f4ed2bc8 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -31,6 +31,10 @@ const (
ServiceLicensing Service = "licensing"
ServiceDataTransfer Service = "datatransfer"
ServiceForms Service = "forms"
+ ServiceYouTube Service = "youtube"
+ ServiceMeet Service = "meet"
+ ServiceAnalytics Service = "analytics"
+ ServiceDriveLabels Service = "drivelabels"
)
const (
@@ -86,6 +90,10 @@ var serviceOrder = []Service{
ServiceLicensing,
ServiceDataTransfer,
ServiceForms,
+ ServiceYouTube,
+ ServiceMeet,
+ ServiceAnalytics,
+ ServiceDriveLabels,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -286,6 +294,44 @@ var serviceInfoByService = map[Service]serviceInfo{
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",
+ },
}
func ParseService(s string) (Service, error) {
@@ -579,7 +625,7 @@ 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:
+ case ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms, ServiceYouTube, ServiceMeet, ServiceAnalytics, ServiceDriveLabels:
return Scopes(service)
default:
return nil, errUnknownService
diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go
index b75f13a2..b5efe934 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -29,6 +29,10 @@ func TestParseService(t *testing.T) {
{"licensing", ServiceLicensing},
{"datatransfer", ServiceDataTransfer},
{"forms", ServiceForms},
+ {"youtube", ServiceYouTube},
+ {"meet", ServiceMeet},
+ {"analytics", ServiceAnalytics},
+ {"drivelabels", ServiceDriveLabels},
}
for _, tt := range tests {
got, err := ParseService(tt.in)
@@ -71,7 +75,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 21 {
+ if len(svcs) != 25 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -80,7 +84,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, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms} {
+ 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} {
if !seen[want] {
t.Fatalf("missing %q", want)
}
From 82483d407cc6e7a2534e197a137c822ed11dc15e Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 05:36:21 -0800
Subject: [PATCH 14/48] feat(admin): add advanced drive, contacts, and cloud
identity/reseller/projects
---
internal/cmd/channel.go | 247 +++++++++
internal/cmd/channel_test.go | 100 ++++
internal/cmd/cloudidentity.go | 603 +++++++++++++++++++++
internal/cmd/cloudidentity_test.go | 150 +++++
internal/cmd/contacts.go | 5 +
internal/cmd/contacts_advanced_test.go | 156 ++++++
internal/cmd/contacts_delegates.go | 142 +++++
internal/cmd/contacts_domain.go | 165 ++++++
internal/cmd/contacts_import.go | 332 ++++++++++++
internal/cmd/drive.go | 6 +
internal/cmd/drive_activity.go | 138 +++++
internal/cmd/drive_advanced_test.go | 271 +++++++++
internal/cmd/drive_cleanup.go | 114 ++++
internal/cmd/drive_orphans.go | 180 ++++++
internal/cmd/drive_revisions.go | 161 ++++++
internal/cmd/drive_shortcuts.go | 108 ++++
internal/cmd/drive_transfer.go | 133 +++++
internal/cmd/projects.go | 214 ++++++++
internal/cmd/projects_test.go | 105 ++++
internal/cmd/reseller.go | 377 +++++++++++++
internal/cmd/reseller_test.go | 115 ++++
internal/cmd/root.go | 5 +
internal/cmd/serviceaccounts.go | 364 +++++++++++++
internal/cmd/serviceaccounts_test.go | 108 ++++
internal/googleapi/cloudchannel.go | 22 +
internal/googleapi/cloudidentity.go | 13 +
internal/googleapi/cloudresourcemanager.go | 22 +
internal/googleapi/driveactivity.go | 22 +
internal/googleapi/iam.go | 22 +
internal/googleapi/reseller.go | 22 +
internal/googleauth/service.go | 70 ++-
internal/googleauth/service_test.go | 10 +-
32 files changed, 4499 insertions(+), 3 deletions(-)
create mode 100644 internal/cmd/channel.go
create mode 100644 internal/cmd/channel_test.go
create mode 100644 internal/cmd/cloudidentity.go
create mode 100644 internal/cmd/cloudidentity_test.go
create mode 100644 internal/cmd/contacts_advanced_test.go
create mode 100644 internal/cmd/contacts_delegates.go
create mode 100644 internal/cmd/contacts_domain.go
create mode 100644 internal/cmd/contacts_import.go
create mode 100644 internal/cmd/drive_activity.go
create mode 100644 internal/cmd/drive_advanced_test.go
create mode 100644 internal/cmd/drive_cleanup.go
create mode 100644 internal/cmd/drive_orphans.go
create mode 100644 internal/cmd/drive_revisions.go
create mode 100644 internal/cmd/drive_shortcuts.go
create mode 100644 internal/cmd/drive_transfer.go
create mode 100644 internal/cmd/projects.go
create mode 100644 internal/cmd/projects_test.go
create mode 100644 internal/cmd/reseller.go
create mode 100644 internal/cmd/reseller_test.go
create mode 100644 internal/cmd/serviceaccounts.go
create mode 100644 internal/cmd/serviceaccounts_test.go
create mode 100644 internal/googleapi/cloudchannel.go
create mode 100644 internal/googleapi/cloudresourcemanager.go
create mode 100644 internal/googleapi/driveactivity.go
create mode 100644 internal/googleapi/iam.go
create mode 100644 internal/googleapi/reseller.go
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..231e4356
--- /dev/null
+++ b/internal/cmd/channel_test.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/cloudchannel/v1"
+ "google.golang.org/api/option"
+)
+
+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 }
+}
+
+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 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"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "entitlements/e1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
diff --git a/internal/cmd/cloudidentity.go b/internal/cmd/cloudidentity.go
new file mode 100644
index 00000000..6f571c54
--- /dev/null
+++ b/internal/cmd/cloudidentity.go
@@ -0,0 +1,603 @@
+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"
+)
+
+const cloudIdentityDefaultParent = "customers/my_customer"
+
+var newCloudIdentityAdminService = googleapi.NewCloudIdentity
+
+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 (customers/my_customer or customers/C123)"`
+ 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 = cloudIdentityDefaultParent
+ }
+
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Name: %s\n", group.Name)
+ if group.GroupKey != nil {
+ fmt.Fprintf(os.Stdout, "Email: %s\n", group.GroupKey.Id)
+ }
+ if group.DisplayName != "" {
+ fmt.Fprintf(os.Stdout, "Display Name: %s\n", group.DisplayName)
+ }
+ if group.Parent != "" {
+ fmt.Fprintf(os.Stdout, "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)
+ fmt.Fprintf(os.Stdout, "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 (customers/my_customer or customers/C123)"`
+ DynamicQuery string `name:"dynamic-query" help:"Dynamic group membership query"`
+}
+
+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 = cloudIdentityDefaultParent
+ }
+
+ 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..dd96fbfb
--- /dev/null
+++ b/internal/cmd/cloudidentity_test.go
@@ -0,0 +1,150 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/cloudidentity/v1"
+ "google.golang.org/api/option"
+)
+
+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)
+ }
+}
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..a99ec3e2
--- /dev/null
+++ b/internal/cmd/contacts_advanced_test.go
@@ -0,0 +1,156 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/people/v1"
+)
+
+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 }
+}
+
+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)
+ }
+}
diff --git a/internal/cmd/contacts_delegates.go b/internal/cmd/contacts_delegates.go
new file mode 100644
index 00000000..311fde67
--- /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..7b59de8f
--- /dev/null
+++ b/internal/cmd/contacts_import.go
@@ -0,0 +1,332 @@
+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)
+ }
+ 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)
+ 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)
+ 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/drive.go b/internal/cmd/drive.go
index 2adac673..5e40c61c 100644
--- a/internal/cmd/drive.go
+++ b/internal/cmd/drive.go
@@ -56,6 +56,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)"`
diff --git a/internal/cmd/drive_activity.go b/internal/cmd/drive_activity.go
new file mode 100644
index 00000000..c3f2e7f3
--- /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 "unknown"
+}
+
+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_advanced_test.go b/internal/cmd/drive_advanced_test.go
new file mode 100644
index 00000000..e81a397b
--- /dev/null
+++ b/internal/cmd/drive_advanced_test.go
@@ -0,0 +1,271 @@
+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 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 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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
diff --git a/internal/cmd/drive_cleanup.go b/internal/cmd/drive_cleanup.go
new file mode 100644
index 00000000..8e3e5789
--- /dev/null
+++ b/internal/cmd/drive_cleanup.go
@@ -0,0 +1,114 @@
+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 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, 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", 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..326825ae
--- /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"`
+ Collect DriveOrphansCollectCmd `cmd:"" name:"collect" help:"Move orphaned files into a folder"`
+}
+
+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..7732105d
--- /dev/null
+++ b/internal/cmd/drive_revisions.go
@@ -0,0 +1,161 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "ID: %s\n", rev.Id)
+ fmt.Fprintf(os.Stdout, "Modified: %s\n", rev.ModifiedTime)
+ fmt.Fprintf(os.Stdout, "Mime: %s\n", rev.MimeType)
+ fmt.Fprintf(os.Stdout, "Keep: %t\n", rev.KeepForever)
+ if rev.LastModifyingUser != nil {
+ fmt.Fprintf(os.Stdout, "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..a9b2cace
--- /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_transfer.go b/internal/cmd/drive_transfer.go
new file mode 100644
index 00000000..fcb7a113
--- /dev/null
+++ b/internal/cmd/drive_transfer.go
@@ -0,0 +1,133 @@
+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 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", 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/projects.go b/internal/cmd/projects.go
new file mode 100644
index 00000000..182cae2f
--- /dev/null
+++ b/internal/cmd/projects.go
@@ -0,0 +1,214 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Project ID: %s\n", resp.ProjectId)
+ fmt.Fprintf(os.Stdout, "Name: %s\n", resp.DisplayName)
+ fmt.Fprintf(os.Stdout, "State: %s\n", resp.State)
+ fmt.Fprintf(os.Stdout, "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..75c517a3
--- /dev/null
+++ b/internal/cmd/projects_test.go
@@ -0,0 +1,105 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/cloudresourcemanager/v3"
+ "google.golang.org/api/option"
+)
+
+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 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(testContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "Project One") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+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(testContextWithStdout(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)
+ }
+}
diff --git a/internal/cmd/reseller.go b/internal/cmd/reseller.go
new file mode 100644
index 00000000..053f1aea
--- /dev/null
+++ b/internal/cmd/reseller.go
@@ -0,0 +1,377 @@
+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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Customer ID: %s\n", resp.CustomerId)
+ fmt.Fprintf(os.Stdout, "Domain: %s\n", resp.CustomerDomain)
+ if resp.CustomerType != "" {
+ fmt.Fprintf(os.Stdout, "Type: %s\n", resp.CustomerType)
+ }
+ if resp.PrimaryAdmin != nil && resp.PrimaryAdmin.PrimaryEmail != "" {
+ fmt.Fprintf(os.Stdout, "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 {
+ 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)
+ }
+
+ fmt.Fprintf(os.Stdout, "Customer: %s\n", resp.CustomerId)
+ fmt.Fprintf(os.Stdout, "Subscription: %s\n", resp.SubscriptionId)
+ fmt.Fprintf(os.Stdout, "SKU: %s\n", resp.SkuId)
+ if resp.Plan != nil {
+ fmt.Fprintf(os.Stdout, "Plan: %s\n", resp.Plan.PlanName)
+ }
+ if resp.Status != "" {
+ fmt.Fprintf(os.Stdout, "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..7895947f
--- /dev/null
+++ b/internal/cmd/reseller_test.go
@@ -0,0 +1,115 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/option"
+ "google.golang.org/api/reseller/v1"
+)
+
+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)
+ }
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 7fefb9f3..bcc6ba01 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -65,6 +65,11 @@ type CLI struct {
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"`
diff --git a/internal/cmd/serviceaccounts.go b/internal/cmd/serviceaccounts.go
new file mode 100644
index 00000000..2a66cb27
--- /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), 0o755); 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..90ea9afb
--- /dev/null
+++ b/internal/cmd/serviceaccounts_test.go
@@ -0,0 +1,108 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/iam/v1"
+ "google.golang.org/api/option"
+)
+
+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 }
+}
+
+func TestServiceAccountsKeysCreateCmd(t *testing.T) {
+ keyPayload := []byte("{}")
+ encoded := base64.StdEncoding.EncodeToString(keyPayload)
+
+ svc, closeSrv := newIAMServiceStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v1/projects/-/serviceAccounts/sa@example.com/keys") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "name": "projects/-/serviceAccounts/sa@example.com/keys/key1",
+ "privateKeyData": encoded,
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ output := filepath.Join(t.TempDir(), "sa.json")
+ cmd := &ServiceAccountsKeysCreateCmd{ServiceAccount: "sa@example.com", Output: output}
+
+ _ = captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ data, err := os.ReadFile(output)
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ if string(data) != string(keyPayload) {
+ t.Fatalf("unexpected key data: %s", string(data))
+ }
+}
+
+func TestServiceAccountsListCmd(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, "/v1/projects/p1/serviceAccounts") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accounts": []map[string]any{{
+ "name": "projects/p1/serviceAccounts/sa@example.com",
+ "email": "sa@example.com",
+ "displayName": "SA",
+ }},
+ })
+ }))
+ t.Cleanup(closeSrv)
+ stubIAMService(t, svc)
+
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &ServiceAccountsListCmd{Project: "p1"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "sa@example.com") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
diff --git a/internal/googleapi/cloudchannel.go b/internal/googleapi/cloudchannel.go
new file mode 100644
index 00000000..5d71d0f8
--- /dev/null
+++ b/internal/googleapi/cloudchannel.go
@@ -0,0 +1,22 @@
+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 bbed3242..92fcefe0 100644
--- a/internal/googleapi/cloudidentity.go
+++ b/internal/googleapi/cloudidentity.go
@@ -37,3 +37,16 @@ func NewCloudIdentityInboundSSO(ctx context.Context, email string) (*cloudidenti
}
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..4645a0c1
--- /dev/null
+++ b/internal/googleapi/cloudresourcemanager.go
@@ -0,0 +1,22 @@
+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/driveactivity.go b/internal/googleapi/driveactivity.go
new file mode 100644
index 00000000..f449f34d
--- /dev/null
+++ b/internal/googleapi/driveactivity.go
@@ -0,0 +1,22 @@
+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/iam.go b/internal/googleapi/iam.go
new file mode 100644
index 00000000..a26e1b04
--- /dev/null
+++ b/internal/googleapi/iam.go
@@ -0,0 +1,22 @@
+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/reseller.go b/internal/googleapi/reseller.go
new file mode 100644
index 00000000..0f1c2931
--- /dev/null
+++ b/internal/googleapi/reseller.go
@@ -0,0 +1,22 @@
+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/googleauth/service.go b/internal/googleauth/service.go
index f4ed2bc8..4c32efcb 100644
--- a/internal/googleauth/service.go
+++ b/internal/googleauth/service.go
@@ -35,6 +35,12 @@ const (
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 (
@@ -94,6 +100,12 @@ var serviceOrder = []Service{
ServiceMeet,
ServiceAnalytics,
ServiceDriveLabels,
+ ServiceDriveActivity,
+ ServiceCloudIdentity,
+ ServiceReseller,
+ ServiceCloudChannel,
+ ServiceCloudResource,
+ ServiceIAM,
}
var serviceInfoByService = map[Service]serviceInfo{
@@ -332,6 +344,62 @@ var serviceInfoByService = map[Service]serviceInfo{
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) {
@@ -625,7 +693,7 @@ 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:
+ 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 b5efe934..ba0d40db 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -33,6 +33,12 @@ func TestParseService(t *testing.T) {
{"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)
@@ -75,7 +81,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
- if len(svcs) != 25 {
+ if len(svcs) != 31 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
@@ -84,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, ServiceAdminDirectory, ServiceReports, ServiceVault, ServiceAlertCenter, ServiceInboundSSO, ServiceAccessContext, ServiceLicensing, ServiceDataTransfer, ServiceForms, ServiceYouTube, ServiceMeet, ServiceAnalytics, ServiceDriveLabels} {
+ 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)
}
From 0c5435556beec76123fcfd05fbb2ab419b11b4ab Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:01:46 -0800
Subject: [PATCH 15/48] feat(cli): add csv, batch, and todrive output
---
internal/cmd/batch.go | 124 +++++++++++++++
internal/cmd/csv.go | 85 ++++++++--
internal/cmd/csv_test.go | 94 +++++++++++
internal/cmd/domains_list.go | 43 ++++--
internal/cmd/exec_helpers.go | 80 ++++++++++
internal/cmd/orgunits_list.go | 42 +++--
internal/cmd/reports.go | 89 +++++++----
internal/cmd/root.go | 96 ++++++------
internal/cmd/split_helpers.go | 20 +++
internal/cmd/todrive_helpers.go | 86 +++++++++++
internal/cmd/users_list.go | 70 ++++++---
internal/csv/processor.go | 265 ++++++++++++++++++++++++++++++++
internal/todrive/writer.go | 183 ++++++++++++++++++++++
internal/todrive/writer_test.go | 84 ++++++++++
14 files changed, 1225 insertions(+), 136 deletions(-)
create mode 100644 internal/cmd/batch.go
create mode 100644 internal/cmd/csv_test.go
create mode 100644 internal/cmd/exec_helpers.go
create mode 100644 internal/cmd/split_helpers.go
create mode 100644 internal/cmd/todrive_helpers.go
create mode 100644 internal/csv/processor.go
create mode 100644 internal/todrive/writer.go
create mode 100644 internal/todrive/writer_test.go
diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go
new file mode 100644
index 00000000..1d25afd9
--- /dev/null
+++ b/internal/cmd/batch.go
@@ -0,0 +1,124 @@
+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"`
+ Parallel int `name:"parallel" help:"Number of commands to run in parallel" default:"1"`
+}
+
+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")
+ }
+
+ 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)
+ 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)
+ 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/csv.go b/internal/cmd/csv.go
index 47279ba1..87bc201f 100644
--- a/internal/cmd/csv.go
+++ b/internal/cmd/csv.go
@@ -1,18 +1,85 @@
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"`
+ Fields string `name:"fields" help:"Comma-separated list of fields to include"`
+ Match []string `name:"matchfield" help:"Only process rows where FIELD:REGEX matches"`
+ Skip []string `name:"skipfield" help:"Skip rows where FIELD:REGEX matches"`
+ SkipRows int `name:"skiprows" help:"Skip first N data rows"`
+ MaxRows int `name:"maxrows" help:"Max number of rows to process"`
+}
+
+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 := splitCSVFields(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, err := csvproc.SubstituteArgs(c.Command, row)
+ if err != nil {
+ failed++
+ return fmt.Errorf("row %d: %w", row.Index, err)
+ }
+ if err := executeSubcommand(ctx, flags, args); err != nil {
+ failed++
+ return fmt.Errorf("row %d: %w", row.Index, err)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ if u != nil {
+ u.Err().Printf("CSV complete: processed=%d failed=%d\n", processed, failed)
+ }
+ return nil
+}
+
+func splitCSVFields(input string) []string {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
return nil
}
- parts := strings.Split(s, ",")
+ parts := strings.Split(trimmed, ",")
out := make([]string, 0, len(parts))
- for _, p := range parts {
- p = strings.TrimSpace(p)
- if p != "" {
- out = append(out, p)
+ for _, part := range parts {
+ if value := strings.TrimSpace(part); value != "" {
+ out = append(out, value)
}
}
return out
diff --git a/internal/cmd/csv_test.go b/internal/cmd/csv_test.go
new file mode 100644
index 00000000..c91c265d
--- /dev/null
+++ b/internal/cmd/csv_test.go
@@ -0,0 +1,94 @@
+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)
+ }
+}
diff --git a/internal/cmd/domains_list.go b/internal/cmd/domains_list.go
index d985bdfc..174cef4e 100644
--- a/internal/cmd/domains_list.go
+++ b/internal/cmd/domains_list.go
@@ -9,7 +9,9 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
-type DomainsListCmd struct{}
+type DomainsListCmd struct {
+ ToDrive ToDriveFlags `embed:""`
+}
func (c *DomainsListCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
@@ -28,27 +30,44 @@ func (c *DomainsListCmd) Run(ctx context.Context, flags *RootFlags) error {
return fmt.Errorf("list domains: %w", err)
}
- if outfmt.IsJSON(ctx) {
- return outfmt.WriteJSON(os.Stdout, resp)
- }
-
if len(resp.Domains) == 0 {
u.Err().Println("No domains found")
return nil
}
- w, flush := tableWriter(ctx)
- defer flush()
- fmt.Fprintln(w, "DOMAIN\tPRIMARY\tVERIFIED\tCREATED")
+ rows := make([][]string, 0, len(resp.Domains))
for _, domain := range resp.Domains {
if domain == nil {
continue
}
- fmt.Fprintf(w, "%s\t%t\t%t\t%s\n",
- sanitizeTab(domain.DomainName),
- domain.IsPrimary,
- domain.Verified,
+ 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]),
)
}
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/orgunits_list.go b/internal/cmd/orgunits_list.go
index d71e07fe..b1993f37 100644
--- a/internal/cmd/orgunits_list.go
+++ b/internal/cmd/orgunits_list.go
@@ -11,8 +11,9 @@ import (
)
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"`
+ 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 {
@@ -41,27 +42,44 @@ func (c *OrgunitsListCmd) Run(ctx context.Context, flags *RootFlags) error {
return fmt.Errorf("list org units: %w", err)
}
- if outfmt.IsJSON(ctx) {
- return outfmt.WriteJSON(os.Stdout, resp)
- }
-
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 _, ou := range resp.OrganizationUnits {
- if ou == nil {
+ for _, row := range rows {
+ if len(row) < 4 {
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
- sanitizeTab(ou.OrgUnitPath),
- sanitizeTab(ou.Name),
- sanitizeTab(ou.OrgUnitId),
- sanitizeTab(ou.Description),
+ sanitizeTab(row[0]),
+ sanitizeTab(row[1]),
+ sanitizeTab(row[2]),
+ sanitizeTab(row[3]),
)
}
return nil
diff --git a/internal/cmd/reports.go b/internal/cmd/reports.go
index c8cee87f..767d7b58 100644
--- a/internal/cmd/reports.go
+++ b/internal/cmd/reports.go
@@ -28,11 +28,12 @@ type ReportsCmd struct {
}
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"`
+ 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 {
@@ -43,15 +44,17 @@ func (c *ReportsUserCmd) Run(ctx context.Context, flags *RootFlags) error {
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"`
+ 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 {
@@ -62,15 +65,17 @@ func (c *ReportsAdminCmd) Run(ctx context.Context, flags *RootFlags) error {
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"`
+ 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 {
@@ -81,15 +86,17 @@ func (c *ReportsLoginCmd) Run(ctx context.Context, flags *RootFlags) error {
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"`
+ 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 {
@@ -100,6 +107,7 @@ func (c *ReportsDriveCmd) Run(ctx context.Context, flags *RootFlags) error {
Filters: c.Filters,
Max: c.Max,
Page: c.Page,
+ ToDrive: c.ToDrive,
})
}
@@ -162,6 +170,7 @@ type activityReportOptions struct {
Filters string
Max int64
Page string
+ ToDrive ToDriveFlags
}
func runActivityReport(ctx context.Context, flags *RootFlags, opts activityReportOptions) error {
@@ -204,18 +213,12 @@ func runActivityReport(ctx context.Context, flags *RootFlags, opts activityRepor
return fmt.Errorf("fetch %s report: %w", opts.Application, err)
}
- if outfmt.IsJSON(ctx) {
- return outfmt.WriteJSON(os.Stdout, resp)
- }
-
if len(resp.Items) == 0 {
u.Err().Println("No events found")
return nil
}
- w, flush := tableWriter(ctx)
- defer flush()
- fmt.Fprintln(w, "TIME\tACTOR\tIP\tEVENTS")
+ rows := make([][]string, 0, len(resp.Items))
for _, item := range resp.Items {
if item == nil {
continue
@@ -226,11 +229,35 @@ func runActivityReport(ctx context.Context, flags *RootFlags, opts activityRepor
actor = item.Actor.Email
}
events := activityEventNames(item.Events)
+ rows = append(rows, toDriveRow(
+ timeStr,
+ actor,
+ item.IpAddress,
+ events,
+ ))
+ }
+
+ reportTitle := fmt.Sprintf("Reports %s", strings.Title(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(timeStr),
- sanitizeTab(actor),
- sanitizeTab(item.IpAddress),
- sanitizeTab(events),
+ sanitizeTab(row[0]),
+ sanitizeTab(row[1]),
+ sanitizeTab(row[2]),
+ sanitizeTab(row[3]),
)
}
printNextPageHint(u, resp.NextPageToken)
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index bcc6ba01..2a1dd5a2 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -40,53 +40,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"`
- 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"`
- 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 }
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/todrive_helpers.go b/internal/cmd/todrive_helpers.go
new file mode 100644
index 00000000..953b7cf3
--- /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 "true"
+ }
+ return "false"
+}
+
+func toDriveNumber(value int64) string {
+ return fmt.Sprintf("%d", value)
+}
diff --git a/internal/cmd/users_list.go b/internal/cmd/users_list.go
index 47294f7a..e745613f 100644
--- a/internal/cmd/users_list.go
+++ b/internal/cmd/users_list.go
@@ -11,17 +11,18 @@ import (
)
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)"`
+ 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 {
@@ -77,19 +78,12 @@ func (c *UsersListCmd) Run(ctx context.Context, flags *RootFlags) error {
return fmt.Errorf("list users: %w", err)
}
- if outfmt.IsJSON(ctx) {
- return outfmt.WriteJSON(os.Stdout, resp)
- }
-
if len(resp.Users) == 0 {
u.Err().Println("No users found")
return nil
}
- tw, flush := tableWriter(ctx)
- defer flush()
-
- fmt.Fprintln(tw, "EMAIL\tNAME\tSUSPENDED\tADMIN\tORG UNIT\tLAST LOGIN")
+ rows := make([][]string, 0, len(resp.Users))
for _, user := range resp.Users {
if user == nil {
continue
@@ -106,13 +100,39 @@ func (c *UsersListCmd) Run(ctx context.Context, flags *RootFlags) error {
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(user.PrimaryEmail),
- sanitizeTab(name),
- sanitizeTab(suspended),
- sanitizeTab(admin),
- sanitizeTab(user.OrgUnitPath),
- sanitizeTab(formatDateTime(user.LastLoginTime)),
+ sanitizeTab(row[0]),
+ sanitizeTab(row[1]),
+ sanitizeTab(row[2]),
+ sanitizeTab(row[3]),
+ sanitizeTab(row[4]),
+ sanitizeTab(row[5]),
)
}
diff --git a/internal/csv/processor.go b/internal/csv/processor.go
new file mode 100644
index 00000000..5f90f4b5
--- /dev/null
+++ b/internal/csv/processor.go
@@ -0,0 +1,265 @@
+package csv
+
+import (
+ "encoding/csv"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strings"
+)
+
+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 fmt.Errorf("empty csv")
+ }
+
+ 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, fmt.Errorf("file is required")
+ }
+ if trimmed == "-" {
+ return csv.NewReader(os.Stdin), nil, nil
+ }
+ f, err := os.Open(trimmed)
+ 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("invalid replacement token: %s", 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("invalid filter %q (expected FIELD:REGEX)", item)
+ }
+ field := normalizeField(parts[0])
+ if field == "" {
+ return nil, fmt.Errorf("invalid filter %q (missing field)", 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/todrive/writer.go b/internal/todrive/writer.go
new file mode 100644
index 00000000..243b881b
--- /dev/null
+++ b/internal/todrive/writer.go
@@ -0,0 +1,183 @@
+package todrive
+
+import (
+ "context"
+ "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
+var newSheetsService = googleapi.NewSheets
+
+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
+ }
+
+ if spreadsheetID == "" {
+ created, err := w.sheets.Spreadsheets.Create(&sheets.Spreadsheet{
+ Properties: &sheets.SpreadsheetProperties{Title: title},
+ }).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
+ }
+ }
+ }
+
+ if spreadsheetID == "" {
+ return nil, fmt.Errorf("missing spreadsheet id")
+ }
+
+ if opts.Update {
+ _, _ = w.sheets.Spreadsheets.Values.Clear(spreadsheetID, "Sheet1", &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, "Sheet1!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", escapeDriveQuery(name))
+ if strings.TrimSpace(folderID) != "" {
+ query = fmt.Sprintf("%s and '%s' in parents", query, 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
+}
+
+func escapeDriveQuery(value string) string {
+ return strings.ReplaceAll(value, "'", "\\'")
+}
diff --git a/internal/todrive/writer_test.go b/internal/todrive/writer_test.go
new file mode 100644
index 00000000..2bba4bc2
--- /dev/null
+++ b/internal/todrive/writer_test.go
@@ -0,0 +1,84 @@
+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/Sheet1!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": "Sheet1!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))
+ }
+}
From cee127b9718bcb921b90aa41daa58b5191874648 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:36:30 -0800
Subject: [PATCH 16/48] test(csv): improve processor tests
- Replace custom contains() helper with Go's built-in strings.Contains()
- Add TestProcess_ReadFromStdin to test stdin input via "-" path
- All 47 tests pass successfully
Co-Authored-By: Claude Opus 4.5
---
internal/csv/processor_test.go | 1177 ++++++++++++++++++++++++++++++++
1 file changed, 1177 insertions(+)
create mode 100644 internal/csv/processor_test.go
diff --git a/internal/csv/processor_test.go b/internal/csv/processor_test.go
new file mode 100644
index 00000000..4099835f
--- /dev/null
+++ b/internal/csv/processor_test.go
@@ -0,0 +1,1177 @@
+package csv
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// 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)
+
+ callbackErr := errors.New("callback failed")
+ var count int
+ err := Process(path, Options{}, func(row Row) error {
+ count++
+ if count == 1 {
+ return callbackErr
+ }
+ return nil
+ })
+
+ if err != callbackErr {
+ 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
+`), 0644)
+ 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 _, err := w.WriteString(csv); err != nil {
+ t.Errorf("write to pipe: %v", err)
+ }
+ }()
+
+ // 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")
+ }
+}
+
From 6a563c7da44f004d02ac2bec5ba7166f6522d614 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:49:30 -0800
Subject: [PATCH 17/48] feat(users): add special character support to password
generation
- Add special character constant with 20+ unique characters
- Guarantee at least one special character in generated passwords
- Meet typical organizational password policies requiring uppercase, lowercase, digit, and special character
- Ensures passwords pass NIST and common security standards
---
internal/cmd/users.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/internal/cmd/users.go b/internal/cmd/users.go
index f96ba06d..c6017da9 100644
--- a/internal/cmd/users.go
+++ b/internal/cmd/users.go
@@ -43,9 +43,10 @@ func generatePassword(length int) (string, error) {
const lower = "abcdefghijklmnopqrstuvwxyz"
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
const digits = "0123456789"
- const all = lower + upper + digits
+ const special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
+ const all = lower + upper + digits + special
- sets := []string{lower, upper, digits}
+ sets := []string{lower, upper, digits, special}
b := make([]byte, length)
for i, set := range sets {
ch, err := randChar(set)
From dc59a18f302370dc86e85d645785985504085a5b Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:52:44 -0800
Subject: [PATCH 18/48] feat(batch,csv): add --dry-run flag to preview commands
without executing
Add --dry-run flag to batch.go and csv.go that shows which commands would be
executed without actually running them. This is important for bulk operations
to safely preview before execution.
- batch.go: Added DryRun field to BatchCmd, implements preview with
"[dry-run] line N: cmd arg1 arg2 ..." format
- csv.go: Added DryRun field to CSVCmd, implements preview with
"[dry-run] row N: cmd arg1 arg2 ..." format
- csv_test.go: Added TestBatchCmdDryRun and TestCSVCmdDryRun tests to verify
dry-run mode prevents command execution
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/batch.go | 14 ++++++++
internal/cmd/csv.go | 13 +++++++-
internal/cmd/csv_test.go | 69 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go
index 1d25afd9..84fef04f 100644
--- a/internal/cmd/batch.go
+++ b/internal/cmd/batch.go
@@ -14,6 +14,7 @@ import (
type BatchCmd struct {
File string `arg:"" name:"file" help:"Batch file"`
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 {
@@ -30,6 +31,19 @@ func (c *BatchCmd) Run(ctx context.Context, flags *RootFlags) error {
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
diff --git a/internal/cmd/csv.go b/internal/cmd/csv.go
index 87bc201f..c92ce600 100644
--- a/internal/cmd/csv.go
+++ b/internal/cmd/csv.go
@@ -17,6 +17,7 @@ type CSVCmd struct {
Skip []string `name:"skipfield" help:"Skip rows where FIELD:REGEX matches"`
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 {
@@ -54,6 +55,12 @@ func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error {
failed++
return fmt.Errorf("row %d: %w", row.Index, err)
}
+ if c.DryRun {
+ if u != nil {
+ u.Err().Printf("[dry-run] row %d: %s\n", row.Index, strings.Join(args, " "))
+ }
+ return nil
+ }
if err := executeSubcommand(ctx, flags, args); err != nil {
failed++
return fmt.Errorf("row %d: %w", row.Index, err)
@@ -65,7 +72,11 @@ func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if u != nil {
- u.Err().Printf("CSV complete: processed=%d failed=%d\n", processed, failed)
+ 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 nil
}
diff --git a/internal/cmd/csv_test.go b/internal/cmd/csv_test.go
index c91c265d..f711b81c 100644
--- a/internal/cmd/csv_test.go
+++ b/internal/cmd/csv_test.go
@@ -92,3 +92,72 @@ func TestBatchCmdParsing(t *testing.T) {
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))
+ }
+}
From 04874032e46c72e031e1b141f2299954c138374a Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:53:24 -0800
Subject: [PATCH 19/48] docs(drive): document orphan detection limitations in
help text
---
internal/cmd/drive_orphans.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/cmd/drive_orphans.go b/internal/cmd/drive_orphans.go
index 326825ae..3936d656 100644
--- a/internal/cmd/drive_orphans.go
+++ b/internal/cmd/drive_orphans.go
@@ -13,8 +13,8 @@ import (
)
type DriveOrphansCmd struct {
- List DriveOrphansListCmd `cmd:"" name:"list" aliases:"ls" help:"List orphaned files"`
- Collect DriveOrphansCollectCmd `cmd:"" name:"collect" help:"Move orphaned files into a folder"`
+ 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 {
From 5ff087b521099fd099eff2c3ee291ca4a36287df Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:13:24 -0800
Subject: [PATCH 20/48] feat(cloudidentity): add GOG_CUSTOMER_ID environment
variable override
Replace hardcoded cloudIdentityDefaultParent constant with a function that checks for the GOG_CUSTOMER_ID environment variable. If set, uses "customers/{value}" as the parent; otherwise falls back to "customers/my_customer". Update help text for --parent flags to document this new env var override.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/cloudidentity.go | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/internal/cmd/cloudidentity.go b/internal/cmd/cloudidentity.go
index 6f571c54..130fe6f3 100644
--- a/internal/cmd/cloudidentity.go
+++ b/internal/cmd/cloudidentity.go
@@ -14,10 +14,15 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
-const cloudIdentityDefaultParent = "customers/my_customer"
-
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"`
@@ -33,7 +38,7 @@ type CloudIdentityGroupsCmd struct {
}
type CloudIdentityGroupsListCmd struct {
- Parent string `name:"parent" help:"Customer parent (customers/my_customer or customers/C123)"`
+ 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"`
}
@@ -52,7 +57,7 @@ func (c *CloudIdentityGroupsListCmd) Run(ctx context.Context, flags *RootFlags)
parent := strings.TrimSpace(c.Parent)
if parent == "" {
- parent = cloudIdentityDefaultParent
+ parent = cloudIdentityParent()
}
call := svc.Groups.List().Parent(parent).PageSize(c.Max)
@@ -152,7 +157,7 @@ func (c *CloudIdentityGroupsGetCmd) Run(ctx context.Context, flags *RootFlags) e
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 (customers/my_customer or customers/C123)"`
+ Parent string `name:"parent" help:"Customer parent (default: customers/my_customer, override with GOG_CUSTOMER_ID env var)"`
DynamicQuery string `name:"dynamic-query" help:"Dynamic group membership query"`
}
@@ -170,7 +175,7 @@ func (c *CloudIdentityGroupsCreateCmd) Run(ctx context.Context, flags *RootFlags
parent := strings.TrimSpace(c.Parent)
if parent == "" {
- parent = cloudIdentityDefaultParent
+ parent = cloudIdentityParent()
}
svc, err := newCloudIdentityAdminService(ctx, account)
From f6c18a14d8be1c5dd91f51a23d01129deee5acd9 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:16:33 -0800
Subject: [PATCH 21/48] fix(todrive): use actual first sheet name instead of
hardcoding Sheet1
Previously, Clear() and Update() calls hardcoded "Sheet1" range regardless of the
actual first sheet name in the spreadsheet. This could cause failures when the
first sheet had a different name.
Changes:
- When creating new spreadsheets, explicitly set the first sheet title to "Data"
- When updating existing spreadsheets, fetch the first sheet name from metadata
- Use the actual sheet name (stored in sheetName variable) in Clear() and Update() calls
This ensures the code works with spreadsheets that have any first sheet name,
not just "Sheet1".
Co-Authored-By: Claude Opus 4.5
---
internal/todrive/writer.go | 17 +++++++++++++++--
internal/todrive/writer_test.go | 4 ++--
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/internal/todrive/writer.go b/internal/todrive/writer.go
index 243b881b..7d9bb300 100644
--- a/internal/todrive/writer.go
+++ b/internal/todrive/writer.go
@@ -70,9 +70,13 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
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)
@@ -84,6 +88,15 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
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 == "" {
@@ -91,7 +104,7 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
}
if opts.Update {
- _, _ = w.sheets.Spreadsheets.Values.Clear(spreadsheetID, "Sheet1", &sheets.ClearValuesRequest{}).Context(ctx).Do()
+ _, _ = w.sheets.Spreadsheets.Values.Clear(spreadsheetID, sheetName, &sheets.ClearValuesRequest{}).Context(ctx).Do()
}
values := make([][]interface{}, 0, len(rows)+1)
@@ -102,7 +115,7 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
values = append(values, toInterfaceRow(row))
}
- _, err := w.sheets.Spreadsheets.Values.Update(spreadsheetID, "Sheet1!A1", &sheets.ValueRange{
+ _, err := w.sheets.Spreadsheets.Values.Update(spreadsheetID, sheetName+"!A1", &sheets.ValueRange{
Values: values,
}).ValueInputOption("RAW").Context(ctx).Do()
if err != nil {
diff --git a/internal/todrive/writer_test.go b/internal/todrive/writer_test.go
index 2bba4bc2..3b455adb 100644
--- a/internal/todrive/writer_test.go
+++ b/internal/todrive/writer_test.go
@@ -25,12 +25,12 @@ func TestWriterCreateAndWrite(t *testing.T) {
"spreadsheetUrl": "https://sheet/1",
})
return
- case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/v4/spreadsheets/sheet1/values/Sheet1!A1"):
+ 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": "Sheet1!A1"})
+ _ = json.NewEncoder(w).Encode(map[string]any{"updatedRange": "Data!A1"})
return
default:
http.NotFound(w, r)
From 8b8a633b62fece576c6055c271c3cfb2071bcbce Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:18:26 -0800
Subject: [PATCH 22/48] docs(help): add examples to complex options help text
- cloudidentity.go: Add CEL expression example to --dynamic-query help
- csv.go: Add field substitution and regex pattern examples
- batch.go: Clarify batch file format in help text
---
internal/cmd/batch.go | 2 +-
internal/cmd/cloudidentity.go | 2 +-
internal/cmd/csv.go | 6 +++---
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go
index 84fef04f..226d0460 100644
--- a/internal/cmd/batch.go
+++ b/internal/cmd/batch.go
@@ -12,7 +12,7 @@ import (
)
type BatchCmd struct {
- File string `arg:"" name:"file" help:"Batch file"`
+ 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"`
}
diff --git a/internal/cmd/cloudidentity.go b/internal/cmd/cloudidentity.go
index 130fe6f3..9545f3df 100644
--- a/internal/cmd/cloudidentity.go
+++ b/internal/cmd/cloudidentity.go
@@ -158,7 +158,7 @@ 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:"Dynamic group membership query"`
+ 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 {
diff --git a/internal/cmd/csv.go b/internal/cmd/csv.go
index c92ce600..87af50f5 100644
--- a/internal/cmd/csv.go
+++ b/internal/cmd/csv.go
@@ -11,10 +11,10 @@ import (
type CSVCmd struct {
File string `arg:"" name:"file" help:"CSV file path"`
- Command []string `arg:"" name:"command" help:"Command template to execute"`
+ 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"`
- Skip []string `name:"skipfield" help:"Skip rows where FIELD:REGEX matches"`
+ 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"`
From ebfbf721acf99bd8dfa906b91a7448c6ac53a5ba Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:31:44 -0800
Subject: [PATCH 23/48] fix(output): use UI layer instead of direct stdout
writes
Replace fmt.Fprintf(os.Stdout, ...) with u.Out().Printf(...) across all
command files for consistent output handling through the UI abstraction.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/alerts.go | 22 ++++++++++++----------
internal/cmd/caa.go | 13 +++++++------
internal/cmd/config_cmd.go | 22 ++++++++++++++--------
internal/cmd/contacts_output.go | 11 +++++------
internal/cmd/drive_revisions.go | 11 ++++++-----
internal/cmd/forms.go | 7 ++++---
internal/cmd/labels.go | 9 +++++----
internal/cmd/licenses.go | 11 ++++++-----
internal/cmd/meet.go | 9 +++++----
internal/cmd/printers.go | 11 ++++++-----
internal/cmd/projects.go | 9 +++++----
internal/cmd/reseller.go | 20 +++++++++++---------
internal/cmd/resources_buildings.go | 9 +++++----
internal/cmd/resources_calendars.go | 19 ++++++++++---------
internal/cmd/schemas.go | 11 ++++++-----
internal/cmd/sso.go | 17 +++++++++--------
internal/cmd/sso_test.go | 2 +-
internal/cmd/transfer.go | 11 ++++++-----
internal/cmd/vault_exports.go | 14 ++++++++------
internal/cmd/vault_holds.go | 12 +++++++-----
internal/cmd/vault_matters.go | 18 +++++++++++-------
21 files changed, 149 insertions(+), 119 deletions(-)
diff --git a/internal/cmd/alerts.go b/internal/cmd/alerts.go
index cddb2521..51956471 100644
--- a/internal/cmd/alerts.go
+++ b/internal/cmd/alerts.go
@@ -96,6 +96,7 @@ type AlertsGetCmd struct {
}
func (c *AlertsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -115,17 +116,17 @@ func (c *AlertsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, alert)
}
- fmt.Fprintf(os.Stdout, "Alert ID: %s\n", alert.AlertId)
- fmt.Fprintf(os.Stdout, "Type: %s\n", alert.Type)
- fmt.Fprintf(os.Stdout, "Source: %s\n", alert.Source)
- fmt.Fprintf(os.Stdout, "Created: %s\n", alert.CreateTime)
- fmt.Fprintf(os.Stdout, "Updated: %s\n", alert.UpdateTime)
- fmt.Fprintf(os.Stdout, "Deleted: %t\n", alert.Deleted)
+ 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 != "" {
- fmt.Fprintf(os.Stdout, "Start Time: %s\n", alert.StartTime)
+ u.Out().Printf("Start Time: %s\n", alert.StartTime)
}
if alert.EndTime != "" {
- fmt.Fprintf(os.Stdout, "End Time: %s\n", alert.EndTime)
+ u.Out().Printf("End Time: %s\n", alert.EndTime)
}
return nil
}
@@ -278,6 +279,7 @@ type AlertsSettingsCmd struct {
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
@@ -297,12 +299,12 @@ func (c *AlertsSettingsGetCmd) Run(ctx context.Context, flags *RootFlags) error
return outfmt.WriteJSON(os.Stdout, settings)
}
- fmt.Fprintf(os.Stdout, "Notifications: %d\n", len(settings.Notifications))
+ u.Out().Printf("Notifications: %d\n", len(settings.Notifications))
for _, n := range settings.Notifications {
if n == nil || n.CloudPubsubTopic == nil {
continue
}
- fmt.Fprintf(os.Stdout, "- %s\n", n.CloudPubsubTopic.TopicName)
+ u.Out().Printf("- %s\n", n.CloudPubsubTopic.TopicName)
}
return nil
}
diff --git a/internal/cmd/caa.go b/internal/cmd/caa.go
index bf1c3dca..52a88a1a 100644
--- a/internal/cmd/caa.go
+++ b/internal/cmd/caa.go
@@ -98,6 +98,7 @@ type CAALevelsGetCmd struct {
}
func (c *CAALevelsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -127,19 +128,19 @@ func (c *CAALevelsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, level)
}
- fmt.Fprintf(os.Stdout, "Name: %s\n", level.Name)
+ u.Out().Printf("Name: %s\n", level.Name)
if level.Title != "" {
- fmt.Fprintf(os.Stdout, "Title: %s\n", level.Title)
+ u.Out().Printf("Title: %s\n", level.Title)
}
if level.Description != "" {
- fmt.Fprintf(os.Stdout, "Description: %s\n", level.Description)
+ u.Out().Printf("Description: %s\n", level.Description)
}
- fmt.Fprintf(os.Stdout, "Type: %s\n", accessLevelType(level))
+ u.Out().Printf("Type: %s\n", accessLevelType(level))
if level.Custom != nil && level.Custom.Expr != nil && level.Custom.Expr.Expression != "" {
- fmt.Fprintf(os.Stdout, "Expression: %s\n", level.Custom.Expr.Expression)
+ u.Out().Printf("Expression: %s\n", level.Custom.Expr.Expression)
}
if level.Basic != nil {
- fmt.Fprintf(os.Stdout, "Conditions: %d\n", len(level.Basic.Conditions))
+ u.Out().Printf("Conditions: %d\n", len(level.Basic.Conditions))
}
return nil
}
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/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/drive_revisions.go b/internal/cmd/drive_revisions.go
index 7732105d..97609a19 100644
--- a/internal/cmd/drive_revisions.go
+++ b/internal/cmd/drive_revisions.go
@@ -86,6 +86,7 @@ type DriveRevisionsGetCmd struct {
}
func (c *DriveRevisionsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -111,12 +112,12 @@ func (c *DriveRevisionsGetCmd) Run(ctx context.Context, flags *RootFlags) error
return outfmt.WriteJSON(os.Stdout, rev)
}
- fmt.Fprintf(os.Stdout, "ID: %s\n", rev.Id)
- fmt.Fprintf(os.Stdout, "Modified: %s\n", rev.ModifiedTime)
- fmt.Fprintf(os.Stdout, "Mime: %s\n", rev.MimeType)
- fmt.Fprintf(os.Stdout, "Keep: %t\n", rev.KeepForever)
+ 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 {
- fmt.Fprintf(os.Stdout, "User: %s\n", rev.LastModifyingUser.EmailAddress)
+ u.Out().Printf("User: %s\n", rev.LastModifyingUser.EmailAddress)
}
return nil
}
diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go
index a6a13b80..fbcb6fa6 100644
--- a/internal/cmd/forms.go
+++ b/internal/cmd/forms.go
@@ -94,6 +94,7 @@ type FormsGetCmd struct {
}
func (c *FormsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -122,10 +123,10 @@ func (c *FormsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
if form.Info != nil {
title = form.Info.Title
}
- fmt.Fprintf(os.Stdout, "ID: %s\n", form.FormId)
- fmt.Fprintf(os.Stdout, "Title: %s\n", title)
+ u.Out().Printf("ID: %s\n", form.FormId)
+ u.Out().Printf("Title: %s\n", title)
if form.ResponderUri != "" {
- fmt.Fprintf(os.Stdout, "Responder URL: %s\n", form.ResponderUri)
+ u.Out().Printf("Responder URL: %s\n", form.ResponderUri)
}
return nil
}
diff --git a/internal/cmd/labels.go b/internal/cmd/labels.go
index 97af6663..b2dddeff 100644
--- a/internal/cmd/labels.go
+++ b/internal/cmd/labels.go
@@ -95,6 +95,7 @@ type LabelsGetCmd struct {
}
func (c *LabelsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -124,11 +125,11 @@ func (c *LabelsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
if resp.Properties != nil {
title = resp.Properties.Title
}
- fmt.Fprintf(os.Stdout, "Name: %s\n", resp.Name)
- fmt.Fprintf(os.Stdout, "Title: %s\n", title)
- fmt.Fprintf(os.Stdout, "Type: %s\n", resp.LabelType)
+ 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 {
- fmt.Fprintf(os.Stdout, "State: %s\n", resp.Lifecycle.State)
+ u.Out().Printf("State: %s\n", resp.Lifecycle.State)
}
return nil
}
diff --git a/internal/cmd/licenses.go b/internal/cmd/licenses.go
index c0f0d2c2..c16a93ce 100644
--- a/internal/cmd/licenses.go
+++ b/internal/cmd/licenses.go
@@ -105,6 +105,7 @@ type LicensesGetCmd struct {
}
func (c *LicensesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -129,14 +130,14 @@ func (c *LicensesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, assignment)
}
- fmt.Fprintf(os.Stdout, "User: %s\n", assignment.UserId)
- fmt.Fprintf(os.Stdout, "Product: %s\n", assignment.ProductId)
+ u.Out().Printf("User: %s\n", assignment.UserId)
+ u.Out().Printf("Product: %s\n", assignment.ProductId)
if assignment.ProductName != "" {
- fmt.Fprintf(os.Stdout, "Product Name: %s\n", assignment.ProductName)
+ u.Out().Printf("Product Name: %s\n", assignment.ProductName)
}
- fmt.Fprintf(os.Stdout, "SKU: %s\n", assignment.SkuId)
+ u.Out().Printf("SKU: %s\n", assignment.SkuId)
if assignment.SkuName != "" {
- fmt.Fprintf(os.Stdout, "SKU Name: %s\n", assignment.SkuName)
+ u.Out().Printf("SKU Name: %s\n", assignment.SkuName)
}
return nil
}
diff --git a/internal/cmd/meet.go b/internal/cmd/meet.go
index 1643c649..3be2ba77 100644
--- a/internal/cmd/meet.go
+++ b/internal/cmd/meet.go
@@ -94,6 +94,7 @@ type MeetSpacesGetCmd struct {
}
func (c *MeetSpacesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -121,11 +122,11 @@ func (c *MeetSpacesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, resp)
}
- fmt.Fprintf(os.Stdout, "Name: %s\n", resp.Name)
- fmt.Fprintf(os.Stdout, "Meeting Code: %s\n", resp.MeetingCode)
- fmt.Fprintf(os.Stdout, "Meeting URI: %s\n", resp.MeetingUri)
+ 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 {
- fmt.Fprintf(os.Stdout, "Access Type: %s\n", resp.Config.AccessType)
+ u.Out().Printf("Access Type: %s\n", resp.Config.AccessType)
}
return nil
}
diff --git a/internal/cmd/printers.go b/internal/cmd/printers.go
index f38d93c8..7af58277 100644
--- a/internal/cmd/printers.go
+++ b/internal/cmd/printers.go
@@ -93,6 +93,7 @@ type PrintersGetCmd struct {
}
func (c *PrintersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -118,14 +119,14 @@ func (c *PrintersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, printer)
}
- fmt.Fprintf(os.Stdout, "ID: %s\n", printer.Id)
- fmt.Fprintf(os.Stdout, "Name: %s\n", printer.DisplayName)
- fmt.Fprintf(os.Stdout, "URI: %s\n", printer.Uri)
+ 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 != "" {
- fmt.Fprintf(os.Stdout, "Org Unit: %s\n", printer.OrgUnitId)
+ u.Out().Printf("Org Unit: %s\n", printer.OrgUnitId)
}
if printer.Description != "" {
- fmt.Fprintf(os.Stdout, "Desc: %s\n", printer.Description)
+ u.Out().Printf("Desc: %s\n", printer.Description)
}
return nil
}
diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go
index 182cae2f..62388c68 100644
--- a/internal/cmd/projects.go
+++ b/internal/cmd/projects.go
@@ -86,6 +86,7 @@ type ProjectsGetCmd struct {
}
func (c *ProjectsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -111,10 +112,10 @@ func (c *ProjectsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, resp)
}
- fmt.Fprintf(os.Stdout, "Project ID: %s\n", resp.ProjectId)
- fmt.Fprintf(os.Stdout, "Name: %s\n", resp.DisplayName)
- fmt.Fprintf(os.Stdout, "State: %s\n", resp.State)
- fmt.Fprintf(os.Stdout, "Parent: %s\n", resp.Parent)
+ 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
}
diff --git a/internal/cmd/reseller.go b/internal/cmd/reseller.go
index 053f1aea..8d3a316d 100644
--- a/internal/cmd/reseller.go
+++ b/internal/cmd/reseller.go
@@ -110,6 +110,7 @@ type ResellerCustomersGetCmd struct {
}
func (c *ResellerCustomersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -134,13 +135,13 @@ func (c *ResellerCustomersGetCmd) Run(ctx context.Context, flags *RootFlags) err
return outfmt.WriteJSON(os.Stdout, resp)
}
- fmt.Fprintf(os.Stdout, "Customer ID: %s\n", resp.CustomerId)
- fmt.Fprintf(os.Stdout, "Domain: %s\n", resp.CustomerDomain)
+ u.Out().Printf("Customer ID: %s\n", resp.CustomerId)
+ u.Out().Printf("Domain: %s\n", resp.CustomerDomain)
if resp.CustomerType != "" {
- fmt.Fprintf(os.Stdout, "Type: %s\n", resp.CustomerType)
+ u.Out().Printf("Type: %s\n", resp.CustomerType)
}
if resp.PrimaryAdmin != nil && resp.PrimaryAdmin.PrimaryEmail != "" {
- fmt.Fprintf(os.Stdout, "Primary Admin: %s\n", resp.PrimaryAdmin.PrimaryEmail)
+ u.Out().Printf("Primary Admin: %s\n", resp.PrimaryAdmin.PrimaryEmail)
}
return nil
}
@@ -275,6 +276,7 @@ type ResellerSubscriptionsGetCmd struct {
}
func (c *ResellerSubscriptionsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -300,14 +302,14 @@ func (c *ResellerSubscriptionsGetCmd) Run(ctx context.Context, flags *RootFlags)
return outfmt.WriteJSON(os.Stdout, resp)
}
- fmt.Fprintf(os.Stdout, "Customer: %s\n", resp.CustomerId)
- fmt.Fprintf(os.Stdout, "Subscription: %s\n", resp.SubscriptionId)
- fmt.Fprintf(os.Stdout, "SKU: %s\n", resp.SkuId)
+ 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 {
- fmt.Fprintf(os.Stdout, "Plan: %s\n", resp.Plan.PlanName)
+ u.Out().Printf("Plan: %s\n", resp.Plan.PlanName)
}
if resp.Status != "" {
- fmt.Fprintf(os.Stdout, "Status: %s\n", resp.Status)
+ u.Out().Printf("Status: %s\n", resp.Status)
}
return nil
}
diff --git a/internal/cmd/resources_buildings.go b/internal/cmd/resources_buildings.go
index ef7693b9..f6cb6602 100644
--- a/internal/cmd/resources_buildings.go
+++ b/internal/cmd/resources_buildings.go
@@ -82,6 +82,7 @@ type ResourcesBuildingsGetCmd struct {
}
func (c *ResourcesBuildingsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -106,13 +107,13 @@ func (c *ResourcesBuildingsGetCmd) Run(ctx context.Context, flags *RootFlags) er
return outfmt.WriteJSON(os.Stdout, building)
}
- fmt.Fprintf(os.Stdout, "ID: %s\n", building.BuildingId)
- fmt.Fprintf(os.Stdout, "Name: %s\n", building.BuildingName)
+ u.Out().Printf("ID: %s\n", building.BuildingId)
+ u.Out().Printf("Name: %s\n", building.BuildingName)
if building.Description != "" {
- fmt.Fprintf(os.Stdout, "Description: %s\n", building.Description)
+ u.Out().Printf("Description: %s\n", building.Description)
}
if len(building.FloorNames) > 0 {
- fmt.Fprintf(os.Stdout, "Floors: %s\n", strings.Join(building.FloorNames, ", "))
+ u.Out().Printf("Floors: %s\n", strings.Join(building.FloorNames, ", "))
}
return nil
}
diff --git a/internal/cmd/resources_calendars.go b/internal/cmd/resources_calendars.go
index b1f9236d..95126b06 100644
--- a/internal/cmd/resources_calendars.go
+++ b/internal/cmd/resources_calendars.go
@@ -97,6 +97,7 @@ type ResourcesCalendarsGetCmd struct {
}
func (c *ResourcesCalendarsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -121,24 +122,24 @@ func (c *ResourcesCalendarsGetCmd) Run(ctx context.Context, flags *RootFlags) er
return outfmt.WriteJSON(os.Stdout, resource)
}
- fmt.Fprintf(os.Stdout, "ID: %s\n", resource.ResourceId)
- fmt.Fprintf(os.Stdout, "Name: %s\n", resource.ResourceName)
- fmt.Fprintf(os.Stdout, "Email: %s\n", resource.ResourceEmail)
- fmt.Fprintf(os.Stdout, "Category: %s\n", resource.ResourceCategory)
+ 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 != "" {
- fmt.Fprintf(os.Stdout, "Description: %s\n", resource.ResourceDescription)
+ u.Out().Printf("Description: %s\n", resource.ResourceDescription)
}
if resource.UserVisibleDescription != "" {
- fmt.Fprintf(os.Stdout, "User Desc: %s\n", resource.UserVisibleDescription)
+ u.Out().Printf("User Desc: %s\n", resource.UserVisibleDescription)
}
if resource.BuildingId != "" {
- fmt.Fprintf(os.Stdout, "Building: %s\n", resource.BuildingId)
+ u.Out().Printf("Building: %s\n", resource.BuildingId)
}
if resource.FloorName != "" {
- fmt.Fprintf(os.Stdout, "Floor: %s\n", resource.FloorName)
+ u.Out().Printf("Floor: %s\n", resource.FloorName)
}
if resource.Capacity != 0 {
- fmt.Fprintf(os.Stdout, "Capacity: %d\n", resource.Capacity)
+ u.Out().Printf("Capacity: %d\n", resource.Capacity)
}
return nil
}
diff --git a/internal/cmd/schemas.go b/internal/cmd/schemas.go
index 64783746..5eaa3455 100644
--- a/internal/cmd/schemas.go
+++ b/internal/cmd/schemas.go
@@ -70,6 +70,7 @@ type SchemasGetCmd struct {
}
func (c *SchemasGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -94,18 +95,18 @@ func (c *SchemasGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, schema)
}
- fmt.Fprintf(os.Stdout, "Name: %s\n", schema.SchemaName)
- fmt.Fprintf(os.Stdout, "ID: %s\n", schema.SchemaId)
+ u.Out().Printf("Name: %s\n", schema.SchemaName)
+ u.Out().Printf("ID: %s\n", schema.SchemaId)
if schema.DisplayName != "" {
- fmt.Fprintf(os.Stdout, "Display: %s\n", schema.DisplayName)
+ u.Out().Printf("Display: %s\n", schema.DisplayName)
}
if len(schema.Fields) > 0 {
- fmt.Fprintf(os.Stdout, "Fields: %d\n", len(schema.Fields))
+ u.Out().Printf("Fields: %d\n", len(schema.Fields))
for _, field := range schema.Fields {
if field == nil {
continue
}
- fmt.Fprintf(os.Stdout, "- %s (%s)\n", field.FieldName, field.FieldType)
+ u.Out().Printf("- %s (%s)\n", field.FieldName, field.FieldType)
}
}
return nil
diff --git a/internal/cmd/sso.go b/internal/cmd/sso.go
index 19365d87..e7e96e68 100644
--- a/internal/cmd/sso.go
+++ b/internal/cmd/sso.go
@@ -28,6 +28,7 @@ type SSOSettingsCmd struct {
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
@@ -47,30 +48,30 @@ func (c *SSOSettingsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, profile)
}
- fmt.Fprintf(os.Stdout, "Profile: %s\n", profile.Name)
+ u.Out().Printf("Profile: %s\n", profile.Name)
if profile.DisplayName != "" {
- fmt.Fprintf(os.Stdout, "Display Name: %s\n", profile.DisplayName)
+ u.Out().Printf("Display Name: %s\n", profile.DisplayName)
}
if profile.IdpConfig != nil {
if profile.IdpConfig.EntityId != "" {
- fmt.Fprintf(os.Stdout, "Entity ID: %s\n", profile.IdpConfig.EntityId)
+ u.Out().Printf("Entity ID: %s\n", profile.IdpConfig.EntityId)
}
if profile.IdpConfig.SingleSignOnServiceUri != "" {
- fmt.Fprintf(os.Stdout, "SSO URL: %s\n", profile.IdpConfig.SingleSignOnServiceUri)
+ u.Out().Printf("SSO URL: %s\n", profile.IdpConfig.SingleSignOnServiceUri)
}
if profile.IdpConfig.LogoutRedirectUri != "" {
- fmt.Fprintf(os.Stdout, "Logout URL: %s\n", profile.IdpConfig.LogoutRedirectUri)
+ u.Out().Printf("Logout URL: %s\n", profile.IdpConfig.LogoutRedirectUri)
}
if profile.IdpConfig.ChangePasswordUri != "" {
- fmt.Fprintf(os.Stdout, "Change Password: %s\n", profile.IdpConfig.ChangePasswordUri)
+ u.Out().Printf("Change Password: %s\n", profile.IdpConfig.ChangePasswordUri)
}
}
if profile.SpConfig != nil {
if profile.SpConfig.EntityId != "" {
- fmt.Fprintf(os.Stdout, "SP Entity ID: %s\n", profile.SpConfig.EntityId)
+ u.Out().Printf("SP Entity ID: %s\n", profile.SpConfig.EntityId)
}
if profile.SpConfig.AssertionConsumerServiceUri != "" {
- fmt.Fprintf(os.Stdout, "SP ACS URL: %s\n", profile.SpConfig.AssertionConsumerServiceUri)
+ u.Out().Printf("SP ACS URL: %s\n", profile.SpConfig.AssertionConsumerServiceUri)
}
}
return nil
diff --git a/internal/cmd/sso_test.go b/internal/cmd/sso_test.go
index 1c67d400..d43c274a 100644
--- a/internal/cmd/sso_test.go
+++ b/internal/cmd/sso_test.go
@@ -39,7 +39,7 @@ func TestSSOSettingsGetCmd(t *testing.T) {
cmd := &SSOSettingsGetCmd{}
out := captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
diff --git a/internal/cmd/transfer.go b/internal/cmd/transfer.go
index 6a6a89a7..27765e90 100644
--- a/internal/cmd/transfer.go
+++ b/internal/cmd/transfer.go
@@ -99,6 +99,7 @@ type TransferGetCmd struct {
}
func (c *TransferGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -123,12 +124,12 @@ func (c *TransferGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, transfer)
}
- fmt.Fprintf(os.Stdout, "ID: %s\n", transfer.Id)
- fmt.Fprintf(os.Stdout, "Old Owner: %s\n", transfer.OldOwnerUserId)
- fmt.Fprintf(os.Stdout, "New Owner: %s\n", transfer.NewOwnerUserId)
- fmt.Fprintf(os.Stdout, "Status: %s\n", transfer.OverallTransferStatusCode)
+ 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 {
- fmt.Fprintf(os.Stdout, "Apps: %d\n", len(transfer.ApplicationDataTransfers))
+ u.Out().Printf("Apps: %d\n", len(transfer.ApplicationDataTransfers))
}
return nil
}
diff --git a/internal/cmd/vault_exports.go b/internal/cmd/vault_exports.go
index 6ac33a0f..277003d3 100644
--- a/internal/cmd/vault_exports.go
+++ b/internal/cmd/vault_exports.go
@@ -83,6 +83,7 @@ type VaultExportsGetCmd struct {
}
func (c *VaultExportsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -102,12 +103,12 @@ func (c *VaultExportsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, exp)
}
- fmt.Fprintf(os.Stdout, "Export ID: %s\n", exp.Id)
- fmt.Fprintf(os.Stdout, "Name: %s\n", exp.Name)
- fmt.Fprintf(os.Stdout, "Status: %s\n", exp.Status)
- fmt.Fprintf(os.Stdout, "Created: %s\n", exp.CreateTime)
+ 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 {
- fmt.Fprintf(os.Stdout, "Corpus: %s\n", exp.Query.Corpus)
+ u.Out().Printf("Corpus: %s\n", exp.Query.Corpus)
}
return nil
}
@@ -152,7 +153,8 @@ func (c *VaultExportsCreateCmd) Run(ctx context.Context, flags *RootFlags) error
return outfmt.WriteJSON(os.Stdout, created)
}
- fmt.Fprintf(os.Stdout, "Created export: %s (%s)\n", created.Name, created.Id)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created export: %s (%s)\n", created.Name, created.Id)
return nil
}
diff --git a/internal/cmd/vault_holds.go b/internal/cmd/vault_holds.go
index 58009325..807fc6ce 100644
--- a/internal/cmd/vault_holds.go
+++ b/internal/cmd/vault_holds.go
@@ -81,6 +81,7 @@ type VaultHoldsGetCmd struct {
}
func (c *VaultHoldsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -100,10 +101,10 @@ func (c *VaultHoldsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, hold)
}
- fmt.Fprintf(os.Stdout, "Hold ID: %s\n", hold.HoldId)
- fmt.Fprintf(os.Stdout, "Name: %s\n", hold.Name)
- fmt.Fprintf(os.Stdout, "Corpus: %s\n", hold.Corpus)
- fmt.Fprintf(os.Stdout, "Scope: %s\n", holdScope(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
}
@@ -184,7 +185,8 @@ func (c *VaultHoldsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, created)
}
- fmt.Fprintf(os.Stdout, "Created hold: %s (%s)\n", created.Name, created.HoldId)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created hold: %s (%s)\n", created.Name, created.HoldId)
return nil
}
diff --git a/internal/cmd/vault_matters.go b/internal/cmd/vault_matters.go
index cdef6858..9eb24cc5 100644
--- a/internal/cmd/vault_matters.go
+++ b/internal/cmd/vault_matters.go
@@ -92,6 +92,7 @@ type VaultMattersGetCmd struct {
}
func (c *VaultMattersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
@@ -111,11 +112,11 @@ func (c *VaultMattersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, matter)
}
- fmt.Fprintf(os.Stdout, "Matter ID: %s\n", matter.MatterId)
- fmt.Fprintf(os.Stdout, "Name: %s\n", matter.Name)
- fmt.Fprintf(os.Stdout, "State: %s\n", matter.State)
+ 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 != "" {
- fmt.Fprintf(os.Stdout, "Description: %s\n", matter.Description)
+ u.Out().Printf("Description: %s\n", matter.Description)
}
return nil
}
@@ -146,7 +147,8 @@ func (c *VaultMattersCreateCmd) Run(ctx context.Context, flags *RootFlags) error
return outfmt.WriteJSON(os.Stdout, created)
}
- fmt.Fprintf(os.Stdout, "Created matter: %s (%s)\n", created.Name, created.MatterId)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created matter: %s (%s)\n", created.Name, created.MatterId)
return nil
}
@@ -193,7 +195,8 @@ func (c *VaultMattersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
return outfmt.WriteJSON(os.Stdout, updated)
}
- fmt.Fprintf(os.Stdout, "Updated matter: %s (%s)\n", updated.Name, updated.MatterId)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Updated matter: %s (%s)\n", updated.Name, updated.MatterId)
return nil
}
@@ -262,7 +265,8 @@ func (c *VaultMattersReopenCmd) Run(ctx context.Context, flags *RootFlags) error
if resp != nil && resp.Matter != nil && resp.Matter.MatterId != "" {
matterID = resp.Matter.MatterId
}
- fmt.Fprintf(os.Stdout, "Reopened matter: %s\n", matterID)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Reopened matter: %s\n", matterID)
return nil
}
From f3c10bf2cda83c1a43e5472e9f77a4b0917575fc Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 12:31:51 -0800
Subject: [PATCH 24/48] test(admin): add comprehensive tests for vault, roles,
and admin groups
- Add vault tests: matters CRUD, exports, holds
- Add roles tests: get, update, delete, privileges
- Add admin groups tests: update, delete, settings, members sync
- Increases cmd package coverage from 63.1% to 65.5%
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/admingroups_test.go | 257 +++++++++++++++++++
internal/cmd/roles.go | 6 +-
internal/cmd/roles_test.go | 258 ++++++++++++++++++-
internal/cmd/vault_test.go | 420 +++++++++++++++++++++++++++++++
4 files changed, 938 insertions(+), 3 deletions(-)
diff --git a/internal/cmd/admingroups_test.go b/internal/cmd/admingroups_test.go
index 9a8d880a..d510d51d 100644
--- a/internal/cmd/admingroups_test.go
+++ b/internal/cmd/admingroups_test.go
@@ -120,3 +120,260 @@ func writeTempFile(t *testing.T, content string) string {
}
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"},
+ },
+ })
+ })
+ srv := stubAdminDirectory(t, h)
+ _ = srv
+
+ 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/roles.go b/internal/cmd/roles.go
index 90cfe634..a3a560db 100644
--- a/internal/cmd/roles.go
+++ b/internal/cmd/roles.go
@@ -173,7 +173,8 @@ func (c *RolesCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, created)
}
- fmt.Fprintf(os.Stdout, "Created role: %s (%d)\n", created.RoleName, created.RoleId)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Created role: %s (%d)\n", created.RoleName, created.RoleId)
return nil
}
@@ -244,7 +245,8 @@ func (c *RolesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
return outfmt.WriteJSON(os.Stdout, updated)
}
- fmt.Fprintf(os.Stdout, "Updated role: %s (%d)\n", updated.RoleName, updated.RoleId)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Updated role: %s (%d)\n", updated.RoleName, updated.RoleId)
return nil
}
diff --git a/internal/cmd/roles_test.go b/internal/cmd/roles_test.go
index 757f9ac0..62939fe0 100644
--- a/internal/cmd/roles_test.go
+++ b/internal/cmd/roles_test.go
@@ -73,7 +73,7 @@ func TestRolesCreateCmd(t *testing.T) {
cmd := &RolesCreateCmd{Name: "Helpdesk", Privileges: "READ,WRITE"}
out := captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
@@ -165,3 +165,259 @@ func TestAdminsCreateCmd(t *testing.T) {
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/vault_test.go b/internal/cmd/vault_test.go
index 5a8f714b..d58c6b18 100644
--- a/internal/cmd/vault_test.go
+++ b/internal/cmd/vault_test.go
@@ -136,3 +136,423 @@ func stubStorage(t *testing.T, handler http.Handler) *httptest.Server {
})
return srv
}
+
+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)
+ }
+ })
+ }
+}
From 6384064226f93e01eafce07ef0976cbaff912e8d Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:18:34 -0800
Subject: [PATCH 25/48] test(admin): add comprehensive tests for resources
commands
Add 45 new tests for resources buildings, calendars, and features commands:
- Buildings: list, get, create, update, delete (with JSON/text output variants)
- Calendars: list, get, create, update, delete (with JSON/text output variants)
- Features: list, create, delete (with JSON/text output variants)
- Input validation tests for empty IDs and missing required fields
- Pagination and filtering tests
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/resources_test.go | 1213 +++++++++++++++++++++++++++++++-
1 file changed, 1189 insertions(+), 24 deletions(-)
diff --git a/internal/cmd/resources_test.go b/internal/cmd/resources_test.go
index 9c4e95dc..231d2fff 100644
--- a/internal/cmd/resources_test.go
+++ b/internal/cmd/resources_test.go
@@ -5,8 +5,14 @@ import (
"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") {
@@ -36,34 +42,197 @@ func TestResourcesBuildingsListCmd(t *testing.T) {
}
}
-func TestResourcesCalendarsCreateCmd(t *testing.T) {
- var gotName string
+func TestResourcesBuildingsListCmd_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") {
+ if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/resources/buildings") {
http.NotFound(w, r)
return
}
- var payload struct {
- ResourceName string `json:"resourceName"`
- ResourceCategory string `json:"resourceCategory"`
+ 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 err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ })
+
+ 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
}
- 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,
+ "buildingId": "b-new",
+ "buildingName": gotPayload.BuildingName,
+ "description": gotPayload.Description,
+ "floorNames": gotPayload.FloorNames,
})
})
stubAdminDirectory(t, h)
flags := &RootFlags{Account: "admin@example.com"}
- cmd := &ResourcesCalendarsCreateCmd{Name: "Training Room", Type: "CONFERENCE_ROOM"}
+ 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 {
@@ -71,39 +240,1035 @@ func TestResourcesCalendarsCreateCmd(t *testing.T) {
}
})
- if gotName != "Training Room" {
- t.Fatalf("unexpected name: %q", gotName)
+ if gotPayload.BuildingName != "New Building" {
+ t.Errorf("expected name 'New Building', got %q", gotPayload.BuildingName)
}
- if !strings.Contains(out, "Created calendar resource") {
+ 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 TestResourcesFeaturesListCmd(t *testing.T) {
+func TestResourcesBuildingsCreateCmd_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") {
+ 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{
- "features": []map[string]any{
- {"name": "Projector"},
- },
+ "buildingId": "b-new",
+ "buildingName": "New Building",
})
})
stubAdminDirectory(t, h)
flags := &RootFlags{Account: "admin@example.com"}
- cmd := &ResourcesFeaturesListCmd{}
+ cmd := &ResourcesBuildingsCreateCmd{Name: "New Building"}
+
+ ctx := testContext(t)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ if err := cmd.Run(ctx, flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "Projector") {
+ 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)
+ }
+}
From 55953a30ed407a96cd20b842c6184be4ab2287ea Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:21:17 -0800
Subject: [PATCH 26/48] test(cmd): add comprehensive tests for alerts and
aliases commands
Add tests for AlertsGetCmd, AlertsDeleteCmd, AlertsUndeleteCmd,
AlertsFeedbackCreateCmd, AlertsFeedbackListCmd, AlertsSettingsUpdateCmd,
AliasesCreateCmd, and AliasesDeleteCmd. Tests cover plain text output,
JSON output mode, validation errors, and confirmation requirements.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/alerts_test.go | 526 +++++++++++++++++++++++++++++++++++
internal/cmd/aliases_test.go | 425 ++++++++++++++++++++++++++++
2 files changed, 951 insertions(+)
diff --git a/internal/cmd/alerts_test.go b/internal/cmd/alerts_test.go
index 250a7598..d0d9283f 100644
--- a/internal/cmd/alerts_test.go
+++ b/internal/cmd/alerts_test.go
@@ -10,6 +10,8 @@ import (
alertcenter "google.golang.org/api/alertcenter/v1beta1"
"google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
func TestAlertsListCmd(t *testing.T) {
@@ -48,6 +50,331 @@ func TestAlertsListCmd(t *testing.T) {
}
}
+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) {
@@ -90,6 +417,205 @@ func TestAlertsFeedbackCreateCmd(t *testing.T) {
}
}
+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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/aliases_test.go b/internal/cmd/aliases_test.go
index 8b41274c..6aeb1f47 100644
--- a/internal/cmd/aliases_test.go
+++ b/internal/cmd/aliases_test.go
@@ -5,6 +5,8 @@ import (
"net/http"
"strings"
"testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
func TestAliasesListCmd_User(t *testing.T) {
@@ -33,3 +35,426 @@ func TestAliasesListCmd_User(t *testing.T) {
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)
+ }
+ })
+ }
+}
From 801423af568d070894147c5554fe6b1baa229c73 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:23:35 -0800
Subject: [PATCH 27/48] test(cmd): add comprehensive tests for domains commands
Add tests for DomainsGetCmd, DomainsCreateCmd, DomainsDeleteCmd,
DomainsAliasesListCmd, DomainsAliasesCreateCmd, and DomainsAliasesDeleteCmd.
Tests cover JSON and plain text output modes, error handling, missing
account validation, and confirmation requirements for destructive operations.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/domains_test.go | 674 +++++++++++++++++++++++++++++++++++
1 file changed, 674 insertions(+)
diff --git a/internal/cmd/domains_test.go b/internal/cmd/domains_test.go
index eb87bd74..5cf41b22 100644
--- a/internal/cmd/domains_test.go
+++ b/internal/cmd/domains_test.go
@@ -5,8 +5,14 @@ import (
"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") {
@@ -35,3 +41,671 @@ func TestDomainsListCmd(t *testing.T) {
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")
+ }
+}
From 265181fdf066e106c65f4bcd54f9e07a3093ab20 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:24:02 -0800
Subject: [PATCH 28/48] test(contacts): add comprehensive tests for contacts
commands
Add tests for contacts delegates, domain, import, export, and dedup commands:
- ContactsDelegatesAddCmd: add delegate functionality with JSON output
- ContactsDelegatesRemoveCmd: remove delegate functionality with JSON output
- ContactsDelegatesListCmd: JSON output and empty results handling
- ContactsDomainCreateCmd: create contact with JSON output and validation
- ContactsDomainDeleteCmd: delete by resource name/email with JSON output
- ContactsDomainListCmd: JSON output and pagination
- ContactsImportCmd: CSV parsing, JSON output, and error handling
- ContactsExportCmd: CSV export to file/stdout with JSON output
- ContactsDedupCmd: duplicate detection and dry-run mode
Also includes unit tests for CSV helper functions:
- normalizeCSVHeader, parseCSVRow, csvPerson
- openCSVReader, openCSVWriter
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/contacts_advanced_test.go | 970 +++++++++++++++++++++++++
internal/cmd/drive_advanced_test.go | 91 +++
2 files changed, 1061 insertions(+)
diff --git a/internal/cmd/contacts_advanced_test.go b/internal/cmd/contacts_advanced_test.go
index a99ec3e2..27c9e22a 100644
--- a/internal/cmd/contacts_advanced_test.go
+++ b/internal/cmd/contacts_advanced_test.go
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"os"
@@ -10,7 +11,11 @@ import (
"strings"
"testing"
+ "google.golang.org/api/option"
"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) {
@@ -20,6 +25,16 @@ func stubPeopleDirectoryService(t *testing.T, svc *people.Service) {
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") {
@@ -154,3 +169,958 @@ func TestContactsDedupCmd(t *testing.T) {
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)
+ })
+}
+
+// --- newPeopleServiceWithEndpoint helper for complex scenarios ---
+
+func newPeopleServiceWithEndpoint(t *testing.T, endpoint string) *people.Service {
+ t.Helper()
+ svc, err := people.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithEndpoint(endpoint),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ return svc
+}
diff --git a/internal/cmd/drive_advanced_test.go b/internal/cmd/drive_advanced_test.go
index e81a397b..4c5ce983 100644
--- a/internal/cmd/drive_advanced_test.go
+++ b/internal/cmd/drive_advanced_test.go
@@ -143,6 +143,68 @@ func TestDriveRevisionsListCmd(t *testing.T) {
}
}
+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) {
@@ -177,6 +239,35 @@ func TestDriveShortcutsCreateCmd(t *testing.T) {
}
}
+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") {
From 5a2f1f7e3ea39e2e2f2fc3b8ab7fea986751d24a Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:24:47 -0800
Subject: [PATCH 29/48] test(admin): add comprehensive tests for printers and
forms commands
Add 36 new tests for printers and forms commands:
Printers (18 tests):
- PrintersListCmd: list, JSON output, empty results, pagination
- PrintersGetCmd: get, JSON output, empty ID validation
- PrintersCreateCmd: create, JSON output, missing name/URI validation
- PrintersUpdateCmd: update, empty ID and no-updates validation
- PrintersDeleteCmd: delete, empty ID validation, confirmation check
- Helper functions: printerResourceName, printerParent
Forms (18 tests):
- FormsListCmd: list, JSON output, empty results, pagination, user filter
- FormsGetCmd: get, JSON output, empty ID validation, no-title handling
- FormsCreateCmd: create, JSON output, missing/whitespace title validation
- FormsResponsesCmd: list responses, JSON output, empty results, empty form ID, pagination
All tests use mock HTTP servers to verify API interactions.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/forms_test.go | 460 ++++++++++++++++++++++++++++++++++
internal/cmd/printers_test.go | 397 +++++++++++++++++++++++++++++
2 files changed, 857 insertions(+)
diff --git a/internal/cmd/forms_test.go b/internal/cmd/forms_test.go
index 1c6ee9aa..6eff8250 100644
--- a/internal/cmd/forms_test.go
+++ b/internal/cmd/forms_test.go
@@ -11,6 +11,8 @@ import (
"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) {
@@ -42,6 +44,249 @@ func TestFormsListCmd(t *testing.T) {
}
}
+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) {
@@ -84,6 +329,221 @@ func TestFormsCreateCmd(t *testing.T) {
}
}
+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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/printers_test.go b/internal/cmd/printers_test.go
index 66ed481c..9e798ab5 100644
--- a/internal/cmd/printers_test.go
+++ b/internal/cmd/printers_test.go
@@ -5,6 +5,8 @@ import (
"net/http"
"strings"
"testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
func TestPrintersListCmd(t *testing.T) {
@@ -36,6 +38,175 @@ func TestPrintersListCmd(t *testing.T) {
}
}
+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) {
@@ -77,3 +248,229 @@ func TestPrintersCreateCmd(t *testing.T) {
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)
+ }
+}
From 96761abfdabecb3de35c081aacb573f656cf8f65 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:26:14 -0800
Subject: [PATCH 30/48] test(admin): add comprehensive tests for admin commands
- Add tests for CAA (Context-Aware Access) levels commands
- Add tests for Cloud Identity management commands
- Add tests for license management commands
- Add tests for schema management commands
- Add tests for data transfer commands
- Fix RootFlags.Yes -> RootFlags.Force in caa_test.go
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/caa_test.go | 301 +++++++++++
internal/cmd/cloudidentity_test.go | 399 +++++++++++++++
internal/cmd/licenses_test.go | 361 +++++++++++++
internal/cmd/schemas_test.go | 793 ++++++++++++++++++++++++++++-
internal/cmd/transfer_test.go | 649 +++++++++++++++++++++++
5 files changed, 2493 insertions(+), 10 deletions(-)
diff --git a/internal/cmd/caa_test.go b/internal/cmd/caa_test.go
index 62a22fcf..bb4c7291 100644
--- a/internal/cmd/caa_test.go
+++ b/internal/cmd/caa_test.go
@@ -104,6 +104,307 @@ func TestCAALevelsCreateCmd(t *testing.T) {
}
}
+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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/cloudidentity_test.go b/internal/cmd/cloudidentity_test.go
index dd96fbfb..d1c317f8 100644
--- a/internal/cmd/cloudidentity_test.go
+++ b/internal/cmd/cloudidentity_test.go
@@ -3,13 +3,18 @@ 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()) {
@@ -148,3 +153,397 @@ func TestCloudIdentityPoliciesListCmd(t *testing.T) {
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/licenses_test.go b/internal/cmd/licenses_test.go
index 616854a5..0078304f 100644
--- a/internal/cmd/licenses_test.go
+++ b/internal/cmd/licenses_test.go
@@ -10,6 +10,8 @@ import (
"google.golang.org/api/licensing/v1"
"google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
func TestLicensesListCmd(t *testing.T) {
@@ -41,6 +43,199 @@ func TestLicensesListCmd(t *testing.T) {
}
}
+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) {
@@ -84,6 +279,133 @@ func TestLicensesAssignCmd(t *testing.T) {
}
}
+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{}
@@ -98,6 +420,45 @@ func TestLicensesProductsCmd(t *testing.T) {
}
}
+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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/schemas_test.go b/internal/cmd/schemas_test.go
index 332df879..82ecbbd4 100644
--- a/internal/cmd/schemas_test.go
+++ b/internal/cmd/schemas_test.go
@@ -5,8 +5,209 @@ import (
"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
@@ -60,31 +261,603 @@ func TestSchemasCreateCmd(t *testing.T) {
}
}
-func TestSchemasListCmd(t *testing.T) {
+func TestSchemasCreateCmd_JSON(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/schemas") {
+ 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")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "schemas": []map[string]any{
- {"schemaName": "Custom", "schemaId": "s1", "fields": []map[string]any{{"fieldName": "Department"}}},
- },
- })
+ 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 := &SchemasListCmd{}
+ 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(testContext(t), flags); err != nil {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "Custom") {
+ 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/transfer_test.go b/internal/cmd/transfer_test.go
index 03e74d32..6a5d1ed8 100644
--- a/internal/cmd/transfer_test.go
+++ b/internal/cmd/transfer_test.go
@@ -3,13 +3,18 @@ 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) {
@@ -110,3 +115,647 @@ func stubDataTransfer(t *testing.T, handler http.Handler) *httptest.Server {
})
return srv
}
+
+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 int64 `json:"id,string"`
+ 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)
+}
From 424851cdd7cedc9b06f0c1653d25f85c680275bf Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:28:43 -0800
Subject: [PATCH 31/48] test(cmd): add config tests and fix transfer test JSON
unmarshaling
- Add comprehensive tests for config commands
- Fix TestTransferApplicationsCmd_JSON: use string type for ID since JSON
output serializes int64 as string
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/config_cmd_test.go | 385 ++++++++++++++++++++++++++++++++
internal/cmd/transfer_test.go | 2 +-
2 files changed, 386 insertions(+), 1 deletion(-)
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/transfer_test.go b/internal/cmd/transfer_test.go
index 6a5d1ed8..fd2eb551 100644
--- a/internal/cmd/transfer_test.go
+++ b/internal/cmd/transfer_test.go
@@ -598,7 +598,7 @@ func TestTransferApplicationsCmd_JSON(t *testing.T) {
var result struct {
Applications []struct {
- ID int64 `json:"id,string"`
+ ID string `json:"id"`
Name string `json:"name"`
} `json:"applications"`
}
From 3fda065b452b7e85fcab6ffeba44c8e170f38e23 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:39:12 -0800
Subject: [PATCH 32/48] test(cmd): add comprehensive tests for labels,
orgunits, projects, reports, reseller, serviceaccounts
Add extensive test coverage for previously uncovered commands:
- labels: list, get, create, delete with JSON/text output
- orgunits: create, delete, get, update, list
- projects: list, get with JSON/text output
- reports: activities, users, customer, drive, tokens
- reseller: customers list/get, subscriptions list/get
- serviceaccounts: list, create, delete
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/labels_test.go | 663 ++++++++++-
internal/cmd/orgunits_test.go | 768 +++++++++++++
internal/cmd/projects_test.go | 783 ++++++++++++-
internal/cmd/reports_test.go | 1564 +++++++++++++++++++++++++-
internal/cmd/reseller_test.go | 608 ++++++++++
internal/cmd/serviceaccounts_test.go | 1120 +++++++++++++++++-
6 files changed, 5460 insertions(+), 46 deletions(-)
diff --git a/internal/cmd/labels_test.go b/internal/cmd/labels_test.go
index c1649ec5..cedcb771 100644
--- a/internal/cmd/labels_test.go
+++ b/internal/cmd/labels_test.go
@@ -10,9 +10,13 @@ import (
"google.golang.org/api/drivelabels/v2"
"google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
-func TestLabelsListCmd(t *testing.T) {
+// ========== 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)
@@ -22,6 +26,7 @@ func TestLabelsListCmd(t *testing.T) {
_ = 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"}},
},
})
})
@@ -37,11 +42,304 @@ func TestLabelsListCmd(t *testing.T) {
})
if !strings.Contains(out, "Confidential") {
- t.Fatalf("unexpected output: %s", out)
+ 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 TestLabelsCreateCmd(t *testing.T) {
+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) {
@@ -50,7 +348,7 @@ func TestLabelsCreateCmd(t *testing.T) {
return
}
var payload struct {
- LabelType string `json:"labelType"`
+ LabelType string `json:"labelType"`
Properties struct {
Title string `json:"title"`
} `json:"properties"`
@@ -79,11 +377,259 @@ func TestLabelsCreateCmd(t *testing.T) {
t.Fatalf("unexpected payload: title=%q type=%q", gotTitle, gotType)
}
if !strings.Contains(out, "Created label") {
- t.Fatalf("unexpected output: %s", out)
+ 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 TestLabelsUpdateCmd(t *testing.T) {
+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") {
@@ -126,10 +672,113 @@ func TestLabelsUpdateCmd(t *testing.T) {
t.Fatalf("unexpected title: %q", gotTitle)
}
if !strings.Contains(out, "Updated label") {
- t.Fatalf("unexpected output: %s", out)
+ 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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/orgunits_test.go b/internal/cmd/orgunits_test.go
index c561c99f..865b32da 100644
--- a/internal/cmd/orgunits_test.go
+++ b/internal/cmd/orgunits_test.go
@@ -2,9 +2,12 @@ package cmd
import (
"encoding/json"
+ "errors"
"net/http"
"strings"
"testing"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
func TestOrgunitsListCmd(t *testing.T) {
@@ -35,3 +38,768 @@ func TestOrgunitsListCmd(t *testing.T) {
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/projects_test.go b/internal/cmd/projects_test.go
index 75c517a3..4ad2dd55 100644
--- a/internal/cmd/projects_test.go
+++ b/internal/cmd/projects_test.go
@@ -3,13 +3,19 @@ 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()) {
@@ -35,6 +41,92 @@ func stubCloudResourceService(t *testing.T, svc *cloudresourcemanager.Service) {
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") {
@@ -61,7 +153,7 @@ func TestProjectsListCmd(t *testing.T) {
cmd := &ProjectsListCmd{Parent: "organizations/123"}
out := captureStdout(t, func() {
- if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
@@ -71,6 +163,413 @@ func TestProjectsListCmd(t *testing.T) {
}
}
+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) {
@@ -91,7 +590,7 @@ func TestProjectsCreateCmd(t *testing.T) {
cmd := &ProjectsCreateCmd{ID: "p1", Name: "Project One", Parent: "organizations/123"}
out := captureStdout(t, func() {
- if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ if err := cmd.Run(projectsTestContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
@@ -103,3 +602,283 @@ func TestProjectsCreateCmd(t *testing.T) {
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_test.go b/internal/cmd/reports_test.go
index 9979907a..c44e5aa7 100644
--- a/internal/cmd/reports_test.go
+++ b/internal/cmd/reports_test.go
@@ -10,6 +10,8 @@ import (
reports "google.golang.org/api/admin/reports/v1"
"google.golang.org/api/option"
+
+ "github.com/steipete/gogcli/internal/outfmt"
)
func TestReportsUserCmd(t *testing.T) {
@@ -48,23 +50,84 @@ func TestReportsUserCmd(t *testing.T) {
}
}
-func TestReportsUsageCmd(t *testing.T) {
+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, "/usage/dates/") {
+ 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{
- "usageReports": []map[string]any{
+ "items": []map[string]any{
{
- "date": "2026-01-02",
- "entity": map[string]any{
- "type": "CUSTOMER",
- "customerId": "my_customer",
+ "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"},
},
- "parameters": []map[string]any{
- {"name": "num_users", "intValue": "42"},
+ },
+ },
+ })
+ })
+ 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"},
},
},
},
@@ -73,7 +136,7 @@ func TestReportsUsageCmd(t *testing.T) {
stubReports(t, h)
flags := &RootFlags{Account: "admin@example.com"}
- cmd := &ReportsUsageCmd{Application: "gmail", Date: "2026-01-02", Parameters: "num_users"}
+ cmd := &ReportsUserCmd{Date: "2026-01-02", Filters: "event_name==login"}
out := captureStdout(t, func() {
if err := cmd.Run(testContext(t), flags); err != nil {
@@ -81,11 +144,1490 @@ func TestReportsUsageCmd(t *testing.T) {
}
})
- if !strings.Contains(out, "num_users=42") {
+ 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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/reseller_test.go b/internal/cmd/reseller_test.go
index 7895947f..c1c7f0f1 100644
--- a/internal/cmd/reseller_test.go
+++ b/internal/cmd/reseller_test.go
@@ -10,6 +10,8 @@ import (
"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()) {
@@ -113,3 +115,609 @@ func TestResellerSubscriptionsCreateCmd(t *testing.T) {
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/serviceaccounts_test.go b/internal/cmd/serviceaccounts_test.go
index 90ea9afb..4ef56c50 100644
--- a/internal/cmd/serviceaccounts_test.go
+++ b/internal/cmd/serviceaccounts_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"os"
@@ -13,6 +14,8 @@ import (
"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()) {
@@ -38,71 +41,1136 @@ func stubIAMService(t *testing.T, svc *iam.Service) {
newIAMService = func(context.Context, string) (*iam.Service, error) { return svc, nil }
}
-func TestServiceAccountsKeysCreateCmd(t *testing.T) {
- keyPayload := []byte("{}")
- encoded := base64.StdEncoding.EncodeToString(keyPayload)
+// ============================================================================
+// 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.MethodPost || !strings.Contains(r.URL.Path, "/v1/projects/-/serviceAccounts/sa@example.com/keys") {
+ 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{
- "name": "projects/-/serviceAccounts/sa@example.com/keys/key1",
- "privateKeyData": encoded,
+ "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"}
- output := filepath.Join(t.TempDir(), "sa.json")
- cmd := &ServiceAccountsKeysCreateCmd{ServiceAccount: "sa@example.com", Output: output}
+ cmd := &ServiceAccountsListCmd{Project: "test-project", Max: 100}
- _ = captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- data, err := os.ReadFile(output)
- if err != nil {
- t.Fatalf("ReadFile: %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 string(data) != string(keyPayload) {
- t.Fatalf("unexpected key data: %s", string(data))
+ 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)
}
}
-func TestServiceAccountsListCmd(t *testing.T) {
+// ============================================================================
+// 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.MethodGet || !strings.Contains(r.URL.Path, "/v1/projects/p1/serviceAccounts") {
+ 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{
- "accounts": []map[string]any{{
- "name": "projects/p1/serviceAccounts/sa@example.com",
- "email": "sa@example.com",
- "displayName": "SA",
- }},
+ "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 := &ServiceAccountsListCmd{Project: "p1"}
+ 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(testContext(t), flags); err != nil {
+ if err := cmd.Run(ctx, flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "sa@example.com") {
- t.Fatalf("unexpected output: %s", out)
+ 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)
+ }
+ })
}
}
From f400895a44dd1e929a6a1c6ca6da68ea0516349f Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:56:43 -0800
Subject: [PATCH 33/48] test(cmd): add comprehensive tests for calendar
commands
Add tests for calendar edit functions and event day parsing:
- applyCreateEventType with various event types and transparency settings
- parseEventTime and parseEventDate with valid and invalid inputs
- edge cases for all-day events and timezone handling
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/calendar_edit_test.go | 459 +++++++++++++++++
internal/cmd/calendar_event_days_test.go | 598 +++++++++++++++++++++++
2 files changed, 1057 insertions(+)
diff --git a/internal/cmd/calendar_edit_test.go b/internal/cmd/calendar_edit_test.go
index ce005320..30b6a0a6 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 != "declineAllConflictingInvitations" {
+ 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" -> "declineAllConflictingInvitations"
+ if event.FocusTimeProperties.AutoDeclineMode != "declineAllConflictingInvitations" {
+ 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 != "officeLocation" {
+ 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..e49c1ac6 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)
+ }
+}
From 7c95b30a4fce21a2ea9b7dfec46a08402f3a6001 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:58:48 -0800
Subject: [PATCH 34/48] test(cmd): add comprehensive auth and chat tests
Add extensive test coverage for authentication and chat commands:
- Auth tests for login, status, logout, info, and switch flows
- Chat tests for spaces, messages, threads operations
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/auth_comprehensive_test.go | 962 ++++++++++++++++++
internal/cmd/chat_test.go | 1206 +++++++++++++++++++++++
2 files changed, 2168 insertions(+)
create mode 100644 internal/cmd/auth_comprehensive_test.go
create mode 100644 internal/cmd/chat_test.go
diff --git a/internal/cmd/auth_comprehensive_test.go b/internal/cmd/auth_comprehensive_test.go
new file mode 100644
index 00000000..bef1141b
--- /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/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)
+ }
+}
From 2e9b9c7e750fe829d677277b54751301ea859a87 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 14:00:27 -0800
Subject: [PATCH 35/48] test(cmd): add comprehensive classroom tests
Add extensive test coverage for Google Classroom commands:
- Courses (list, get, create, update, join, leave, url)
- Coursework (list, get, create, assignees)
- Guardians and guardian invites
- Invitations (list, get, create, accept, delete)
- Announcements (list, get, create, delete)
- Materials (list, get, create, update, delete)
- Roster, students, teachers, profile
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/classroom_test.go | 1517 ++++++++++++++++++++++++++++++++
1 file changed, 1517 insertions(+)
create mode 100644 internal/cmd/classroom_test.go
diff --git a/internal/cmd/classroom_test.go b/internal/cmd/classroom_test.go
new file mode 100644
index 00000000..36e8646d
--- /dev/null
+++ b/internal/cmd/classroom_test.go
@@ -0,0 +1,1517 @@
+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 {
+ 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")
+ }
+}
From bd9f2b63ecbf5672e2ee357de8e62a75fe138be0 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 14:51:46 -0800
Subject: [PATCH 36/48] test(cmd): expand coverage and dedupe helpers
---
internal/cmd/admins_test.go | 589 ++++++++
internal/cmd/channel_test.go | 557 ++++++++
internal/cmd/confirm_coverage_test.go | 183 +++
internal/cmd/contacts_advanced_test.go | 15 -
internal/cmd/drive_activity_test.go | 521 +++++++
internal/cmd/gmail_advanced_test.go | 1508 +++++++++++++++++++++
internal/cmd/gmail_thread_helpers_test.go | 6 +-
internal/cmd/gmail_thread_test.go | 4 +-
internal/cmd/gmail_url_test.go | 4 +-
internal/cmd/meet_test.go | 529 +++++++-
internal/cmd/sso_test.go | 1327 +++++++++++++++++-
internal/cmd/tasks_helpers_test.go | 987 ++++++++++++++
internal/cmd/users_advanced_test.go | 1323 ++++++++++++++++++
13 files changed, 7485 insertions(+), 68 deletions(-)
create mode 100644 internal/cmd/admins_test.go
create mode 100644 internal/cmd/confirm_coverage_test.go
create mode 100644 internal/cmd/drive_activity_test.go
create mode 100644 internal/cmd/gmail_advanced_test.go
create mode 100644 internal/cmd/tasks_helpers_test.go
create mode 100644 internal/cmd/users_advanced_test.go
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/channel_test.go b/internal/cmd/channel_test.go
index 231e4356..a5a59166 100644
--- a/internal/cmd/channel_test.go
+++ b/internal/cmd/channel_test.go
@@ -10,6 +10,8 @@ import (
"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()) {
@@ -35,6 +37,8 @@ func stubCloudChannelService(t *testing.T, svc *cloudchannel.Service) {
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") {
@@ -67,6 +71,370 @@ func TestChannelCustomersListCmd(t *testing.T) {
}
}
+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"}
+
+ out := captureStdout(t, func() {
+ if err := cmd.Run(testContext(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "offers/offer1") || !strings.Contains(out, "sku1") || !strings.Contains(out, "product1") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+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") {
@@ -98,3 +466,192 @@ func TestChannelEntitlementsListCmd(t *testing.T) {
t.Fatalf("unexpected output: %s", out)
}
}
+
+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/confirm_coverage_test.go b/internal/cmd/confirm_coverage_test.go
new file mode 100644
index 00000000..b1240260
--- /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 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, Err: errors.New("test")}
+ if err.Code != tc.code {
+ t.Fatalf("expected code %d, got %d", tc.code, err.Code)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/contacts_advanced_test.go b/internal/cmd/contacts_advanced_test.go
index 27c9e22a..4318b96e 100644
--- a/internal/cmd/contacts_advanced_test.go
+++ b/internal/cmd/contacts_advanced_test.go
@@ -11,7 +11,6 @@ import (
"strings"
"testing"
- "google.golang.org/api/option"
"google.golang.org/api/people/v1"
"github.com/steipete/gogcli/internal/outfmt"
@@ -1110,17 +1109,3 @@ func TestContactsDelegatesListCmd_WithUserOverride(t *testing.T) {
_ = cmd.Run(testContextWithStderr(t), flags)
})
}
-
-// --- newPeopleServiceWithEndpoint helper for complex scenarios ---
-
-func newPeopleServiceWithEndpoint(t *testing.T, endpoint string) *people.Service {
- t.Helper()
- svc, err := people.NewService(context.Background(),
- option.WithoutAuthentication(),
- option.WithEndpoint(endpoint),
- )
- if err != nil {
- t.Fatalf("NewService: %v", err)
- }
- return svc
-}
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/gmail_advanced_test.go b/internal/cmd/gmail_advanced_test.go
new file mode 100644
index 00000000..b03c5a5d
--- /dev/null
+++ b/internal/cmd/gmail_advanced_test.go
@@ -0,0 +1,1508 @@
+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/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+// ==================== 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 result != nil && 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 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, nil
+ }
+
+ 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, nil
+ }
+
+ 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, nil
+ }
+
+ 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, nil
+ }
+
+ 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, nil
+ }
+
+ 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() {
+ 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)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ 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() {
+ 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)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ 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() {
+ 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)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ 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() {
+ 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)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ cmd := &GmailThreadAttachmentsCmd{Download: true, OutputDir: OutputDirFlag{Dir: outDir}}
+ if err := runKong(t, cmd, []string{"t1", "--download", "--out-dir", outDir}, ctx, flags); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ })
+
+ 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() {
+ 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)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ 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() {
+ 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)
+ ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+
+ 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_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/meet_test.go b/internal/cmd/meet_test.go
index 3467267a..24504064 100644
--- a/internal/cmd/meet_test.go
+++ b/internal/cmd/meet_test.go
@@ -3,15 +3,63 @@ 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/outfmt"
+ "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) *httptest.Server {
+ 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()
+ })
+ return srv
+}
+
+// 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") {
@@ -31,7 +79,7 @@ func TestMeetSpacesListCmd(t *testing.T) {
cmd := &MeetSpacesListCmd{}
out := captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ if err := cmd.Run(testMeetContext(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
@@ -41,6 +89,260 @@ func TestMeetSpacesListCmd(t *testing.T) {
}
}
+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 := testMeetContext(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 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 := testMeetContext(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["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) {
@@ -67,7 +369,7 @@ func TestMeetSpacesCreateCmd(t *testing.T) {
cmd := &MeetSpacesCreateCmd{AccessType: "OPEN"}
out := captureStdout(t, func() {
- if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
@@ -80,6 +382,127 @@ func TestMeetSpacesCreateCmd(t *testing.T) {
}
}
+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(testMeetContextWithStdout(t), flags); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ })
+
+ if !strings.Contains(out, "spaces/newspace") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+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 := testMeetContext(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["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) {
@@ -97,7 +520,7 @@ func TestMeetSpacesEndCmd(t *testing.T) {
cmd := &MeetSpacesEndCmd{Space: "space1"}
out := captureStdout(t, func() {
- if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
+ if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
@@ -110,23 +533,91 @@ func TestMeetSpacesEndCmd(t *testing.T) {
}
}
-func stubMeet(t *testing.T, handler http.Handler) *httptest.Server {
- t.Helper()
+func TestMeetSpacesEndCmd_EmptySpace(t *testing.T) {
+ flags := &RootFlags{Account: "user@example.com"}
+ cmd := &MeetSpacesEndCmd{Space: ""}
- 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)
+ err := cmd.Run(testMeetContext(t), flags)
+ if err == nil {
+ t.Fatal("expected error for empty space")
}
- newMeetService = func(context.Context, string) (*meet.Service, error) { return svc, nil }
- t.Cleanup(func() {
- newMeetService = orig
- srv.Close()
+ 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{})
})
- return srv
+ 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 := testMeetContext(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["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/sso_test.go b/internal/cmd/sso_test.go
index d43c274a..69ce06c5 100644
--- a/internal/cmd/sso_test.go
+++ b/internal/cmd/sso_test.go
@@ -3,15 +3,25 @@ package cmd
import (
"context"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"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"
)
+// -----------------------------------------------------------------------------
+// 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") {
@@ -49,47 +59,188 @@ func TestSSOSettingsGetCmd(t *testing.T) {
}
}
-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/") {
+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{
- "orgUnitId": "ou-123",
- "orgUnitPath": "/Sales",
+ "inboundSamlSsoProfiles": []map[string]any{
+ {
+ "name": "inboundSamlSsoProfiles/profile-1",
+ "displayName": "Workspace",
+ "idpConfig": map[string]any{
+ "entityId": "https://idp.example.com",
+ "singleSignOnServiceUri": "https://sso.example.com",
+ },
+ },
+ },
})
})
- stubAdminDirectory(t, adminHandler)
+ stubInboundSSO(t, h)
- var gotTarget, gotMode string
- cloudHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ flags := &RootFlags{Account: "admin@example.com"}
+ cmd := &SSOSettingsGetCmd{}
+
+ 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"] != "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.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
+ 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)
}
- gotTarget = payload.TargetOrgUnit
- gotMode = payload.SsoMode
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
- "name": "operations/op-1",
+ "name": "operations/op-update-1",
})
- return
default:
http.NotFound(w, r)
}
})
- stubInboundSSO(t, cloudHandler)
+ stubInboundSSO(t, h)
flags := &RootFlags{Account: "admin@example.com"}
- cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Sales", Mode: "SSO_ON"}
+ cmd := &SSOSettingsUpdateCmd{SSOURL: "https://new-sso.example.com"}
out := captureStdout(t, func() {
if err := cmd.Run(testContextWithStdout(t), flags); err != nil {
@@ -97,17 +248,1139 @@ func TestSSOAssignmentsCreateCmd(t *testing.T) {
}
})
- if gotTarget != "orgUnits/ou-123" {
- t.Fatalf("expected target orgUnits/ou-123, got %q", gotTarget)
+ if gotURL != "https://new-sso.example.com" {
+ t.Fatalf("expected sso url to be set, got: %s", gotURL)
}
- if gotMode != "DOMAIN_WIDE_SAML_IF_ENABLED" {
- t.Fatalf("expected mode DOMAIN_WIDE_SAML_IF_ENABLED, got %q", gotMode)
+ if !strings.Contains(out, "Updated inbound SSO profile") {
+ t.Fatalf("unexpected output: %s", out)
}
- if !strings.Contains(out, "Created inbound SSO assignment") {
+}
+
+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), 0600); 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"}
+
+ 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["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(" "), 0600); 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(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",
+ "samlSsoInfo": map[string]any{
+ "inboundSamlSsoProfile": "inboundSamlSsoProfiles/profile-1",
+ },
+ },
+ },
+ })
+ })
+ 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("unexpected output: %s", out)
+ }
+ if !strings.Contains(out, "SSO_OFF") {
+ t.Fatalf("expected mode in output: %s", out)
+ }
+}
+
+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{}
+
+ 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"] != "token-123" {
+ t.Fatalf("unexpected nextPageToken: %v", result["nextPageToken"])
+ }
+}
+
+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"}
+
+ 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"] != "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(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-1",
+ })
+ return
+ }
+ http.NotFound(w, r)
+ })
+ stubInboundSSO(t, h)
+
+ flags := &RootFlags{Account: "admin@example.com", Force: true}
+ cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-1"}
+
+ 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 deletedID != "assignment-1" {
+ t.Fatalf("unexpected deleted ID: %s", deletedID)
+ }
+ if !strings.Contains(out, "Deleted inbound SSO assignment") {
+ t.Fatalf("unexpected output: %s", out)
+ }
+}
+
+func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) {
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete {
+ 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"}
+
+ 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"] != "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"), 0600); 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
+ result, 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"), 0600); 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
+ result, 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"}
+
+ 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["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) *httptest.Server {
t.Helper()
diff --git a/internal/cmd/tasks_helpers_test.go b/internal/cmd/tasks_helpers_test.go
new file mode 100644
index 00000000..afee83fd
--- /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 := splitCSVFields(tt.input)
+ if tt.want == nil && got != nil {
+ t.Errorf("splitCSVFields(%q) = %v, want nil", tt.input, got)
+ return
+ }
+ if tt.want != nil && got == nil {
+ t.Errorf("splitCSVFields(%q) = nil, want %v", tt.input, tt.want)
+ return
+ }
+ if len(got) != len(tt.want) {
+ t.Errorf("splitCSVFields(%q) = %v, want %v", tt.input, got, tt.want)
+ return
+ }
+ for i := range got {
+ if got[i] != tt.want[i] {
+ t.Errorf("splitCSVFields(%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/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")
+ }
+ })
+}
From c9058ac9fe387269fd8cafe63d507df73eb53ccd Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 15:07:20 -0800
Subject: [PATCH 37/48] test(cmd): centralize json helpers and stubs
---
internal/cmd/channel_test.go | 18 ----
internal/cmd/gmail_advanced_test.go | 68 +++++---------
internal/cmd/sso_test.go | 137 +++++++++-------------------
internal/cmd/testutil_test.go | 19 ++++
4 files changed, 85 insertions(+), 157 deletions(-)
diff --git a/internal/cmd/channel_test.go b/internal/cmd/channel_test.go
index a5a59166..60a7f089 100644
--- a/internal/cmd/channel_test.go
+++ b/internal/cmd/channel_test.go
@@ -4,32 +4,14 @@ 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
diff --git a/internal/cmd/gmail_advanced_test.go b/internal/cmd/gmail_advanced_test.go
index b03c5a5d..0d23b87c 100644
--- a/internal/cmd/gmail_advanced_test.go
+++ b/internal/cmd/gmail_advanced_test.go
@@ -19,6 +19,16 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
+func testContextJSONGmail(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)
+ }
+ ctx := ui.WithUI(context.Background(), u)
+ return outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+}
+
// ==================== gmail_attachments.go tests ====================
func TestAttachmentOutputFromInfo(t *testing.T) {
@@ -637,11 +647,11 @@ func TestLooksLikeBase64(t *testing.T) {
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("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},
@@ -841,13 +851,7 @@ func TestGmailURLCmd_JSON_MultipleThreads(t *testing.T) {
flags := &RootFlags{Account: "test@example.com"}
out := captureStdout(t, func() {
- 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)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-
+ ctx := testContextJSONGmail(t)
cmd := &GmailURLCmd{ThreadIDs: []string{"t1", "t2"}}
if err := runKong(t, cmd, []string{"t1", "t2"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -925,13 +929,7 @@ func TestGmailThreadAttachmentsCmd_EmptyThread_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- 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)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-
+ ctx := testContextJSONGmail(t)
cmd := &GmailThreadAttachmentsCmd{}
if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1005,13 +1003,7 @@ func TestGmailThreadAttachmentsCmd_WithAttachments_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- 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)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-
+ ctx := testContextJSONGmail(t)
cmd := &GmailThreadAttachmentsCmd{}
if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1100,13 +1092,7 @@ func TestGmailThreadAttachmentsCmd_Download_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- 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)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-
+ ctx := testContextJSONGmail(t)
cmd := &GmailThreadAttachmentsCmd{Download: true, OutputDir: OutputDirFlag{Dir: outDir}}
if err := runKong(t, cmd, []string{"t1", "--download", "--out-dir", outDir}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1170,13 +1156,7 @@ func TestGmailThreadGetCmd_JSON_EmptyThread(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- 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)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-
+ ctx := testContextJSONGmail(t)
cmd := &GmailThreadGetCmd{}
if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1308,13 +1288,7 @@ func TestGmailThreadModifyCmd_Success_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- 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)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-
+ ctx := testContextJSONGmail(t)
cmd := &GmailThreadModifyCmd{}
if err := runKong(t, cmd, []string{"t1", "--add", "MyLabel", "--remove", "OldLabel"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
diff --git a/internal/cmd/sso_test.go b/internal/cmd/sso_test.go
index 69ce06c5..01322e19 100644
--- a/internal/cmd/sso_test.go
+++ b/internal/cmd/sso_test.go
@@ -18,6 +18,15 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
+func testContextJSONSSO(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})
+}
+
// -----------------------------------------------------------------------------
// SSOSettingsGetCmd Tests
// -----------------------------------------------------------------------------
@@ -84,8 +93,7 @@ func TestSSOSettingsGetCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOSettingsGetCmd{}
- 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})
+ ctx := testContextJSONSSO(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -352,7 +360,7 @@ func TestSSOSettingsUpdateCmd_Certificate(t *testing.T) {
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), 0600); err != nil {
+ if err := os.WriteFile(tmpFile, []byte(certContent), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -416,8 +424,7 @@ func TestSSOSettingsUpdateCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOSettingsUpdateCmd{SSOURL: "https://sso.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})
+ ctx := testContextJSONSSO(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -464,7 +471,7 @@ func TestSSOSettingsUpdateCmd_MissingAccount(t *testing.T) {
func TestSSOSettingsUpdateCmd_EmptyCertificate(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "empty.pem")
- if err := os.WriteFile(tmpFile, []byte(" "), 0600); err != nil {
+ if err := os.WriteFile(tmpFile, []byte(" "), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -498,45 +505,6 @@ func TestSSOSettingsUpdateCmd_EmptyCertificate(t *testing.T) {
// SSOAssignmentsListCmd Tests
// -----------------------------------------------------------------------------
-func TestSSOAssignmentsListCmd(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",
- "samlSsoInfo": map[string]any{
- "inboundSamlSsoProfile": "inboundSamlSsoProfiles/profile-1",
- },
- },
- },
- })
- })
- 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("unexpected output: %s", out)
- }
- if !strings.Contains(out, "SSO_OFF") {
- t.Fatalf("expected mode in output: %s", out)
- }
-}
-
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") {
@@ -560,8 +528,7 @@ func TestSSOAssignmentsListCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOAssignmentsListCmd{}
- 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})
+ ctx := testContextJSONSSO(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -569,12 +536,28 @@ func TestSSOAssignmentsListCmd_JSON(t *testing.T) {
}
})
- var result map[string]any
+ 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 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)
}
}
@@ -972,8 +955,7 @@ func TestSSOAssignmentsCreateCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "SSO_OFF"}
- 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})
+ ctx := testContextJSONSSO(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -1017,46 +999,13 @@ func TestSSOAssignmentsCreateCmd_EmptyOrgUnit(t *testing.T) {
// SSOAssignmentsDeleteCmd Tests
// -----------------------------------------------------------------------------
-func TestSSOAssignmentsDeleteCmd(t *testing.T) {
+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-1",
- })
- return
- }
- http.NotFound(w, r)
- })
- stubInboundSSO(t, h)
-
- flags := &RootFlags{Account: "admin@example.com", Force: true}
- cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-1"}
-
- 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 deletedID != "assignment-1" {
- t.Fatalf("unexpected deleted ID: %s", deletedID)
- }
- if !strings.Contains(out, "Deleted inbound SSO assignment") {
- t.Fatalf("unexpected output: %s", out)
- }
-}
-
-func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) {
- h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodDelete {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "operations/op-delete-json",
@@ -1070,8 +1019,7 @@ func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com", Force: true}
cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-json"}
- 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})
+ ctx := testContextJSONSSO(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -1083,6 +1031,12 @@ func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) {
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"])
}
@@ -1212,7 +1166,7 @@ func TestReadValueOrFile(t *testing.T) {
// Test @file syntax
tmpFile := filepath.Join(t.TempDir(), "testfile.txt")
- if err := os.WriteFile(tmpFile, []byte("file-content"), 0600); err != nil {
+ if err := os.WriteFile(tmpFile, []byte("file-content"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -1238,7 +1192,7 @@ func TestReadValueOrFile(t *testing.T) {
// Test file path detection (file exists)
tmpFile2 := filepath.Join(t.TempDir(), "detectfile.txt")
- if err := os.WriteFile(tmpFile2, []byte("detected-content"), 0600); err != nil {
+ if err := os.WriteFile(tmpFile2, []byte("detected-content"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -1355,8 +1309,7 @@ func TestClearInboundSSOAssignments_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "NONE"}
- 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})
+ ctx := testContextJSONSSO(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go
index ac27a783..d9dca6ba 100644
--- a/internal/cmd/testutil_test.go
+++ b/internal/cmd/testutil_test.go
@@ -6,12 +6,15 @@ import (
"errors"
"io"
"net/http"
+ "net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/alecthomas/kong"
+ "google.golang.org/api/cloudchannel/v1"
+ "google.golang.org/api/option"
"github.com/steipete/gogcli/internal/googleauth"
)
@@ -70,6 +73,22 @@ func pickNonLocalTimezone(t *testing.T) string {
return pickTimezoneExcluding(t, time.Local.String(), "local")
}
+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 captureStdout(t *testing.T, fn func()) string {
t.Helper()
From 6c6778c41d96df5c5d83bbae4390ceaa52a44e5c Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 16:49:04 -0800
Subject: [PATCH 38/48] test(cmd): share json ctx and parse outputs
---
internal/cmd/channel_test.go | 53 +++++++++++++++++++++++++----
internal/cmd/gmail_advanced_test.go | 23 ++++---------
internal/cmd/meet_test.go | 46 +++++++++++++++++--------
internal/cmd/sso_test.go | 25 ++++----------
internal/cmd/testutil_test.go | 11 ++++++
5 files changed, 101 insertions(+), 57 deletions(-)
diff --git a/internal/cmd/channel_test.go b/internal/cmd/channel_test.go
index 60a7f089..fd9cec81 100644
--- a/internal/cmd/channel_test.go
+++ b/internal/cmd/channel_test.go
@@ -228,14 +228,38 @@ func TestChannelOffersListCmd(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &ChannelOffersListCmd{ChannelAccount: "acc"}
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ if err := cmd.Run(ctx, flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "offers/offer1") || !strings.Contains(out, "sku1") || !strings.Contains(out, "product1") {
- t.Fatalf("unexpected output: %s", out)
+ 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)
}
}
@@ -438,14 +462,31 @@ func TestChannelEntitlementsListCmd(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &ChannelEntitlementsListCmd{ChannelAccount: "acc", Customer: "cust1"}
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
- if err := cmd.Run(testContext(t), flags); err != nil {
+ if err := cmd.Run(ctx, flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "entitlements/e1") {
- t.Fatalf("unexpected output: %s", out)
+ 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)
}
}
diff --git a/internal/cmd/gmail_advanced_test.go b/internal/cmd/gmail_advanced_test.go
index 0d23b87c..45d02f39 100644
--- a/internal/cmd/gmail_advanced_test.go
+++ b/internal/cmd/gmail_advanced_test.go
@@ -15,20 +15,9 @@ import (
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
- "github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
-func testContextJSONGmail(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)
- }
- ctx := ui.WithUI(context.Background(), u)
- return outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
-}
-
// ==================== gmail_attachments.go tests ====================
func TestAttachmentOutputFromInfo(t *testing.T) {
@@ -851,7 +840,7 @@ func TestGmailURLCmd_JSON_MultipleThreads(t *testing.T) {
flags := &RootFlags{Account: "test@example.com"}
out := captureStdout(t, func() {
- ctx := testContextJSONGmail(t)
+ 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)
@@ -929,7 +918,7 @@ func TestGmailThreadAttachmentsCmd_EmptyThread_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- ctx := testContextJSONGmail(t)
+ ctx := testContextJSON(t)
cmd := &GmailThreadAttachmentsCmd{}
if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1003,7 +992,7 @@ func TestGmailThreadAttachmentsCmd_WithAttachments_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- ctx := testContextJSONGmail(t)
+ ctx := testContextJSON(t)
cmd := &GmailThreadAttachmentsCmd{}
if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1092,7 +1081,7 @@ func TestGmailThreadAttachmentsCmd_Download_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- ctx := testContextJSONGmail(t)
+ ctx := testContextJSON(t)
cmd := &GmailThreadAttachmentsCmd{Download: true, OutputDir: OutputDirFlag{Dir: outDir}}
if err := runKong(t, cmd, []string{"t1", "--download", "--out-dir", outDir}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1156,7 +1145,7 @@ func TestGmailThreadGetCmd_JSON_EmptyThread(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- ctx := testContextJSONGmail(t)
+ ctx := testContextJSON(t)
cmd := &GmailThreadGetCmd{}
if err := runKong(t, cmd, []string{"t1"}, ctx, flags); err != nil {
t.Fatalf("execute: %v", err)
@@ -1288,7 +1277,7 @@ func TestGmailThreadModifyCmd_Success_JSON(t *testing.T) {
flags := &RootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
- ctx := testContextJSONGmail(t)
+ 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)
diff --git a/internal/cmd/meet_test.go b/internal/cmd/meet_test.go
index 24504064..224ff687 100644
--- a/internal/cmd/meet_test.go
+++ b/internal/cmd/meet_test.go
@@ -13,7 +13,6 @@ import (
"google.golang.org/api/meet/v2"
"google.golang.org/api/option"
- "github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
@@ -79,13 +78,26 @@ func TestMeetSpacesListCmd(t *testing.T) {
cmd := &MeetSpacesListCmd{}
out := captureStdout(t, func() {
- if err := cmd.Run(testMeetContext(t), flags); err != nil {
+ if err := cmd.Run(testContextJSON(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "spaces/space1") {
- t.Fatalf("unexpected output: %s", out)
+ 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)
}
}
@@ -130,8 +142,7 @@ func TestMeetSpacesListCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "user@example.com"}
cmd := &MeetSpacesListCmd{}
- ctx := testMeetContext(t)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -323,8 +334,7 @@ func TestMeetSpacesGetCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "user@example.com"}
cmd := &MeetSpacesGetCmd{Space: "abc123"}
- ctx := testMeetContext(t)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -401,13 +411,21 @@ func TestMeetSpacesCreateCmd_NoAccessType(t *testing.T) {
cmd := &MeetSpacesCreateCmd{}
out := captureStdout(t, func() {
- if err := cmd.Run(testMeetContextWithStdout(t), flags); err != nil {
+ if err := cmd.Run(testContextJSON(t), flags); err != nil {
t.Fatalf("Run: %v", err)
}
})
- if !strings.Contains(out, "spaces/newspace") {
- t.Fatalf("unexpected output: %s", out)
+ 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)
}
}
@@ -483,8 +501,7 @@ func TestMeetSpacesCreateCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "user@example.com"}
cmd := &MeetSpacesCreateCmd{}
- ctx := testMeetContext(t)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -601,8 +618,7 @@ func TestMeetSpacesEndCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "user@example.com"}
cmd := &MeetSpacesEndCmd{Space: "abc123"}
- ctx := testMeetContext(t)
- ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
diff --git a/internal/cmd/sso_test.go b/internal/cmd/sso_test.go
index 01322e19..382f99ee 100644
--- a/internal/cmd/sso_test.go
+++ b/internal/cmd/sso_test.go
@@ -3,7 +3,6 @@ package cmd
import (
"context"
"encoding/json"
- "io"
"net/http"
"net/http/httptest"
"os"
@@ -13,20 +12,8 @@ import (
"google.golang.org/api/cloudidentity/v1"
"google.golang.org/api/option"
-
- "github.com/steipete/gogcli/internal/outfmt"
- "github.com/steipete/gogcli/internal/ui"
)
-func testContextJSONSSO(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})
-}
-
// -----------------------------------------------------------------------------
// SSOSettingsGetCmd Tests
// -----------------------------------------------------------------------------
@@ -93,7 +80,7 @@ func TestSSOSettingsGetCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOSettingsGetCmd{}
- ctx := testContextJSONSSO(t)
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -424,7 +411,7 @@ func TestSSOSettingsUpdateCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOSettingsUpdateCmd{SSOURL: "https://sso.example.com"}
- ctx := testContextJSONSSO(t)
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -528,7 +515,7 @@ func TestSSOAssignmentsListCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOAssignmentsListCmd{}
- ctx := testContextJSONSSO(t)
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -955,7 +942,7 @@ func TestSSOAssignmentsCreateCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "SSO_OFF"}
- ctx := testContextJSONSSO(t)
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -1019,7 +1006,7 @@ func TestSSOAssignmentsDeleteCmd_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com", Force: true}
cmd := &SSOAssignmentsDeleteCmd{AssignmentID: "inboundSsoAssignments/assignment-json"}
- ctx := testContextJSONSSO(t)
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
@@ -1309,7 +1296,7 @@ func TestClearInboundSSOAssignments_JSON(t *testing.T) {
flags := &RootFlags{Account: "admin@example.com"}
cmd := &SSOAssignmentsCreateCmd{OrgUnit: "/Test", Mode: "NONE"}
- ctx := testContextJSONSSO(t)
+ ctx := testContextJSON(t)
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go
index d9dca6ba..86357587 100644
--- a/internal/cmd/testutil_test.go
+++ b/internal/cmd/testutil_test.go
@@ -17,6 +17,8 @@ import (
"google.golang.org/api/option"
"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
@@ -73,6 +75,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 newCloudChannelServiceStub(t *testing.T, handler http.HandlerFunc) (*cloudchannel.Service, func()) {
t.Helper()
From 4f5c0fab94f0f88241eb1b915531138b95f8cefb Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 17:27:25 -0800
Subject: [PATCH 39/48] fix(drive): escape user values in Drive API query
strings
Add shared EscapeDriveQueryValue to googleapi package that escapes
backslashes and single quotes, preventing query injection when
user-provided values contain those characters. Applied at all five
interpolation sites and deduplicated existing local escape helpers.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/drive.go | 7 ++-----
internal/cmd/drive_cleanup.go | 5 +++--
internal/cmd/drive_transfer.go | 3 ++-
internal/googleapi/drive.go | 12 ++++++++++++
internal/todrive/writer.go | 4 ++--
5 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go
index 5e40c61c..910281ac 100644
--- a/internal/cmd/drive.go
+++ b/internal/cmd/drive.go
@@ -802,7 +802,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 {
@@ -820,10 +820,7 @@ func buildDriveSearchQuery(text string) string {
}
func escapeDriveQueryString(s string) string {
- // Escape backslashes first, then single quotes
- s = strings.ReplaceAll(s, "\\", "\\\\")
- s = strings.ReplaceAll(s, "'", "\\'")
- return s
+ return googleapi.EscapeDriveQueryValue(s)
}
func driveType(mimeType string) string {
diff --git a/internal/cmd/drive_cleanup.go b/internal/cmd/drive_cleanup.go
index 8e3e5789..82fa9e03 100644
--- a/internal/cmd/drive_cleanup.go
+++ b/internal/cmd/drive_cleanup.go
@@ -8,6 +8,7 @@ import (
"google.golang.org/api/drive/v3"
+ "github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
@@ -43,7 +44,7 @@ func (c *DriveCleanupEmptyFoldersCmd) Run(ctx context.Context, flags *RootFlags)
query := "mimeType='application/vnd.google-apps.folder' and trashed=false"
if strings.TrimSpace(c.Parent) != "" {
- query = fmt.Sprintf("%s and '%s' in parents", query, strings.TrimSpace(c.Parent))
+ query = fmt.Sprintf("%s and '%s' in parents", query, googleapi.EscapeDriveQueryValue(strings.TrimSpace(c.Parent)))
}
deleted := 0
@@ -99,7 +100,7 @@ func (c *DriveCleanupEmptyFoldersCmd) Run(ctx context.Context, flags *RootFlags)
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", folderID)).
+ Q(fmt.Sprintf("'%s' in parents and trashed=false", googleapi.EscapeDriveQueryValue(folderID))).
PageSize(1).
Fields("files(id)").
SupportsAllDrives(true).
diff --git a/internal/cmd/drive_transfer.go b/internal/cmd/drive_transfer.go
index fcb7a113..dc5c2e5f 100644
--- a/internal/cmd/drive_transfer.go
+++ b/internal/cmd/drive_transfer.go
@@ -8,6 +8,7 @@ import (
"google.golang.org/api/drive/v3"
+ "github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
@@ -45,7 +46,7 @@ func (c *DriveTransferCmd) Run(ctx context.Context, flags *RootFlags) error {
pageToken := ""
for {
call := svc.Files.List().
- Q(fmt.Sprintf("'%s' in owners and trashed=false", from)).
+ 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)
diff --git a/internal/googleapi/drive.go b/internal/googleapi/drive.go
index 5e1bde75..1c635f11 100644
--- a/internal/googleapi/drive.go
+++ b/internal/googleapi/drive.go
@@ -3,12 +3,24 @@ 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/todrive/writer.go b/internal/todrive/writer.go
index 7d9bb300..cd8ffb4f 100644
--- a/internal/todrive/writer.go
+++ b/internal/todrive/writer.go
@@ -142,7 +142,7 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
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", escapeDriveQuery(name))
if strings.TrimSpace(folderID) != "" {
- query = fmt.Sprintf("%s and '%s' in parents", query, 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 {
@@ -192,5 +192,5 @@ func toInterfaceRow(values []string) []interface{} {
}
func escapeDriveQuery(value string) string {
- return strings.ReplaceAll(value, "'", "\\'")
+ return googleapi.EscapeDriveQueryValue(value)
}
From 9f4f4af668e34f77f1ad34b670364e54fe565e07 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 17:37:45 -0800
Subject: [PATCH 40/48] fix(review): address code review findings across branch
- Inline Drive query escape wrappers to use shared EscapeDriveQueryValue directly
- Add comprehensive unit tests for EscapeDriveQueryValue
- Fix CloudIdentity groups get to use UI layer instead of direct os.Stdout
- Remove duplicate splitCSVFields, use splitCSV from split_helpers.go
- Make adminCustomerID configurable via GOG_CUSTOMER_ID env var
- Add t.Parallel() safety comment to testutil_test.go
- Move cloudchannel stub from testutil_test.go to channel_test.go
- Fix batch.go channel close pattern with goroutine
- Document readValueOrFile file detection heuristic
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/admin_directory_helpers.go | 13 ++++++++++-
internal/cmd/admins.go | 8 +++----
internal/cmd/batch.go | 3 +--
internal/cmd/channel_test.go | 18 +++++++++++++++
internal/cmd/cloudidentity.go | 11 ++++-----
internal/cmd/csv.go | 17 +-------------
internal/cmd/domains_aliases.go | 6 ++---
internal/cmd/domains_create.go | 2 +-
internal/cmd/domains_delete.go | 2 +-
internal/cmd/domains_get.go | 2 +-
internal/cmd/domains_list.go | 2 +-
internal/cmd/drive.go | 6 +----
internal/cmd/drive_test.go | 8 +++++--
internal/cmd/licenses.go | 2 +-
internal/cmd/orgunits_create.go | 2 +-
internal/cmd/orgunits_delete.go | 2 +-
internal/cmd/orgunits_get.go | 2 +-
internal/cmd/orgunits_list.go | 2 +-
internal/cmd/orgunits_update.go | 2 +-
internal/cmd/printers.go | 4 ++--
internal/cmd/reports.go | 2 +-
internal/cmd/resources_buildings.go | 10 ++++-----
internal/cmd/resources_calendars.go | 10 ++++-----
internal/cmd/resources_features.go | 6 ++---
internal/cmd/roles.go | 18 +++++++--------
internal/cmd/schemas.go | 12 +++++-----
internal/cmd/sso.go | 7 +++++-
internal/cmd/tasks_helpers_test.go | 10 ++++-----
internal/cmd/testutil_test.go | 24 +++++---------------
internal/cmd/transfer.go | 2 +-
internal/googleapi/drive_test.go | 30 +++++++++++++++++++++++++
internal/todrive/writer.go | 5 +----
32 files changed, 141 insertions(+), 109 deletions(-)
create mode 100644 internal/googleapi/drive_test.go
diff --git a/internal/cmd/admin_directory_helpers.go b/internal/cmd/admin_directory_helpers.go
index fb4b9d55..360c498d 100644
--- a/internal/cmd/admin_directory_helpers.go
+++ b/internal/cmd/admin_directory_helpers.go
@@ -1,3 +1,14 @@
package cmd
-const adminCustomerID = "my_customer"
+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/admins.go b/internal/cmd/admins.go
index f6bc2c48..7a0c89bf 100644
--- a/internal/cmd/admins.go
+++ b/internal/cmd/admins.go
@@ -36,7 +36,7 @@ func (c *AdminsListCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- call := svc.RoleAssignments.List(adminCustomerID).MaxResults(c.Max)
+ call := svc.RoleAssignments.List(adminCustomerID()).MaxResults(c.Max)
if c.Page != "" {
call = call.PageToken(c.Page)
}
@@ -127,7 +127,7 @@ func (c *AdminsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
assignment.OrgUnitId = orgID
}
- created, err := svc.RoleAssignments.Insert(adminCustomerID, assignment).Context(ctx).Do()
+ 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)
}
@@ -160,7 +160,7 @@ func (c *AdminsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := svc.RoleAssignments.Delete(adminCustomerID, c.AssignmentID).Context(ctx).Do(); err != nil {
+ if err := svc.RoleAssignments.Delete(adminCustomerID(), c.AssignmentID).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete admin assignment %s: %w", c.AssignmentID, err)
}
@@ -199,7 +199,7 @@ func resolveUserID(ctx context.Context, svc *admin.Service, user string) (string
}
func resolveOrgUnitID(ctx context.Context, svc *admin.Service, path string) (string, error) {
- ou, err := svc.Orgunits.Get(adminCustomerID, path).Context(ctx).Do()
+ ou, err := svc.Orgunits.Get(adminCustomerID(), path).Context(ctx).Do()
if err != nil {
return "", fmt.Errorf("resolve org unit %s: %w", path, err)
}
diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go
index 226d0460..66380e2d 100644
--- a/internal/cmd/batch.go
+++ b/internal/cmd/batch.go
@@ -73,8 +73,7 @@ func (c *BatchCmd) Run(ctx context.Context, flags *RootFlags) error {
tasks <- task
}
close(tasks)
- wg.Wait()
- close(results)
+ go func() { wg.Wait(); close(results) }()
failed := 0
for err := range results {
diff --git a/internal/cmd/channel_test.go b/internal/cmd/channel_test.go
index fd9cec81..a543e75b 100644
--- a/internal/cmd/channel_test.go
+++ b/internal/cmd/channel_test.go
@@ -4,14 +4,32 @@ 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
diff --git a/internal/cmd/cloudidentity.go b/internal/cmd/cloudidentity.go
index 9545f3df..2f9b2423 100644
--- a/internal/cmd/cloudidentity.go
+++ b/internal/cmd/cloudidentity.go
@@ -133,15 +133,16 @@ func (c *CloudIdentityGroupsGetCmd) Run(ctx context.Context, flags *RootFlags) e
return outfmt.WriteJSON(os.Stdout, group)
}
- fmt.Fprintf(os.Stdout, "Name: %s\n", group.Name)
+ u := ui.FromContext(ctx)
+ u.Out().Printf("Name: %s\n", group.Name)
if group.GroupKey != nil {
- fmt.Fprintf(os.Stdout, "Email: %s\n", group.GroupKey.Id)
+ u.Out().Printf("Email: %s\n", group.GroupKey.Id)
}
if group.DisplayName != "" {
- fmt.Fprintf(os.Stdout, "Display Name: %s\n", group.DisplayName)
+ u.Out().Printf("Display Name: %s\n", group.DisplayName)
}
if group.Parent != "" {
- fmt.Fprintf(os.Stdout, "Parent: %s\n", group.Parent)
+ u.Out().Printf("Parent: %s\n", group.Parent)
}
if len(group.Labels) > 0 {
labels := make([]string, 0, len(group.Labels))
@@ -149,7 +150,7 @@ func (c *CloudIdentityGroupsGetCmd) Run(ctx context.Context, flags *RootFlags) e
labels = append(labels, key)
}
sort.Strings(labels)
- fmt.Fprintf(os.Stdout, "Labels: %s\n", strings.Join(labels, ", "))
+ u.Out().Printf("Labels: %s\n", strings.Join(labels, ", "))
}
return nil
}
diff --git a/internal/cmd/csv.go b/internal/cmd/csv.go
index 87af50f5..2124e3c8 100644
--- a/internal/cmd/csv.go
+++ b/internal/cmd/csv.go
@@ -38,7 +38,7 @@ func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- fields := splitCSVFields(c.Fields)
+ fields := splitCSV(c.Fields)
processed := 0
failed := 0
@@ -80,18 +80,3 @@ func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error {
}
return nil
}
-
-func splitCSVFields(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 {
- if value := strings.TrimSpace(part); value != "" {
- out = append(out, value)
- }
- }
- return out
-}
diff --git a/internal/cmd/domains_aliases.go b/internal/cmd/domains_aliases.go
index 2591ca4f..01c112fa 100644
--- a/internal/cmd/domains_aliases.go
+++ b/internal/cmd/domains_aliases.go
@@ -25,7 +25,7 @@ func (c *DomainsAliasesListCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}
- resp, err := svc.DomainAliases.List(adminCustomerID).Context(ctx).Do()
+ resp, err := svc.DomainAliases.List(adminCustomerID()).Context(ctx).Do()
if err != nil {
return fmt.Errorf("list domain aliases: %w", err)
}
@@ -77,7 +77,7 @@ func (c *DomainsAliasesCreateCmd) Run(ctx context.Context, flags *RootFlags) err
DomainAliasName: c.Alias,
ParentDomainName: c.Parent,
}
- created, err := svc.DomainAliases.Insert(adminCustomerID, req).Context(ctx).Do()
+ created, err := svc.DomainAliases.Insert(adminCustomerID(), req).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create domain alias %s: %w", c.Alias, err)
}
@@ -110,7 +110,7 @@ func (c *DomainsAliasesDeleteCmd) Run(ctx context.Context, flags *RootFlags) err
return err
}
- if err := svc.DomainAliases.Delete(adminCustomerID, c.Alias).Context(ctx).Do(); err != nil {
+ if err := svc.DomainAliases.Delete(adminCustomerID(), c.Alias).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete domain alias %s: %w", c.Alias, err)
}
diff --git a/internal/cmd/domains_create.go b/internal/cmd/domains_create.go
index f55c4c07..ca35225e 100644
--- a/internal/cmd/domains_create.go
+++ b/internal/cmd/domains_create.go
@@ -28,7 +28,7 @@ func (c *DomainsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
req := &admin.Domains{DomainName: c.Domain}
- created, err := svc.Domains.Insert(adminCustomerID, req).Context(ctx).Do()
+ created, err := svc.Domains.Insert(adminCustomerID(), req).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create domain %s: %w", c.Domain, err)
}
diff --git a/internal/cmd/domains_delete.go b/internal/cmd/domains_delete.go
index a6aa4a8a..e3a807f7 100644
--- a/internal/cmd/domains_delete.go
+++ b/internal/cmd/domains_delete.go
@@ -27,7 +27,7 @@ func (c *DomainsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := svc.Domains.Delete(adminCustomerID, c.Domain).Context(ctx).Do(); err != nil {
+ if err := svc.Domains.Delete(adminCustomerID(), c.Domain).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete domain %s: %w", c.Domain, err)
}
diff --git a/internal/cmd/domains_get.go b/internal/cmd/domains_get.go
index b42d4377..889b4b0e 100644
--- a/internal/cmd/domains_get.go
+++ b/internal/cmd/domains_get.go
@@ -25,7 +25,7 @@ func (c *DomainsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- domain, err := svc.Domains.Get(adminCustomerID, c.Domain).Context(ctx).Do()
+ domain, err := svc.Domains.Get(adminCustomerID(), c.Domain).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get domain %s: %w", c.Domain, err)
}
diff --git a/internal/cmd/domains_list.go b/internal/cmd/domains_list.go
index 174cef4e..9b19935f 100644
--- a/internal/cmd/domains_list.go
+++ b/internal/cmd/domains_list.go
@@ -25,7 +25,7 @@ func (c *DomainsListCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- resp, err := svc.Domains.List(adminCustomerID).Context(ctx).Do()
+ resp, err := svc.Domains.List(adminCustomerID()).Context(ctx).Do()
if err != nil {
return fmt.Errorf("list domains: %w", err)
}
diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go
index 910281ac..868b8784 100644
--- a/internal/cmd/drive.go
+++ b/internal/cmd/drive.go
@@ -815,14 +815,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 {
- return googleapi.EscapeDriveQueryValue(s)
-}
-
func driveType(mimeType string) string {
if mimeType == "application/vnd.google-apps.folder" {
return "folder"
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/licenses.go b/internal/cmd/licenses.go
index c16a93ce..989f7e80 100644
--- a/internal/cmd/licenses.go
+++ b/internal/cmd/licenses.go
@@ -47,7 +47,7 @@ func (c *LicensesListCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- customer := adminCustomerID
+ customer := adminCustomerID()
var resp *licensing.LicenseAssignmentList
if strings.TrimSpace(c.SKU) != "" {
call := svc.LicenseAssignments.ListForProductAndSku(product, c.SKU, customer)
diff --git a/internal/cmd/orgunits_create.go b/internal/cmd/orgunits_create.go
index 169be1a1..cf68c475 100644
--- a/internal/cmd/orgunits_create.go
+++ b/internal/cmd/orgunits_create.go
@@ -41,7 +41,7 @@ func (c *OrgunitsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
Description: c.Description,
}
- created, err := svc.Orgunits.Insert(adminCustomerID, org).Context(ctx).Do()
+ created, err := svc.Orgunits.Insert(adminCustomerID(), org).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create org unit: %w", err)
}
diff --git a/internal/cmd/orgunits_delete.go b/internal/cmd/orgunits_delete.go
index 0714d2f2..20b447eb 100644
--- a/internal/cmd/orgunits_delete.go
+++ b/internal/cmd/orgunits_delete.go
@@ -27,7 +27,7 @@ func (c *OrgunitsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := svc.Orgunits.Delete(adminCustomerID, c.Path).Context(ctx).Do(); err != nil {
+ if err := svc.Orgunits.Delete(adminCustomerID(), c.Path).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete org unit %s: %w", c.Path, err)
}
diff --git a/internal/cmd/orgunits_get.go b/internal/cmd/orgunits_get.go
index 98f45673..27b15540 100644
--- a/internal/cmd/orgunits_get.go
+++ b/internal/cmd/orgunits_get.go
@@ -25,7 +25,7 @@ func (c *OrgunitsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- ou, err := svc.Orgunits.Get(adminCustomerID, c.Path).Context(ctx).Do()
+ 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)
}
diff --git a/internal/cmd/orgunits_list.go b/internal/cmd/orgunits_list.go
index b1993f37..27f87099 100644
--- a/internal/cmd/orgunits_list.go
+++ b/internal/cmd/orgunits_list.go
@@ -33,7 +33,7 @@ func (c *OrgunitsListCmd) Run(ctx context.Context, flags *RootFlags) error {
parent = "/"
}
- resp, err := svc.Orgunits.List(adminCustomerID).
+ resp, err := svc.Orgunits.List(adminCustomerID()).
OrgUnitPath(parent).
Type(c.Type).
Context(ctx).
diff --git a/internal/cmd/orgunits_update.go b/internal/cmd/orgunits_update.go
index 055e82ce..38b02c8e 100644
--- a/internal/cmd/orgunits_update.go
+++ b/internal/cmd/orgunits_update.go
@@ -53,7 +53,7 @@ func (c *OrgunitsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- updated, err := svc.Orgunits.Update(adminCustomerID, c.Path, org).Context(ctx).Do()
+ 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)
}
diff --git a/internal/cmd/printers.go b/internal/cmd/printers.go
index 7af58277..295ca8ae 100644
--- a/internal/cmd/printers.go
+++ b/internal/cmd/printers.go
@@ -273,7 +273,7 @@ func (c *PrintersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
}
func printerParent() string {
- return fmt.Sprintf("customers/%s", adminCustomerID)
+ return fmt.Sprintf("customers/%s", adminCustomerID())
}
func printerResourceName(id string) string {
@@ -281,5 +281,5 @@ func printerResourceName(id string) string {
if strings.HasPrefix(id, "customers/") {
return id
}
- return fmt.Sprintf("customers/%s/chrome/printers/%s", adminCustomerID, id)
+ return fmt.Sprintf("customers/%s/chrome/printers/%s", adminCustomerID(), id)
}
diff --git a/internal/cmd/reports.go b/internal/cmd/reports.go
index 767d7b58..ec64c722 100644
--- a/internal/cmd/reports.go
+++ b/internal/cmd/reports.go
@@ -278,7 +278,7 @@ func runUsageReport(ctx context.Context, flags *RootFlags, application, date, pa
date = reportDate(date)
- call := svc.CustomerUsageReports.Get(date).CustomerId(adminCustomerID)
+ call := svc.CustomerUsageReports.Get(date).CustomerId(adminCustomerID())
params := strings.TrimSpace(parameters)
if params == "" {
params = application
diff --git a/internal/cmd/resources_buildings.go b/internal/cmd/resources_buildings.go
index f6cb6602..510941fa 100644
--- a/internal/cmd/resources_buildings.go
+++ b/internal/cmd/resources_buildings.go
@@ -37,7 +37,7 @@ func (c *ResourcesBuildingsListCmd) Run(ctx context.Context, flags *RootFlags) e
return err
}
- call := svc.Resources.Buildings.List(adminCustomerID)
+ call := svc.Resources.Buildings.List(adminCustomerID())
if c.Max > 0 {
call = call.MaxResults(c.Max)
}
@@ -98,7 +98,7 @@ func (c *ResourcesBuildingsGetCmd) Run(ctx context.Context, flags *RootFlags) er
return err
}
- building, err := svc.Resources.Buildings.Get(adminCustomerID, buildingID).Context(ctx).Do()
+ building, err := svc.Resources.Buildings.Get(adminCustomerID(), buildingID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get building %s: %w", buildingID, err)
}
@@ -149,7 +149,7 @@ func (c *ResourcesBuildingsCreateCmd) Run(ctx context.Context, flags *RootFlags)
building.FloorNames = floors
}
- created, err := svc.Resources.Buildings.Insert(adminCustomerID, building).Context(ctx).Do()
+ created, err := svc.Resources.Buildings.Insert(adminCustomerID(), building).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create building %s: %w", name, err)
}
@@ -197,7 +197,7 @@ func (c *ResourcesBuildingsUpdateCmd) Run(ctx context.Context, flags *RootFlags)
patch.Description = strings.TrimSpace(*c.Description)
}
- updated, err := svc.Resources.Buildings.Patch(adminCustomerID, buildingID, patch).Context(ctx).Do()
+ updated, err := svc.Resources.Buildings.Patch(adminCustomerID(), buildingID, patch).Context(ctx).Do()
if err != nil {
return fmt.Errorf("update building %s: %w", buildingID, err)
}
@@ -235,7 +235,7 @@ func (c *ResourcesBuildingsDeleteCmd) Run(ctx context.Context, flags *RootFlags)
return err
}
- if err := svc.Resources.Buildings.Delete(adminCustomerID, buildingID).Context(ctx).Do(); err != nil {
+ if err := svc.Resources.Buildings.Delete(adminCustomerID(), buildingID).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete building %s: %w", buildingID, err)
}
diff --git a/internal/cmd/resources_calendars.go b/internal/cmd/resources_calendars.go
index 95126b06..e30f95be 100644
--- a/internal/cmd/resources_calendars.go
+++ b/internal/cmd/resources_calendars.go
@@ -38,7 +38,7 @@ func (c *ResourcesCalendarsListCmd) Run(ctx context.Context, flags *RootFlags) e
return err
}
- call := svc.Resources.Calendars.List(adminCustomerID)
+ call := svc.Resources.Calendars.List(adminCustomerID())
if c.Max > 0 {
call = call.MaxResults(c.Max)
}
@@ -113,7 +113,7 @@ func (c *ResourcesCalendarsGetCmd) Run(ctx context.Context, flags *RootFlags) er
return err
}
- resource, err := svc.Resources.Calendars.Get(adminCustomerID, resourceID).Context(ctx).Do()
+ resource, err := svc.Resources.Calendars.Get(adminCustomerID(), resourceID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get calendar resource %s: %w", resourceID, err)
}
@@ -177,7 +177,7 @@ func (c *ResourcesCalendarsCreateCmd) Run(ctx context.Context, flags *RootFlags)
Capacity: c.Capacity,
}
- created, err := svc.Resources.Calendars.Insert(adminCustomerID, resource).Context(ctx).Do()
+ created, err := svc.Resources.Calendars.Insert(adminCustomerID(), resource).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create calendar resource %s: %w", name, err)
}
@@ -225,7 +225,7 @@ func (c *ResourcesCalendarsUpdateCmd) Run(ctx context.Context, flags *RootFlags)
patch.Capacity = *c.Capacity
}
- updated, err := svc.Resources.Calendars.Patch(adminCustomerID, resourceID, patch).Context(ctx).Do()
+ 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)
}
@@ -263,7 +263,7 @@ func (c *ResourcesCalendarsDeleteCmd) Run(ctx context.Context, flags *RootFlags)
return err
}
- if err := svc.Resources.Calendars.Delete(adminCustomerID, resourceID).Context(ctx).Do(); err != nil {
+ if err := svc.Resources.Calendars.Delete(adminCustomerID(), resourceID).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete calendar resource %s: %w", resourceID, err)
}
diff --git a/internal/cmd/resources_features.go b/internal/cmd/resources_features.go
index 860d9eb5..4cadeaf0 100644
--- a/internal/cmd/resources_features.go
+++ b/internal/cmd/resources_features.go
@@ -35,7 +35,7 @@ func (c *ResourcesFeaturesListCmd) Run(ctx context.Context, flags *RootFlags) er
return err
}
- call := svc.Resources.Features.List(adminCustomerID)
+ call := svc.Resources.Features.List(adminCustomerID())
if c.Max > 0 {
call = call.MaxResults(c.Max)
}
@@ -92,7 +92,7 @@ func (c *ResourcesFeaturesCreateCmd) Run(ctx context.Context, flags *RootFlags)
}
feature := &admin.Feature{Name: name}
- created, err := svc.Resources.Features.Insert(adminCustomerID, feature).Context(ctx).Do()
+ created, err := svc.Resources.Features.Insert(adminCustomerID(), feature).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create feature %s: %w", name, err)
}
@@ -130,7 +130,7 @@ func (c *ResourcesFeaturesDeleteCmd) Run(ctx context.Context, flags *RootFlags)
return err
}
- if err := svc.Resources.Features.Delete(adminCustomerID, name).Context(ctx).Do(); err != nil {
+ if err := svc.Resources.Features.Delete(adminCustomerID(), name).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete feature %s: %w", name, err)
}
diff --git a/internal/cmd/roles.go b/internal/cmd/roles.go
index a3a560db..2dab6f1d 100644
--- a/internal/cmd/roles.go
+++ b/internal/cmd/roles.go
@@ -40,7 +40,7 @@ func (c *RolesListCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- call := svc.Roles.List(adminCustomerID).MaxResults(c.Max)
+ call := svc.Roles.List(adminCustomerID()).MaxResults(c.Max)
if c.Page != "" {
call = call.PageToken(c.Page)
}
@@ -100,7 +100,7 @@ func (c *RolesGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
if role == nil {
- role, err = svc.Roles.Get(adminCustomerID, roleID).Context(ctx).Do()
+ role, err = svc.Roles.Get(adminCustomerID(), roleID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get role %s: %w", c.Role, err)
}
@@ -164,7 +164,7 @@ func (c *RolesCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
RolePrivileges: privs,
}
- created, err := svc.Roles.Insert(adminCustomerID, role).Context(ctx).Do()
+ created, err := svc.Roles.Insert(adminCustomerID(), role).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create role %s: %w", c.Name, err)
}
@@ -202,7 +202,7 @@ func (c *RolesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
if role == nil {
- role, err = svc.Roles.Get(adminCustomerID, roleID).Context(ctx).Do()
+ role, err = svc.Roles.Get(adminCustomerID(), roleID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get role %s: %w", c.Role, err)
}
@@ -236,7 +236,7 @@ func (c *RolesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
return usage("no updates specified")
}
- updated, err := svc.Roles.Update(adminCustomerID, roleID, role).Context(ctx).Do()
+ updated, err := svc.Roles.Update(adminCustomerID(), roleID, role).Context(ctx).Do()
if err != nil {
return fmt.Errorf("update role %s: %w", c.Role, err)
}
@@ -275,7 +275,7 @@ func (c *RolesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := svc.Roles.Delete(adminCustomerID, roleID).Context(ctx).Do(); err != nil {
+ if err := svc.Roles.Delete(adminCustomerID(), roleID).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete role %s: %w", c.Role, err)
}
@@ -297,7 +297,7 @@ func (c *RolesPrivilegesCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- resp, err := svc.Privileges.List(adminCustomerID).Context(ctx).Do()
+ resp, err := svc.Privileges.List(adminCustomerID()).Context(ctx).Do()
if err != nil {
return fmt.Errorf("list privileges: %w", err)
}
@@ -353,7 +353,7 @@ func resolveRole(ctx context.Context, svc *admin.Service, role string) (string,
func listAllRoles(ctx context.Context, svc *admin.Service) ([]*admin.Role, error) {
roles := make([]*admin.Role, 0)
- call := svc.Roles.List(adminCustomerID).MaxResults(200)
+ call := svc.Roles.List(adminCustomerID()).MaxResults(200)
for {
resp, err := call.Context(ctx).Do()
if err != nil {
@@ -437,7 +437,7 @@ func updateRolePrivileges(ctx context.Context, svc *admin.Service, existing []*a
}
func privilegeMap(ctx context.Context, svc *admin.Service) (map[string]*admin.Privilege, error) {
- resp, err := svc.Privileges.List(adminCustomerID).Context(ctx).Do()
+ resp, err := svc.Privileges.List(adminCustomerID()).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("list privileges: %w", err)
}
diff --git a/internal/cmd/schemas.go b/internal/cmd/schemas.go
index 5eaa3455..eac1c92d 100644
--- a/internal/cmd/schemas.go
+++ b/internal/cmd/schemas.go
@@ -35,7 +35,7 @@ func (c *SchemasListCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- resp, err := svc.Schemas.List(adminCustomerID).Context(ctx).Do()
+ resp, err := svc.Schemas.List(adminCustomerID()).Context(ctx).Do()
if err != nil {
return fmt.Errorf("list schemas: %w", err)
}
@@ -86,7 +86,7 @@ func (c *SchemasGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- schema, err := svc.Schemas.Get(adminCustomerID, name).Context(ctx).Do()
+ schema, err := svc.Schemas.Get(adminCustomerID(), name).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get schema %s: %w", name, err)
}
@@ -148,7 +148,7 @@ func (c *SchemasCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
Fields: fields,
}
- created, err := svc.Schemas.Insert(adminCustomerID, schema).Context(ctx).Do()
+ created, err := svc.Schemas.Insert(adminCustomerID(), schema).Context(ctx).Do()
if err != nil {
return fmt.Errorf("create schema %s: %w", name, err)
}
@@ -188,7 +188,7 @@ func (c *SchemasUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- schema, err := svc.Schemas.Get(adminCustomerID, name).Context(ctx).Do()
+ schema, err := svc.Schemas.Get(adminCustomerID(), name).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get schema %s: %w", name, err)
}
@@ -248,7 +248,7 @@ func (c *SchemasUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
schema.Fields = updatedFields
- updated, err := svc.Schemas.Update(adminCustomerID, name, schema).Context(ctx).Do()
+ updated, err := svc.Schemas.Update(adminCustomerID(), name, schema).Context(ctx).Do()
if err != nil {
return fmt.Errorf("update schema %s: %w", name, err)
}
@@ -286,7 +286,7 @@ func (c *SchemasDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := svc.Schemas.Delete(adminCustomerID, name).Context(ctx).Do(); err != nil {
+ if err := svc.Schemas.Delete(adminCustomerID(), name).Context(ctx).Do(); err != nil {
return fmt.Errorf("delete schema %s: %w", name, err)
}
diff --git a/internal/cmd/sso.go b/internal/cmd/sso.go
index e7e96e68..c370f74a 100644
--- a/internal/cmd/sso.go
+++ b/internal/cmd/sso.go
@@ -387,7 +387,7 @@ func resolveOrgUnitResource(ctx context.Context, flags *RootFlags, orgUnit strin
return "", err
}
- ou, err := svc.Orgunits.Get(adminCustomerID, orgUnit).Context(ctx).Do()
+ ou, err := svc.Orgunits.Get(adminCustomerID(), orgUnit).Context(ctx).Do()
if err != nil {
return "", fmt.Errorf("resolve org unit %s: %w", orgUnit, err)
}
@@ -431,6 +431,11 @@ func clearInboundSSOAssignments(ctx context.Context, svc *cloudidentity.Service,
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 == "" {
diff --git a/internal/cmd/tasks_helpers_test.go b/internal/cmd/tasks_helpers_test.go
index afee83fd..bf639ff1 100644
--- a/internal/cmd/tasks_helpers_test.go
+++ b/internal/cmd/tasks_helpers_test.go
@@ -220,22 +220,22 @@ func TestSplitCSVFields(t *testing.T) {
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
- got := splitCSVFields(tt.input)
+ got := splitCSV(tt.input)
if tt.want == nil && got != nil {
- t.Errorf("splitCSVFields(%q) = %v, want nil", tt.input, got)
+ t.Errorf("splitCSV(%q) = %v, want nil", tt.input, got)
return
}
if tt.want != nil && got == nil {
- t.Errorf("splitCSVFields(%q) = nil, want %v", tt.input, tt.want)
+ t.Errorf("splitCSV(%q) = nil, want %v", tt.input, tt.want)
return
}
if len(got) != len(tt.want) {
- t.Errorf("splitCSVFields(%q) = %v, want %v", tt.input, got, 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("splitCSVFields(%q)[%d] = %v, want %v", tt.input, i, got[i], tt.want[i])
+ t.Errorf("splitCSV(%q)[%d] = %v, want %v", tt.input, i, got[i], tt.want[i])
}
}
})
diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go
index 86357587..0258af67 100644
--- a/internal/cmd/testutil_test.go
+++ b/internal/cmd/testutil_test.go
@@ -1,20 +1,22 @@
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"
"errors"
"io"
"net/http"
- "net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/alecthomas/kong"
- "google.golang.org/api/cloudchannel/v1"
- "google.golang.org/api/option"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/outfmt"
@@ -84,22 +86,6 @@ func testContextJSON(t *testing.T) context.Context {
return outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
}
-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 captureStdout(t *testing.T, fn func()) string {
t.Helper()
diff --git a/internal/cmd/transfer.go b/internal/cmd/transfer.go
index 27765e90..c455db2a 100644
--- a/internal/cmd/transfer.go
+++ b/internal/cmd/transfer.go
@@ -44,7 +44,7 @@ func (c *TransferListCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- call := svc.Transfers.List().CustomerId(adminCustomerID)
+ call := svc.Transfers.List().CustomerId(adminCustomerID())
if c.OldOwner != "" {
call = call.OldOwnerUserId(c.OldOwner)
}
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/todrive/writer.go b/internal/todrive/writer.go
index cd8ffb4f..d96064b1 100644
--- a/internal/todrive/writer.go
+++ b/internal/todrive/writer.go
@@ -140,7 +140,7 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
}
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", escapeDriveQuery(name))
+ 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)))
}
@@ -191,6 +191,3 @@ func toInterfaceRow(values []string) []interface{} {
return row
}
-func escapeDriveQuery(value string) string {
- return googleapi.EscapeDriveQueryValue(value)
-}
From 02cd6db3d3342bc82fde09af59cab524fd6e7c9c Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 18:07:27 -0800
Subject: [PATCH 41/48] style: fix goimports and gofumpt formatting
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/analytics_test.go | 2 +-
internal/cmd/calendar_edit_test.go | 6 +++---
internal/cmd/calendar_event_days_test.go | 4 ++--
internal/cmd/drive_orphans.go | 2 +-
internal/csv/processor_test.go | 3 +--
internal/googleapi/analytics.go | 2 +-
internal/todrive/writer.go | 7 ++++---
7 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/internal/cmd/analytics_test.go b/internal/cmd/analytics_test.go
index 2b4b77e2..80f2668b 100644
--- a/internal/cmd/analytics_test.go
+++ b/internal/cmd/analytics_test.go
@@ -8,7 +8,7 @@ import (
"strings"
"testing"
- "google.golang.org/api/analyticsadmin/v1beta"
+ analyticsadmin "google.golang.org/api/analyticsadmin/v1beta"
"google.golang.org/api/option"
)
diff --git a/internal/cmd/calendar_edit_test.go b/internal/cmd/calendar_edit_test.go
index 30b6a0a6..577c6537 100644
--- a/internal/cmd/calendar_edit_test.go
+++ b/internal/cmd/calendar_edit_test.go
@@ -94,9 +94,9 @@ func TestApplyCreateEventType_Default(t *testing.T) {
func TestApplyCreateEventType_FocusTime(t *testing.T) {
cmd := &CalendarCreateCmd{
- FocusAutoDecline: "all",
+ FocusAutoDecline: "all",
FocusDeclineMessage: "I'm busy",
- FocusChatStatus: "doNotDisturb",
+ FocusChatStatus: "doNotDisturb",
}
event := &calendar.Event{}
@@ -144,7 +144,7 @@ func TestApplyCreateEventType_FocusTimeDefaults(t *testing.T) {
func TestApplyCreateEventType_OutOfOffice(t *testing.T) {
cmd := &CalendarCreateCmd{
- OOOAutoDecline: "new",
+ OOOAutoDecline: "new",
OOODeclineMessage: "On vacation",
}
event := &calendar.Event{}
diff --git a/internal/cmd/calendar_event_days_test.go b/internal/cmd/calendar_event_days_test.go
index e49c1ac6..a9a3ae99 100644
--- a/internal/cmd/calendar_event_days_test.go
+++ b/internal/cmd/calendar_event_days_test.go
@@ -432,8 +432,8 @@ func TestWrapEventsWithDays_Nil(t *testing.T) {
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{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)
diff --git a/internal/cmd/drive_orphans.go b/internal/cmd/drive_orphans.go
index 3936d656..eef2399c 100644
--- a/internal/cmd/drive_orphans.go
+++ b/internal/cmd/drive_orphans.go
@@ -166,7 +166,7 @@ func (c *DriveOrphansCollectCmd) Run(ctx context.Context, flags *RootFlags) erro
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
- "moved": moved,
+ "moved": moved,
"folder": folderID,
})
}
diff --git a/internal/csv/processor_test.go b/internal/csv/processor_test.go
index 4099835f..a993af0d 100644
--- a/internal/csv/processor_test.go
+++ b/internal/csv/processor_test.go
@@ -852,7 +852,7 @@ func TestProcess_MalformedCSV(t *testing.T) {
// Unbalanced quotes cause csv.ReadAll to fail
err := os.WriteFile(path, []byte(`email,name
"alice@test.com,"Alice
-`), 0644)
+`), 0o644)
if err != nil {
t.Fatalf("write file: %v", err)
}
@@ -1174,4 +1174,3 @@ bob@test.com,Bob,inactive
t.Errorf("row[1].name: got %q, want %q", rows[1].Values["name"], "Bob")
}
}
-
diff --git a/internal/googleapi/analytics.go b/internal/googleapi/analytics.go
index 566d1caf..c698baec 100644
--- a/internal/googleapi/analytics.go
+++ b/internal/googleapi/analytics.go
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
- "google.golang.org/api/analyticsadmin/v1beta"
+ analyticsadmin "google.golang.org/api/analyticsadmin/v1beta"
"github.com/steipete/gogcli/internal/googleauth"
)
diff --git a/internal/todrive/writer.go b/internal/todrive/writer.go
index d96064b1..685c0af8 100644
--- a/internal/todrive/writer.go
+++ b/internal/todrive/writer.go
@@ -12,8 +12,10 @@ import (
"github.com/steipete/gogcli/internal/googleapi"
)
-var newDriveService = googleapi.NewDrive
-var newSheetsService = googleapi.NewSheets
+var (
+ newDriveService = googleapi.NewDrive
+ newSheetsService = googleapi.NewSheets
+)
const defaultSheetName = "Report"
@@ -190,4 +192,3 @@ func toInterfaceRow(values []string) []interface{} {
}
return row
}
-
From 30e6a797b850e33f85f031d2cead040dad468404 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Tue, 3 Feb 2026 18:56:57 -0800
Subject: [PATCH 42/48] fix(lint): resolve all golangci-lint issues
Address 124 lint findings across err113, goconst, govet shadow,
gosec, unparam, wsl, staticcheck, thelper, nilnil, predeclared,
ineffassign, and errorlint categories.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/admingroups_test.go | 6 +-
internal/cmd/admins.go | 5 +-
internal/cmd/alerts.go | 2 +-
internal/cmd/alerts_test.go | 3 +-
internal/cmd/aliases.go | 2 +-
internal/cmd/analytics_test.go | 3 +-
internal/cmd/auth.go | 2 +-
internal/cmd/auth_comprehensive_test.go | 6 +-
internal/cmd/auth_keyring.go | 2 +-
internal/cmd/batch.go | 2 +-
internal/cmd/caa.go | 10 ++-
internal/cmd/caa_test.go | 3 +-
internal/cmd/calendar_build.go | 8 +-
internal/cmd/calendar_create_update_test.go | 6 +-
internal/cmd/calendar_edit_test.go | 8 +-
internal/cmd/calendar_focus_time.go | 6 +-
internal/cmd/calendar_focus_time_test.go | 2 +-
internal/cmd/calendar_print_test.go | 2 +-
internal/cmd/calendar_working_location.go | 4 +-
.../cmd/calendar_working_location_test.go | 2 +-
internal/cmd/classroom_test.go | 2 +
internal/cmd/cloudidentity.go | 4 +-
internal/cmd/confirm_coverage_test.go | 4 +-
internal/cmd/contacts_delegates.go | 2 +-
internal/cmd/contacts_import.go | 7 +-
internal/cmd/csv.go | 10 +--
internal/cmd/domains_aliases.go | 2 +-
internal/cmd/domains_delete.go | 2 +-
internal/cmd/drive.go | 5 +-
internal/cmd/drive_activity.go | 2 +-
internal/cmd/drive_advanced_test.go | 3 +-
internal/cmd/drive_cleanup.go | 2 +-
internal/cmd/drive_orphans.go | 2 +-
internal/cmd/drive_revisions.go | 2 +-
internal/cmd/drive_shortcuts.go | 2 +-
internal/cmd/drive_transfer.go | 2 +-
internal/cmd/forms_test.go | 6 +-
internal/cmd/gmail_advanced_test.go | 21 ++---
internal/cmd/gmail_attachments.go | 4 +-
internal/cmd/gmail_thread.go | 2 +-
internal/cmd/gmail_watch_state.go | 2 +-
internal/cmd/groups.go | 1 -
internal/cmd/groups_admin.go | 12 +--
internal/cmd/labels.go | 4 +-
internal/cmd/labels_test.go | 3 +-
internal/cmd/licenses.go | 2 +-
internal/cmd/licenses_test.go | 3 +-
internal/cmd/lookerstudio.go | 2 +-
internal/cmd/meet_test.go | 3 +-
internal/cmd/orgunits_delete.go | 2 +-
internal/cmd/printers.go | 8 +-
internal/cmd/projects.go | 2 +-
internal/cmd/reports.go | 4 +-
internal/cmd/reports_test.go | 3 +-
internal/cmd/resources_buildings.go | 2 +-
internal/cmd/resources_calendars.go | 2 +-
internal/cmd/resources_features.go | 2 +-
internal/cmd/roles.go | 5 +-
internal/cmd/root.go | 7 +-
internal/cmd/schemas.go | 2 +-
internal/cmd/serviceaccounts.go | 6 +-
internal/cmd/sso.go | 12 +--
internal/cmd/sso_test.go | 11 ++-
internal/cmd/todrive_helpers.go | 4 +-
internal/cmd/transfer_test.go | 3 +-
internal/cmd/users.go | 8 +-
internal/cmd/users_2fa.go | 4 +-
internal/cmd/users_create.go | 3 +-
internal/cmd/users_delete.go | 2 +-
internal/cmd/users_password.go | 3 +-
internal/cmd/users_test.go | 3 +-
internal/cmd/users_update.go | 2 +-
internal/cmd/vault_exports.go | 4 +-
internal/cmd/vault_holds.go | 22 +++---
internal/cmd/vault_matters.go | 4 +-
internal/cmd/vault_test.go | 6 +-
internal/cmd/youtube_test.go | 3 +-
internal/csv/processor.go | 54 +++++++++++--
internal/csv/processor_test.go | 79 +++++++++++++++++--
internal/googleapi/accesscontext.go | 2 +
internal/googleapi/admin_directory.go | 2 +
internal/googleapi/alertcenter.go | 2 +
internal/googleapi/analytics.go | 2 +
internal/googleapi/cloudchannel.go | 2 +
internal/googleapi/cloudidentity.go | 4 +
internal/googleapi/cloudresourcemanager.go | 2 +
internal/googleapi/datatransfer.go | 2 +
internal/googleapi/drive.go | 1 +
internal/googleapi/driveactivity.go | 2 +
internal/googleapi/drivelabels.go | 2 +
internal/googleapi/forms.go | 2 +
internal/googleapi/groupssettings.go | 2 +
internal/googleapi/iam.go | 2 +
internal/googleapi/licensing.go | 2 +
internal/googleapi/meet.go | 2 +
internal/googleapi/reports.go | 2 +
internal/googleapi/reseller.go | 2 +
internal/googleapi/storage.go | 2 +
internal/googleapi/vault.go | 2 +
internal/googleapi/youtube.go | 2 +
internal/googleauth/service_test.go | 1 +
internal/todrive/writer.go | 22 +++++-
internal/todrive/writer_test.go | 6 ++
103 files changed, 370 insertions(+), 176 deletions(-)
diff --git a/internal/cmd/admingroups_test.go b/internal/cmd/admingroups_test.go
index d510d51d..0670c054 100644
--- a/internal/cmd/admingroups_test.go
+++ b/internal/cmd/admingroups_test.go
@@ -73,7 +73,7 @@ func TestGroupsSettingsCmd_Get(t *testing.T) {
}
}
-func stubGroupsSettings(t *testing.T, handler http.Handler) *httptest.Server {
+func stubGroupsSettings(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -91,7 +91,6 @@ func stubGroupsSettings(t *testing.T, handler http.Handler) *httptest.Server {
newGroupsSettings = orig
srv.Close()
})
- return srv
}
func TestReadCSVEmails(t *testing.T) {
@@ -365,8 +364,7 @@ func TestListGroupMembers(t *testing.T) {
},
})
})
- srv := stubAdminDirectory(t, h)
- _ = srv
+ stubAdminDirectory(t, h)
svc, _ := newAdminDirectoryForServer(httptest.NewServer(h))
members, err := listGroupMembers(context.Background(), svc, "team@example.com")
diff --git a/internal/cmd/admins.go b/internal/cmd/admins.go
index 7a0c89bf..13cd9ff2 100644
--- a/internal/cmd/admins.go
+++ b/internal/cmd/admins.go
@@ -119,7 +119,8 @@ func (c *AdminsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
ScopeType: "CUSTOMER",
}
if strings.TrimSpace(c.OrgUnit) != "" {
- orgID, err := resolveOrgUnitID(ctx, svc, c.OrgUnit)
+ var orgID string
+ orgID, err = resolveOrgUnitID(ctx, svc, c.OrgUnit)
if err != nil {
return err
}
@@ -151,7 +152,7 @@ func (c *AdminsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete admin assignment %s", c.AssignmentID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete admin assignment %s", c.AssignmentID)); err != nil {
return err
}
diff --git a/internal/cmd/alerts.go b/internal/cmd/alerts.go
index 51956471..a8194d9f 100644
--- a/internal/cmd/alerts.go
+++ b/internal/cmd/alerts.go
@@ -142,7 +142,7 @@ func (c *AlertsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete alert %s", c.AlertID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete alert %s", c.AlertID)); err != nil {
return err
}
diff --git a/internal/cmd/alerts_test.go b/internal/cmd/alerts_test.go
index d0d9283f..7e39392d 100644
--- a/internal/cmd/alerts_test.go
+++ b/internal/cmd/alerts_test.go
@@ -616,7 +616,7 @@ func TestAlertsSettingsUpdateCmd_JSON(t *testing.T) {
}
}
-func stubAlertCenter(t *testing.T, handler http.Handler) *httptest.Server {
+func stubAlertCenter(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -634,5 +634,4 @@ func stubAlertCenter(t *testing.T, handler http.Handler) *httptest.Server {
newAlertCenterService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/aliases.go b/internal/cmd/aliases.go
index 03fc419b..4c65a07e 100644
--- a/internal/cmd/aliases.go
+++ b/internal/cmd/aliases.go
@@ -137,7 +137,7 @@ func (c *AliasesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete alias %s", c.Alias)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete alias %s", c.Alias)); err != nil {
return err
}
diff --git a/internal/cmd/analytics_test.go b/internal/cmd/analytics_test.go
index 80f2668b..255dd449 100644
--- a/internal/cmd/analytics_test.go
+++ b/internal/cmd/analytics_test.go
@@ -98,7 +98,7 @@ func TestAnalyticsDataStreamsCmd(t *testing.T) {
}
}
-func stubAnalytics(t *testing.T, handler http.Handler) *httptest.Server {
+func stubAnalytics(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -116,5 +116,4 @@ func stubAnalytics(t *testing.T, handler http.Handler) *httptest.Server {
newAnalyticsAdminService = orig
srv.Close()
})
- return srv
}
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
index bef1141b..ad7ab2b4 100644
--- a/internal/cmd/auth_comprehensive_test.go
+++ b/internal/cmd/auth_comprehensive_test.go
@@ -412,7 +412,7 @@ func TestAuthKeyringCmd_DefaultConvertsToAuto(t *testing.T) {
}
ctx := ui.WithUI(context.Background(), u)
- if err := runKong(t, &AuthKeyringCmd{}, []string{"default"}, ctx, nil); err != nil {
+ if err = runKong(t, &AuthKeyringCmd{}, []string{"default"}, ctx, nil); err != nil {
t.Fatalf("run: %v", err)
}
@@ -522,12 +522,12 @@ func TestBestServiceAccountPathAndMtime_ServiceAccountPath(t *testing.T) {
}
// Create the config directory
- if err := os.MkdirAll(filepath.Dir(saPath), 0o700); err != nil {
+ 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 {
+ if err = os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
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
index 66380e2d..b0da32ca 100644
--- a/internal/cmd/batch.go
+++ b/internal/cmd/batch.go
@@ -104,7 +104,7 @@ func readBatchLines(path string) ([]batchTask, error) {
if strings.TrimSpace(path) == "-" {
scanner = bufio.NewScanner(os.Stdin)
} else {
- f, err := os.Open(path)
+ 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)
}
diff --git a/internal/cmd/caa.go b/internal/cmd/caa.go
index 52a88a1a..25e528f0 100644
--- a/internal/cmd/caa.go
+++ b/internal/cmd/caa.go
@@ -191,7 +191,8 @@ func (c *CAALevelsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if c.Basic {
- conditions, err := parseCAAConditions(c.Conditions)
+ var conditions []*accesscontextmanager.Condition
+ conditions, err = parseCAAConditions(c.Conditions)
if err != nil {
return err
}
@@ -269,7 +270,8 @@ func (c *CAALevelsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if len(c.Conditions) > 0 {
- conditions, err := parseCAAConditions(c.Conditions)
+ var conditions []*accesscontextmanager.Condition
+ conditions, err = parseCAAConditions(c.Conditions)
if err != nil {
return err
}
@@ -330,7 +332,7 @@ func (c *CAALevelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete access level %s", fullName)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete access level %s", fullName)); err != nil {
return err
}
@@ -391,7 +393,7 @@ func accessLevelType(level *accesscontextmanager.AccessLevel) string {
if level.Custom != nil {
return "custom"
}
- return "unknown"
+ return trackingUnknown
}
func accessLevelTitle(name string) string {
diff --git a/internal/cmd/caa_test.go b/internal/cmd/caa_test.go
index bb4c7291..4d966579 100644
--- a/internal/cmd/caa_test.go
+++ b/internal/cmd/caa_test.go
@@ -405,7 +405,7 @@ func TestAccessLevelTitle(t *testing.T) {
}
}
-func stubAccessContextManager(t *testing.T, handler http.Handler) *httptest.Server {
+func stubAccessContextManager(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -423,5 +423,4 @@ func stubAccessContextManager(t *testing.T, handler http.Handler) *httptest.Serv
newAccessContextManagerService = orig
srv.Close()
})
- return srv
}
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 577c6537..8abee413 100644
--- a/internal/cmd/calendar_edit_test.go
+++ b/internal/cmd/calendar_edit_test.go
@@ -110,7 +110,7 @@ func TestApplyCreateEventType_FocusTime(t *testing.T) {
if event.FocusTimeProperties == nil {
t.Fatal("expected FocusTimeProperties to be set")
}
- if event.FocusTimeProperties.AutoDeclineMode != "declineAllConflictingInvitations" {
+ if event.FocusTimeProperties.AutoDeclineMode != declineAllConflicting {
t.Fatalf("unexpected AutoDeclineMode: %q", event.FocusTimeProperties.AutoDeclineMode)
}
if event.FocusTimeProperties.DeclineMessage != "I'm busy" {
@@ -132,8 +132,8 @@ func TestApplyCreateEventType_FocusTimeDefaults(t *testing.T) {
if event.FocusTimeProperties == nil {
t.Fatal("expected FocusTimeProperties to be set")
}
- // Default auto decline mode should be "all" -> "declineAllConflictingInvitations"
- if event.FocusTimeProperties.AutoDeclineMode != "declineAllConflictingInvitations" {
+ // 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"
@@ -221,7 +221,7 @@ func TestApplyCreateEventType_WorkingLocation_Office(t *testing.T) {
if event.WorkingLocationProperties == nil {
t.Fatal("expected WorkingLocationProperties to be set")
}
- if event.WorkingLocationProperties.Type != "officeLocation" {
+ if event.WorkingLocationProperties.Type != locTypeOffice {
t.Fatalf("unexpected working location type: %q", event.WorkingLocationProperties.Type)
}
office := event.WorkingLocationProperties.OfficeLocation
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/classroom_test.go b/internal/cmd/classroom_test.go
index 36e8646d..10dd5929 100644
--- a/internal/cmd/classroom_test.go
+++ b/internal/cmd/classroom_test.go
@@ -46,6 +46,8 @@ func stubClassroomService(t *testing.T, handler http.Handler) (*classroom.Servic
// 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")
diff --git a/internal/cmd/cloudidentity.go b/internal/cmd/cloudidentity.go
index 2f9b2423..97d93529 100644
--- a/internal/cmd/cloudidentity.go
+++ b/internal/cmd/cloudidentity.go
@@ -285,7 +285,7 @@ func (c *CloudIdentityGroupsDeleteCmd) Run(ctx context.Context, flags *RootFlags
return usage("group is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", groupKey)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", groupKey)); err != nil {
return err
}
@@ -462,7 +462,7 @@ func (c *CloudIdentityMembersRemoveCmd) Run(ctx context.Context, flags *RootFlag
return usage("--email is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", memberEmail, groupKey)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", memberEmail, groupKey)); err != nil {
return err
}
diff --git a/internal/cmd/confirm_coverage_test.go b/internal/cmd/confirm_coverage_test.go
index b1240260..856e2c9e 100644
--- a/internal/cmd/confirm_coverage_test.go
+++ b/internal/cmd/confirm_coverage_test.go
@@ -150,7 +150,7 @@ func TestExitError_Unwrap(t *testing.T) {
// errors.Unwrap should return the inner error
unwrapped := errors.Unwrap(exitErr)
- if unwrapped != innerErr {
+ if !errors.Is(unwrapped, innerErr) {
t.Fatalf("Unwrap should return inner error, got %v", unwrapped)
}
@@ -174,7 +174,7 @@ func TestExitError_ExitCodes(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- err := &ExitError{Code: tc.code, Err: errors.New("test")}
+ 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_delegates.go b/internal/cmd/contacts_delegates.go
index 311fde67..3f3d2f7f 100644
--- a/internal/cmd/contacts_delegates.go
+++ b/internal/cmd/contacts_delegates.go
@@ -120,7 +120,7 @@ func (c *ContactsDelegatesRemoveCmd) Run(ctx context.Context, flags *RootFlags)
return usage("--delegate is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("remove delegate %s", delegate)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove delegate %s", delegate)); err != nil {
return err
}
diff --git a/internal/cmd/contacts_import.go b/internal/cmd/contacts_import.go
index 7b59de8f..dab59421 100644
--- a/internal/cmd/contacts_import.go
+++ b/internal/cmd/contacts_import.go
@@ -108,7 +108,8 @@ func (c *ContactsExportCmd) Run(ctx context.Context, flags *RootFlags) error {
if pageToken != "" {
call = call.PageToken(pageToken)
}
- resp, err := call.Do()
+ var resp *people.ListConnectionsResponse
+ resp, err = call.Do()
if err != nil {
return fmt.Errorf("list contacts: %w", err)
}
@@ -262,7 +263,7 @@ func openCSVReader(path string) (*csv.Reader, io.Closer, error) {
if trimmed == "-" {
return csv.NewReader(os.Stdin), nil, nil
}
- f, err := os.Open(trimmed)
+ 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)
}
@@ -277,7 +278,7 @@ func openCSVWriter(path string) (*csv.Writer, io.Closer, error) {
if trimmed == "-" {
return csv.NewWriter(os.Stdout), nil, nil
}
- f, err := os.Create(trimmed)
+ 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)
}
diff --git a/internal/cmd/csv.go b/internal/cmd/csv.go
index 2124e3c8..5f764206 100644
--- a/internal/cmd/csv.go
+++ b/internal/cmd/csv.go
@@ -50,10 +50,10 @@ func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error {
MaxRows: c.MaxRows,
}, func(row csvproc.Row) error {
processed++
- args, err := csvproc.SubstituteArgs(c.Command, row)
- if err != nil {
+ args, subErr := csvproc.SubstituteArgs(c.Command, row)
+ if subErr != nil {
failed++
- return fmt.Errorf("row %d: %w", row.Index, err)
+ return fmt.Errorf("row %d: %w", row.Index, subErr)
}
if c.DryRun {
if u != nil {
@@ -61,9 +61,9 @@ func (c *CSVCmd) Run(ctx context.Context, flags *RootFlags) error {
}
return nil
}
- if err := executeSubcommand(ctx, flags, args); err != nil {
+ if execErr := executeSubcommand(ctx, flags, args); execErr != nil {
failed++
- return fmt.Errorf("row %d: %w", row.Index, err)
+ return fmt.Errorf("row %d: %w", row.Index, execErr)
}
return nil
})
diff --git a/internal/cmd/domains_aliases.go b/internal/cmd/domains_aliases.go
index 01c112fa..be8f5964 100644
--- a/internal/cmd/domains_aliases.go
+++ b/internal/cmd/domains_aliases.go
@@ -101,7 +101,7 @@ func (c *DomainsAliasesDeleteCmd) Run(ctx context.Context, flags *RootFlags) err
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete domain alias %s", c.Alias)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete domain alias %s", c.Alias)); err != nil {
return err
}
diff --git a/internal/cmd/domains_delete.go b/internal/cmd/domains_delete.go
index e3a807f7..e4a5709f 100644
--- a/internal/cmd/domains_delete.go
+++ b/internal/cmd/domains_delete.go
@@ -18,7 +18,7 @@ func (c *DomainsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete domain %s", c.Domain)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete domain %s", c.Domain)); err != nil {
return err
}
diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go
index 868b8784..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 {
@@ -600,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)")
}
diff --git a/internal/cmd/drive_activity.go b/internal/cmd/drive_activity.go
index c3f2e7f3..c1b84736 100644
--- a/internal/cmd/drive_activity.go
+++ b/internal/cmd/drive_activity.go
@@ -102,7 +102,7 @@ func activityActor(actors []*driveactivity.Actor) string {
if actor.Anonymous != nil {
return "anonymous"
}
- return "unknown"
+ return trackingUnknown
}
func activityAction(detail *driveactivity.ActionDetail) string {
diff --git a/internal/cmd/drive_advanced_test.go b/internal/cmd/drive_advanced_test.go
index 4c5ce983..29fb8d12 100644
--- a/internal/cmd/drive_advanced_test.go
+++ b/internal/cmd/drive_advanced_test.go
@@ -340,7 +340,7 @@ func TestDriveTransferCmd(t *testing.T) {
}
}
-func stubDriveActivity(t *testing.T, handler http.Handler) *httptest.Server {
+func stubDriveActivity(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -358,5 +358,4 @@ func stubDriveActivity(t *testing.T, handler http.Handler) *httptest.Server {
newDriveActivityService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/drive_cleanup.go b/internal/cmd/drive_cleanup.go
index 82fa9e03..4beedb2c 100644
--- a/internal/cmd/drive_cleanup.go
+++ b/internal/cmd/drive_cleanup.go
@@ -33,7 +33,7 @@ func (c *DriveCleanupEmptyFoldersCmd) Run(ctx context.Context, flags *RootFlags)
account = strings.TrimSpace(c.User)
}
- if err := confirmDestructive(ctx, flags, "delete empty Drive folders"); err != nil {
+ if err = confirmDestructive(ctx, flags, "delete empty Drive folders"); err != nil {
return err
}
diff --git a/internal/cmd/drive_orphans.go b/internal/cmd/drive_orphans.go
index eef2399c..24530b6f 100644
--- a/internal/cmd/drive_orphans.go
+++ b/internal/cmd/drive_orphans.go
@@ -101,7 +101,7 @@ func (c *DriveOrphansCollectCmd) Run(ctx context.Context, flags *RootFlags) erro
account = strings.TrimSpace(c.User)
}
- if err := confirmDestructive(ctx, flags, "collect orphaned files into a folder"); err != nil {
+ if err = confirmDestructive(ctx, flags, "collect orphaned files into a folder"); err != nil {
return err
}
diff --git a/internal/cmd/drive_revisions.go b/internal/cmd/drive_revisions.go
index 97609a19..4fd7bdcd 100644
--- a/internal/cmd/drive_revisions.go
+++ b/internal/cmd/drive_revisions.go
@@ -140,7 +140,7 @@ func (c *DriveRevisionsDeleteCmd) Run(ctx context.Context, flags *RootFlags) err
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 {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete revision %s for file %s", revID, fileID)); err != nil {
return err
}
diff --git a/internal/cmd/drive_shortcuts.go b/internal/cmd/drive_shortcuts.go
index a9b2cace..0e775fc4 100644
--- a/internal/cmd/drive_shortcuts.go
+++ b/internal/cmd/drive_shortcuts.go
@@ -86,7 +86,7 @@ func (c *DriveShortcutsDeleteCmd) Run(ctx context.Context, flags *RootFlags) err
return usage("shortcut-id is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete shortcut %s", shortcutID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete shortcut %s", shortcutID)); err != nil {
return err
}
diff --git a/internal/cmd/drive_transfer.go b/internal/cmd/drive_transfer.go
index dc5c2e5f..a4f4dac0 100644
--- a/internal/cmd/drive_transfer.go
+++ b/internal/cmd/drive_transfer.go
@@ -33,7 +33,7 @@ func (c *DriveTransferCmd) Run(ctx context.Context, flags *RootFlags) error {
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 {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("transfer Drive ownership from %s to %s", from, to)); err != nil {
return err
}
diff --git a/internal/cmd/forms_test.go b/internal/cmd/forms_test.go
index 6eff8250..3ed5d729 100644
--- a/internal/cmd/forms_test.go
+++ b/internal/cmd/forms_test.go
@@ -544,7 +544,7 @@ func TestFormsResponsesCmd_Pagination(t *testing.T) {
}
}
-func stubForms(t *testing.T, handler http.Handler) *httptest.Server {
+func stubForms(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -562,10 +562,9 @@ func stubForms(t *testing.T, handler http.Handler) *httptest.Server {
newFormsService = orig
srv.Close()
})
- return srv
}
-func stubDrive(t *testing.T, handler http.Handler) *httptest.Server {
+func stubDrive(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -583,5 +582,4 @@ func stubDrive(t *testing.T, handler http.Handler) *httptest.Server {
newDriveService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/gmail_advanced_test.go b/internal/cmd/gmail_advanced_test.go
index 45d02f39..6586abc1 100644
--- a/internal/cmd/gmail_advanced_test.go
+++ b/internal/cmd/gmail_advanced_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"io"
"net/http"
"net/http/httptest"
@@ -18,6 +19,8 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
+var errUnexpectedServiceCreation = errors.New("unexpected service creation")
+
// ==================== gmail_attachments.go tests ====================
func TestAttachmentOutputFromInfo(t *testing.T) {
@@ -486,7 +489,7 @@ func TestParseListUnsubscribe_NoBrackets(t *testing.T) {
func TestParseListUnsubscribe_InvalidLinks(t *testing.T) {
result := parseListUnsubscribe("not a link, also not a link")
- if result != nil && len(result) != 0 {
+ if len(result) != 0 {
t.Fatalf("expected no valid links, got %v", result)
}
}
@@ -727,7 +730,7 @@ func TestGmailThreadGetCmd_EmptyThreadID(t *testing.T) {
// 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, nil
+ return nil, errUnexpectedServiceCreation
}
flags := &RootFlags{Account: "a@b.com"}
@@ -750,7 +753,7 @@ func TestGmailThreadModifyCmd_EmptyThreadID(t *testing.T) {
newGmailService = func(context.Context, string) (*gmail.Service, error) {
t.Fatalf("should not create service for empty thread ID")
- return nil, nil
+ return nil, errUnexpectedServiceCreation
}
flags := &RootFlags{Account: "a@b.com"}
@@ -773,7 +776,7 @@ func TestGmailThreadModifyCmd_NoLabels(t *testing.T) {
newGmailService = func(context.Context, string) (*gmail.Service, error) {
t.Fatalf("should not create service when no labels specified")
- return nil, nil
+ return nil, errUnexpectedServiceCreation
}
flags := &RootFlags{Account: "a@b.com"}
@@ -796,7 +799,7 @@ func TestGmailThreadAttachmentsCmd_EmptyThreadID(t *testing.T) {
newGmailService = func(context.Context, string) (*gmail.Service, error) {
t.Fatalf("should not create service for empty thread ID")
- return nil, nil
+ return nil, errUnexpectedServiceCreation
}
flags := &RootFlags{Account: "a@b.com"}
@@ -819,7 +822,7 @@ func TestGmailAttachmentCmd_MissingArgs(t *testing.T) {
newGmailService = func(context.Context, string) (*gmail.Service, error) {
t.Fatalf("should not create service for missing args")
- return nil, nil
+ return nil, errUnexpectedServiceCreation
}
flags := &RootFlags{Account: "a@b.com"}
@@ -1083,8 +1086,8 @@ func TestGmailThreadAttachmentsCmd_Download_JSON(t *testing.T) {
out := captureStdout(t, func() {
ctx := testContextJSON(t)
cmd := &GmailThreadAttachmentsCmd{Download: true, OutputDir: OutputDirFlag{Dir: outDir}}
- if err := runKong(t, cmd, []string{"t1", "--download", "--out-dir", outDir}, ctx, flags); err != nil {
- t.Fatalf("execute: %v", err)
+ if runErr := runKong(t, cmd, []string{"t1", "--download", "--out-dir", outDir}, ctx, flags); runErr != nil {
+ t.Fatalf("execute: %v", runErr)
}
})
@@ -1095,7 +1098,7 @@ func TestGmailThreadAttachmentsCmd_Download_JSON(t *testing.T) {
Cached bool `json:"cached"`
} `json:"attachments"`
}
- if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+ if err = json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}
if len(parsed.Attachments) != 1 {
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_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_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/groups.go b/internal/cmd/groups.go
index d93e69fe..1e99766e 100644
--- a/internal/cmd/groups.go
+++ b/internal/cmd/groups.go
@@ -175,7 +175,6 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
if groupEmail == "" {
return usage("group email required")
}
- action = ""
case "add":
if groupEmail == "" || memberEmail == "" {
return usage("group and member email required")
diff --git a/internal/cmd/groups_admin.go b/internal/cmd/groups_admin.go
index 81801b38..13cb8f24 100644
--- a/internal/cmd/groups_admin.go
+++ b/internal/cmd/groups_admin.go
@@ -115,7 +115,7 @@ func (c *GroupsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", c.Group)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete group %s", c.Group)); err != nil {
return err
}
@@ -154,9 +154,9 @@ func (c *GroupsSettingsCmd) Run(ctx context.Context, flags *RootFlags) error {
hasUpdates := c.WhoCanJoin != nil || c.WhoCanPost != nil || c.WhoCanViewGroup != nil || c.WhoCanViewMembers != nil
if !hasUpdates {
- settings, err := svc.Groups.Get(c.Group).Context(ctx).Do()
- if err != nil {
- return fmt.Errorf("get group settings %s: %w", c.Group, err)
+ 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)
@@ -249,7 +249,7 @@ func (c *GroupsMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) erro
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", c.Email, c.Group)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", c.Email, c.Group)); err != nil {
return err
}
@@ -355,7 +355,7 @@ func normalizeGroupRole(role string) (string, error) {
}
func readCSVEmails(path string) ([]string, error) {
- f, err := os.Open(path)
+ 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)
}
diff --git a/internal/cmd/labels.go b/internal/cmd/labels.go
index b2dddeff..ddf93e44 100644
--- a/internal/cmd/labels.go
+++ b/internal/cmd/labels.go
@@ -254,7 +254,7 @@ func (c *LabelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
}
label = normalizeLabelName(label)
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete label %s", label)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete label %s", label)); err != nil {
return err
}
@@ -327,7 +327,7 @@ func (c *LabelsDisableCmd) Run(ctx context.Context, flags *RootFlags) error {
}
label = normalizeLabelName(label)
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("disable label %s", label)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("disable label %s", label)); err != nil {
return err
}
diff --git a/internal/cmd/labels_test.go b/internal/cmd/labels_test.go
index cedcb771..aab5bddc 100644
--- a/internal/cmd/labels_test.go
+++ b/internal/cmd/labels_test.go
@@ -779,7 +779,7 @@ func TestLabelsUpdateCmd_APIError(t *testing.T) {
// ========== Helper Functions ==========
-func stubDriveLabels(t *testing.T, handler http.Handler) *httptest.Server {
+func stubDriveLabels(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -797,5 +797,4 @@ func stubDriveLabels(t *testing.T, handler http.Handler) *httptest.Server {
newDriveLabelsService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/licenses.go b/internal/cmd/licenses.go
index 989f7e80..a33058f2 100644
--- a/internal/cmd/licenses.go
+++ b/internal/cmd/licenses.go
@@ -197,7 +197,7 @@ func (c *LicensesRevokeCmd) Run(ctx context.Context, flags *RootFlags) error {
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 {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("revoke license %s/%s for %s", c.Product, c.SKU, user)); err != nil {
return err
}
diff --git a/internal/cmd/licenses_test.go b/internal/cmd/licenses_test.go
index 0078304f..9cdeab5f 100644
--- a/internal/cmd/licenses_test.go
+++ b/internal/cmd/licenses_test.go
@@ -459,7 +459,7 @@ func TestLicensesProductsCmd_ContainsExpectedProducts(t *testing.T) {
}
}
-func stubLicensing(t *testing.T, handler http.Handler) *httptest.Server {
+func stubLicensing(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -477,5 +477,4 @@ func stubLicensing(t *testing.T, handler http.Handler) *httptest.Server {
newLicensingService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/lookerstudio.go b/internal/cmd/lookerstudio.go
index 1800d7c4..2b139fa6 100644
--- a/internal/cmd/lookerstudio.go
+++ b/internal/cmd/lookerstudio.go
@@ -162,7 +162,7 @@ func (c *LookerStudioPermissionsRemoveCmd) Run(ctx context.Context, flags *RootF
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 {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from asset %s", permissionID, assetID)); err != nil {
return err
}
diff --git a/internal/cmd/meet_test.go b/internal/cmd/meet_test.go
index 224ff687..28a2ac18 100644
--- a/internal/cmd/meet_test.go
+++ b/internal/cmd/meet_test.go
@@ -36,7 +36,7 @@ func testMeetContextWithStdout(t *testing.T) context.Context {
return ui.WithUI(context.Background(), u)
}
-func stubMeet(t *testing.T, handler http.Handler) *httptest.Server {
+func stubMeet(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -54,7 +54,6 @@ func stubMeet(t *testing.T, handler http.Handler) *httptest.Server {
newMeetService = orig
srv.Close()
})
- return srv
}
// MeetSpacesListCmd tests
diff --git a/internal/cmd/orgunits_delete.go b/internal/cmd/orgunits_delete.go
index 20b447eb..8f1d8bcd 100644
--- a/internal/cmd/orgunits_delete.go
+++ b/internal/cmd/orgunits_delete.go
@@ -18,7 +18,7 @@ func (c *OrgunitsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete org unit %s", c.Path)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete org unit %s", c.Path)); err != nil {
return err
}
diff --git a/internal/cmd/printers.go b/internal/cmd/printers.go
index 295ca8ae..e4395a96 100644
--- a/internal/cmd/printers.go
+++ b/internal/cmd/printers.go
@@ -49,7 +49,8 @@ func (c *PrintersListCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(c.OrgUnit) != "" {
orgUnit := strings.TrimSpace(c.OrgUnit)
orgUnit = strings.TrimPrefix(orgUnit, "orgUnits/")
- orgUnitID, err := resolveOrgUnitID(ctx, svc, orgUnit)
+ var orgUnitID string
+ orgUnitID, err = resolveOrgUnitID(ctx, svc, orgUnit)
if err != nil {
return err
}
@@ -162,7 +163,8 @@ func (c *PrintersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(c.OrgUnit) != "" {
orgUnit := strings.TrimSpace(c.OrgUnit)
orgUnit = strings.TrimPrefix(orgUnit, "orgUnits/")
- orgUnitID, err := resolveOrgUnitID(ctx, svc, orgUnit)
+ var orgUnitID string
+ orgUnitID, err = resolveOrgUnitID(ctx, svc, orgUnit)
if err != nil {
return err
}
@@ -251,7 +253,7 @@ func (c *PrintersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return usage("printer ID is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete printer %s", printerID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete printer %s", printerID)); err != nil {
return err
}
diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go
index 62388c68..30adb924 100644
--- a/internal/cmd/projects.go
+++ b/internal/cmd/projects.go
@@ -181,7 +181,7 @@ func (c *ProjectsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return usage("project is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete project %s", project)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete project %s", project)); err != nil {
return err
}
diff --git a/internal/cmd/reports.go b/internal/cmd/reports.go
index ec64c722..2050a362 100644
--- a/internal/cmd/reports.go
+++ b/internal/cmd/reports.go
@@ -8,6 +8,8 @@ import (
"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"
@@ -237,7 +239,7 @@ func runActivityReport(ctx context.Context, flags *RootFlags, opts activityRepor
))
}
- reportTitle := fmt.Sprintf("Reports %s", strings.Title(opts.Application))
+ 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
}
diff --git a/internal/cmd/reports_test.go b/internal/cmd/reports_test.go
index c44e5aa7..e6669450 100644
--- a/internal/cmd/reports_test.go
+++ b/internal/cmd/reports_test.go
@@ -1628,7 +1628,7 @@ func TestFormatUsageParameters(t *testing.T) {
}
}
-func stubReports(t *testing.T, handler http.Handler) *httptest.Server {
+func stubReports(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -1646,5 +1646,4 @@ func stubReports(t *testing.T, handler http.Handler) *httptest.Server {
newReportsService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/resources_buildings.go b/internal/cmd/resources_buildings.go
index 510941fa..074f0ddb 100644
--- a/internal/cmd/resources_buildings.go
+++ b/internal/cmd/resources_buildings.go
@@ -226,7 +226,7 @@ func (c *ResourcesBuildingsDeleteCmd) Run(ctx context.Context, flags *RootFlags)
return usage("building ID is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete building %s", buildingID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete building %s", buildingID)); err != nil {
return err
}
diff --git a/internal/cmd/resources_calendars.go b/internal/cmd/resources_calendars.go
index e30f95be..c28e16d0 100644
--- a/internal/cmd/resources_calendars.go
+++ b/internal/cmd/resources_calendars.go
@@ -254,7 +254,7 @@ func (c *ResourcesCalendarsDeleteCmd) Run(ctx context.Context, flags *RootFlags)
return usage("resource ID is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete calendar resource %s", resourceID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete calendar resource %s", resourceID)); err != nil {
return err
}
diff --git a/internal/cmd/resources_features.go b/internal/cmd/resources_features.go
index 4cadeaf0..c6aba53d 100644
--- a/internal/cmd/resources_features.go
+++ b/internal/cmd/resources_features.go
@@ -121,7 +121,7 @@ func (c *ResourcesFeaturesDeleteCmd) Run(ctx context.Context, flags *RootFlags)
return usage("feature name is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete feature %s", name)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete feature %s", name)); err != nil {
return err
}
diff --git a/internal/cmd/roles.go b/internal/cmd/roles.go
index 2dab6f1d..e370c2e8 100644
--- a/internal/cmd/roles.go
+++ b/internal/cmd/roles.go
@@ -224,7 +224,8 @@ func (c *RolesUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
addNames := splitCSV(c.AddPrivileges)
removeNames := splitCSV(c.RemovePrivileges)
if len(addNames) > 0 || len(removeNames) > 0 {
- updatedPrivs, err := updateRolePrivileges(ctx, svc, role.RolePrivileges, addNames, removeNames)
+ var updatedPrivs []*admin.RoleRolePrivileges
+ updatedPrivs, err = updateRolePrivileges(ctx, svc, role.RolePrivileges, addNames, removeNames)
if err != nil {
return err
}
@@ -261,7 +262,7 @@ func (c *RolesDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete role %s", c.Role)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete role %s", c.Role)); err != nil {
return err
}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 2a1dd5a2..116c6029 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -21,6 +21,9 @@ import (
const (
colorAuto = "auto"
colorNever = "never"
+
+ strTrue = "true"
+ strFalse = "false"
)
type RootFlags struct {
@@ -193,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
index eac1c92d..8526dd22 100644
--- a/internal/cmd/schemas.go
+++ b/internal/cmd/schemas.go
@@ -277,7 +277,7 @@ func (c *SchemasDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return usage("schema name is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete schema %s", name)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete schema %s", name)); err != nil {
return err
}
diff --git a/internal/cmd/serviceaccounts.go b/internal/cmd/serviceaccounts.go
index 2a66cb27..70465a56 100644
--- a/internal/cmd/serviceaccounts.go
+++ b/internal/cmd/serviceaccounts.go
@@ -152,7 +152,7 @@ func (c *ServiceAccountsDeleteCmd) Run(ctx context.Context, flags *RootFlags) er
return usage("service account is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete service account %s", sa)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete service account %s", sa)); err != nil {
return err
}
@@ -272,7 +272,7 @@ func (c *ServiceAccountsKeysCreateCmd) Run(ctx context.Context, flags *RootFlags
return fmt.Errorf("decode key data: %w", err)
}
- if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
+ 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 {
@@ -306,7 +306,7 @@ func (c *ServiceAccountsKeysDeleteCmd) Run(ctx context.Context, flags *RootFlags
return usage("service account and key are required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete key %s", key)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete key %s", key)); err != nil {
return err
}
diff --git a/internal/cmd/sso.go b/internal/cmd/sso.go
index c370f74a..b9ab465f 100644
--- a/internal/cmd/sso.go
+++ b/internal/cmd/sso.go
@@ -13,6 +13,8 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
+const ssoModeOff = "SSO_OFF"
+
var newInboundSSOService = googleapi.NewCloudIdentityInboundSSO
type SSOCmd struct {
@@ -325,7 +327,7 @@ func (c *SSOAssignmentsDeleteCmd) Run(ctx context.Context, flags *RootFlags) err
return usage("assignment ID is required")
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete inbound SSO assignment %s", c.AssignmentID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete inbound SSO assignment %s", c.AssignmentID)); err != nil {
return err
}
@@ -363,8 +365,8 @@ func firstInboundSamlProfile(ctx context.Context, svc *cloudidentity.Service) (*
func mapInboundSSOMode(mode string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(mode)) {
- case "SSO_OFF":
- return "SSO_OFF", nil
+ case ssoModeOff:
+ return ssoModeOff, nil
case "SSO_ON":
return "DOMAIN_WIDE_SAML_IF_ENABLED", nil
default:
@@ -446,7 +448,7 @@ func readValueOrFile(value string) (string, error) {
if path == "" {
return "", fmt.Errorf("empty @file path")
}
- data, err := os.ReadFile(path)
+ data, err := os.ReadFile(path) //nolint:gosec // G304: user-provided file path is intentional
if err != nil {
return "", err
}
@@ -456,7 +458,7 @@ func readValueOrFile(value string) (string, error) {
return trimmed, nil
}
if info, err := os.Stat(trimmed); err == nil && !info.IsDir() {
- data, err := os.ReadFile(trimmed)
+ data, err := os.ReadFile(trimmed) //nolint:gosec // G304: user-provided file path is intentional
if err != nil {
return "", err
}
diff --git a/internal/cmd/sso_test.go b/internal/cmd/sso_test.go
index 382f99ee..e3526a91 100644
--- a/internal/cmd/sso_test.go
+++ b/internal/cmd/sso_test.go
@@ -1153,7 +1153,7 @@ func TestReadValueOrFile(t *testing.T) {
// Test @file syntax
tmpFile := filepath.Join(t.TempDir(), "testfile.txt")
- if err := os.WriteFile(tmpFile, []byte("file-content"), 0o600); err != nil {
+ if err = os.WriteFile(tmpFile, []byte("file-content"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -1163,7 +1163,7 @@ func TestReadValueOrFile(t *testing.T) {
}
// Test @file with empty path
- result, err = readValueOrFile("@")
+ _, err = readValueOrFile("@")
if err == nil {
t.Fatal("expected error for empty @file path")
}
@@ -1179,7 +1179,7 @@ func TestReadValueOrFile(t *testing.T) {
// Test file path detection (file exists)
tmpFile2 := filepath.Join(t.TempDir(), "detectfile.txt")
- if err := os.WriteFile(tmpFile2, []byte("detected-content"), 0o600); err != nil {
+ if err = os.WriteFile(tmpFile2, []byte("detected-content"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -1189,7 +1189,7 @@ func TestReadValueOrFile(t *testing.T) {
}
// Test nonexistent file in @syntax
- result, err = readValueOrFile("@/nonexistent/file.txt")
+ _, err = readValueOrFile("@/nonexistent/file.txt")
if err == nil {
t.Fatal("expected error for nonexistent @file")
}
@@ -1321,7 +1321,7 @@ func TestClearInboundSSOAssignments_JSON(t *testing.T) {
// Test Helper Functions
// -----------------------------------------------------------------------------
-func stubInboundSSO(t *testing.T, handler http.Handler) *httptest.Server {
+func stubInboundSSO(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -1339,5 +1339,4 @@ func stubInboundSSO(t *testing.T, handler http.Handler) *httptest.Server {
newInboundSSOService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/cmd/todrive_helpers.go b/internal/cmd/todrive_helpers.go
index 953b7cf3..2931ea86 100644
--- a/internal/cmd/todrive_helpers.go
+++ b/internal/cmd/todrive_helpers.go
@@ -76,9 +76,9 @@ func toDriveRow(values ...string) []string {
func toDriveBool(value bool) string {
if value {
- return "true"
+ return strTrue
}
- return "false"
+ return strFalse
}
func toDriveNumber(value int64) string {
diff --git a/internal/cmd/transfer_test.go b/internal/cmd/transfer_test.go
index fd2eb551..314ac5e3 100644
--- a/internal/cmd/transfer_test.go
+++ b/internal/cmd/transfer_test.go
@@ -95,7 +95,7 @@ func TestTransferCreateCmd(t *testing.T) {
}
}
-func stubDataTransfer(t *testing.T, handler http.Handler) *httptest.Server {
+func stubDataTransfer(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -113,7 +113,6 @@ func stubDataTransfer(t *testing.T, handler http.Handler) *httptest.Server {
newDataTransferService = orig
srv.Close()
})
- return srv
}
func TestTransferListCmd_JSON(t *testing.T) {
diff --git a/internal/cmd/users.go b/internal/cmd/users.go
index c6017da9..481cd5c8 100644
--- a/internal/cmd/users.go
+++ b/internal/cmd/users.go
@@ -100,11 +100,11 @@ func normalizeUserHashFunction(value string) (string, error) {
}
}
-func randInt(max int) (int, error) {
- if max <= 0 {
- return 0, fmt.Errorf("invalid max %d", max)
+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(max)))
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(maxVal)))
if err != nil {
return 0, err
}
diff --git a/internal/cmd/users_2fa.go b/internal/cmd/users_2fa.go
index 52a43bff..1fa8bf9b 100644
--- a/internal/cmd/users_2fa.go
+++ b/internal/cmd/users_2fa.go
@@ -20,7 +20,7 @@ func (c *UsersTurnOff2SVCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("turn off 2-step verification for %s", c.User)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("turn off 2-step verification for %s", c.User)); err != nil {
return err
}
@@ -118,7 +118,7 @@ func (c *UsersBackupCodesDeleteCmd) Run(ctx context.Context, flags *RootFlags) e
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete all backup codes for %s", c.User)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete all backup codes for %s", c.User)); err != nil {
return err
}
diff --git a/internal/cmd/users_create.go b/internal/cmd/users_create.go
index 42f820c4..32c3401d 100644
--- a/internal/cmd/users_create.go
+++ b/internal/cmd/users_create.go
@@ -61,7 +61,8 @@ func (c *UsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if c.HashFunction != "" {
- hash, err := normalizeUserHashFunction(c.HashFunction)
+ var hash string
+ hash, err = normalizeUserHashFunction(c.HashFunction)
if err != nil {
return err
}
diff --git a/internal/cmd/users_delete.go b/internal/cmd/users_delete.go
index d1a093b4..45d749f9 100644
--- a/internal/cmd/users_delete.go
+++ b/internal/cmd/users_delete.go
@@ -18,7 +18,7 @@ func (c *UsersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete user %s", c.User)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete user %s", c.User)); err != nil {
return err
}
diff --git a/internal/cmd/users_password.go b/internal/cmd/users_password.go
index 75010cee..3fecc0bd 100644
--- a/internal/cmd/users_password.go
+++ b/internal/cmd/users_password.go
@@ -46,7 +46,8 @@ func (c *UsersPasswordCmd) Run(ctx context.Context, flags *RootFlags) error {
}
user.ForceSendFields = append(user.ForceSendFields, "ChangePasswordAtNextLogin")
if c.HashFunction != "" {
- hash, err := normalizeUserHashFunction(c.HashFunction)
+ var hash string
+ hash, err = normalizeUserHashFunction(c.HashFunction)
if err != nil {
return err
}
diff --git a/internal/cmd/users_test.go b/internal/cmd/users_test.go
index 5435dd6d..f52e34bb 100644
--- a/internal/cmd/users_test.go
+++ b/internal/cmd/users_test.go
@@ -147,7 +147,7 @@ func TestUsersCountCmd(t *testing.T) {
}
}
-func stubAdminDirectory(t *testing.T, handler http.Handler) *httptest.Server {
+func stubAdminDirectory(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -161,7 +161,6 @@ func stubAdminDirectory(t *testing.T, handler http.Handler) *httptest.Server {
newAdminDirectory = orig
srv.Close()
})
- return srv
}
func newAdminDirectoryForServer(srv *httptest.Server) (*admin.Service, error) {
diff --git a/internal/cmd/users_update.go b/internal/cmd/users_update.go
index 4bc0d8e4..031ae147 100644
--- a/internal/cmd/users_update.go
+++ b/internal/cmd/users_update.go
@@ -94,7 +94,7 @@ func (c *UsersUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if c.Admin != nil {
- if err := svc.Users.MakeAdmin(c.User, &admin.UserMakeAdmin{Status: *c.Admin}).Context(ctx).Do(); err != 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 {
diff --git a/internal/cmd/vault_exports.go b/internal/cmd/vault_exports.go
index 277003d3..5010e2ae 100644
--- a/internal/cmd/vault_exports.go
+++ b/internal/cmd/vault_exports.go
@@ -184,7 +184,7 @@ func (c *VaultExportsDownloadCmd) Run(ctx context.Context, flags *RootFlags) err
return fmt.Errorf("export %s has no Cloud Storage files", c.ExportID)
}
- if err := os.MkdirAll(c.Output, 0o755); err != nil {
+ if err = os.MkdirAll(c.Output, 0o750); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
@@ -236,7 +236,7 @@ func downloadExportFile(ctx context.Context, svc *storage.Service, file *vault.C
}
path := filepath.Join(outputDir, name)
- out, err := os.Create(path)
+ out, err := os.Create(path) //nolint:gosec // path is constructed from validated components
if err != nil {
return "", fmt.Errorf("create file: %w", err)
}
diff --git a/internal/cmd/vault_holds.go b/internal/cmd/vault_holds.go
index 807fc6ce..09c8d577 100644
--- a/internal/cmd/vault_holds.go
+++ b/internal/cmd/vault_holds.go
@@ -154,24 +154,24 @@ func (c *VaultHoldsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if orgUnit != "" {
- adminSvc, err := newAdminDirectory(ctx, account)
- if err != nil {
- return err
+ adminSvc, adminErr := newAdminDirectory(ctx, account)
+ if adminErr != nil {
+ return adminErr
}
- orgID, err := resolveOrgUnitID(ctx, adminSvc, orgUnit)
- if err != nil {
- return err
+ orgID, orgErr := resolveOrgUnitID(ctx, adminSvc, orgUnit)
+ if orgErr != nil {
+ return orgErr
}
hold.OrgUnit = &vault.HeldOrgUnit{OrgUnitId: orgID}
}
if strings.TrimSpace(c.Query) != "" {
- if hold.Corpus == "DRIVE" {
+ switch hold.Corpus {
+ case "DRIVE":
return usage("drive holds do not support --query")
- }
- if hold.Corpus == "MAIL" {
+ case "MAIL":
hold.Query = &vault.CorpusQuery{MailQuery: &vault.HeldMailQuery{Terms: c.Query}}
- } else if hold.Corpus == "GROUPS" {
+ case "GROUPS":
hold.Query = &vault.CorpusQuery{GroupsQuery: &vault.HeldGroupsQuery{Terms: c.Query}}
}
}
@@ -202,7 +202,7 @@ func (c *VaultHoldsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete hold %s", c.HoldID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete hold %s", c.HoldID)); err != nil {
return err
}
diff --git a/internal/cmd/vault_matters.go b/internal/cmd/vault_matters.go
index 9eb24cc5..ca34cd1c 100644
--- a/internal/cmd/vault_matters.go
+++ b/internal/cmd/vault_matters.go
@@ -211,7 +211,7 @@ func (c *VaultMattersCloseCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("close matter %s", c.MatterID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("close matter %s", c.MatterID)); err != nil {
return err
}
@@ -281,7 +281,7 @@ func (c *VaultMattersDeleteCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}
- if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete matter %s", c.MatterID)); err != nil {
+ if err = confirmDestructive(ctx, flags, fmt.Sprintf("delete matter %s", c.MatterID)); err != nil {
return err
}
diff --git a/internal/cmd/vault_test.go b/internal/cmd/vault_test.go
index d58c6b18..2ec1d2d2 100644
--- a/internal/cmd/vault_test.go
+++ b/internal/cmd/vault_test.go
@@ -95,7 +95,7 @@ func TestVaultExportsDownloadCmd(t *testing.T) {
}
}
-func stubVault(t *testing.T, handler http.Handler) *httptest.Server {
+func stubVault(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -113,10 +113,9 @@ func stubVault(t *testing.T, handler http.Handler) *httptest.Server {
newVaultService = orig
srv.Close()
})
- return srv
}
-func stubStorage(t *testing.T, handler http.Handler) *httptest.Server {
+func stubStorage(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -134,7 +133,6 @@ func stubStorage(t *testing.T, handler http.Handler) *httptest.Server {
newStorageService = orig
srv.Close()
})
- return srv
}
func TestVaultMattersGetCmd(t *testing.T) {
diff --git a/internal/cmd/youtube_test.go b/internal/cmd/youtube_test.go
index c8a411f8..9c2fbd81 100644
--- a/internal/cmd/youtube_test.go
+++ b/internal/cmd/youtube_test.go
@@ -70,7 +70,7 @@ func TestYouTubeChannelsCmd(t *testing.T) {
}
}
-func stubYouTube(t *testing.T, handler http.Handler) *httptest.Server {
+func stubYouTube(t *testing.T, handler http.Handler) {
t.Helper()
srv := httptest.NewServer(handler)
@@ -88,5 +88,4 @@ func stubYouTube(t *testing.T, handler http.Handler) *httptest.Server {
newYouTubeService = orig
srv.Close()
})
- return srv
}
diff --git a/internal/csv/processor.go b/internal/csv/processor.go
index 5f90f4b5..040c5301 100644
--- a/internal/csv/processor.go
+++ b/internal/csv/processor.go
@@ -2,6 +2,7 @@ package csv
import (
"encoding/csv"
+ "errors"
"fmt"
"io"
"os"
@@ -9,6 +10,14 @@ import (
"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
@@ -32,6 +41,7 @@ func Process(path string, opts Options, fn func(Row) error) error {
if err != nil {
return err
}
+
if closer != nil {
defer closer.Close()
}
@@ -40,23 +50,27 @@ func Process(path string, opts Options, fn func(Row) error) error {
if err != nil {
return fmt.Errorf("read csv: %w", err)
}
+
if len(records) == 0 {
- return fmt.Errorf("empty csv")
+ 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
}
@@ -83,21 +97,25 @@ func SubstituteArgs(args []string, row Row) ([]string, error) {
}
out[i] = sub
}
+
return out, nil
}
func openCSV(path string) (*csv.Reader, io.Closer, error) {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
- return nil, nil, fmt.Errorf("file is required")
+ return nil, nil, errFileRequired
}
+
if trimmed == "-" {
return csv.NewReader(os.Stdin), nil, nil
}
- f, err := os.Open(trimmed)
+
+ 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
}
@@ -106,6 +124,7 @@ func normalizeHeader(header []string) []string {
for i, h := range header {
out[i] = normalizeField(h)
}
+
return out
}
@@ -113,12 +132,14 @@ 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
}
@@ -132,17 +153,20 @@ func mapRow(headers, row []string, allowed map[string]struct{}) map[string]strin
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
}
@@ -150,15 +174,18 @@ 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
}
@@ -166,15 +193,18 @@ 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
}
@@ -184,6 +214,7 @@ func substituteArg(arg string, row Row) (string, error) {
if err != nil {
return "", err
}
+
return replaced, nil
}
@@ -192,6 +223,7 @@ func substituteArg(arg string, row Row) (string, error) {
if field == "" {
return "", nil
}
+
return row.Values[field], nil
}
@@ -206,11 +238,13 @@ func replaceDoubleTilde(input string, row Row) (string, error) {
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
@@ -223,20 +257,23 @@ func resolveToken(token string, row Row) (string, error) {
if strings.Contains(token, "~!~") {
parts := strings.Split(token, "~!~")
if len(parts) != 3 {
- return "", fmt.Errorf("invalid replacement token: %s", token)
+ 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
}
@@ -247,19 +284,24 @@ func ParseFieldFilters(inputs []string) ([]FieldFilter, error) {
if trimmed == "" {
continue
}
+
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) != 2 {
- return nil, fmt.Errorf("invalid filter %q (expected FIELD:REGEX)", item)
+ return nil, fmt.Errorf("%w: %q (expected FIELD:REGEX)", errInvalidFilterFormat, item)
}
+
field := normalizeField(parts[0])
if field == "" {
- return nil, fmt.Errorf("invalid filter %q (missing field)", item)
+ 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
index a993af0d..37f34cf1 100644
--- a/internal/csv/processor_test.go
+++ b/internal/csv/processor_test.go
@@ -8,17 +8,22 @@ import (
"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()
}
@@ -89,9 +94,11 @@ func TestSubstituteArgs_SimpleSubstitution(t *testing.T) {
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])
@@ -154,9 +161,11 @@ func TestSubstituteArgs_AdvancedSubstitution(t *testing.T) {
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])
@@ -237,14 +246,18 @@ func TestSubstituteArgs_RegexReplacement(t *testing.T) {
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])
@@ -256,10 +269,12 @@ func TestSubstituteArgs_RegexReplacement(t *testing.T) {
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)
}
@@ -332,13 +347,16 @@ func TestParseFieldFilters_Valid(t *testing.T) {
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)
}
@@ -376,6 +394,7 @@ func TestParseFieldFilters_Errors(t *testing.T) {
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)
}
@@ -396,6 +415,7 @@ 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
@@ -403,6 +423,7 @@ charlie@example.com,Charlie,Brown
if err != nil {
t.Fatalf("Process error: %v", err)
}
+
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
@@ -411,9 +432,11 @@ charlie@example.com,Charlie,Brown
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"])
}
@@ -426,6 +449,7 @@ test@test.com,Test,User
path := createTempCSV(t, csv)
var row Row
+
err := Process(path, Options{}, func(r Row) error {
row = r
return nil
@@ -438,9 +462,11 @@ test@test.com,Test,User
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)
}
@@ -453,6 +479,7 @@ 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
@@ -465,12 +492,15 @@ user@test.com,User,admin,active
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")
}
@@ -488,6 +518,7 @@ charlie@test.com,active,user
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
@@ -499,6 +530,7 @@ charlie@test.com,active,user
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)
}
@@ -515,6 +547,7 @@ charlie@test.com,active,user
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
@@ -526,6 +559,7 @@ charlie@test.com,active,user
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")
@@ -547,6 +581,7 @@ dave@test.com,inactive,admin
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
@@ -559,6 +594,7 @@ dave@test.com,inactive,admin
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] {
@@ -578,6 +614,7 @@ charlie@example.net,example.net
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
@@ -601,6 +638,7 @@ 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
@@ -612,6 +650,7 @@ row4@test.com,Row4
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])
}
@@ -627,6 +666,7 @@ row4@test.com,Row4
path := createTempCSV(t, csv)
var count int
+
err := Process(path, Options{MaxRows: 2}, func(row Row) error {
count++
return nil
@@ -651,6 +691,7 @@ 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
@@ -662,6 +703,7 @@ row5@test.com,Row5
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)
}
@@ -674,19 +716,20 @@ bob@test.com,Bob
`
path := createTempCSV(t, csv)
- callbackErr := errors.New("callback failed")
var count int
err := Process(path, Options{}, func(row Row) error {
count++
if count == 1 {
- return callbackErr
+ return errCallbackFailed
}
+
return nil
})
- if err != callbackErr {
+ 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)
}
@@ -694,6 +737,7 @@ bob@test.com,Bob
func TestProcess_EmptyCSV(t *testing.T) {
path := createTempCSV(t, "")
+
err := Process(path, Options{}, func(row Row) error {
return nil
})
@@ -708,6 +752,7 @@ func TestProcess_HeaderOnly(t *testing.T) {
path := createTempCSV(t, csv)
var count int
+
err := Process(path, Options{}, func(row Row) error {
count++
return nil
@@ -715,6 +760,7 @@ func TestProcess_HeaderOnly(t *testing.T) {
if err != nil {
t.Fatalf("Process error: %v", err)
}
+
if count != 0 {
t.Errorf("expected 0 rows (header only), got %d", count)
}
@@ -763,6 +809,7 @@ alice@test.com,Alice
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)
}
@@ -775,6 +822,7 @@ func TestProcess_ValueTrimming(t *testing.T) {
path := createTempCSV(t, csv)
var row Row
+
err := Process(path, Options{}, func(r Row) error {
row = r
return nil
@@ -787,6 +835,7 @@ func TestProcess_ValueTrimming(t *testing.T) {
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"])
}
@@ -800,6 +849,7 @@ alice@test.com,ignored,Alice
path := createTempCSV(t, csv)
var row Row
+
err := Process(path, Options{}, func(r Row) error {
row = r
return nil
@@ -811,9 +861,11 @@ alice@test.com,ignored,Alice
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"])
}
@@ -828,6 +880,7 @@ 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
@@ -883,12 +936,15 @@ bob@example.com,Bob,Jones,Sales
}
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 {
@@ -930,6 +986,7 @@ dave@test.com,active,guest
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
@@ -957,6 +1014,7 @@ dave@test.com,active,guest
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
@@ -982,6 +1040,7 @@ alice@test.com,active
nilFilter := FieldFilter{Field: "status", Regex: nil}
var count int
+
err := Process(path, Options{Match: []FieldFilter{nilFilter}}, func(row Row) error {
count++
return nil
@@ -1009,6 +1068,7 @@ func TestSubstituteArgs_NestedTildes(t *testing.T) {
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])
}
@@ -1021,6 +1081,7 @@ func TestSubstituteArgs_EmptyRow(t *testing.T) {
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)
}
@@ -1055,9 +1116,11 @@ func TestParseFieldFilters_ComplexRegex(t *testing.T) {
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)
}
@@ -1075,6 +1138,7 @@ 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
@@ -1107,6 +1171,7 @@ Français,Paris,🇫🇷
path := createTempCSV(t, csv)
var rows []Row
+
err := Process(path, Options{}, func(row Row) error {
rows = append(rows, row)
return nil
@@ -1144,17 +1209,20 @@ bob@test.com,Bob,inactive
// Write CSV data to the pipe
go func() {
defer w.Close()
- if _, err := w.WriteString(csv); err != nil {
- t.Errorf("write to pipe: %v", err)
+
+ 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
@@ -1170,6 +1238,7 @@ bob@test.com,Bob,inactive
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
index 469b02de..b312d815 100644
--- a/internal/googleapi/accesscontext.go
+++ b/internal/googleapi/accesscontext.go
@@ -14,9 +14,11 @@ func NewAccessContextManager(ctx context.Context, email string) (*accesscontextm
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
index 3316ad09..21078e26 100644
--- a/internal/googleapi/admin_directory.go
+++ b/internal/googleapi/admin_directory.go
@@ -14,9 +14,11 @@ func NewAdminDirectory(ctx context.Context, email string) (*admin.Service, error
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
index 60f40520..4fa5ea26 100644
--- a/internal/googleapi/alertcenter.go
+++ b/internal/googleapi/alertcenter.go
@@ -14,9 +14,11 @@ func NewAlertCenter(ctx context.Context, email string) (*alertcenter.Service, er
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
index c698baec..779b90a8 100644
--- a/internal/googleapi/analytics.go
+++ b/internal/googleapi/analytics.go
@@ -14,9 +14,11 @@ func NewAnalyticsAdmin(ctx context.Context, email string) (*analyticsadmin.Servi
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
index 5d71d0f8..6ce5cd72 100644
--- a/internal/googleapi/cloudchannel.go
+++ b/internal/googleapi/cloudchannel.go
@@ -14,9 +14,11 @@ func NewCloudChannel(ctx context.Context, email string) (*cloudchannel.Service,
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 92fcefe0..0f0e9369 100644
--- a/internal/googleapi/cloudidentity.go
+++ b/internal/googleapi/cloudidentity.go
@@ -31,10 +31,12 @@ func NewCloudIdentityInboundSSO(ctx context.Context, email string) (*cloudidenti
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
}
@@ -44,9 +46,11 @@ func NewCloudIdentity(ctx context.Context, email string) (*cloudidentity.Service
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
index 4645a0c1..9954b57b 100644
--- a/internal/googleapi/cloudresourcemanager.go
+++ b/internal/googleapi/cloudresourcemanager.go
@@ -14,9 +14,11 @@ func NewCloudResourceManager(ctx context.Context, email string) (*cloudresourcem
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
index 62ece8d7..84dd2ed5 100644
--- a/internal/googleapi/datatransfer.go
+++ b/internal/googleapi/datatransfer.go
@@ -14,9 +14,11 @@ func NewDataTransfer(ctx context.Context, email string) (*datatransfer.Service,
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 1c635f11..c834b518 100644
--- a/internal/googleapi/drive.go
+++ b/internal/googleapi/drive.go
@@ -18,6 +18,7 @@ func EscapeDriveQueryValue(s string) string {
// Escape backslashes first, then single quotes.
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "'", "\\'")
+
return s
}
diff --git a/internal/googleapi/driveactivity.go b/internal/googleapi/driveactivity.go
index f449f34d..feab641c 100644
--- a/internal/googleapi/driveactivity.go
+++ b/internal/googleapi/driveactivity.go
@@ -14,9 +14,11 @@ func NewDriveActivity(ctx context.Context, email string) (*driveactivity.Service
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
index 66a91264..e1acd453 100644
--- a/internal/googleapi/drivelabels.go
+++ b/internal/googleapi/drivelabels.go
@@ -14,9 +14,11 @@ func NewDriveLabels(ctx context.Context, email string) (*drivelabels.Service, er
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
index 6a9aef61..407b2c38 100644
--- a/internal/googleapi/forms.go
+++ b/internal/googleapi/forms.go
@@ -14,9 +14,11 @@ func NewForms(ctx context.Context, email string) (*forms.Service, error) {
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
index 0c5db7df..0c0cfde1 100644
--- a/internal/googleapi/groupssettings.go
+++ b/internal/googleapi/groupssettings.go
@@ -14,9 +14,11 @@ func NewGroupsSettings(ctx context.Context, email string) (*groupssettings.Servi
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
index a26e1b04..3083c9b7 100644
--- a/internal/googleapi/iam.go
+++ b/internal/googleapi/iam.go
@@ -14,9 +14,11 @@ func NewIAM(ctx context.Context, email string) (*iam.Service, error) {
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
index 1fa47017..df49d444 100644
--- a/internal/googleapi/licensing.go
+++ b/internal/googleapi/licensing.go
@@ -14,9 +14,11 @@ func NewLicensing(ctx context.Context, email string) (*licensing.Service, error)
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
index 87f43c83..be522478 100644
--- a/internal/googleapi/meet.go
+++ b/internal/googleapi/meet.go
@@ -14,9 +14,11 @@ func NewMeet(ctx context.Context, email string) (*meet.Service, error) {
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
index fc6b82a1..f371104f 100644
--- a/internal/googleapi/reports.go
+++ b/internal/googleapi/reports.go
@@ -14,9 +14,11 @@ func NewReports(ctx context.Context, email string) (*reports.Service, error) {
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
index 0f1c2931..a99be885 100644
--- a/internal/googleapi/reseller.go
+++ b/internal/googleapi/reseller.go
@@ -14,9 +14,11 @@ func NewReseller(ctx context.Context, email string) (*reseller.Service, error) {
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
index 49953a5a..e764cde2 100644
--- a/internal/googleapi/storage.go
+++ b/internal/googleapi/storage.go
@@ -14,9 +14,11 @@ func NewStorage(ctx context.Context, email string) (*storage.Service, error) {
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
index 3df23b20..41a519b0 100644
--- a/internal/googleapi/vault.go
+++ b/internal/googleapi/vault.go
@@ -14,9 +14,11 @@ func NewVault(ctx context.Context, email string) (*vault.Service, error) {
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
index c68baaad..b3e68d63 100644
--- a/internal/googleapi/youtube.go
+++ b/internal/googleapi/youtube.go
@@ -14,9 +14,11 @@ func NewYouTube(ctx context.Context, email string) (*youtube.Service, error) {
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_test.go b/internal/googleauth/service_test.go
index ba0d40db..5fc7288f 100644
--- a/internal/googleauth/service_test.go
+++ b/internal/googleauth/service_test.go
@@ -120,6 +120,7 @@ func TestUserServices(t *testing.T) {
if !seenDocs {
t.Fatalf("missing docs in user services")
}
+
if !seenForms {
t.Fatalf("missing forms in user services")
}
diff --git a/internal/todrive/writer.go b/internal/todrive/writer.go
index 685c0af8..b83adf32 100644
--- a/internal/todrive/writer.go
+++ b/internal/todrive/writer.go
@@ -2,6 +2,7 @@ package todrive
import (
"context"
+ "errors"
"fmt"
"strings"
"time"
@@ -17,6 +18,8 @@ var (
newSheetsService = googleapi.NewSheets
)
+var errMissingSpreadsheetID = errors.New("missing spreadsheet id")
+
const defaultSheetName = "Report"
// Options control Google Sheets output.
@@ -45,10 +48,12 @@ func New(ctx context.Context, account string) (*Writer, error) {
if err != nil {
return nil, err
}
+
sheetsSvc, err := newSheetsService(ctx, account)
if err != nil {
return nil, err
}
+
return &Writer{drive: driveSvc, sheets: sheetsSvc}, nil
}
@@ -57,12 +62,14 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
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 {
@@ -85,6 +92,7 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
}
spreadsheetID = created.SpreadsheetId
spreadsheetURL = created.SpreadsheetUrl
+
if strings.TrimSpace(opts.FolderID) != "" {
if err := w.moveToFolder(ctx, spreadsheetID, opts.FolderID); err != nil {
return nil, err
@@ -96,13 +104,14 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
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, fmt.Errorf("missing spreadsheet id")
+ return nil, errMissingSpreadsheetID
}
if opts.Update {
@@ -113,6 +122,7 @@ func (w *Writer) Write(ctx context.Context, headers []string, rows [][]string, o
if len(headers) > 0 {
values = append(values, toInterfaceRow(headers))
}
+
for _, row := range rows {
values = append(values, toInterfaceRow(row))
}
@@ -146,14 +156,18 @@ func (w *Writer) findSpreadsheet(ctx context.Context, name, folderID string) (st
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
}
@@ -162,14 +176,18 @@ func (w *Writer) moveToFolder(ctx context.Context, fileID, folderID string) erro
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
}
@@ -182,6 +200,7 @@ func (w *Writer) shareWith(ctx context.Context, fileID, email string) error {
if err != nil {
return fmt.Errorf("share sheet: %w", err)
}
+
return nil
}
@@ -190,5 +209,6 @@ func toInterfaceRow(values []string) []interface{} {
for i, value := range values {
row[i] = value
}
+
return row
}
diff --git a/internal/todrive/writer_test.go b/internal/todrive/writer_test.go
index 3b455adb..80e29a09 100644
--- a/internal/todrive/writer_test.go
+++ b/internal/todrive/writer_test.go
@@ -24,13 +24,16 @@ func TestWriterCreateAndWrite(t *testing.T) {
"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)
@@ -46,6 +49,7 @@ func TestWriterCreateAndWrite(t *testing.T) {
origSheets := newSheetsService
origDrive := newDriveService
+
t.Cleanup(func() {
newSheetsService = origSheets
newDriveService = origDrive
@@ -75,9 +79,11 @@ func TestWriterCreateAndWrite(t *testing.T) {
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))
}
From dbdea013c6d51e4fb3deb9adaf5ce406d48f1c22 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Wed, 4 Feb 2026 10:34:05 -0800
Subject: [PATCH 43/48] feat(gmail): add signature support to send command
Add --signature, --signature-name, and --signature-file flags to append
Gmail signatures to outgoing messages. Supports fetching from Gmail
send-as settings or from a local file.
Co-Authored-By: Claude Opus 4.5
---
README.md | 3 +
docs/spec.md | 2 +-
internal/cmd/gmail_send.go | 117 ++++++++++-
internal/cmd/gmail_send_signature_test.go | 188 ++++++++++++++++++
.../cmd/gmail_send_validation_more_test.go | 1 +
5 files changed, 309 insertions(+), 2 deletions(-)
create mode 100644 internal/cmd/gmail_send_signature_test.go
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/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index 053ba1af..c2191125 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -30,6 +30,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 +63,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 +106,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 +144,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 +211,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 +245,68 @@ 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
+ }
+ b, err := os.ReadFile(path) //nolint:gosec // user-provided path
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("signature file not found: %s", path)
+ }
+ 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)
+ }
+ return &signatureContent{
+ Plain: content,
+ HTML: 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
+ }
+}
+
func buildSendBatches(toRecipients, ccRecipients, bccRecipients []string, track, trackSplit bool) []sendBatch {
totalRecipients := len(toRecipients) + len(ccRecipients) + len(bccRecipients)
if track && trackSplit && totalRecipients > 1 {
@@ -243,6 +333,31 @@ 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
+}
+
+func signatureHTMLToPlain(signatureHTML string) string {
+ if strings.TrimSpace(signatureHTML) == "" {
+ return ""
+ }
+ withBreaks := strings.ReplaceAll(signatureHTML, "
", "\n")
+ withBreaks = strings.ReplaceAll(withBreaks, "
", "\n")
+ withBreaks = strings.ReplaceAll(withBreaks, "
", "\n")
+ return strings.TrimSpace(stripHTML(withBreaks))
+}
+
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..82fe37a3
--- /dev/null
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -0,0 +1,188 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "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") {
+ 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)
+ }
+}
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 {
From 52a4c764a526936f8e370689cfb07f7f44c50cb1 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Wed, 4 Feb 2026 10:38:29 -0800
Subject: [PATCH 44/48] fix(gmail): resolve lint issues in signature code
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_send.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index c2191125..ff65ae30 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -106,7 +106,7 @@ 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 {
+ if err = validateSignatureFlags(c.Signature, c.SignatureName, c.SignatureFile); err != nil {
return err
}
@@ -303,7 +303,7 @@ func (c *GmailSendCmd) resolveSignature(ctx context.Context, svc *gmail.Service,
HTML: sa.Signature,
}, nil
default:
- return nil, nil
+ return nil, nil //nolint:nilnil // nil result + nil error means no signature was requested
}
}
From ee7af8052541264a77ecb432431abf9aeba2ded2 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Wed, 4 Feb 2026 10:40:46 -0800
Subject: [PATCH 45/48] fix(gmail): improve HTML-to-plain signature conversion
Handle p/div/tr closing tags as line breaks, convert anchor tags to
"text (URL)" format, collapse excessive newlines, and trim trailing
whitespace. Adds table-driven unit tests covering all tag variants.
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_send.go | 56 +++++++++++++-
internal/cmd/gmail_send_signature_test.go | 93 +++++++++++++++++++++++
2 files changed, 145 insertions(+), 4 deletions(-)
diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index ff65ae30..8ec525a2 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/mail"
"os"
+ "regexp"
"strings"
"google.golang.org/api/gmail/v1"
@@ -348,14 +349,61 @@ func appendSignatureHTML(bodyHTML, signatureHTML string) string {
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 ""
}
- withBreaks := strings.ReplaceAll(signatureHTML, "
", "\n")
- withBreaks = strings.ReplaceAll(withBreaks, "
", "\n")
- withBreaks = strings.ReplaceAll(withBreaks, "
", "\n")
- return strings.TrimSpace(stripHTML(withBreaks))
+
+ 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)
}
func sendGmailBatches(ctx context.Context, svc *gmail.Service, opts sendMessageOptions, batches []sendBatch) ([]sendResult, error) {
diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go
index 82fe37a3..c5c3828d 100644
--- a/internal/cmd/gmail_send_signature_test.go
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -186,3 +186,96 @@ func TestGmailSendCmd_SignatureFileMissing(t *testing.T) {
t.Fatalf("expected signature file error, got: %v", err)
}
}
+
+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: "",
+ 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)
+ }
+ })
+ }
+}
From 45399772555dbaacb5518fa07fd4c1c4946b4937 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Wed, 4 Feb 2026 10:43:19 -0800
Subject: [PATCH 46/48] fix(gmail): add size limit for signature file
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_send.go | 12 ++++++-
internal/cmd/gmail_send_signature_test.go | 40 +++++++++++++++++++++++
2 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index 8ec525a2..30084a93 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -17,6 +17,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)"`
@@ -272,11 +275,18 @@ func (c *GmailSendCmd) resolveSignature(ctx context.Context, svc *gmail.Service,
if err != nil {
return nil, err
}
- b, err := os.ReadFile(path) //nolint:gosec // user-provided path
+ 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)
diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go
index c5c3828d..6c47202a 100644
--- a/internal/cmd/gmail_send_signature_test.go
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -7,6 +7,8 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
@@ -187,6 +189,44 @@ func TestGmailSendCmd_SignatureFileMissing(t *testing.T) {
}
}
+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 TestSignatureHTMLToPlain(t *testing.T) {
tests := []struct {
name string
From a4db6b0ba3074171b9e3673d05f692e499c6bc21 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Wed, 4 Feb 2026 10:46:06 -0800
Subject: [PATCH 47/48] fix(gmail): detect and handle signature file content
type
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_send.go | 25 ++-
internal/cmd/gmail_send_signature_test.go | 226 ++++++++++++++++++++++
2 files changed, 250 insertions(+), 1 deletion(-)
diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index 30084a93..dc7681d1 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
+ "html"
"net/mail"
"os"
"regexp"
@@ -293,9 +294,15 @@ func (c *GmailSendCmd) resolveSignature(ctx context.Context, svc *gmail.Service,
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: content,
+ HTML: plainTextToHTML(content),
}, nil
case sigName != "" || c.Signature:
sendAsEmail := sendingEmail
@@ -416,6 +423,22 @@ func signatureHTMLToPlain(signatureHTML string) string {
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
index 6c47202a..e5ca8384 100644
--- a/internal/cmd/gmail_send_signature_test.go
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -227,6 +227,232 @@ func TestGmailSendCmd_SignatureFileTooLarge(t *testing.T) {
}
}
+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 TestSignatureHTMLToPlain(t *testing.T) {
tests := []struct {
name string
From 72bd7a7f073bcef1b114c5b4511311cde1cfe568 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Wed, 4 Feb 2026 10:48:18 -0800
Subject: [PATCH 48/48] test(gmail): add unit tests for signature helpers and
alias path
Co-Authored-By: Claude Opus 4.5
---
internal/cmd/gmail_send_signature_test.go | 157 ++++++++++++++++++++++
1 file changed, 157 insertions(+)
diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go
index e5ca8384..b67bdee3 100644
--- a/internal/cmd/gmail_send_signature_test.go
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -453,6 +453,163 @@ func TestSignatureFileContentType_HTML(t *testing.T) {
}
}
+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" + ``,
+ },
+ {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